AOP@Work:用Contract4J进行组件设计-用契约式设计和AspectJ改进

简介:契约式设计(Design by Contract)是切实可行的技术,可以阐明组 件 设计的细节、为客户记录正确的组件用法,并用编程的方式测试组件使用的顺应 性(compliance)。在 AOP@Work 的最后一篇中,Dean Wampler 介绍 了 Contract4J,这是契约式设计的工具,它用 Java ™ 5 标注 (annotation)指定合约,并在运行时用 AspectJ 方面计算合约。在成为 AOP 工具包中新增的一个举足轻重的工具的同时,Contract4J 迎合了面向方面设计 的 新趋势。

假设您刚刚加入一个构建银行应用程序的项目。在浏览代码时,您发现(已 经 简化的)BankAccount 的下面这个接口:

interface BankAccount { float getBalance(); float deposit(float amount); float withdraw(float amount); ...}

上面这个接口虽然简洁,但遗留了许多问题没有回答。deposit() 或 withdraw() 的 amount 参数可以是负数或零么?允许负余额(透支)么?如果 指 定了错误的 amount,deposit() 或 withdrawal() 中会发生什么情况呢?

显然,对于该接口的实现者和使用公开该接口的组件的人来说,能够回答这 些 问题是重要的。一种隐式地指定行为的方法是使用以 JUnit(请参阅 参考资料 ) 编写的单元测试。使用 JUnit 测试,可以用各种合法和不合法的参数调用这些 方 法,并作出有关预期结果行为发生的断言。另一种方法是契约式设计,这是阐明 组件设计细节的一项切实可行的技术。

在 AOP@Work 系列的最后这篇文章中,我将介绍 Contract4J,这是一个基于 AspectJ 的工具,支持契约式设计。我将介绍如何用 Contract4J 隐式地指定组 件行为,为用户记录组件的正确用法,并用编程的方式测试组件使用的顺应性。 在这篇文章最后,我将讨论 Contract4J 如何迎合面向方面设计中正在出现的趋 势。

契约式设计概述

使用契约式设计,可以用可编程表达式指定对于组件输入和返回结果的要求 。 在开发人员和 QA 测试期间,对表达式进行计算,如果测试失败,程序执行立即 终止。程序的终止带有有用的诊断信息,迫使开发人员立即修复 bug。

强制立即终止看起来可能有点麻烦。为什么要放过错误消息还继续运行呢? 虽 然继续运行看起来可能比较有生产效率,但实际上不是的。首先,如果没被强制 要求立即处理 bug,就会推迟修复 bug,这样 bug 就会累积。其次,失败的测 试 应当代表发生了意料之外的事(例如,引用为空),正常的执行不能继续。虽然 可以放入 “意外处理” 代码,但是这反而可能会把实现复杂化,出现永远不会 发生的情况,从而增加代码的复杂性和更多 bug 的风险。

指定组件行为

契约式设计是一种发现和修复代码中逻辑错误的工具。它并不解决其他诸如 性 能、用户输入之类的问题。契约式设计使用三类测试来指定和确保组件的行为:

对组件输入(例如传递给方法的参数)的测试叫做前置条件测试。它们指定 组 件执行请求的操作之前需要满足的条件。客户必须满足这些要求才能使用组件。

后置条件测试 确保组件完成操作的时候结果符合要求,假设前置条件已经满 足。后置条件测试通常用方法返回值的断言来表示。

最后,不变条件测试 断言永远不变的条件。类在方法调用之前和之后必须保 持不变(一旦对象已经构建)。方法在调用之前和之后必须保持不变;字段则在 对象的整个生命周期中保持不变。

注意,在生产部署时可以关闭契约式设计测试,以消除它们的开销。

单元测试和契约式设计

契约式设计比起单元测试有些优势,但是这两种方法是互补的。契约式设计 的 一个主要优势是,它在接口或类本身当中提供了关于预期行为的显式信息。对于 组件开发人员和客户来说,结果实际上就是一种能够用编程方式进行测试的文档 。契约式设计也做了显式的合约定义,而在单元测试中这些更隐式。我喜欢交替 使用这两种技术,特别是在使用难以进行单元测试的技术(例如 EJB2 bean)时 。很快就会看到,Contract4J 添加了强迫使用约束的特性,这比 Junit 测试中 隐式的非正式文档有相当大的优势。

Contract4J 简介

Contract4J 是一个开源的开发人员工具,它用 Java 5 标注(请参阅 参考 资 料)实现契约式设计。在幕后,它用方面在应当执行测试的程序连接点处(例如 ,对方法的调用)插入 “建议”,它还对这些测试的失败进行处理,即终止程 序 执行。

再来看 BankAccount 接口,但是这次使用 Contract4J 标注。注意,我用黑 体把原来的代码突出,并且对有些字符串作了换行,以便更加清晰:

清单 1. 使用 Contract4J 标注的 BankAccount

@Contract@Invar("$this.balance > = 0.0")interface BankAccount { @Post("$return >= 0.0") float getBalance (); @Pre("amount >= 0.0") @Post("$this.balance == $old ($this.balance)+amount     && $return == $this.balance")float deposit(float amount); @Pre("amount >= 0.0 &&    $this.balance -- amount >= 0.0")  @Post("$this.balance == $old($this.balance)-amount     && $return == $this.balance") float withdraw(float amount); ...}

表 1 定义了清单 1 中看到的关键字:

表 1. Contract4J 关键字示例

关键字 定义 $this 要测试的对象 $target 目前仅用于字段不变测试的字段(可以用 $this.field_name 来引用 字段)。未来的使用可能会把 $target 扩展到其他上下文。 $return 方法返回的对象(或基本的值)。只在后置条件测试中有效。 $args[n] 传递给方法的第 n 个 参数,从 0 开始计数。也可以用名称引用参 数 。 $old 括号中的内容的 “旧” 值(在实际执行连接点之前)。只在不变条 件测试和后置条件测试中有效。因为 Java 不要求所有的类都支持 “克隆”, 所 以 Contract4J 无法知道是否可克隆特定对象。所以,$old(…) 中的表达式应 当只包含基本的值或不会改变的对象。否则 “旧” 值在连接点执行的时候可能 会变化,从而产生意料之外的结果。示例表达式包含 $old(“$this.userName”) 和 $old(“$this.calcMin(x,y)”)。Contract4J 文档详细描述了允许的表达式。

BankAccount 合约

根据前一节学到的内容,清单 1 中的标注应当不再神秘。@Contract 标注表 示拥有合约规范的接口(或类)。@Pre、@Post 和 @Invar 标注分别定义前置条 件测试、后置条件测试和不变条件测试。还会注意到清单 1 中的测试是定义成 字 符串的 Java 表达式,结果为 true 或 false。

如果遗漏了测试表达式,Contract4J 会根据上下文使用合理的默认设置。例 如,字段的默认不变条件要求字段不能为空。类似地,默认的方法前置条件要求 所有非基本的输入参数不能为空,默认的方法后置条件要求返回值不能为空。

BankAccount 的接口规范包含一个类范围的不变条件测试,即余额总要大于 或 等于 0(对不起,不允许透支)。getBalance() 方法有一个后置条件,即它必 须 返回大于或等于 0 的值。注意,虽然接口不能定义字段,但不变条件测试和其 他 测试引用了一个隐含的余额字段。不过,因为接口定义了对应的 JavaBean 存取 器方法,所以 Contract4J 推导出字段的存在。注意,getBalance() 的后置条 件 测试看起来可能与类的不变条件测试重复,但它只是部分地测试方法会返回余额 这个假设。

合约还有几个前置条件,要求客户传递给 withdraw() 和 deposit() 的参数 大于或等于 0。withdraw() 有额外的前置条件,要求取款的数额不能超过现有 余 额。最后,withdraw() 和 deposit() 有相似的后置条件要求;返回的值必须等 于新余额,新余额必须等于旧余额减去或加上输入的数额。

通用提示

Contract4J 发行版中的 README(请参阅 下面)更详细地讨论了它的语法, 包括已知的限制和特性。发行版中 Contract4J 自己的单元测试提供了有效和无 效的测试表达式的丰富示例。

也可以在类或方面上编写合约测试,在这些类或方面中可以在构造函数上定 义 测试,在实例字段上定义不变条件。(上面的类不变条件实际上就是一个字段不 变条件的规范!)方法和构造函数也可以有不变条件测试。

由于正如组件用户和子类所看到的,合约会影响离散的执行点,所以 Contract4J 把 public、protected 和包可见的方法当成 “原子的”。这意味 着 可以在方法中临时地违反测试,只要在方法完成的时候满足条件即可。注意,对 于带有测试的其他方法或字段的调用也会触发这些测试,所以要有一些特殊情况 的例外,以防止方面代码中的无限递归之类的问题。而且,Contract4J 目前不 允 许在 private 方法上定义测试,因为外部客户看不到这些方法。对静态方法的 测 试也不支持,因为它们不影响对象的状态。但是,在未来的版本中可能会消除这 两个 “理论上的” 限制。

最后,字段的不变条件测试只是在读写字段之后才进行,以便允许惰性计算 。 类似地,字段的不变条件测试从不在对象构造期间进行,但是它们会在构造完成 之后进行。

Contract4J 的替代

编写契约式设计测试,实际上可以不需要 Contract4J。只要编写自己的方面 (就像在本系列前一篇文章中讨论的那样;请参阅 参考资料)即可。 Contract4J 的优势是没有 AspectJ 经验的开发人员也能使用它;只需要对构建过程做简单 的 修改即可,下面我将讨论这个问题。Contract4J 还提供了非常简洁的定义测试 的 方式,采用熟悉的 Java 构造,不必定义许多额外的 AspectJ “样板文件”。 合 约不仅可以执行,还是用户可见的代码、文档和信息的组成部分,而如果是在分 散的方面中捕获到的,这些信息则会很模糊。

正如前面提到过的,单元测试和契约式设计用不同的方式实现类似的目标。 像 Contract4J 这样的契约式设计工具在单元测试比较分散或比较困难的时候最 有帮助。集成和内置测试,有助于捕获这些经常被更低级的测试忽略的模糊的集 成问题。不论是否使用 Contract4J 进行单元测试,考虑组件的合约都会改进设 计。

Contract4J

在运行时,Contract4J 使用内置的方面建议应该在其中执行测试的连接点。 这些方面中的切入点查找合适的标注。前置条件测试由 before 建议处理,该建 议就在对应的方法执行连接点之前执行。before 建议使用 Apache Jakarta Commons JEXL 解释器把测试字符串中的特殊关键字转换成合适的对象,并计算 生成的表达式,返回 true(通过)或 false(失败)。如果测试失败,就报告 错误消息,指出故障点,同时程序执行中断。

例如,在 清单 1 中,如果调用 withdraw(),那么就在执行方法之前, Contract4J 会用 amount 的输入值计算表达式 amount >= 0。例如,如果 amount = -1.0,那么测试失败,就会报告出带有堆栈信息、指出故障位置的报 告,并且应用程序退出。

同样,后置条件测试大致与 after 建议对应。但是,为了支持 $old 关键字 ,实际上使用的是 around 建议,在该建议中,计算 $old 关键字中的 “子表 达式”,保存结果,执行原来的连接点,然后插入 “旧” 值,再计算完整的测 试表达式。

最后,不变条件测试使用 around 建议,在该建议中,在连接点执行之前和 之后都计算测试,同时具有前面提到过的例外。

调用像 JEXL 这样的解析器确实会增加不小的开销,因为 Contract4J 只设 计为在开发和测试期间使用,而在这两个期间内,开销不是严重的问题。但是, 可能会发现有些频繁执行的代码块不应当拥有测试。

采用 Contract4J

因为 Contract4J 的合约测试是用熟悉的 Java 规范编写的,所以把它采用 到 Java 环境中很简单,包括四个步骤:

1.下载 Contract4J 并解压缩到方便的地方。除非想重新构建它(按照 README 中包含的说明),否则只需要 contract4j5.jar 文件。

2.把 contract4j5.jar 文件的位置添加到构建 CLASSPATH。

3.下载 并安装 AspectJ。

4.从当前 Java 编译器切换到 AspectJ 的 “ajc” 编译器,它也可以编译 Java 代码。AspectJ 的主页上提供了详细信息,发行版自带了 Ant 脚本。或者 ,如果喜欢继续使用现有的 Java 编译器,可以有两个附加选项:

可以在构 建的末尾加入一个 ajc “织入” 步骤,把 contract4j5.jar 中的方面编织进 预编译的类或 JAR 中。

可以在装入时 “织入” 合约,正如 AspectJ 文档中所解释的。

5.现在请开始把 Contract4J 标注添加到源代码中,以定义自己的合约!

定制 Contract4J

在运行时使用属性文件或 API 调用,可以开启或禁止所有测试,即前置条件 测试、后置条件测试或者不变条件测试。正常情况下,对于生产部署,构建时应 当不用 contract4j5.jar,以便不增加运行时开销。

使用 API 调用可以有丰富的定制,包括 “插件钩子”,用来插入自己的 Java 类,实现不同的行为。甚至可以替换 JEXL 表达式解释器。

Contract4J 的主页(请参阅 参考资料)提供了有关 API 、其他定制选项以 及允许的测试表达式、已知的限制和特性方面的丰富文档。也可以在发行版中的 构建 “ant docs” 目标,以生成完整的 Javadocs。

Contract4J 和 AOP

除了是开发人员的有用工具之外,Contract4J 的意义还有两个原因:首先, 它是越来越多的采用方面的 Java 开发工具中的一个,对于开发人员来说或多或 少地是透明的。另一个示例是在本系列前面讨论过的 Glassbox InspecTor。此 外,Spring 框架大量地采用纯 Java 和 AspectJ 方面来支持中间件服务,而 JBoss 也使用纯 Java 方面实现同一目的。请参阅 参考资料,了解关于这三个 项目的更多内容。

其次,Contract4J 使用简单的基于接口的方式进行方面设计。面向方面社区 中的许多人目前都在把基于接口的设计这个概念从对象世界扩展到正在出现的方 面/对象世界中,所以这个主题值得进一步讨论。

定义方面接口

标注通常用来指示代码的元信息。在这个示例中,Contract4J 用标注捕获组 件的合约约束,这些约束已经成为了接口的一个基本组成部分,而不是 “附属 于” 接口的东西。实际上,合约不是通常 AOP 意义上的 “横切”,不属于与 组件的主要问题域 “正交” 的问题域的一部分。在 BankAccount 示例中,帐 户余额允许的值,即帐户对象 “状态” 的一部分,是帐户对象的一个有机部分 ,而不是与帐户对象的正交。

所以,严格来说,契约式设计看起来可能根本不是 AOP 技术的备选方案。但 是,虽然合约本身是 BankAccount 域所必不可少的部分,但这个信息的使用则 是 横切的。Contract4J 的兴趣在于强制用编程的方式实现合约,而 Contract4J 标注的设计目的就是为了支持这个目标。不过,在自动生成单元测 试的工具或 IDE 中,可以方便地利用通过标注公开的合约信息:如果组件使用 不当,就会警告用户。

使用标注,Contract4J 定义了模式形式的协议,即一种接口,用来表达合约 信息。在这一方面,Contract4J 类似于 Observer 模式:标注形式的合约规范 是 “可以观察的”,可以由工具操作。协议用结构化英语的形式表达,例如方 法后置条件可以这么表达:

if @Contract is on a class and @Post is on a method, thenget the @Post expression string andevaluate it, aborting if false.

Contract4J 方面用 AspectJ 实现这一逻辑。方面不要求被测试类的显式信 息,例如它们的名称或方法名称。方面关心的只是标注。所以,方面可以完全保 持通用和可重用。可以把这与许多典型的 AspectJ 方面对比,后者编写时显式 地引用了特定的包和类,从而使重用和演变更加困难。

一个类似的使用标注来承载元信息的示例是,定义来与 Hibernate 和 EJB3 一起用于表达 POJO 中持久性需求的标注集(请参阅 参考资料)。

方面接口的挑战

当然,基于标注的元信息能走多远,依赖于方面设计技术能走多远。就像使 用对象设计时,我们应当预料到创建可重用的、松散耦合的、面向方面的系统和 可重用的、通用的方面库会大量地要求基于接口的技术。接口定义了耦合组件的 适当抽象,却没有公开太多细节。所以,在软件发展的过程中,接口要比底层组 件更稳定。

而就方面来说,它们带来了独特的挑战。从方面的性质来说,方面实际上广 泛地接触到了系统的其余部分,而对象组件则更加 “本地化”。方面还有用新 的、更精细的方式修改对象状态和行为的能力,从而引起了对维护系统完整性、 健壮性、甚至整体理解的担心。

为了解决这些问题,研究人员和实践人员都在探寻方面/对象系统的接口不应 只包含我们已经习惯的方法和状态信息,还应当包含合约信息,合约信息限定方 面允许对其他组件所做的修改(听起来有点熟悉?)例如,方面可能不允许对组 件做状态修改,或者出于性能和安全原因而被限制为不能建议某些 “关键区域 ”。

所以,不是方面切入点直接耦合到组件细节,比如特定的命名规范,方面而 是要耦合到组件实现的接口。组件将用接口向方面公开允许的连接点和状态信息 。方面将只通过公开的接口建议组件。由于接口要比实现它们的组件更稳定,所 以耦合也会变得更稳定,在系统发展的时候,也更能跟得上变化。就像在对象系 统中一样,基于方面的编程可以让设计人员能够更容易地构建健壮的、大型的、 仍然可以重用的方面/对象系统。

结束语

Contract4J 使用 Java 5 标注,以直观的方式使得契约式设计测试的定义变 得更有效更简单。这些测试在测试时自动计算,有助于捕获代码中的逻辑错误。 Contract4J 利用了 AspectJ 的威力,却不要求开发人员是使用 AspectJ 的专 家。

肯承认错误则错已改了一半

AOP@Work:用Contract4J进行组件设计-用契约式设计和AspectJ改进

相关文章:

你感兴趣的文章:

标签云: