SOA实现:服务设计原则(二)

日期: 2008-08-03 作者:David Artus 来源:TechTarget中国 英文

  在本地调用时,清单3所示的服务接口可能能够正常工作。不过,如果服务是在远程位置向使用者提供的,则该服务在常见使用场景中的性能可能会很差。例如,在使用服务检索数据来填充显示书的目录项的屏幕时,将有必要进行多个独立的远程调用,以检索书名、作者和出版日期。进行这些调用可能会有很大的性能损失。远程服务应提供粗粒度的操作,以在单个调用中检索关于某本书的所有信息。


  可远程调用的服务的这个设计原则得到了广泛的认可;我们在此处强调此原则的目的在于说明被封装的服务调用细节可能给我们如何选择设计方法带来很大的影响。我们认为,同步调用和异步调用之间的选择也可能对服务接口设计有类似的影响。


  这就引入了一个重要的问题:设计服务时,什么决定了所使用的调用方式?服务设计人员是否可以自由选择本地调用和远程调用、同步调用和异步调用?我们建议 SOA 应对这方面进行说明。我们提出此建议有两个原因。首先,我们希望通过确保一致性提高易用性;编排流程时,服务最好具有可预测的特征。其次,我们希望通过将使用者与提供者分离来提高灵活性。通过鼓励进行远程调用,我们可以进行位置、平台和编程语言分离。通过鼓励进行异步调用,我们可以分离使用者和提供者的可用性特征。


  如果SOA要具有描述性,是否应声明所有服务都应设计为允许远程、异步调用?我们建议对此描述性采用更为细粒度的方法。可能的服务类型包括提供业务相关较多的操作,如PlaceOrder,也包括技术性侧重较多的操作,如CheckUserInRole。SOA完全应该对不同的服务类别进行不同的描述。我们预期将更多地调用与业务相关的操作,而技术操作完全可能采用本地调用的方式。


  服务具有无状态接口


  我们在服务应设计为可重用中提到,应该将服务设计为可伸缩且可部署到高可用性基础结构中。此总体原则的一个推论就是,服务不应为有状态型的。即,它们不应依赖于使用者和提供者间长期存在的关系,操作调用也不应隐式地依赖于前一个调用。为了说明这一点,我们以下面的电话转换为例:


  清单4. 有状态转换


 Q:What is Dave’s account balance?


 A: It’s £320


 Q:What’s his credit limit?


 A:It’s £2,000


  此示例演示了转换的两个有状态方面。第二个问题通过使用“his”引用第一个问题。这个示例中的操作依赖于转换上下文。现在让我们考虑一下所提供的应答。请注意,响应中没有上下文信息。应当只有在询问者知道所询问的问题时,这个应答才有意义。在此示例中,要求使用者维护对话状态,以便解释所得到的应答。这两个有状态关系(连续的调用之间和请求与响应之间的关系)都与SOA服务设计有关。


  首先,我们考虑一下依赖于前一操作建立的上下文的操作。假如这是一个与呼叫中心的交互。只要与同一个操作人员对话,对话就可以有效地结束。但我们假设呼叫被中断了,如下所示:


  清单5. 被中断的有状态转换


    Q:What is Dave’s account balance?
 
 Operator 1: It’s £320
 
  An interruption occurs, and the caller talks to a different operator.


 Q:What’s his credit limit?
 
 Operator 2: Who?
 
  中断导致上下文丢失,因此第二个问题是没有意义的。就这个电话对话而言,我们可以通过重新建立上下文而抵消中断带来的后果:“我在问Dave的银行帐户的信息,您能告诉我他的信用额度吗?”不过,在可扩展服务调用领域,有状态对话通常更为麻烦,在此领域中,重新建立上下文可能在技术上不可行,或者可能带来很大的性能开销。


  通常,构建可伸缩的可靠基础结构与允许有状态交互之间有紧密的关系。创建支持有状态的服务调用的SOA基础结构在技术上可行。可以使用的技术包括:


  ·使用Http Cookie维护会话上下文
  ·使用有状态会话EJB;Bean的句柄在SOAP Header中传递


  不过,我们必须仔细考虑最终基础结构的可伸缩性和可靠性。是否要求使用关联性?即,相同的使用者发出的连续请求是否必须交付到相同的提供者实例?要求使用关联性是一种有状态性与可伸缩性及可靠性冲突的情况。如果基础结构可以随意将请求提交到多个提供者实例中的一个,就可简化负载平衡,而各个提供者实例的可靠性要求就可缓和。


  如果没有关联性需求,且允许基础结构将一个使用者的连续请求交付到不同的提供者实例,则任何会话状态必须对所有提供者实例可用。应用服务器基础结构提供会话复制机制。此类机制可以用于提供会话状态,但使用它们会有性能损失。而且,我们的Web开发经验表明,如果没有可靠的指南,开发人员将可以随意使用会话状态;过度使用HTTP会话通常是导致性能低下的常见原因。请参阅“Performance Analysis for Java Web Sites”(作者:joines、Willenborg和Hygh,第59页—60页,Addison-Wesley ISBN 0201844540)。


  我们强烈建议,服务应设计为可避免维护会话上下文的需求。


  现在,让我们考虑一下对话的其他有状态方面以及请求和响应间的关系。我们是否要采用上面的电话对话方式进行服务设计,依赖会话上下文来解释响应“What is Dave’s credit limit?”——“£320”——然后我们将对SOA基础结构再次进行约束。


  基础结构必须适应各种可能性,如某些使用者无法在临时停机的情况下保留其会话状态。


  我们可以通过将服务设计为在响应中包含合适的关联信息,从而避免会话状态的需求,例如以下的响应:


  清单6. 包含关联信息的对话


 Q: What is Dave’s credit limit?


 A: Dave’s credit limit is £2000


  该响应既标识人员又提供具体的数据。当包装遗留系统时,通常由适配器负责提供此类关联信息。现有同步API完全有可能不提供关联数据。在响应中包含关联信息之所以是很好的做法,有很多原因。首先,它简化了弹性可伸缩解决方案的构造,还能提供很大的诊断帮助,且在不可能向原始请求程序交付错误响应时非常重要。未交付的消息可能放置在错误队列上,每个此类消息的解释都要求使用上下文信息。


  总之,仔细地进行服务设计可以避免对状态对话的需求,从而简化可靠的可伸缩 SOA 基础结构的实现。


  服务应使用状态事务建模


  前面给出了一个总的建议,以避免依赖对话状态,但我们应当记住,有用的计算机系统通常将为有状态的;通常反映了业务对象的生命周期。


  例如,考虑购物中的一个订单的生命周期:创建订单。从用户的角度来看,创建了一个空的购物车。用户将随后向订单添加物品,即将其放入购物车中。最后提交订单,然后订单将被传送给配送部门。图1显示了对此生命周期建模的简化状态转换图。



  图1. 订单生命周期状态转换
 
  该模型说明了一些有状态的行为。例如,我们看到,在处于Open状态时可以向订单添加行式项目,但在提交后就不能了。


  让我们考虑以下 Order 服务的设计。我们可以采用如清单7所示的接口。


  清单7. Order服务设计


 OrderService {
 
  void addLineItemToOrder(int orderId, int productId, int quantity)
  
  void assignOrderToPacker(int packerId)
  
  int createOrder(int customerId) // returns id of new order
  
  int packItemForOrder(int orderId, int quantity) // returns quantity left to ship
  
  boolean shipOrder(int orderId) // returns whether all order is now shipped
  
  void submitOrder(int orderId)
  
  // … query operations elided …


  我们要考虑此接口的易用性。(更现实一些,我们应该考虑那些具有更多方法的完整接口,如用于列出和删除行式项目的方法。)如果没有状态图供参考,即使查看我们的这个小示例,也非常难于分清方法应该按照何种顺序进行调用。因此我们认为,服务设计人员必须进行一定的工作,以简化使用者的任务。我们提供了一些可能的技术。


  首先,考虑操作和参数的名称。我们上面的示例中的名称是经过细心选择的,并进行一定的努力,以推导出方法的可能调用顺序。请比较清单8和清单9中的示例,这两个代码片段几乎完全一样,只是操作和参数的名称不同。


  清单8. 不合理选择的操作和参数名称


 ZettuylService {
  int wibble(int wibId, int wobId, String which);
  int wobble(int quibId);
  boolean wrubble(int wibId);
  void quibble(int widId)
  void quash(int wibId)
  Stuff[] getStuff(int wibId );
  void  quite(int wibId);
  Things[] getThings(int wibId);
  void hinge(int wibId, intwobId);
  int henge(int wibId , Stuff someStuff)
  }
 
  清单9. 合理选择的操作和参数名称


 ExpenseService {
  int approveClaimItem(int claimid, int itemid, String comment);
  int createClaim(String userId);
  boolean auditClaim(int claimid);
  void approveClaim(claimid)
  void returnClaim(claimid)
  ClaimItemDetails[] getClaimItems(int claimid );
  void  payClaim(int claimid);
  ClaimErrors[] validateClaim(int claimid);
  void removeClaimItem(int claimid, int itemid);
  int addClaimItem(int claimid, ClaimItemDetails details)
 }
 
  清单8中的名称都是难以理解的。而清单9中选择的名称说明了服务的目的,并可以减少很多操作应按特定顺序调用的情况。例如,createClaim()将在approveClaim()前使用,而后者又将在payClaim()前使用。因此,正如我们在前面的命名服务时应以最大化易用性为目标所指出的,名称的选择对易用性影响非常大。


  其次,前面的Order的状态转换图可清楚说明订单的有状态行为。该图提供了有用的说明信息,显示了订单的状态以及每个状态中相应的操作。


  增加易用性的第二项技术是,要记住并非所有记录的值都可以通过服务接口定义实现最佳的交付。记录良好的WSDL文件很有价值,但一起提供的关系图和示例也有很大的价值。


  增加易用性的另一项技术是,创建反映业务对象生命周期的状态的服务接口。在我们费用申领示例中,每笔费用申领的生命周期都包含四个状态,如图2中所示。



  图2. expense对象的四个状态
 
  这些状态之所以重要,有两个原因。第一,每个状态基本上都与不同的系统用户相关。例如,当费用申领处于构建(building)状态时,主要系统用户是输入费用申领详细信息的申领人,而在审核(auditing)状态中,则是由具有批准权利的人对费用申领进行检查。


  第二,主要状态之间的转换通常反映了不同IT系统之间的数据流。例如,在构建(building)状态期间,可以在用户的工作站上使用一个瘦客户端应用程序来捕获费用申领。提交后,费用申领将传递给费用申领处理系统,而当得到批准后,费用申领将传递给另外一个系统,即支付系统。在传递过程中,我们需要提到的是,如果实现的确要将数据从一个系统传递到另一个系统,则需要尤为注意负责在系统之间进行传输的操作(在我们的示例中为submitClaim()和approveClaim())。它们的实现将需要对两个系统进行更新,而这样很容易丧失两个系统中任意一个的可用性。这些方法的实现将可以通过使用异步排队机制得到改善。


  由于业务对象状态常常能同时反映业务和技术两方面的内容,因此完全可以将原始ExpenseClaimService拆分为适应每个状态的多个服务。我们可以得到如清单10中所示的服务。


  清单10. 根据状态划分服务


 ClaimEntryService { 
  createClaim(String userId); 
  ClaimItemDetails[] getClaimItems(int );.
  ClaimErrors[] validateClaim(int claimid);
  void removeClaimItem(int claimid, int itemid);
  int addClaimItem(int claimid, ClaimItemDetails details)
  int submitClaim(int claimid);
 }
 
 ClaimApprovalService {
  int approveClaimItem(int claimid, int itemid, String comment);
  void approveClaim(claimid)
  void returnClaim(claimid)
  ClaimItemDetails[] getClaimItems(int );.
  ClaimErrors[] validateClaim(int claimid);
 }


 ClaimPaymentService {
  void  payClaim(int claimid);
 }
 
  通过这种方式,能更方便地理解每个服务。而且,将接口这样划分将可能非常适合服务(或服务集)的开发、部署、维护和使用方式。这些服务很可能针对不同的使用者,可以由独立的开发团队进行开发,可以分开部署,因而具有分离的版本周期。换句话说,通过将重点放在对象生命周期上,我们就可以建立具有恰当粒度的服务。


  服务操作设计原则


  前面我们讨论了服务的总体设计方面的问题,接下来就要讨论一下各个服务操作的设计了。


  操作表示业务动作。


  我们已经指出,总的原则是,我们应该优先对服务和操作使用业务领域的名称,使用动词作为操作名称。对于操作,我们将这个建议进一步深化:应当使用具体的业务含义而不是泛型操作对操作进行定义。例如,不要使用泛泛的updateCustomerDetails操作,而要创建ChangeCustomerAddress、RecordCustomerMarriage和AddalternativeCustomerContactNumber之类的操作。此方法具有以下好处:


  ·操作与具体业务场景对应。此类场景可能不仅是简单的更新数据库中的记录。例如,更改地址或婚姻状况可以要求生成正式的文档,而将要求系统记录该文档的详细信息——或扫描版本。如果使用不太具体的操作(如updateCustomerDetails),则较难实现此类业务场景。
  ·各个操作接口将非常简单,且易于理解,从而提高了易用性。
  ·每个操作的更新单元得到了清楚的定义(在我们的示例中为地址、婚姻状况和电话号码)。在实现具有高并发性要求的系统时,我们可以基于操作的要求采用更细粒度的锁定策略,从而减少资源争用。


  操作应采用粗粒度参数


  在讨论操作参数时,同样要面对粒度的问题。请比较清单11和清单12中所示的createNewCustomer操作的两个接口。


  清单11. 采用细粒度参数的createNewCustomer操作接口


 int createNewCustomer(String familyName,
  String givenName,
  String initials,
  int age
  String address1
  String address2
  String postcode
  // …
 )
 
  清单12. 采用单个粗粒度参数的createNewCustomer操作接口


 int createNewCustomer( CustomerDetails newDetails)
 
  清单11显示了一个具有很多细粒度参数的操作。而在清单12中的操作则采用结构化类型作为单个粗粒度参数。我们之所以建议使用粗粒度参数,有两个原因。首先,它们提供了创建灵活操作的机会,支持在不干扰现有使用者的情况下提供新版本的操作。其次,具有大量类型相似的参数的操作易于在从第三代语言代码进行调用时出现转换错误。相反,当数据放置在所使用的结构化类型的显式方法(如setGivenName()和setInitials())中时,此方法出错的几率更小。


  操作设计应考虑并发性


  传统的事务型编程模型(如Entity Enterprise Java Bean(Entity Bean)所支持的编程模型)允许实现数据库更新,因此其数据库锁定方式如清单13中所示。


  清单13. 事务型编程模型


  Begin Transaction
  
  Retrieve data from database – locking record
  
  Modify values
  
  update database record with modified values
  
  Commit Transaction – unlocking record


  请注意,数据库锁定从第1行检索时一直保持到第5行的提交操作。这样以一定的延迟确保了正确的并发行为。如果我们希望设计一个提供数据库更新功能的服务,则可以提供与清单8中的第2行和第4行的检索和写入操作对应的操作。不过,我们强烈建议,不要在高度分离且可能异步的SOA基础结构中的连续调用间保持锁定。我们建议采用乐观锁定策略,将并发控制的责任委派给相应的应用程序逻辑。


  乐观锁定策略中的更新请求可以解释为“以下是基于记录XYZ的V版本的一些记录更新。请仅在从我读取该记录后没有人进行修改的情况下进行更改。”


  以下是为清单12所示的同一个模型使用了数据库触发器和修订计数器的乐观锁定实现。该实现要求执行以下步骤:


  ·向要使用乐观锁定的表中添加一个额外的整数列;该列保存修订计数器。
  ·向数据库添加触发器,以便对该表中的记录的每个更新都会导致修订计数器递增。
  ·所有检索操作均会返回包含修订计数器的数据项。
  ·所有更新操作都必须包含从检索获得的修订计数器。
  ·更新操作实现必须对数据库记录进行限定的更新操作,如“如果修订计数器等于…则更新记录…”如果其间对记录进行了任何修改,此更新操作将失败——在其间,如果出现了更新,则会触发更新触发器,因此会修改修订计数器。
  ·如果由于其他使用者在其间进行了更新而导致更新失败,则将向使用者报告一个特定的错误。


  请注意,此实现在更新时要求使用者提供正确的修订计数器;进行纠正的责任分散到数据库、提供者和使用者三者身上。另外,还请注意,此实现设计真的非常乐观;如果争用的概率很低,就能很好地工作。如果可能出现更新冲突,则重试的性能开销将非常大。另外,还需要一些其他可能的乐观锁定策略和详细设计,以制定合适的并发方案。


  考虑到管理并发更新的相对复杂性,我们提出一个相关的建议:尽可能使用无状态语义。例如,与实现等效的“Retrieve record”-“Write record”两个操作(使用者会在检索和写入操作间使值递增)相比,可能实现具有良好并发行为的单个操作“Increment balance by X”更为容易。


  结束语


  本文的主要目的是强调在面向服务的体系结构中服务设计的重要性。我们并不认为文中包含了全部设计原则。而是希望通过这些原则能够说明,每个SOA都需要慎重地为其企业确定一组恰当的原则,并随后确定每个服务创建人员都能遵循这些原则。


  关于作者


  David Artus是IBM Software Services for WebSphere团队的成员,在英国IBM Hursley Lab工作。他从1999年就开始提供WebSphere咨询和指导服务。在1999年加入IBM之前,David曾从事过多个行业的工作,包括投资金融、旅游和IT咨询。他所感兴趣的领域包括分布式系统的设计、对象技术和面向服务的体系结构。

我们一直都在努力坚持原创.......请不要一声不吭,就悄悄拿走。

我原创,你原创,我们的内容世界才会更加精彩!

【所有原创内容版权均属TechTarget,欢迎大家转发分享。但未经授权,严禁任何媒体(平面媒体、网络媒体、自媒体等)以及微信公众号复制、转载、摘编或以其他方式进行使用。】

微信公众号

TechTarget微信公众号二维码

TechTarget

官方微博

TechTarget中国官方微博二维码

TechTarget中国

相关推荐