查看原文
其他

数字化转型:服务化设计原则

钟华 技术琐话 2021-08-09

在设计服务中心的过程中,对服务中心内服务接口和数据模型的设计非常重要,良好的设计原则和方法可以最大化地保障服务中心的可扩展性。


强烈建议读者学习著名建模专家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;

}


(6)隔离变化原则

当服务中心核心领域模型的对象进入前台应用中,要避免服务中心内部的重构或者模型变更导致前台应用也跟着变化。

比如前面描述的“文档服务”,其中Article对象在服务中心内部可能作为核心建模的领域模型,甚至作为对象和数据库映射(O/R mapping)等。如果文档服务给服务消费者直接返回Article,即使没有前面所说的冗余字段、复杂类型等问题,也可能让服务外部用户与服务内部系统的核心领域模型产生一定的关联,甚至可能与O/R mapping机制、数据表结构等产生关联,这样一来,内部的重构很可能影响到服务外部的用户。

同样,可采用外观模式和DTO作为中介者和缓冲带,隔离内外系统,把内部系统变化对外部的冲击降到最低。


(7)契约包装

虽然使用了DTO和外观模式将服务生产端的变化与服务消费端进行了隔离,但DTO和外观模式可能被服务消费端的程序到处引用,这样消费端程序就较强地耦合在服务契约上了。一旦契约更改,或者消费端要选择完全不同的服务提供方(有不同的契约),修改时工作量可能就非常大了。在较理想的面向服务设计中,可以考虑包装远程服务访问逻辑,也称为服务代理(Delegate Service)模式,由消费端自己主导定义接口和参数类型,并将服务调用转发给真正的服务客户端,从而让服务使用者完全屏蔽服务契约。

服务代理示例如下:

//ArticlesService是消费端自定义的接口

class ArticlesServiceDelegate implements ArticlesService {

    //假设是某种自动生成的service客户端stub类

    private ArticleFacadeStub stub;


    public void deleteArticles(List<Long> ids) {

        stub.deleteArticles(ids);

    }

}

在此示例的前台应用中,所有有关文档服务调用的地方引用的都是ArticlesService,而不是“文档服务”提供的ArticleFacadeStub,这样就算服务提供端的ArticleFacadeStub发生了变更或者重构,也只需要在ArticlesService类中进行相应的调整,而无须更改更多的代码。


(8)服务无状态原则

为了保证服务中心的服务稳定性以及可扩展性,必须将服务设计为可伸缩的且可部署到高可用的基础结构中。此重要原则的一个推论就是,服务不应为“有状态型”的。即服务不应依赖于服务使用者和服务生产者之间长期存在的关系,服务调用也不应显式或隐式地依赖于前一次调用。为了说明这一点,我们举一个简单的例子,下面是一个电话对话:

问:小明的账号余额是多少?

  答:320元。

问:他的信用额度是多少?

  答:2000元。

此示例演示了典型的有状态模式。第二个问题通过使用“他的”引用第一个问题。这个示例中的操作依赖于转换上下文。现在让我们考虑一下所提供的应答,请注意,回答中没有上下文信息。只有在被询问者知道所询问的问题时,这个回答才有意义。在此示例中,要求使用者维护对话状态,以便解释所得到的应答。

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

问:小明的账号余额是多少?  

  话务员1:320元。


此时通话中断,被转接到另一个话务员:

问:他的信用额度是多少?

  话务员2:谁?

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

是否要求使用关联性。即相同的服务使用者发出的连续请求是否必须交付到相同的服务提供者实例,要求使用关联性是一种有状态性与可伸缩性及可靠性冲突的情况。为了保持服务中心各服务能力的服务质量,我们必须优先考虑最终服务架构的可伸缩性和可靠性。所以笔者强烈建议,将服务设计为可避免维护会话上下文的需求。

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

问:小明的信用额度是多少?

  答:小明的信用额度是2000元。

在响应中包含关联信息是很好的做法,原因很多。首先,它简化了可伸缩解决方案的构造,还能提供更多的诊断帮助,且在不可能向原始请求程序交付错误响应时非常重要。总之,仔细地进行服务设计可以避免对状态的需求,从而简化可靠的、可伸缩服务结构的实现。


(9)服务命名原则

我们在选择服务、操作、数据类型和参数的名称时有一个指导原则:希望最大化服务的易用性。我们希望帮助业务应用开发人员标识实现业务流程所需的服务和操作,因此,强烈建议对服务使用者定义专业领域内有意义的名称,优先选用业务概念而不是技术概念。

建议就是:应使用名词对服务进行命名,使用动词对操作进行命名。例如,以下是使用动词短语和IT构造的服务定义:

ManageCustomerData { 

    insertCustomerRecord();

    updateCustomerRecord();

    //etc ... }

接下来是使用名词和动词短语及业务概念的服务定义:

CustomerService {

    createNewCustomer();

    changeCustomerAddress();

    correctCustomerAddress();

    // etc ... }

比较明显,第二个示例的易用性更好一些。在第二个示例中,服务的业务用途非常清楚,而不仅仅指示其输出。因此,建议不要使用“update-CustomerRecord”(可以为出于任何原因进行的任何更新),而使用“enable-OverdraftFacility(启用透支能力)”。与此类似,在客户搬迁时,我们使用“changeCustomerAddress”方法更改客户地址;而在希望更正无效数据时使用“correctCustomerAddress”更正客户地址,因为这样很容易看出这两个操作采用了不同的服务逻辑。


(10)服务操作设计原则

这是对于服务操作命名设计原则的进一步深化:应当使用具体的业务含义而不是泛型操作对操作进行定义。例如,不要使用泛泛的update-CustomerDetails操作,而要创建changeCustomerAddress、recordCustomer-Marriage和addAlternativeCustomerContactNumber之类的操作。此方法具有以下好处:

操作与具体业务场景对应。此类场景可能不仅是简单地更新数据库中的记录。例如,更改地址或婚姻状况可能需要更改其他业务模块中的相关信息,比如婚姻状况的修改可能会引起会员权益的改变。如果使用不太具体的操作(如UpdateCustomerDetails),则不适合实现此类业务场景。

各个操作接口将非常简单,且易于理解,从而提高易用性。

每个操作的更新单元有清楚的定义(在我们的示例中为地址、婚姻状况和电话号码)。在实现具有高并发性要求的系统时,我们可以基于操作的要求采用更细粒度的锁定策略,从而减少资源争用。

针对操作中参数的设计,应采用粗粒度和灵活性强的参数,目的是尽量减少因为需求变更带来的参数结构变化。以CreateNewCustomer操作的两个接口为例:

采用细粒度参数的CreateNewCustomer操作接口如下:

int CreateNewCustomer(String familyName,String givenName,

    String initials, int age,String address1,

    String address2, String postcode    // ...  )

采用单个粗粒度参数的CreateNewCustomer操作接口如下:

int CreateNewCustomer( CustomerDetails newDetails)

以上两段示例代码显示了一个具有很多细粒度参数的操作和采用结构化类型作为单个粗粒度参数的操作。之所以建议使用粗粒度参数,是因为这样能够在很大程度上避免因为细粒度参数变化带来服务整体版本升级。

从参数灵活性的角度看,要考虑服务需求的多样性和灵活性。比如,在查询商品信息时,商品定义的字段很多,不同的业务关注的字段不一样,所以在定义接口时,可通过传入业务方需要返回的商品的字段,将这些字段保存在List对象中,服务获取对应字段的值后封装成对应的Map对象返回。这样通过一个商品查询的操作方法就能满足不同应用系统对商品字段的信息获取需求。


(11)重要的服务不能依赖非重要的服务

中台建设是以服务为中心,即整个体系间的交互均以服务的形式进行。不仅前台应用和中台的各服务中心会以服务的方式进行交互,而且各服务中心之间也会这样交互。在有些情况下,前台应用在业务复杂度发展到一定程度后,也会建立起在该应用系统内部的服务体系。比如,天猫和淘宝这样的业务前端应用已经非常复杂,其内部就构建起了一个多层的服务体系。业务中台的各服务中心为这个服务体系的最下层,之上的各前端业务系统中又会按照自身业务的特点建立起自己的服务层级。

在整个服务体系中,有交易、商品、订单相关等这一类非常核心和重要的服务,也有相对不重要的服务,如运费计算或者前端应用中所创建的服务。从服务对业务的影响程度、服务范围就会体现出服务重要性不同,而且服务重要性的不同也直接决定了能得到的支持和保障资源会有差异,从而最终会体现在服务的稳定和可靠性方面。所以越在下层的服务会越稳定,越往上层的服务则不管是稳定性还是业务兼容性方面都不如下层服务。


“重要的服务不能依赖非重要的服务”这一原则可以更加细化,如下所示:

  • 上可依赖下。越上层的服务实现可以依赖下层的服务,也可跨级依赖。

  • 下不可依赖上。下层的服务实现和运行一定不能依赖上层的服务,否则就会出现因为上层服务质量问题和不稳定的表现影响到下层的重要服务,而下层服务的故障将会影响到依赖这一服务的所有平级服务中心和前台应用的情况,会出现严重的“雪崩”效应。

  • 平级可依赖,避免循环依赖。这一原则最典型的体现是业务中台的各服务中心在服务层级中均属于平级,它们均有同级别的服务运营要求,是可以互相依赖的。

  • 高级别不可依赖低级别。业务重要性明显高的服务不能依赖业务重要性低的服务,应做好相应的服务降级,或者通过前台业务隔离这种情况的服务依赖。

总结:简单就是美,过多的原则可能会让整体的设计变得臃肿,在什么情况下采用什么样的原则,需要建立在对业务理解的基础上,而且需要在实践过程中不断练习,从而能更从容地应对服务设计相关的问题。


本文由机械工业出版社独家授权发布,中台圣经——《企业IT架构转型之道》作者钟华新作!《数字化转型的道与术:以平台思维为核心支撑企业战略可持续发展》。


十余年数字化实战经验再升华!开创性提出数字化转型中平台思维的十大要素。来自实践,并能指导实践。系统化介绍数字化转型的思路与方法,以及产业互联网平台的建设思路,为各种业务模式的数字化转型提供高价值参考。

 



加入技术琐话读者群讨论,请在公众号回复关键词:读者群。


 往期推荐 


技术琐话 



以分布式设计、架构、体系思想为基础,兼论研发相关的点点滴滴,不限于代码、质量体系和研发管理。本号由坐馆老司机技术团队维护。


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存