在设计服务中心的过程中,对服务中心内服务接口和数据模型的设计非常重要,良好的设计原则和方法可以最大化地保障服务中心的可扩展性。
强烈建议读者学习著名建模专家Eric Evans最具影响力的著作Domain-Driven Design-Tackling Complexity in the Heart of Software(《领域驱动设计:软件核心复杂性应对之道》)以及Thomas Erl的著作SOA principles of Service Design(《SOA服务设计原则》),在实际的服务中心设计过程中,大多数情况下都可参考这两本书中的原则和方法。本书的目的是从全局的视野介绍如何更好地进行数字化企业的建设,所以不会深入探讨这个技术。
1)Façade(外观)模式。
接下来在介绍服务化设计原则时,会多次出现Facade模式。
外观模式的使用原理如图4-11所示。
外观模式的优点如下:
松散耦合:外观模式使得前台应用与中台服务中心可以进行松散耦合,让服务中心内部的模块能更容易地扩展和维护。
简单易用:外观模式让服务中心的服务更加易用,前台应用不再需要了解服务中心内部的实现,也不需要跟服务中心内部众多的功能模块进行交互,只需跟外观类交互就可以了。
更好地划分访问层次:通过合理使用外观模式,可以更好地划分访问的层次,有些方法是对系统外的,有些方法是系统内部使用的。把需要暴露给外部的功能集中到外观中,这样既方便客户端使用,也很好地隐藏了内部的细节。
2)DTO的使用。
DTO可以将服务中心复杂或易变的数据对象对前台应用屏蔽,让前台具备更好的稳定性。DTO是系统分层设计和服务化架构中经常使用的技术,概念本身也容易理解,如图4-12所示。
业务中台架构的核心是各个业务领域的设计建模以及服务接口的设计,笔者结合业界优秀的设计原则以及自己的实践,将服务接口典型的设计原则整理如下,供各位读者参考。
(1)契约先行
服务间的交互类似于不同组织之间的合作,按照正常逻辑,两个组织之间合作的首要任务就是先签订明确的契约,详细规定双方合作的内容、合作的形式,等等,这样才能对双方形成强有力的约束和保障,同时工作也能够并行不悖,不用相互等待。因此服务化架构中最佳的实践方式也是服务契约先行,即先做服务契约的设计。在进行服务接口设计时需要有业务、产品和技术等不同方面的人员共同参与,并定义出相应的契约,然后再实现具体的代码。
在实际的中台架构设计阶段,当在企业不同的业务部门收集到业务需求,形成产品需求调研文档后,需要从全局的视角对服务中心的服务接口进行统筹设计,即不是按照单一应用场景,如仅从电商或仅从CRM系统的角度,进行服务接口设计。虽然这些前台系统都是按照步骤逐步建设起来的,但服务中心的接口设计首先需要在全局的业务视角下进行规划和设计,有了清晰的接口设计,前台和服务中心就有了清晰而相对稳定的交互边界,就能大大降低后期实现和运营期的协作成本,总体效率更高。
由于服务的用户范围很广,在服务契约公开发布之后就要保证良好的稳定性,不能随便重构,即使升级也要考虑尽可能地向下兼容性。
(2)服务功能内聚
服务功能内聚几乎是任何服务化设计中最基本的要求。要创建功能内聚的服务接口,应该使功能相关的一组操作聚合到一起,同时必须将可能影响到业务正确性的逻辑在对应的服务中提供,而不能依赖服务调用方遵循正确逻辑。比如,用户注册的服务,其中包含了对于用户邮箱格式、用户名称以及密码强度的校验逻辑,虽然这些逻辑在前台应用的Web页面或者App中都进行了相关的校验,但前台应用最终调用用户中心的用户注册服务时,依然要在该服务中实现对这些用户属性的校验工作,而不能寄希望于前台应用做这些校验工作,这样才能避免因为前台应用遗漏校验而导致不合规则的用户能成功进行注册。一个典型的服务功能内聚的例子如
图4-13所示。
(3)服务粗粒度
服务的使用者对特定业务流程的了解一般比不上服务中心内部的人,所以服务的接口设计通常需要粗粒度,一个操作有可能对应一个完整的业务用例或者业务流程,这样既能减少远程调用次数,又能降低学习成本和耦合度。
例如,文档服务要给前台应用提供批量删除文章的支持,已有接口中提供deleteArticle(long id)方法,可以供用户自己做循环调用来实现批量删除文章的目的。此时,服务中心最好提供deleteArticles(Set<Long> ids)方法供前台应用调用,将N次远程调用减少为一次。
再例如,用户下订单的用例,要有一系列操作:
addItem(累计商品)→addTax(计算税)→calculateTotalPrice(计算总价)→ placeOrder (创建订单)
交易中心当然可以将这些服务以单个接口方法的方式提供给前台应用,这样不仅需要前台应用对于订单创建流程和逻辑有更高的要求,而且会增加出现服务调用错误的概率,最好封装一个粗粒度的方法供用户做一次性远程调用,同时也隐藏了内部业务的很多复杂性。服务调用方也从依赖4个方法变成了依赖1个方法,从而大大降低了程序耦合度。
另外,从服务和接口方法的数量角度来看,服务将通常作为测试和发布的单位,如果粒度过粗,将大量操作分组到单个服务中,则可能增加单个服务的使用者,这样就为服务使用者快速找到正确的操作带来了挑战,从而导致服务使用体验不佳。要更改服务,势必需要重新发布整个服务,从而影响较多使用者。
所以要避免服务粒度的两个极端:
提供仅有几个方法的很多服务。
数十或数百个操作均集中在几个服务中。
应考虑多个因素,如可维护性、可操作性和易用性,并进行折中。
还有一种划分服务粒度的方法是,创建反映业务对象生命周期的状态的服务接口。例如,费用申领中,每笔费用申领的生命周期都包含四个状态,如图4-14所示。
由于业务对象状态常常能同时反映业务和技术两方面的内容,因此完全可以将ExpenseClaimService(费用申领服务)拆分为适应每个状态的多个服务:ClaimEntryService(费用构建服务)、ClaimApprovalService(费用审批服务)、ClaimPaymentService(费用支付服务),得到如下所示的服务代码:
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);
}
通过这种方式,能更方便地理解每个服务。而且,将接口这样划分非常适合服务的开发、部署、维护和使用方式。总结来说,通过将划分逻辑放在对象生命周期上,我们就可以建立具有恰当粒度的服务。
(4)消除冗余数据
由于服务的远程调用需要网络开销,特别是在并发量很大的场景下,这样的开销就不是一个可以忽略的因素了。所以在服务的输入参数和返回结果中,要尽量避免携带当前业务场景不需要的冗余字段,来减少序列化和传输的开销。同时,去掉冗余字段也可以简化接口,避免给外部用户带来不必要的困惑。
比如“文档服务”中有个返回文章列表的方法:
List<Article> getArticles(...)
如果业务需求仅仅是要列出文章的标题,那么在返回的文章对象中就要避免携带它的内容等字段。
这里有一个经典解决方案,就是引入前面提到的DTO模式,专门针对前台业务应用定制要传输的数据字段,这里需要添加一个AriticleSummary(文章概要)的额外数据传输对象:
List<ArticleSummary> getArticleSummaries(...)
ArticleSummary能很好地避免服务中心与前台应用间的冗余数据传输。
(5)通用契约
由于服务不假设用户的范围,所以一般要支持不同语言和平台的客户端。但各种语言和平台在功能丰富性上有很大差异,这就决定了服务契约必须取常见语言、平台以及序列化方式的最大公约数,才能保证服务具备广泛兼容性。因此,服务契约中不能有某些语言才具备的高级特性,参数和返回值也必须是被广泛支持的较简单的数据类型(比如不能有对象循环引用)。
例如,原有对象模型如下:
Class Foo {
private Pattern regex;
}
其中,Pattern是Java特有的预编译,可序列化正则表达式(可提高性能),但在没有特定框架支持的情况下,其他开发语言可能识别不了,所以最好采用DTO的方式改成常用的数据类型,如下所示:
Class FooDto {
private String regex;
}



