使用 Spring AOP 和 AspectJ 编排工作流

阅读数:11526 2009 年 6 月 9 日

1. 简介

如果你需要实现一个流式的流程,特别是嵌入的,并且你想让其易于配置、扩展、管理和维护。你是否需要一个功能齐备的 BPM 引擎呢:引擎都有自己的抽象负载,它对于你正在寻找的简单流程编排来说似乎过于笨重了;或者有什么轻量级的替代方案可以使用,让我们不必采用一个功能齐备的 BPM 引擎?本文说明了如何使用面向方面编程(AOP)技术来构建并编排高可配置、可扩展的轻量级嵌入式流程流(process flow)。目前例子是基于 Spring AOP 和 Aspect J 的,其他 AOP 技术也可实现同样的结果。

2. 问题

在我们继续深入讨论之前,首先我们需要更好地理解实际的问题,然后试着把我们对问题的理解与一套可用模式、工具和 / 或技术进行匹配,看看是否能找到一个合适的。我们的问题就是一个流程(process)本身,那么让我们好好理解一下它吧。什么是流程?流程是经过协调的活动的集合,这些活动致使一组目标得到实现。活动(activity)是指令执行的一个单元,它是一个流程的基本组成部分。每个活动操作一部分共享数据(上下文),以实现流程整体目标的一部分。已被实现的流程目标的各部分代表既成的事实(facts),这些事实被用来协调剩余活动的执行。这实质上把流程重新定义为一个在事实集合上进行操作的规则模式,用来协调定义该流程的那些活动的执行。为了让流程协调活动执行,它必须知道如下属性:

  • 活动——定义流程的活动
  • 共享数据 / 上下文——定义共享数据的机制和活动所完成的事实
  • 转移规则——基于已注册的事实,定义前一个活动结束之后跟着是哪个活
  • 执行决策——定义执行转移规则的机制
  • 初始化数据 / 上下文(可选)——由该流程操作的共享数据的初始化状态

下图显示了流程的高层结构:

我们现在可以用如下需求集合来形式化一个流程:

  • 定义把流程装配为一个活动集合的机制
  • 定义各个活动
  • 定义共享数据的占位符
  • 定义在流程范围内的这些活动协调执行的机制
  • 定义转移规则和执行决策机制,根据由活动注册的事实执行转移规则

3. 架构和设计

我们定义架构将从解决头 4 个需求开始:

  • 定义把流程装配为一个活动集合的机制
  • 定义各个活动
  • 定义共享数据的占位符
  • 定义在流程范围内的这些活动协调执行的机制

活动是一个无状态工作者,它应该接收一个包含一些数据(上下文)的 token。活动应该通过读写来操作这一共享的数据 token,同时执行由这个活动所定义的业务逻辑。共享的数据 token 定义了一个流程的执行上下文。

为了坚持前面我们制定的轻量级原则,没有理由不把我们的活动定义为实现了 POJI(Plan Old Java Interfaces)的 POJO(Plain Old Java Objects)。

这里是 Activity 接口的定义,它只有一个process(Object obj)方法,其输入参数代表了一个共享数据(上下文)的占位符。

public interface Activity {
   public void process (Object data);
}

一个共享数据的占位符可以是结构化或非结构化(比如 Map)对象。这完全取决于你。为简单起见,通常我们的 Activity 接口把它定义为java.lang.Object,但是在真实环境中,它或许被表达为某种结构化对象类型,流程的所有参与者都知道这一结构。

流程

因为流程是活动的集合,我们需要定出装配及执行这种集合的机制。

有许多方式可以达成这一目的。其中之一是把所有活动插入到某种类型的有序集合中,按其预先定义好的顺序迭代它调用每个活动。这种方法的可配置性和可扩展性明显很差,因为流程控制和执行的所有方面(aspects)都将被硬编码。

我们还可以用某种非传统的方式来考虑流程:假定流程是一个行为较少的抽象,并没有具体实现。可是,从一个活动过滤器(Activity Filters)链中过滤这种抽象,可以定义这个流程的性质、状态和行为。

假定我们有一个叫做 GenericProcess 的类,其定义了 process(..) 方法:

public class GenericProcess {
   public void process(Object obj){
      System.out.println("executing process");
   }
}

如果我们直接传递输入对象来调用process(..)方法,不会发生什么事情,因为process(..)方法没有做什么事情,上下文的状态将保持非修改状态。但是如果我们试图在调用process(..)之前找到一种方法来引入活动,并让该活动修改这个输入对象,那么process(..)方法仍将保持不变,但是因为输入对象是由活动预先处理过的,流程上下文的整体结果将会改变。

这种把拦截过滤器(Intercepting Filter)应用到目标资源的技术在拦截过滤模式中有很好的文档记录,在今天的企业级应用中被广泛使用。典型的例子是 Servlet 过滤器(Filters)。

拦截过滤器模式用一个过滤器封装了已有应用资源,这个过滤器拦截了请求的接收及响应的传递。一个拦截过滤器可以前置处理(pre-process)或重定向(redirect)应用请求,而且可以后置处理(post-process)或替换(replace)应用响应的内容。拦截过滤器还可以改变顺序,无需改变源代码就可以把一个分离的、可声明部署的服务链加入到现有资源——http://java.sun.com/blueprints/patterns/InterceptingFilter.html

过去,这一架构通常用来解决非功能关注点(concerns),比如安全、事务等等……但是你可以清楚地看到,通过拦截过滤器(代表各个活动)链来装配类流程结构,同样的方法也可以很容易被应用来解决应用的功能特性。

下图显示了对于一个流程(Process)的调用如何被过滤器链拦截,其中每个过滤器都与流程的某个活动(Activity)相关联,这样,实际的目标流程组件没有什么事情可干,使之成为一个空的、可重用的目标对象。改变该过滤器链你就得到了自己一个不同的流程。

唯一留下来要做的事情就是看看有没有一个框架,可以帮助我们以优雅的方式装配类似这样的东西。

基于代理的(Proxy-based)Spring AOP 看起来是理想的选择,因为它给我们提供了简单的结构和最重要的东西——执行机制。它将使我们能够定义一个有序拦截过滤器集合,来代表一个给定流程的各个活动。输入的流程请求将会由这些过滤器所代理,用活动过滤器(Activity Filters)中的行为实现来装饰流程。

这样留给我们的只有下面唯一的需求:

  • 定义转移规则和执行决策机制,根据由活动注册的事实执行转移规则

基于代理的过滤器的美妙之处是转移机制本身。每当我们调用一个目标对象(流程)时,拦截过滤器将一个接一个的被代理机制所调用。这是自动的,而且在每个活动必须被调用的情况下工作得相当好。但是实际并不总是这种情况。早先我们在问题定义中描述之一是:“已经被实现的流程目标的那部分,表示已完成的事实,它被用来协调剩余活动的执行”——这意味着一个活动完成并不是必须转移到另一个活动。在实际的流程中,转移必须严格地基于前一个活动已完成和 / 或未完成的事实。这些事实必须向共享数据占位符注册,这样它们就可以被审核。

完成这一点可以简单到在我们的拦截过滤器中放一个 IF 语句:


public Object invoke(MethodInvocation invocation){
   if (fact(s) exists){
      invoke activity
   }
}

但是这将产生多个问题。在我们研究是些什么问题之前,让我们先搞清楚一件事:在当前的结构中,每个拦截过滤器紧密地与相应的 POJO 活动耦合在一起。正因为如此,我们可以很容易把所有活动逻辑保持在拦截过滤器本身中。唯一能够阻止我们这样做的是我们希望活动保持为 POJO,这意味着在拦截过滤器中的代码将简单地委派给活动回调(Activity callback)。

这意味着如果我们把转移求值逻辑放在活动中,我们将从根儿上把这两个关注点耦合在一起(活动转移罗辑和活动业务罗辑),这将违背分离关注点的基本架构原则并将导致关注点 / 代码耦合(concern/code coupling)。另一个问题则涉及到所有拦截过滤器会重复同一转移逻辑。我们称之为关注点 / 代码扩散(concern/code scattering)。转移逻辑横贯所有拦截过滤器,你可能已经猜到了,AOP 再次成为所选技术。

我们所需做的一切就是写一个 around advice,它将使我们能够拦截对实际过滤器类目标方法的调用,对输入求值,并作出转移决策,要么允许,要么不允许目标方法执行。唯一要告诫的是我们的目标类本身恰恰就是拦截过滤器。因此本质上我们正在试图拦截拦截器。不幸的是 Spring AOP 不能帮上忙,因为它是基于代理的,因此我们的拦截过滤器已经是代理基础架构的一部分了,我们不能代理这个代理(proxy the proxy)。

但是 AOP 最好的特性之一就是它可以有多种不同的风格和实现(例如,基于代理的,字节码编织等等……)。尽管我们不能使用基于代理的 AOP 来代理另一个代理,但是我们使用字节码编织 AOP, 谁也拦不住,它将通过编织(编译时或装载时)我们的转移求值逻辑,来形成我们代理的拦截过滤器,这样就可以保持转移和业务逻辑分离。使用像 AspectJ 这样的框架很容易做到这一点。这样做,我们就给我们的架构引入了第二个 AOP 层,这是非常有意思的实现。我们使用 Spring AOP 来解决功能关注点,比如用活动编排流程;同时,我们又使用 AspectJ 来解决非功能关注点,比如活动导航和转移。

下图记录了被显示为两个 AOP 层的流程流的最终结构,其中功能 AOP 层(Functional AOP Layer)负责从有序的拦截过滤器集合中装配流程,而非功能 AOP 层(Non-Functional AOP Layer)则解决转移控制的问题。

为证明这一架构的工作情况,我们将实现一个简单的用例——购买物品(Purchase Item),它定义了一个简单的流程流。

4. 用例(购买物品)

想象一下你正在在线购物。你已经选择了某项物品,把它放入到购物车中,然后前去结账,给出你的信用卡信息并最终提交购买物品请求(purchase item request)。系统将初始化购买物品流程(Purchase Item process)。

先决条件

流程必须接收包含物品、账单、配送信息的数据

主流程

1. 校验物品数量

2. 获得贷记授权

3. 配送

这个流程当前定义了 3 个活动,如下图所示:

这个图还显示了不受控制(ungoverned)的活动转移。但实际上,如果物品数量不够会发生什么?“获得贷记授权”活动应该执行吗?“配送”应该随之进行吗?

另一个有趣的告诫是:根据条件,贷记授权(credit authorization)可能不会自动完成(授权网络关闭了),而你或客户服务代表不得不直接打电话给贷记公司以获得认证码。一旦该认证码被获得并输入到系统中,这个流程应该从何处重新开始或继续呢?从头来还是直接进入配送环节?我觉得应该进入配送环节,但是怎样才能做到呢?我们怎么才能从中间重新启动该流程,而不用维护和管理许多执行控制呢?

有意思的是,使用 AOP 我们不需要维护执行控制,也不需要维护流程的流向。它是在通过拦截过滤器链时由框架本身来处理的。我们需要做的一切就是提出一种机制,根据注册事实允许或不允许各个过滤器执行。

“校验物品数量”将注册物品数量充足的事实,该事实是“获得贷记授权”的前提。“获得贷记授权”也要注册贷记已授权这一事实,而这又是“配送”活动的前提。存在或缺少事实也将被用来决策何时不执行一个特定活动,这又把我们带回到了“手工贷记审核”场景,怎么才能从中间重新启动流程呢,或者问一个更好的问题: 我们怎样才能重新启动该流程,而不重复该流程上下文中已经执行过的活动?

记得吗,共享的数据 token(上下文)也代表了流程的状态。这一状态包含了该流程登记的所有事实。这些事实被求值以做出转移决策。因此,在“手工贷记审核”场景中,如果我们从最开始重新提交整个流程,我们的转移管理机制,在遇到第一个活动“校验物品数量”之前,会马上意识到物品数数量充足事实(item available fact)已经注册过了,这个活动不应再次重复,因此,它将跳到下一个活动——“贷记审核”。因为贷记已授权事实也已经注册过了(通过某种手工录入方式),它将再次跳到下一个活动“配送”,只允许这一活动执行并完成该流程。

在我们进入实际例子之前,有一个更重要的话题还要讨论一下,那就是活动被定义的顺序。尽管从一开始好像活动的顺序在(这些活动所定义的)流程转移决策中并不起任何作用。

流程中活动的顺序只是代表了流程本身的平衡能力——该策略基于事实的可能性和概率,它们的存在将给下一个活动的执行或不执行创造一个理想的环境。改变活动的顺序永远不应影响整个流程。

实例:

Legend:   d - depends

  p - produces

Process:



  ProcessContext = A(d-1, 2; p-3) -> B (d-1, 3; p-4, 5) -> C(d-4, 5);

按照上面的公式,当流程在给定的 ProcessContext 内开始时,第一个要考虑的活动是 A,在它被调用之前依赖于事实 1 和 2。假定事实 1 和 2 存在于 ProcessContext 中,活动 A 将执行并产生事实 3。在线上的下一个活动是 B,它依赖于事实 1 和 3。我们知道我们的流程在活动 A 执行之前事实 3 发生的可能性和概率非常小。可是在活动 A 被执行之后,事实 3 存在的可能性和概率则相当高,因此活动 B 的顺序是跟在 A 后面。

但是如果我们把活动 B 和 A 的顺序颠倒一下,会有什么变化?

  ProcessContext = B (d-1, 3; p-4, 5) -> A(d-1, 2; p-3) -> C(d-4, 5);

变化不大。当流程被调用时,维护着事实注册表的 ProcessContext 将很快断定已注册的事实不足以允许活动 B 被调用,因此它将跳到下一个活动 A。假定事实 1 和 2 是存在的,对事实进行评估将确定已注册的事实足以允许调用活动 A,等等。活动 C 也将被跳过,因为它缺少由 B 产生的先决条件。如果流程再次与同一个的 ProcessContext 一起被提交,活动 B 将被调用,因为活动 A 在流程前一次调用过程中已经注册了活动 B 所需的事实,满足了 B 执行的前提条件。活动 A 将被跳过,因为 ProcesContext 知道活动 A 已经做了它的工作。活动 C 也会被调用,因为活动 B 已经注册了足够的事实以满足活动 C 的先决条件。

因此,正如你所看到的,变换活动的顺序不会改变流程行为,但可影响流程的自动化特征。

5. 实例

该例子包含了如下制品:

GenericProcess 的实现,正如你所见,它包含的代码没什么意义,事实上它从未会包含任何有意义代码。这个类的唯一目的就是作为应用代表各个活动的拦截过滤器链的目标类。

它相应的 Spring 定义为:

PurchaseItem(购买物品)流程的其他配置包括三部分:

第一部分(第 14 行)——流程装配 AOP 配置,包含把 GenericProcessImpl.execute(..) 方法定义为连接点(Join Point)的 pointcut。你还可以看到我们使用了 bean(purchseItem) pointcut 表达限定我们正在拦截哪个 bean。通过用应用于不同过滤器链的不同 bean 名创建 GenericProcessImpl 的另一个实例,我们可以定义多个流程。它还包含了对实现为 Aopaliance 拦截器的活动过滤器的引用。默认的,过滤器按照从顶置底的顺序排列,然而为了更清楚,我们还可以使用 order 属性。

第二部分(第 30 行)——通过定义 ActivityFilterInterceptor 的三个实例来配置活动拦截器。每个实例将被注入后面定义的相应 POJO 活动 bean 和事实属性。事实属性定义了一个简单的规则机制,允许我们描述一个简单的条件,基于此,下面的活动将被允许或不允许执行。例如:validateItemFilter 定义了 “!VALIDATED_ITEM”事实规则,它将被解释为:如果 VALIDATED_ITEM 事实还未被注册在事实注册表中,则允许调用活动。只要 validatItemActivity 执行了,这一事实将被注册在事实注册表中:如果这一事实还未注册,它将允许这一活动执行;如果事实已经注册,它将在流程与同一执行上下文一起重新提交时保护该活动不会被重复执行。

第三部分(第 47 行)——为我们的流程配置三个 POJO 活动。

ActivityFilterInterceptor——它所做的所有事情就是调用底层 POJO 活动并把该活动返回的事实进行注册(第 53 行),并且可以让 POJO 活动对事实注册表(Fact Registry)或流程的任何其他底层架构组件的地点保持未知(请见下面代码片断)。可是正如我们后面将要看到的,这一拦截器本身的调用是根据每个拦截器配置中所描述的事实规则由 AspectJ advice 所控制的(第二个 AOP 层),从而控制各个活动的执行。

各个 POJO 活动简单地返回它们要注册的所有事实的 String 数组,然后由自己的拦截器将其注册到事实注册表中(见上面代码片断)。

TransitionGovernorAspect——是一个 AspectJ 组件,拦截对每个 Spring AOP 拦截器(代表各个活动)的调用。它是通过使用 Around advise 做到这一点的,在其中它对事实规则和当前事实注册表进行比较,就执行或跳过下面的活动拦截器调用做出决策。可以通过调用它自己的 invocation 对象(ProceedingJoinPoint thisJoinPoint)的 proceed(..) 方法做到这一点,或者调用拦截过滤器的 invocation 对象(MethodInvocation proxyMethodInvocation)的 proceed(..) 方法来做到这一点。

由于它是用 AspectJ aspect 实现的,我们需要在 META-INF/aop.xml 中提供配置(见下面配置片断)。

因为我们要使用装载时 AOP,我们需要在 Spring 配置中注册编织器。我们通过使用 context 名字空间来做到这一点:

此时,我们已经做好测试准备了。正如你所见,测试没有什么特殊的,其步骤是:

  • 获得 ApplicationContext
  • 获得 GenericProcess 对象
  • 创建一个事实注册列表
  • 创建对象(在我们的用例中是 Map), 代表输入数据以及执行上下文
  • 调用 process 方法

由于我们使用的是 AspectJ 装载时编织,因此需要提供 -javaagent 选项作为我们的 VM 参数。

VM 参数是:

  -javaagent:lib/spring-agent.jarspring-agient.jar已经存在于 lib 目录中了。



在执行之后你应该看到类似下面的输出:

正如你从该测试所看到的,初始的事实列表是空的,但是如果你用已有事实填充它,那么流程流将被改变。

试着把给注册表增加事实这行代码的注释去掉。

在你的测试中的把如下代码行注释去掉:

// factRegistry.add("VALIDATED_ITEM"); 你的输出将变为:

6. 结论

该方法说明了怎样使用两层 AOP 来装配、编排并控制流程流(process flow)。第一层是用 Spring AOP 实现的,将流程装配为拦截过滤器链,其中每个过滤器都被注入了相应活动。第二层是用 AspectJ 实现的,提供流程的编排及流控制。通过拦截过滤器链来代理我们的流程,将使我们能够定义和维护流程的流向。而代理机制无需像 BPM 这样单独的引擎的,也提供了执行环境。我们通过使用已有技术(Spring AOP)提供的控制和执行机制做到了这一点。

该方法是轻量级、嵌入式的。它使用已有 Spring 基础架构并建立在流程是已编排的活动集合的前提之上。每个活动是一个 POJO 而且完全不知道任何管理它的底层架构 / 控制器组件。这有几个优点。除了典型的松耦合架构优点外,随着像 OSGi 这样的技术不断的普及和采纳,保持活动和活动调用控制分离,把活动实现为一个 OSGi 服务也成为可能,这使得每个活动都成为独立的单元(部署、更新、卸载等等……)。易于测试是另一个优点。因为活动是 POJO,它们可以在使用它们的应用之外作为 POJO 来测试。他们有定义良好的输入 / 输出契约(它需要的数据以及它预期产生的数据)。你可以单独测试每个活动。

分离控制逻辑(拦截过滤器)和业务逻辑(POJO 活动)将使你能够给流程事实规则接插更加成熟的规则门面(facade),同样,测试转移逻辑也应该不影响由下面活动实现的业务逻辑。

活动是独立的基本组成部分,可以被在一些其它流程中重复使用。例如“贷记审核”活动可以很容易被重用,将其装配在某些其它需要贷记审核的流程上。

7. 参考资料及资源

8. 关于作者

Oleg Zhurakousky 是一个 IT 专家,目前是 SpringSource 的高级顾问,拥有 14 年以上跨多领域的软件工程经验,包括:软件架构及设计、咨询、业务分析和应用开发。九十年代初他开始其职业生涯,从事 COBOL & CICS 方面的工作。自 1999 年以来他已将精力集中在专业 Java 和 Java EE 开发上。2004 年以后,Oleg 着重使用了几个开源技术和平台(特别是 Spring),同时在数个跨行业的项目中工作,比如:通信、银行、法律实施、美国国防部及其它行业。

查看英文原文:Workflow Orchestration Using Spring AOP and AspectJ


感谢张龙对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家加入到InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论