事务策略: 客户端编排策略
 

2009-08-17 作者:Mark Richards 来源:IBM

 
本文内容包括:
有时,应用程序的表示层必须处理多个 API 层方法调用之间的协调,以完成单个事务工作单元。在本文中, 事务策略 系列作者 Mark Richards 将讨论客户端编排(Client Orchestration)事务策略,并阐述如何在 Java™ 平台中实现它。

如果您一直在阅读本系列,那么应该知道现在需要一个有效且可靠的事务策略来确保数据的高度一致性和高度完整性,而与您所使用的语言、环境、框架和平台无关。在本文中,我将讨论客户端编排事务策略,而我之前在 “模型和策略概述” 一文中简要介绍了这方面的内容。我的建议仍然是,在应用程序的客户端层必须向 API 层发起一个或多个调用才能完成单个事务工作单元时使用此策略。我将在我的代码示例中使用 EJB 3.0 规范;其概念对于 Spring Framework 和 Java Open Transaction Manager (JOTM) 是相同的。

有时,应用程序是使用细粒度的 API 层编写的,这需要客户端向 API 层发起多个调用才能实现单个逻辑工作单元(LUW)。这可能是因为复杂和多样的客户端请求不能使用粗粒度的 API 模型进行聚合,或者仅仅是由较差的应用程序设计造成的。无论何种原因,当来自客户端的多个 API 层方法调用超过一个合理的范围来重构为单个 API 层调用时,则应该摒弃较为简单的 API 层策略 并采用客户端编排事务策略。

基本结构

在 “API 层策略” 中,我概述了构建事务策略的两条黄金法则:

  • 开始事务的方法被指定为事务所有者
  • 只有事务所有者才能回滚事务。

我再次提到了这些规则,因为它们同样适用于客户端编排事务策略。无论开始事务的方法身处何处,事务所有者都是管理事务和执行提交或回滚的惟一方法,这一点非常重要。

图 1 展示了一个适用于大多数 Java 应用程序的典型逻辑应用层栈:

图 1. 体系结构层次和事务逻辑
体系结构层次和事务逻辑

图 1 中的体系结构实现了客户端编排事务策略。包含事务逻辑的类显示为红色阴影。注意,在此策略中,客户端层和 API 层包含事务逻辑。客户端层控制事务作用域。事务从此处开始、提交和回滚。API 层方法包含一些事务指令,它们指示事务管理程序整合和使用由客户端层开始的事务作用域。业务层和持久层不包含事务逻辑,这意味着这些层不会开始、提交或回滚事务,它们也不包含事务注释,比如说 EJB 3.0 中的 @TransactionAttribute

不要受限于图 1 显示的 4 个层次。您的应用程序体系结构可以拥有更多或更少的层次。您可以将表示层和域层结合在一个 WAR 文件中,或者您的域类可以包含在一个单独的 EAR 文件中。您可以让业务逻辑包含在域类中,并将它们结合为一个层次。这不会对事务策略的运行以及实现造成影响。

这种事务策略非常适合拥有复杂和细粒度 API 层的应用程序。这些应用程序 — 通常称作 chatty — 需要一些对 API 的调用以实现单个 LUW。客户端编排事务策略并没有 API Layer 事务策略那样的单 API 层调用限制:您可以从客户端层向 API 层发起一个调用,或者针对每个 LUW 发起一个调用。但是,从应用程序体系结构的角度来说,这种事务策略的限制大于其他事务策略,因为客户端层必须能够开始一个事务并将它传播给 API 层。这意味着您不能使用 Java Message Service (JMS) 消息传递客户端、Web 服务客户端或者非 Java 客户端。此外,客户端层和 API 层之间的通信协议(如果有)必须支持事务的传播(举例来说,通过 Internet Inter-Orb Protocol [RMI-IIOP] 传输 RMI;参见 参考资料。)

我并不赞成使用细粒度的 API 层 chatty 应用程序体系结构;我认为,如果 您的应用程序是 chatty 式的,并且不能重构,则客户端编排事务策略可能是正确的选择。

策略规则和特性

以下规则和特性适用于客户端编排事务策略:

  • 只有应用程序体系结构的客户端层和 API 层中的方法才应该包含事务逻辑。其他方法、类或组件都不应包含事务逻辑(包括事务注释、程序化事务逻辑和回滚逻辑)。
  • 客户端层方法是惟一负责开始、提交和回滚事务的方法。
  • 开始事务的客户端层方法被称作事务所有者
  • 在大多数情况下,客户端层需要程序化的事务,这表示您必须通过编程获取事务管理程序,并编写开始、提交和回滚逻辑。此规则的例外情况是,管理事务作用域的客户端层中的客户端业务代理由 Spring Framework 托管为 Spring bean。在这种情况下,您可以使用 Spring 提供的声明式事务模型。
  • 由于您不能通过编程传递事务上下文,因此 API 层必须使用声明式事务模型,这表示容器将管理事务。您只需要指定事务属性(没有回滚代码或回滚指令!)。
  • API 层中的所有公共写方法(插入、更新和删除)都应该标记一个 MANDATORY 事务属性,这表示需要事务,但必须在调用方法之前建立事务上下文。与 REQUIRED 属性不同,MANDATORY 属性将不会 开始不存在的事务,而是抛出一个异常,指示需要事务。
  • API 层中的所有公共写方法(插入、更新和删除)都不应该包含回滚逻辑,无论抛出的异常的类型是什么。
  • API 层中的所有公共读方法默认都应该标记一个 SUPPORTS 事务属性。这将确保在作用域的上下文中调用读方法时,它将包含在事务作用域中。否则,它将在没有事务上下文的情况下运行,这需要假设它是在逻辑工作单元(LUW)中惟一调用的方法。我在此处假设读操作(作为 API 层的入口点)不会对数据库调用写操作。
  • 客户端层中的事务上下文将传播给 API 层方法以及在 API 层中调用的所有方法。
  • 如果客户端层向 API 层发起远程调用,则客户端层必须使用支持传播事务上下文的协议和事务管理程序(比如说 RMI-IIOP)。

局限和限制

如前所述,这种事务策略的最大一个限制就是,客户端层必须能够开始一个事务并将它传播给 API 层。这意味着用于在客户端层和 API 层之间进行通信的协议以及客户端的类型在应用程序体系结构中发挥着重要的作用。举例来说,您不能将此策略用于 Web 服务客户端或者 JMS 客户端,同时也不能在客户端层和 API 层之间依赖 HTTP 通信;这两层之间中所使用的协议必须能够支持事务的传播。

API 层策略 不同,此策略的另一个限制是,您不能 “欺骗” 并将它增量式地引入到应用程序体系结构中。对于 API 层事务策略,您在重构过程中在客户端层上开始一个事务并不会引起灾难性后果。在 API 层事务策略中这样做的影响是客户端层将不能对异常采取正确的措施,并且您将不能回滚已经回滚到 API 层中的事务。小问题很多,但并不具有毁灭性。

但是,通过客户端编排事务策略,由于 API 层使用 MANDATORY 事务属性,并且不包含事务回滚逻辑,因此客户端层方法必须开始一个事务。将 API 层方法修改为 REQUIRED 并添加回滚逻辑,这样会带来 “API 层策略” 中的 “局限和限制” 一节中概述的相同问题。此外,在 API 层方法中使用 REQUIRED 意味着事务可以 由 API 层开始,因此这违反了客户端编排事务策略的主要原则。

事务策略实现

客户端编排事务策略的实现相当简单,但由于它涉及体系结构的客户端层和 API 层,因此我将针对两个层的方法展示事务逻辑和策略实现。

回顾 策略规则和特性 一节,在大多数情况下,客户端层都需要使用程序化事务,除非它作为 Spring 托管 bean 在 Spring 上下文中运行。由于我在实现示例中使用的是 EJB3,因此我将展示使用程序化事务的实现。您可以参考 “模型和策略概述” 或者 Spring Framework 文档,了解如何在 Spring 中使用程序化事务(参见 参考资料)。

此外,如果您运行了一个外部客户端,则应该确保事务管理程序支持跨 JVM 传播事务。在我的示例中,我使用 JBoss 4.2.0、EJB 3.0、Java Persistence API (JPA) 和 Hibernate 3.2.3,并运行使用 InnoDB 引擎的 MySQL 5.0.51b。这种环境(特别是 JBoss)支持跨多个 JVM 传播客户端事务(使用 RMI-IIOP)。

我将从读操作开始,因为它们是最早出现的。对于在客户端层发起的数据库读操作,从事务的角度来说您不需要对客户端代码执行任何操作,因为数据库读操作不需要事务(参见 “了解事务陷阱” 中的 了解事务陷阱 侧栏)。但是,通过 API 层事务策略,您会希望将 API 层读操作方法设置为 SUPPORTS,以确保在作用域的上下文中调用读方法时,它将包含在事务作用域中。

清单 1 演示了一个简单的调用读操作的客户端层方法。注意,getTrade() 读方法中不需要事务逻辑:

清单 1. 读操作 — 客户端层
 
				
package com.trading.client;

import javax.naming.InitialContext;
import com.trading.common.TradeData;
import com.trading.common.TradingService;

public class TradingClient {

   public static void main(String[] args) {
      new TradingClient().getTrade();
   }
	
   public void getTrade() {
      try {
         InitialContext ctx = new InitialContext();
         TradingService service = (TradingService)
            ctx.lookup("TradingServiceImpl/remote");
   
         TradeData trade = service.getTrade(11);
         System.out.println(trade);		
      } catch (Exception e) {
         e.printStackTrace();
      }
   }
}

TradingServiceImpl EJB3 无状态会话 bean 中的相应 getTrade() API 层读方法如清单 2 所示:

清单 2. 读操作 — API 层
 
				
package com.trading.server;

import javax.ejb.Stateless;
import javax.ejb.Remote;
import javax.ejb.TransactionAttribute;
import javax.ejb.TransactionAttributeType;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

import com.trading.common.TradeData;
import com.trading.common.TradingService;

@Stateless
@Remote(TradingService.class)
public class TradingServiceImpl implements TradingService {
   @PersistenceContext EntityManager em;
   
   @TransactionAttribute(TransactionAttributeType.SUPPORTS)
   public TradeData getTrade(long tradeId) throws Exception {
      return em.find(TradeData.class, tradeId);
   }		
}

注意,清单 2 中使用了 SUPPORTS 事务属性,这表示如果此方法是独立调用的,那么它将不会开始事务,但如果它是在已有事务中调用的话,则会使用一个已有的事务上下文。

对于数据库更新操作、客户端层,作为事务所有者,负责获取事务管理程序,开始事务,然后提交事务或者根据操作的输出回滚它。通常,您需要使用客户端层中的程序化事务。在 EJB3 中,其实现方法是首先与应用服务器建立一个 InitialContext,然后查找 UserTransaction 的 Java Naming and Directory Interface (JNDI) 名称。对于 JBoss,JNDI 名称是 UserTransaction。您可以参考我编写的事务书籍,获取大多数常用应用服务器的 JNDI 名称清单(参见 参考资料),或者参阅您所使用的应用服务器的文档。建立 UserTransaction 之后,您可以编写 begin() 方法来开始事务,以及 commit() 方法来提交事务,并且 — 如果出现异常 — 编写 rollback() 方法来回滚事务。清单 3 显示了一个客户端层方法的完整源代码,该方法向 API 层发起更新请求,以插入股票交易并更新客户帐户:

清单 3. 更新操作 — 客户端层
 
				
package com.trading.client;

import javax.naming.InitialContext;
import javax.transaction.UserTransaction;
import com.trading.common.AcctData;
import com.trading.common.TradeData;
import com.trading.common.TradingService;

public class TradingClient {

   UserTransaction txn = null;
   
   public static void main(String[] args) {
      new TradingClient().placeTrade();
   }
	
   public void placeTrade() {
      try {
         InitialContext ctx = new InitialContext();
         TradingService service = (TradingService)
            ctx.lookup("TradingServiceImpl/remote");

         TradeData trade = new TradeData();
         trade.setAcctId(1234);
         trade.setAction("BUY");
         trade.setSymbol("AAPL");
         trade.setShares(100);
         trade.setPrice(103.45);
			
         txn = (UserTransaction)ctx.lookup("UserTransaction");
         txn.begin();
         service.insertTrade(trade);
         service.updateAcct(trade);
         txn.commit();
      } catch (Exception e) {
         try {
            txn.rollback();
         } catch (Exception e2) {
            e2.printStackTrace();
         } 
         System.out.println("ERROR: Trade Not Placed");
         e.printStackTrace();
      }
   }
}

由于客户端层中的更新方法始终是客户端编排事务策略中的事务所有者,因此 API 层中的公共方法永远都不应该开始事务。基于此原因,它们必须使用 MANDATORY 事务属性,这表示方法需要事务,但不应该在别处开始它(比如说在客户端层中)。此外,与第二条黄金法则一致,API 层中的更新方法不应包含任何事务回滚逻辑。清单 4 显示了一个完整的 EJB3 无状态会话 bean 示例,它为 清单 3 中的相应的客户端层代码实现了客户端编排事务策略:

清单 4. 更新操作 — API 层
 
				
package com.trading.server;

import javax.ejb.Remote;
import javax.ejb.SessionContext;
import javax.ejb.Stateless;
import javax.ejb.TransactionAttribute;
import javax.ejb.TransactionAttributeType;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

import com.trading.common.AcctData;
import com.trading.common.TradeData;
import com.trading.common.TradingService;

@Stateless
@Remote(TradingService.class)
public class TradingServiceImpl implements TradingService {
   @PersistenceContext EntityManager em;
   
   @TransactionAttribute(TransactionAttributeType.MANDATORY)
   public TradeData insertTrade(TradeData trade) throws Exception { 
      trade.setStage("PLACED");		
      em.persist(trade);
      return trade;
   }

   @TransactionAttribute(TransactionAttributeType.MANDATORY)
   public void updateAcct(TradeData trade) throws Exception {
      AcctData acct = em.find(AcctData.class, trade.getAcctId());
      if (trade.getAction().equals("BUY")) {
         acct.setBalance(acct.getBalance() - (trade.getShares() * trade.getPrice()));
      } else {
         acct.setBalance(acct.getBalance() + (trade.getShares() * trade.getPrice()));
      }
   }
}

从清单 4 中可以看到,如果任何一个更新方法抛出异常,则客户端层方法将负责执行必要的事务回滚。您还可以从清单 4 中看出,采用这种策略,客户端层必须 能够开始和传播事务;否则,您将遇到一个 javax.ejb.EJBTransactionRequiredException,这表示需要事务才能调用更新方法。

结束语

当来自客户端层的大多数请求都需要向 API 层发起多个调用才能完成一个 LUW 时,客户端编排事务策略将非常有用。但是需要注意 — 实现这种策略会对应用程序的体系结构造成一些限制,这主要体现在体系结构能支持哪些类型的客户端,以及客户端层和 API 层之间所使用的通信协议。这种事务策略的另一个缺点是,在客户端层中使用程序化事务始终会存在引发 “程序员错误” 的可能性,更不用说客户端开发人员现在必须学习 Java Transaction API (JTA) 和相应的事务逻辑。

不要尝试在相同应用程序中混合客户端编排策略和 API 层策略,以期解决应用程序体系结构中的所有变化。这是不会起作用的,并且会造成数据库中的数据不一致,以及过度复杂的设计。如果您的客户端不支持事务,但您发现客户端编排事务策略非常合适,那么需要执行一些重构。摆脱这种 “混合” 问题的一种方法是提供一个 “替代 API” 层,它使用 API 层事务策略调用使用客户端编排策略的 API 层。但是,需要记住对这种替代 API 的调用必须是单一的调用(在 API 层事务策略中指定)。从本质上说,您将替代 API 作为 API 层的客户端对待。在此基础上,您可以发起多个 API 层调用,因为事务会在新的替代 API 中生成。

参考资料

学习 获得产品和技术
  • Java Open Transaction Manager:JOTM 是一个独立的开源事务管理器,它实现了 XA 协议并且与 JTA API 兼容。当您使用独立 Tomcat 或 Jetty 容器,或者使用需要事务支持的独立应用程序时,它非常有用。
讨论

火龙果软件/UML软件工程组织致力于提高您的软件工程实践能力,我们不断地吸取业界的宝贵经验,向您提供经过数百家企业验证的有效的工程技术实践经验,同时关注最新的理论进展,帮助您“领跑您所在行业的软件世界”。
资源网站: UML软件工程组织