上一篇:从Java类库看设计模式(1)

下一篇:界面组装器模式

数据校验器架构模式组

在我们各种类型的应用开发中有一个必不可少的环节-数据校验,无论是大型企业应用,还是一个简单的程序。如果没有统一的架构,可能校验代码会遍布整个应用,一旦校验规则改变就需要修改多处代码,这是一种不好的设计,因为数据校验与应用逻辑耦合得太紧。数据校验不外乎语法校验和语义校验两类,本文描述了一组架构上的模式来对这两类需求提供解决方案。该模式组按照待校验数据的粒度大小和业务规则的复杂程度分成多种类型:隔离校验器,可组装校验器,动态策略校验器,动态注册校验器等。大家可以针对自己的应用选择合适的架构。应用这组模式还可以获得一个好处,如果需要的话,我们可以把数据校验器当作一个横切关注点(Crosscut concern),应用 AOP(Aspect of Programming)技术,这样可以彻底分离出数据校验逻辑代码。

问题引出

让我们从几个应用场景(user scenario)开始吧,第一个场景是网站上的注册用户,注册时需要填写很多数据,这些数据都需要校验后才能写进数据库,比如用户名,校验规则可能是:用户名由 a ~ z 的英文字母 ( 不区分大小写 )、0 ~ 9 的数字、点、减号或下划线组成,长度为 3 ~ 18 个字符。这种关于数据的结构正确性方面的校验我们称之为语法校验。而身份证号码这种数据,它需要根据出生日期校验身份证号码的正确性,不仅仅是填够了 16 或 19 位数字就行。这种关于数据的内容正确性方面的校验称之为语义校验。一般情况下语法和语义方面的校验是在一块处理的,比如身份证号码,必然也需要校验数据是否全是数字和必须是 16 或 19 位,这是语法校验,同时它需要和出生日期相符,这又是语义校验。从架构的角度而言,这种情况下区分语法和语义的意义不太大,因为没必要把它分成两个步骤用两个方法来处理。但是有些应用,比如数据是一段 XML 的文本串,首先需要校验 XML 字符串的结构-语法是否符合相应的 schema,然后再校验其中某个元素的内容-语义的正确性,这可能就需要分开来处理比较合适,因为语法校验是业务无关的,而后者的语义校验是业务相关的,业务相关就意味着一旦业务规则改变,校验规则就可能改变,所以这种情况最好将语义校验分离出来。

第二个应用场景是一个 MDA(Model Driven Architecture)工具开发的例子,我们都用过大名鼎鼎的 Rational Rose 或 Microsoft Visio。这些工具都提供从 UML 模型生成代码的功能,这就是 MDA,它们将 UML 模型映射成模型的元数据 (meta-data)(称之为元模型 meta-model),然后从元模型可以转换成各种支持语言的代码,如 Java, C++。当我们在视图上画一个 UML 元素(如类 Class),然后为其定义了某种 Stereotype 来标识他的业务语义,比如数据库的表 Table,或者自定义的一个用于表示 Web Service 的 Service 元素。接下来我们要将该元素生成相应的代码,这时当你选定元素时,运行时系统并不知道该元素是普通的 Class,还是 Table,因为在运行时环境中都是 UML 的 Class 实例对象,这就需要我们提供校验逻辑来处理了,处理 Class 的校验逻辑和 Table 的校验逻辑自然不应该放在一起,更何况如果是自定义的扩展元素,根本不可能把校验代码写到已有系统里去。这就需要我们提供一个统一的校验器接口,不同的校验逻辑封装在单独的类中。进一步,我们需要对这些独立的校验器进行集中组装和管理,因为我们不必每次都去实例化这些工具类,实例化后将它们缓存起来就可以了。

第三个应用场景是一个银行并购的案例,假如银行 A 并购了银行 B,两家银行都有各自已有的电子银行应用,并购后要将两家应用整合成一个统一的应用,其中有一个余额查询业务,在进行具体的查询操作事务之前,需要校验用户输入的帐号 account,两家银行已有的帐号各有不同的创建规则,比如银行 A 是 16 位数字作为帐户,首 4 位是银行代号,第二个 4 位是地区代号,第三个 4 位是网点代号,尾 4 位是用户编号。而银行 B 则是 19 位数字作为帐户,各个区段的含义也和银行 A 不一样,这就要求用户填写一个帐户的时候,后台必须对应两套数据校验规则,而且应用需要根据一定的规则来选择银行 A 的校验策略或银行 B 的校验策略。而且更复杂的情况是,银行的帐户还可能是升位后的(比如从 12 位升到 16 位),这样必须同时兼顾新旧帐户,也就是说有多套校验规则来处理,我们的数据校验器需要支持业务规则的动态切换。 这里面可能有一个有争议的地方,校验帐号时需要有具体的业务规则支持,那么这算不算是业务逻辑呢,当然这个校验逻辑并不那么纯粹,软件设计并不是个黑白的二元世界,各种层次的对象混合在一起很正常,我们也不大可能什么东西都能做个分水岭把它们隔离开来。另外,这里的校验逻辑还是和银行应用别的业务逻辑不大一样,比如转帐交易,这个动作的触发是一定要在一个高安全可靠的事务中执行的,而我们的校验帐号过程可能不需要运行在事务中,或者只运行在低安全可靠级别的事务中即可。这是有本质区别的,所以把这种掺有业务规则的校验划分到校验逻辑里而不是业务逻辑中是有理由的。

隔离校验器

针对上述第一类应用场景,我们只需要把数据校验逻辑从其他业务逻辑中剥离出来,将校验逻辑委任到一个单独的校验类中去。把校验职责分离出来后,第一个好处是:一旦我们需要更改校验逻辑,只要修改校验类代码即可,而不用修改其他任何业务逻辑类。第二个好处是:可以集中管理控制所有的数据校验逻辑,提高了代码的内聚性,而且让代码简洁、清晰。当然这里说的所有数据集中控制不一定就是全放在一个类中,如果有必要,也可以将数据按照不同的类型分组,每一个组封装在一个校验类中。第三个好处是可重用性高,校验逻辑封装成了一个工具类,自然可重用性大大提高。

在设计这个隔离校验器类时还有一些需要权衡的地方,在设计某一个数据的校验方法时,比如用户名的校验,如果数据出错了,简单的情况下,我们只需返回一个 boolean 值,告诉用户数据有误。而如果是身份证号码这类数据出错了,可能就需要提供更细粒度的错误类型给用户,告诉用户是与出生日期不符还是位数不够。对这种错误种类较多的情况,我们可以返回错误代号(如 int 值)来区别各种错误,这是非面向对象语言的一种做法,在面向对象中我们可以用一个异常 Exception 来返回错误类型,这比返回错误代号更好,因为错误代号需要解析成具体的错误信息,这个解析工作还得由校验器类的 API 使用者来调,这个使用者是其它的业务逻辑类,这就是说业务逻辑类还是耦合了数据校验错误处理逻辑,显然不如用异常处理来的彻底。

代码如下:

清单 1: UserInfoValidator.java

从上面代码可以看出,我们用了静态 static 方法,因为我们这是个工具类,没有什么状态需要存储,所以不需要实例化类。而且调用校验方法会很频繁,用静态方法可以提升性能。

另外还有一点值得一提,我们封装了一个身份证数据类,里面包含了三个属性:身份证号,出生日期,性别。验证身份证号需要出生日期和性别奇偶码这一点是没有异议的,但为什么不用三个单独的参数呢,这里的封装为以后提供了更大的灵活性,比如将来我们打算将身份证验证逻辑做得更精细,需要判断出生地区的代码是否和身份证的头几位一致,这可能就需要四个参数了,或者我们的出生日期需要换一个类(Date->Calendar)来表示,显然我们只需要修改身份证数据封装类,而不用修改调用接口。

可组装校验器

针对第二类场景,我们对每一个数据类提供一个独立的校验规则类,因为这个数据类本身已经包含了语法和语义逻辑。语法逻辑是与数据结构相关的,在我们的示例中,是判断对象是否是 UML 的 Class 实例。而语义逻辑是与业务规则相关的,每一个数据类关联的业务规则不尽相同,可能来自不同领域,或不同的业务组件或系统;另外由于业务规则的易变性较强,可扩展性和可配置性要求也较高,所以有必要为每一个数据类设置专属的校验类。这里我们将每一个校验类称作一条校验规则 (Rule)。校验规则类的接口和实现代码如下:

清单 2: IVRule.java
清单 3: ServiceVRule.java

在这里,我们还是提供了两种校验结果返回机制,boolean 值和抛出异常,在具体应用时大家可以选择一个即可。这里的校验规则实现是关于 Web Service 的,它的语法校验是检查数据是否是 UML 的 Class 对象,而语义校验是检查 Stereotype 是否是 Service,并检查 Stereotype 中是否含有 WSDL(Web service description language) 属性。

接下来,怎么应用这些校验规则 rule 呢?一个系统中会有很多校验规则类,那么就需要一个管理机制来管理这些校验类,最简单的我们只需定义一个方法 validate(Object value, IVRule rule),提供一层简单的封装,它可以起到一个代理的作用,比如,应用 Proxy 模式,我们可以对校验规则本身做一些安全性认证方面的工作,然后才决定是否可以用该校验规则。而更完善的管理机制是提供一个更灵活的环境,让用户可以动态组装,改变,查找校验规则类。基本思路是:我们将校验规则类当作一种可重用资源,提供一个组装工厂环境,用户可以将校验规则 rule 注册到工厂里,工厂会实例化和缓存这些类,并提供查找服务;然后提供一个校验器来从工厂里查找出相应的校验规则 rule 类,为用户提供校验服务。这里面用到了 Factory,Flyweight,Registry 模式。(本文引用的模式请参考相关模式)

第一步,我们设计一个组装工厂类,它提供实例化、缓存、和查找服务,很显然,实例化类是一个 Factory 模式的基本职责,将实例缓存 cache 是一个 Flyweight 模式的基本职责,查找服务可以很简单,在我们的例子中就是从一个 Map 中取出校验规则 rule 实例,也可以复杂化,比如我们的校验规则类是一个远程资源,或者是实例化这个类需要用到其它的远程资源,如数据库,那这个查找功能实现起来可能就复杂些,可以通过 JNDI 来查找,也可以将远程资源暴露成服务(Web Service)并注册到 UDDI (Universal Discover Description and Integration),然后从 UDDI 中查找服务。从这里我们可以看到这个组装工厂类具备管理校验规则 rule 类的整个生命周期的职责,这为校验器应用提供了很大的灵活性和可扩展性,假如我们今后需要实现一个实例池或资源池 Pool 来管理这些校验规则实例,那么只需要将组装工厂类的功能稍作修改和扩展就可,而不必触及校验器应用的其他类,因为我们已经将实例的管理逻辑从整个校验器应用中剥离出来,管理职责的变化只局限在组装工厂类内,对别的类是封闭的,这正体现面向对象的基本设计原则之一 Open-Close 原则(对修改封闭,对扩展开放)。组装工厂类的代码如下:

清单 4: VRuleAssemblerFactory.java

从上面的代码可以看到,我们用一个 Map 作为缓存库,注册一个校验规则时以字符串作为关键字,当然也可以用别的自定义类型,在查询时利用了 Map 的查找功能很简单高效地实现了查找功能,另外我们还定义了一个批量载入校验规则的方法,这个功能是为了用户使用方便,在应用系统初始化时执行一次,而且可以透明地载入校验规则,在本例中只是硬编码了这些校验规则类,需要的话我们可以从别的元数据文件中 (XML 或 CSV 文件 ) 导入。

还需注意一点的是,这个组装工厂是一个全局类,在这里是以静态 static 方式实现的,当然也可以以 Singleton 方式来实现,还可以以线程安全 ThreadLocal 的方式来实现。

第二步,我们设计校验器类,校验器类应该作为整个校验器应用的 Façade,用户需要校验数据时只需和它打交道,这就很好的把校验规则类隐藏了起来,因此校验器的职责有查找相应的校验规则类和执行校验。校验器类的接口和实现代码如下:

清单 5: IValidator.java
清单 6: AssemblyValidator.java

从上面代码可以看到,我们使用了 Singleton 模式,因为没必要每次都实例化校验器类。另外我们在这里提供了两套返回机制,还有两套取校验规则的方式,这都可以根据实际应用作出取舍。

可组装校验器的架构图如下:

图 1:可组装校验器的架构图

动态策略校验器

针对第三类场景,我们必须支持一种数据对应有多套业务校验规则,我们可以把每种业务规则都建模成一个校验规则类,但这样做灵活性和可扩展性就很差了。如果我们对一种数据只用一个校验规则类,而将多套业务规则建模成多种策略,在校验规则类中应用这些策略,这样做好处在于:一可以对用户隐藏业务规则,二是将来对策略进行修改或增加新的策略都不需要更改用户的调用接口,三是我们可以在运行时动态地改变业务规则-策略。要实现上述需求,我们只需要在可组装校验器架构的基础上,对校验规则 rule 类引入 Strategy 模式。首先我们设计策略类,对于银行并购这个例子,对帐号的校验可以分成以下几步:校验银行代号,地区代号,网点代号,用户代号,因此在这里我们先根据各个银行的业务策略对帐号进行分割,比如 19 位的帐号分成 4 位银行代号,4 位地区代号,4 位网点代号,和 7 位用户代号,然后再执行上述几步校验。业务策略类的接口和实现代码如下:

清单 7:IAccountStrategy.java
清单 8:BankAAccountStrategy.java

接下来我们设计校验规则类,该类主要有选择策略和使用策略来校验两种职责,代码如下:

清单 9:

在这里我们再一次用到了 Registry 模式,可以看到这个校验规则类具有管理和缓存策略类的职责。当然我们还可以在该类中增加一个方法 regiesterStrategy() 用来在运行时动态地增加业务规则策略,但目前我们的应用没有这么复杂的需求,就算将来有也很容易重构目前的架构,所以这个设计活动应该点到为止。这也是设计中的一个权衡点,设计是没有绝对完美的,人们在追逐绝对完美设计的过程中经常把对未来的种种揣测当作真正的需求,结果只能是危及整个设计,导致代码臃肿,难以维护,僵化,灵活性差。适度的设计才是完美的。

动态策略校验器的架构图如下:

图 2:可组装校验器的架构图

在本例中,我们并不是从整合遗留资产的角度出发的,在实际的例子中,银行 A 和银行 B 可能都已存在各自的校验类,这些类的接口不会是一致的,而且返回类型可能是 boolean 也可能是异常,甚至银行 A 是 Java 应用而银行 B 是 C 应用,这样的话我们必须将这些遗留应用中已有的校验类适配成现在的接口,这里可以对具体的 Strategy 实现类应用 Adapter 模式。

模式与价值观

模式的三要素-问题,语境,解决方案我们在前面已经论述过了,每个模式都有它自己独特的价值观,那么这组架构模式给我们带来了什么?

首先,它将校验逻辑从应用逻辑中解耦出来,使得应用和校验器可以独立变化。第二,它促进了代码重用,校验器可以用到任何应用逻辑中去,不必局限于一处。如果需要的话,我们甚至可以将整个校验器当作一个横切关注点 (Crosscut concern),应用 AOP(Aspect of Programming)技术,将待校验数据当作 Pointcut,这样在应用的代码中会看不到任何校验代码的痕迹,这就彻底分离出了数据校验逻辑代码。第三,从应用场合来看,隔离校验器主要用在那些数据类型简单而且校验规则简单的数据校验中,可组装校验器用在那些数据类型复杂或校验规则复杂、多变的数据校验中,而动态策略校验器则用在同一个数据的校验就有多种校验规则策略的数据校验中。可以看到,这几种模式是根据待校验数据的粒度大小和业务规则的复杂程度来划分的。

接下来,我们研究一下模式的变体。可组装校验器是这组模式的核心,它有很多变体,其实动态策略校验器就是它的一种变体,其他变体还有复合规则检验器,链式检验器,动态注册检验器等。比如在 XML 校验器 SAX 的实现中,用户可以动态地插入校验 handle,或者我们需要对一个数据依次执行多套校验规则,而不像之前一次只有一个校验规则会被执行。对于这种需求,我们有三种方案可选,第一种是复合规则检验器,利用 Composite 模式来实现校验规则 IVRule 接口,复合的校验规则类中包含一组简单的校验规则类 VRule,当调用复合类的 validate() 方法时,复合类会依次调用所有的简单校验规则类。第二种是链式校验器,利用 Chain of responsibility 模式来实现校验规则 IVRule 接口,前一个校验规则类执行校验后传递到下一个校验规则类,一层层按固定顺序传递下去,每一个校验规则类关注的校验点不一样,这适合于顺序固定的情况。第三种是动态注册校验器,利用 Registry 模式,将校验规则类 VRule 动态地注册到校验器类 Validator 中去,比如注册到一个 List 或 Map 中,在校验器类的 validate() 方法中可以按某种算法来实现调用校验规则类的顺序或更复杂的调用逻辑。很显然动态注册校验器很灵活,可扩展性也很强,但同时对校验器使用者来说,它复杂了,校验规则也不透明了。所以并非灵活性和可扩展性越强就越好,一切应该取决于需求,如果你一次只有一个校验规则执行就没必要再引入复杂性了。

另外还可以对校验方法的返回类型作一下扩展,boolean 值和异常作为返回一般来说足够了,但如果我们的返回结果比较复杂,比如前面讲到的一个数据需要执行多个校验规则的情况,返回的结果可能需要将多个校验规则的返回结果汇总,也可能需要更细级别的结果。这就需要一种工业级的返回机制,在 Eclipse 中就有这么一个返回类型,称为状况对象 (Status object),它可以对返回类型进行分级:OK、 Warning、 Error,甚至还封装了更低级的异常。而且针对返回状态比较复杂的情况,还应用了 Composite 模式实现了一个 MultiStatus 类来组合多个错误状态。这在校验结果不仅仅是 true 或 false 两种状态的场合下非常有用,而且可以记录跟踪校验规则信息。不过这是一种重量级的返回机制。下面展示了状况对象的接口代码:

// 清单 10:IStatus.java

结束语

这组架构模式不局限于某种语言、应用,可以应用到任何场合。如果我们将数据校验当作一项业务操作的话,可以将它扩展到其他领域。模式可以促进好的架构,也可能导致万劫不复,关键取决于设计者的把握。所以,我们在选择模式的时候,一定要考量模式的何种特性对你最有价值,模式所提供的价值观与您的需求期望是否吻合。

相关模式

  • Factory Method:工厂模式用于产生类的实例。
  • Singleton:单例模式用于保证一个类只产生一个实例。
  • Flyweight:享元模式用于缓存和维护一组实例。
  • Composite:复合模式用于组合一组类,而这些类和复合类具有同一接口。
  • Facade:门面模式用于为客户提供统一的外观,隐藏复杂的实现细节。
  • Proxy:代理模式用于代理一个对象,控制其他对象对该对象的访问。
  • Strategy:策略模式用于提供一组可互换的算法 / 策略,它们遵循同一接口。
  • Adapter:适配器模式用于将类的接口适配为另一种接口。
  • Chain of responsibility:职责链模式用于为链式传递处理请求。

以上模式来自《 GOF 设计模式》。

  • Registry:注册器模式用于注册和管理一个或多个对象。(来自《企业架构模式》)

参考文献

  • 《 Design Patterns:Elements of Reusable Object-Oriented software 》-《设计模式:可复用面向对象软件的基础》 ERICH GAMMA RICHARD HELM RALPH JOHNSON JOHN VLISSIDES (GOF)。
  • 《 Patterns of Enterprise Application Architecture 》-《企业架构模式》 Martin Fowler。

上一篇:从Java类库看设计模式(1)

下一篇:界面组装器模式