利用异常进行系统中通用的消息通知和事件处理
这个系统来源于这学期要进行的课程设计的一个框架方案,觉得也还算能拿出来丢丢人,就放上来挨砖啦~
Cst.Notification:统一的事件通知模式
背景介绍
对于以面向对象设计为基础的系统,在复杂度上升的同时,对所有的事务有一个统一的处理是尤为关键的一点。
而对于一个典型的以页面为基本元素的Web应用,各页面间的跳转以及在此之间体现出来的业务流程是建模者和实现者都需要高度关注的关键。
因此,在详细分析和收集了多个Web应用的设计和实现之后,一个结论是:一种统一的事件点通知方式是十分重要的。
比如一个简单的系统,在用户注册这一环节上就有可能出现多种结果,有可能用户名无效,用户名重复,也有可能注册成功,而对于三种情况,一一编写页面跳转及信息提示的程序,无疑给程序员带来了巨大的工作量,同时大量的硬编码也使系统的可维护性直线下降。
而Cst.Notification模块所要处理的问题,即是给出一种合理的统一的事件通知方案,使整个系统以某一个核心元件为中心形成高度内聚的体系,同时提供灵活、高效的使用方式。
可行性考虑
针对此次设计的“面向对象”的特点,统一的事件通知的方式中,最为有效且可行的无疑是面向对象语言原生支持的异常机制。
从技术上而言,异常可以在调用堆栈的各层之间传播,可以通过可预计的防范式编程进行捕捉,可以集中于一层进行统一的处理或分发至其他各层,具有高度的灵活性,并且由于是语言原生支持,在其内核中有着相应的优化,效率也是可取的。
而从语义上说,也许会有部分人认为“异常只是在系统出现错误时使用”,而对于所有的事件,无论成功或者失败,都使用异常进行通知是违背了面向对象的“与现实联系”的观点的。
实则不然,在面向对象中,对异常的解释实际上是“当系统运作中出现不可预测的情况时,由异常对象对当前场景进行捕获和包装并通知其他模块”。由此可见,异常并不是用于“出错”的情况,而是用于“出现不可预测情况”的场景之中,而事实上对于一个以业务逻辑为驱动的应用而言,每一个环节都会有分支,这种分支正好造成了“不可预测”的结果。
比如说用户注册,当用户提交信息(通过填写表单并单击提交按钮)时,他是否可以事先知道自己的注册是否成功?是否可以明确自己的用户名不会重复呢?答案是否定的。而处理用户提交信息的程序是否知道此次提交成功与否?答案也是否定的。因此在这一流程的执行过程中,充满着大量的不确定性,正符合了异常“不可预测的情况”的使用场景,也因此使用异常作为整个系统的所有事件的统一通知模型在语义上是可取的。
设计概览
排除使用系统的用户和系统本身,在通用型的异常通知模块的设计中,必须有一个异常管理器以创建相应的异常(根据系统所指定的状态编码),同时还需要有一个或多个异常处理器来处理不同的异常。
在一次执行过程中,用户将请求提交到系统,系统在处理请求后,指定状态码,请求异常管理器生成相应的异常(失败和成功都会对应不同的异常),随后将此异常交给对应的异常处理器进行处理。
一次完成的请求处理如下图所示
而具体的状态码—异常之间的联系,为了灵活性的需要,可以通过配置文件进行设置。
对于配置文件,应要求一个系统存大多个不同的模块,每个模块使用不同的状态码段,比如会员管理模块使用1-1024号状态码,而社区模块使用1025-2048号状态码。每一个状态码对应唯一的异常信息,此异常信息包括:
消息—用于人性化地告知用户的消息,最终应显示在用户界面上
异常类型—在此设定成功、信息、错误、异常4种情况
是否记录—如果需要记录,应交由相应的日志模块记录事件情况
模块名—对应此异常所属的模块名称
同时,对于不同的异常,应能在配置文件中动态地指定其异常处理器。从灵活性和简捷性考虑,应该可以在“模块”和“状态码”两个层次上指定处理器,如果“状态码”项中没有指定处理器,则使用“模块”中指定的默认处理器进行处理,因此异常信息中还需要保存“处理器类型”的属性,以便处理时实例化相应类型的处理器。
对于处理器,可以使用一个统一的接口,此接口提供一个方法,方法接收一个异常对象作为参数,进行处理后不需要返回任何值。
因此在配置文件中,应形成系统—模块—状态码信息的分层结构,所期望的配置文件如下
<cst.notification>
<modules>
<module name="Portal"
handler="Sample.Portal.Handler, Sample">
<statusCodes>
<status code="1025"
message="{0},感谢注册本系统" type="Success" log="false"/>
<status code="1026"
message="用户名不合法" type="Error" log="true"
handler="Sample.Portal.RegisterErrorHandler,
Sample"/>
<status code="1027"
message="用户名已注册" type="Error" log="false"
handler="Sample.Portal.RegisterErrorHandler,
Sample"/>
<status code="1028"
message="用户注册失败" type="Error" log="true"
handler="Sample.Portal.RegisterErrorHandler,
Sample"/>
</statusCodes>
</module>
<module name="Community"
handler="Sample.Community.Handler,
Sample">
<statusCodes>
<status code="2049"
message="发帖成功"
type="Success"
log="false"/>
<status code="2050"
message="发帖失败"
type="Error"
log="true"/>
</statusCodes>
</module>
</modules>
</cst.notification>
实现方案
首先建立一个接口,即异常处理器的接口,在此命名为INotificationHandler,提供一个方法,命名为HandleNotification,此方法接收CstException的对象作为参数。
为了方便调用,提供一个静态辅助类提供,使用Façade模式提供处理的入口,在此将这个类命名为NotificationHandling,提供一个方法Handle,接收一个CstException对象为参数,此方法的作用是:首先尝试实例化CstException对象中的HandlerType类型,如果无法实例化,则从配置文件中找到相应的配置节点并实例化配置中的对应HandlerType,随后使用实例化后的INotificationHandler的HandleNotification方法进行处理。
各实用类之间的关系如下图
而其中至关重要的是“配置模块”包中的内容,此包中的类将提供对配置文件的解析功能,同时配置文件最外层节点对应的NotificationSection将提供根据状态码创建异常对象的功能。
对于配置文件的解析,.NET框架提供了System.Configuration命名空间中的ConfigurationSection,ConfigurationElement,ConfigurationElementCollection等类进行支持,因此在有了对配置文件的设计之后,可以简单地实现相关类,其关系如下图
包中的类之间的关系与XML的结构非常相似,呈树状层级进行聚合,同时在每一个节点中都提供部分自定义的信息以供配置。
整个模块实际工作流程如下图所示
至此已经完成了整个通知模块的实现,从结构上并不复杂,因此日后的维护也较为方便。
使用说明
A. 需求分析
在此示例中,将以Windows Console Application的方式模仿一个简单的系统,假设此系统中有2个模块,模块名称为Portal及Community,其中Portal模块为门户应用,包含了注册用户的功能;Community模块为社区应用,包含了在相应版块发帖的功能。
对于Portal的注册功能需求如下
1. 用户注册成功的情况下需要在控制台显示出“XXX,感谢注册本系统”的字样
2. 用户名无效,此时在控制台显示出“您所使用的用户名无效”的字样
3. 用户名已注册(假设系统已有一用户名为UserABC用户),此时在控制台显示出“您输入的用户名已被注册”的字样
4. 其他原因导致注册失败(未知错误),此时在控制台显示出“注册失败”字样
对于Community的发帖功能需求如下
1. 发帖成功则在控制台显示“您已成功于xxxx年xx月xx日xx时xx分xx秒发布名为xxx的帖子”的字样
2. 发帖失败则在控制台显示“发帖失败”的字样
对于系统的业务规则限制表述如下
1. 用户名长度为3-10个字符,包含任意字符
2. 帖子主题长度为1-80个字符,包含任意字符
B. 编写配置文件
首先打开App.config文件(如果文件不存在则新建一个),以App.config正常配置方式,添加configuration根节点,在根节点下的configSections节点(如果不存在则添加一个)中加入以下节点
<section name="cst.notification"
type="Cst.Core.Notification.Configuration.NotificationSection,
Cst.Core"/>
此段内容通知.NET的配置解析器,对cst.notification节点使用已定义的类NotificationSection进行解析。
随后在文件中新增cst.notification节点,并添加Portal和Community模块节点
<cst.notification>
<modules>
<module name="Portal"
handler="Sample.Portal.Handler, Sample">
<statusCodes>
<status code="1025"
message="{0},感谢注册本系统"
type="Success"
log="false"/>
<status code="1026"
message="用户名不合法"
type="Error" log="true"
handler="Sample.Portal.RegisterErrorHandler, Sample"/>
<status code="1027"
message="用户名已注册"
type="Error" log="false"
handler="Sample.Portal.RegisterErrorHandler, Sample"/>
<status code="1028"
message="用户注册失败"
type="Error" log="true"
handler="Sample.Portal.RegisterErrorHandler, Sample"/>
</statusCodes>
</module>
<module name="Community"
handler="Sample.Community.Handler, Sample">
<statusCodes>
<status code="2049"
message="您已成功于{0:yyyy年MM月dd日hh时mm分ss秒}发布名为{1}的帖子"
type="Success"
log="false"/>
<status code="2050"
message="发帖失败"
type="Error"
log="true"/>
</statusCodes>
</module>
</modules>
</cst.notification>
根据事先定义的节点的schema,此节点中定义了Portal和Module两个模块,分别使用Sample.Portal.Handler和Sample.Community.Handler为默认的处理器。
在Portal模块中,定义了从1025至1028共4个状态码,分别代表用户注册成功、用户名不合法、用户名已注册、用户注册失败4项内容。
由于在注册成功的显示信息中需要用到用户输入的用户名,所以以{0}作为占位符显示用户名。
对于用户注册失败的处理,在配置中统一使用了Sample.Portal.RegisterErrorHandler作为处理器。
在Community模块中,定义了2049和2050两个状态码,都使用默认处理器进行处理,对于发帖成功的提示信息,由于需要格式地显示日期,所以使用{0:…}的格式化方式,并以{1}为点位符显示帖子主题。
C. 实现相应处理器
首先实现Sample.Portal.Handler处理器,此处理器仅仅负责用户注册成功时消息的显示,其代码如下
internal class Handler : INotificationHandler
{
#region
INotificationHandler Members
public void
HandleNotification(CstException info)
{
Console.WriteLine(info.Message);
}
#endregion
}
处理器需要实现INotificationHandler接口,并实现其中的HandlerNotification方法,在此实现方案为将异常中的Message显示在控制台上。
随后实现Sample.Portal.RegisterErrorHandler,这个处理器需要处理用户注册失败的3种情况,而对于用户名不合法以及其他原因的失败,需要进行日志记录,随后将消息显示于控制台,处理器同样实现INotificationHandler接口,其中的HandleNotification方法代码如下
public void HandleNotification(CstException info)
{
if (info.RequireLog)
{
Log(info.StatusCode.ToString() + ": " + info.Message);
}
Console.WriteLine(info.Message);
}
如果异常对象中的RequireLog为true的话,则调用Log方法进行日志记录,随后在控制台上显示出异常对象中的Message。
最后是Sample.Community.Handler,此处理器需要处理发帖成功与失败两种情况,在此假设发帖成功后需要将社区总帖子数加1,所以处理器的HandlerNotification方法代码如下
public void
HandleNotification(CstException info)
{
if (info.RequireLog)
{
Log(info.StatusCode.ToString() + ":
" + info.Message);
}
//如果是发帖成功
if (info.StatusCode == 2049)
{
IncreaseThreadCount();
}
Console.WriteLine(info.Message;);
}
在处理的过程中加入了更多的逻辑,除了对日志记录的操作,进一步对StatusCode属性进行了判断,如果是2049号通知(发帖成功)则调用IncreaseThreadCount()将社区总帖子数加1,最后依旧在控制台上显示Message的内容。
D. 实现具体操作
有了异常作为统一的消息通知方式,对具体内容的实现就非常方便,首先实现注册功能,将此功能封装在Example.Portal.UserManager类中,代码如下
public static class UserManager
{
//配置节点
private static
readonly NotificationSection
section =
(NotificationSection)ConfigurationManager.GetSection(
NotificationSection.SectionName);
public static
void Register(string
userName)
{
if
(IsUserNameValid(userName) == false)
{
//1026号用户名无将
throw
section.CreateException(1026);
}
if
(IsUserNameDuplicated(userName) == true)
{
//1027号用户已存在
throw
section.CreateException(1027);
}
try
{
SaveUser(userName);
}
catch (Exception ex)
{
//1028号未知错误
throw
section.CreateCustomException(1028, ex);
}
//1025号注册成功
throw
section.CreateException(1025, userName);
}
private static
bool IsUserNameValid(string
userName)
{
return
((userName.Length >= 3) && (userName.Length <= 10));
}
private static
bool IsUserNameDuplicated(string userName)
{
return
(userName == "UserABC");
}
private static
void SaveUser(string
userName)
{
//将用户名持久化
}
}
对代码不作详细的解释,所有的通知都以异常的方式进行传递。
随后实现发帖功能,封闭于Sample.Community.ThreadManager类中,代码如下
E. 编写主程序
最后的任务是编写一个主程序向用户展示简单的界面,接收用户的输入,将输入交给相应的逻辑单位进行处理
主程序的另一个主要任务是捕获所有抛出的CstException异常,调用NotificationHandling类的Handle方法进行处理,代码如下
class Program
{
static void
Main(string[] args)
{
while (true)
{
try
{
Start();
}
catch
(CstException ex)
{
NotificationHandling.Handle(ex);
}
catch
(ApplicationException ex)
{
return;
}
}
}
private static
void Start()
{
Console.WriteLine("请选择功能:");
Console.WriteLine("1: 注册");
Console.WriteLine("2:
发帖");
int
choice = Int32.Parse(Console.ReadLine());
switch
(choice)
{
case
1:
Console.WriteLine("用户名:");
string
username = Console.ReadLine();
UserManager.Register(username);
break;
case
2:
Console.WriteLine("主题:");
string
title = Console.ReadLine();
Console.WriteLine("内容:");
string
content = Console.ReadLine();
ThreadManager.Post(title,
content);
break;
default:
throw
new ApplicationException("结束");
}
}
}
主程序中的Start方法即程序主体,而在入口Main函数中,使用try{}块将Start方法的调用包装起来,并捕获CstException进行处理,为了使程序可以终止,捕获ApplicationException作为程序结束的标志即可。
最佳实践
使用枚举表示状态号
在Notification模块的使用过程中,最重要的便是StatusCode,此状态号将在整个系统中表示一个唯一的事件,因此正确的StatusCode对系统的正确运行非常重要。
同时由于WebForm对测试的支持很差,在真正运行系统以前很难查找出StatusCode的错误。
再者,在程序中应用一个可读性更高的方案来表示StatusCode也有利于程序的维护,毕竟随时都需要查询字典来确定状态码并不是十分愉快的经历。
鉴于此,模块推荐使用枚举进行状态码的表示,这在减少了错误输入状态码的情况的同时,也大大提升了程序的可读性。
在此以上例中的用户注册一例,展示如何使用枚举来表示状态码:
首先需要新建一个枚举,在此称为PortalActivity,代码如下
internal enum PortalActivity
{
RegisterSuccess = 1025, InvalidUserName = 1026,
DuplicatedUserName = 1027, RegisterFail = 1028
}
枚举中将各事件与配置文件中的状态码一一对应。
在此枚举存在的基础上,便可以以枚举的值作为NotificationSection的CreateException方法的参数,这是基于.NET框架的一个特性,即枚举类型默认继承自System.Int32值类型,并且与int类型之间可以进行显式转换,因此原有的UserManager中的Register方法修改如下
public static void Register(string
userName)
{
if (IsUserNameValid(userName) == false)
{
//1026号用户名无将
throw
section.CreateException((int)PortalActivity.InvalidUserName);
}
if (IsUserNameDuplicated(userName) == true)
{
//1027号用户已存在
throw
section.CreateException((int)PortalActivity.DuplicatedUserName);
}
try
{
SaveUser(userName);
}
catch (Exception
ex)
{
//1028号未知错误
throw
section.CreateCustomException((int)PortalActivity.RegisterFail, ex);
}
//1025号注册成功
throw section.CreateException((int)PortalActivity.RegisterSuccess,
userName);
}
从代码中可见,其逻辑与原有的完全相同,而仅仅修改了CreateException方法的参数,将原有的硬编码状态码改为从PortalActivity枚举中获得一个相对更具有语义的值,并显式转换为int。
至此,使用枚举进行状态码的控制已经完成,这会给程序和代码都带来更高的语义性,同时提高系统的可维护性,是一种值得推荐的方案。
从App.config中分离Notification配置
由于在默认的.NET应用程序,特别是Web应用程序中,App.config(web.config)将承担非常多的系统配置的任务,这样做虽然集中了配置的管理,但无疑为具体内容的定位及修改带来了不少的麻烦。
鉴于此,一种推荐的方案是,将自定义的配置内容分享到各自的配置文件中,而这种方案的实现方法即配置文件中的configSource属性,对于具体的应用,下面将以分离上例的配置文件为示例进行演示:
首先创建一个新的配置文件,假定其文件名为Notification.config,并将原来处于App.config中的cst.notification节点移到Notification.config中
随后修改App.config文件,修改后的文件如下
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<configSections>
<section name="cst.notification"
type="Cst.Core.Notification.Configuration.NotificationSection,
Cst.Core"/>
</configSections>
<cst.notification configSource="Notification.config"/>
</configuration>
修改后的App.config已经不在有原本繁琐的配置,而是使用configSource属性将配置段指向了已经创建的Notification.config文件中。
最后为了使Notification.config随着系统的发布而得到发布,在单击Solution Explorer中的Notification.config文件后,在Properties窗口中将Copy to Output Directory项改为Always即可。
注意,由于Notification.config与.NET规定的配置文件的结构不符,因此在编译时期会出现一个警告,不需要重视此警告,警告的大致内容如下
Warning 3 The 'cst.notification' element is not
declared.
与WebForm进行集成
在示例中,为了更简捷清楚地说明模块的使用方法,仅以简单的Console Application作为示例,而在真正的应用环境中,开发者面对的往往是ASP.NET WebForm之类应用,在IIS控制的系统生命周期之下,无法简单地使用try{}块将入口程序包围并进行异常的处理,因此需要以其他的方法对系统运行过程中所有的CstException进行捕捉和处理。
从理论上分析,ASP.NET提供了HttpContext.Current.Server.GetLastError()方法来获取最后抛出的异常,而对系统的Error事件进行注册即可以在异常被抛出后进行及时的处理。
因此一种可行的方案是通过自定义一个Page的子类,假定名称为BasePage,作为系统中所有页面的基类,并重写BasePage的OnError方法来实现错误的处理,其代码如下
public class BasePage : Page
{
protected override
void OnError(EventArgs
e)
{
//处理CstException
CstException
ex = Server.GetLastError() as CstException;
if (ex
!= null)
{
NotificationHandling.Handle(ex);
}
base.OnError(e);
}
}
之后所有的页面只需要修改代码后置的.cs文件,使页面对应的类继承自BasePage即可实现模块的功能。
虽然自定义BasePage是一种简洁高效的实现方案,但是对于所有页面进行基类的限定,并且每生成一个页面都需要手动修改基类无疑是一件麻烦的事,也因此需要寻找一种更为合理的解决方案。
ASP.NET的系统生命周期中,有一个重要的概念即HttpModule,一个系统可以同时拥有多个HttpModule,此HttpModule将在系统的各个生命关键点(如Authenticate或者Authorize)进行注册和处理,所以我们只需要创建一个HttpModule,并注册系统生命周期中的Error事件即可以进行处理。
为了实现在HttpModule层次上的模块功能集成,我们需要建立一个实现了IhttpModule接口的类,在此假定名称为ErrorHandlerHttpModule,代码如下
public class ErrorHandlerHttpModule : IHttpModule
{
#region
IHttpModule Members
public void
Dispose()
{
}
public void
Init(HttpApplication context)
{
context.Error += new EventHandler(Application_Error);
}
void Application_Error(object sender, EventArgs
e)
{
CstException
ex =
HttpContext.Current.Server.GetLastError()
as CstException;
if (ex
!= null)
{
NotificationHandling.Handle(ex);
}
}
#endregion
}
IhttpModule接口要求实现Dispose和Init方法,其中的Dispose方法不是我们需要关心的,而Init方法中,我们需要注册HttpApplication的Error事件,而在事件的处理中我们就通过GetLastError()方法捕获最后抛出的异常,同时进行了相应的处理。
最后,需要修改系统的配置文件以支持此HttpModule,为此打开Web应用程序创建时就自带的web.config文件,找到system.web节点下的httpModules节点,在其所有子节点之前添加以下内容
<add name="ErrorHandlingModule" type="Sample.Module.ErrorHandlerHttpModule,
Sample"/>
此段指示系统添加一个HttpModule,其类型指向刚刚建立的ErrorHandlerHttpModule,在配置之后启动应用程序(如果之前已经启动了应用程序,可能需要重新启动),此HttpModule即会工作。
对于以HttpModule的方式进行异常的管理,可能部分人会有多线程对程序运行和使用的干扰的困惑,在此作一解答:
虽然IIS管理的ASP.NET应用程序并不是一个单线程的线性执行的程序,因此从普通的角度来看,从某一点抛出异常到GetLastError()方法被调用的这段时间内,可能由于其他线程的执行的干扰,导致最终获取的异常并不是最后抛出的异常。
对于这一点,事实上ASP.NET应用程序虽然不是一个单线程的线性执行单元,但对于每一次会话及请求,其执行是动作于单一线程之上的线性执行单元,而HttpContext.Current也仅与此线程联系,所以其中的Server属性是并不会跨越线程进行访问,因此这种方案是线程安全的,且对系统本身并没有侵入性。