利用异常进行系统中通用的消息通知和事件处理

这个系统来源于这学期要进行的课程设计的一个框架方案,觉得也还算能拿出来丢丢人,就放上来挨砖啦~

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,随后使用实例化后的INotificationHandlerHandleNotification方法进行处理。

各实用类之间的关系如下图


而其中至关重要的是“配置模块”包中的内容,此包中的类将提供对配置文件的解析功能,同时配置文件最外层节点对应的NotificationSection将提供根据状态码创建异常对象的功能。

对于配置文件的解析,.NET框架提供了System.Configuration命名空间中的ConfigurationSectionConfigurationElementConfigurationElementCollection等类进行支持,因此在有了对配置文件的设计之后,可以简单地实现相关类,其关系如下图


包中的类之间的关系与XML的结构非常相似,呈树状层级进行聚合,同时在每一个节点中都提供部分自定义的信息以供配置。

整个模块实际工作流程如下图所示


至此已经完成了整个通知模块的实现,从结构上并不复杂,因此日后的维护也较为方便。

使用说明

A.     需求分析

在此示例中,将以Windows Console Application的方式模仿一个简单的系统,假设此系统中有2个模块,模块名称为PortalCommunity,其中Portal模块为门户应用,包含了注册用户的功能;Community模块为社区应用,包含了在相应版块发帖的功能。

对于Portal的注册功能需求如下

1.       用户注册成功的情况下需要在控制台显示出“XXX,感谢注册本系统”的字样

2.       用户名无效,此时在控制台显示出“您所使用的用户名无效”的字样

3.       用户名已注册(假设系统已有一用户名为UserABC用户),此时在控制台显示出“您输入的用户名已被注册”的字样

4.       其他原因导致注册失败(未知错误),此时在控制台显示出“注册失败”字样

对于Community的发帖功能需求如下

1.       发帖成功则在控制台显示“您已成功于xxxxxxxxxxxxxx秒发布名为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节点,并添加PortalCommunity模块节点

<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:yyyyMMddhhmmss}发布名为{1}的帖子"

                type="Success"

                log="false"/>

        <status code="2050"

                message="发帖失败"

                type="Error"

                log="true"/>

      </statusCodes>

    </module>

  </modules>

</cst.notification>

根据事先定义的节点的schema,此节点中定义了PortalModule两个模块,分别使用Sample.Portal.HandlerSample.Community.Handler为默认的处理器。

Portal模块中,定义了从102510284个状态码,分别代表用户注册成功、用户名不合法、用户名已注册、用户注册失败4项内容。

由于在注册成功的显示信息中需要用到用户输入的用户名,所以以{0}作为占位符显示用户名。

对于用户注册失败的处理,在配置中统一使用了Sample.Portal.RegisterErrorHandler作为处理器。

Community模块中,定义了20492050两个状态码,都使用默认处理器进行处理,对于发帖成功的提示信息,由于需要格式地显示日期,所以使用{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);

}

如果异常对象中的RequireLogtrue的话,则调用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号用户已存在