You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

19 KiB

10 | 将模型实现为RESTful API

你好我是徐昊。今天我们来聊聊如何将模型映射为RESTful API。

通过前面九节课的学习你应该已经学会了如何通过几种设计模式避免架构约束对模型的影响第4-6节也学会了几种不同的建模方法以构造业务模型第7-9节。然而我们之前讲的内容主要的着眼点在于如何获得模型以及如何组织代码。接下来就是通过模型获得API从而将模型作为业务能力暴露给其他消费者调用。

在前面的课程中,我们一直都在强调领域驱动设计受到行业热捧的一个原因:寻找到一个在软件系统生命周期内稳固的不变点,由它构成架构、协同与交流的基础,帮助我们更好地应对软件中的不确定性

API作为对外暴露的接口也是需要保持高稳定性的组件。我们希望API最好能像领域模型一样稳定。于是通过领域模型驱动获得API的设计Domain Model Driven API Design就成了一种非常自然的选择。

那么今天我们就来讲一讲怎样构建领域驱动的API设计以及为何我们会选用RESTful API。

什么风格的API适合作为模型API

想要实现领域模型驱动API并不困难但是我们需要选择恰当的API风格要从数据角度而不要从行为角度去构建API以保证构建的API能够和领域模型结合得更加紧密。

从行为角度和数据角度构造API存在什么差异呢让我们通过一个例子来解释它我想你也猜到了。对还是极客时间的例子我们在讲解催化剂方法时展示过它:

如果从行为角度进行API设计那么我们的着眼点就应该从人机交互上入手寻找系统提供了什么服务并针对这些服务进行API设计。

于是在这个例子中我们就应该从“送朋友专栏”“订阅专栏”“用户注册”“阅读内容”“专栏评分”这些交互入手建立对应的API。

我们很自然可以想到为每一个交互定义一个接口从而得到服务的定义以gRPC为例

service GeekTime {
    rpc SendGift(SendGiftRequest) returns SendGiftReply {}
  
    rpc SubscribeColumn(SubscribeColumnRequest) returns SubscribeColumnReply {}
  
    rpc RegisterUser(RegisterUserRequest) returns RegisterUserReply {}
    
    rpc ReadColumn(ReadColumnRequest) returns ReadColumnReply {}
    
    rpc RateColumn(RateColumnRequest) returns RateColumnReply {}     
}

message SendGiftRequest {
...
}

..

这种API风格被称为RPC风格Remote Procedure Call Style。RPC的雏形出现于1960年代末在1980年代逐渐成为分布式系统的默认风格1981年被Bruce Nelson正式命名为Remote Procedure Call。它是我们最熟悉也是最古老的一种远程调用风格。RPC风格的优点是简单直接,提供什么功能都在接口里说清楚。

然而从模型的角度上来讲除非你严格地使用催化剂方法否则RPC风格跟模型之间有所隔阂也就是说结合得不够紧密。主要表现在如下两个方面。

首先,RPC的方法名并不来自于领域对象。已经学过催化剂方法的你应该知道,交互是一种我们将隐藏在领域模型中的业务维度展开的方式,目的是让业务方能够理解不同的业务流程是如何作用于领域模型的。因而实际上,交互是业务维度在领域模型上的补充。所以,我们从交互入手获得的RPC风格API并不是源自领域模型的而是源自其他业务维度的。

其次,RPC风格的API的消息与返回信息通常也与领域模型无关而是围绕着交互给出的具体反馈。比如上面例子中阅读内容这个API接口ReadColumn其返回值ReadColumnReply很大可能是仅仅包含对应篇章的内容而不会包含与之关联的作者信息。

可以看到,在RPC这种风格的API中方法名与领域模型无关消息与返回值也与领域模型无关。因而我们可以说它就不是领域模型驱动的API设计。

当然严格意义来讲我们可以说RPC风格的API是统一语言驱动的API设计。不过经过7-9节的学习你应该已经相信我们能够使用领域模型取代统一语言了于是通过领域模型而不是统一语言驱动API设计便是我们努力的方向。

如果我们从数据角度构建API会得到什么不一样的结果呢这个时候我们就要思考在不同的交互行为中需要分别修改哪些数据以及我们要如何表示这些数据。

以最简单的用户注册为例我们实际上是构造了新的User对象并将其存储到数据库中。而订阅专栏则是针对某个特定的User在其聚合User-Subscription中增加了新的Subscription。

我们永远都可以从另一个角度看待交互行为:在操作结束之后对交互背后的模型带来了什么样的改动。这就是从数据角度去建模行为。也正是从这个角度为我们提供了一种与模型紧密相关的构造API的方法。

该怎么表示这些API我们可以先使用URI表示所需修改的对象然后再说明我们希望这个对象发生什么样的改变。

比如在用户注册的场景中,我们可以使用/users表示系统中所有的用户。那么注册新用户,就是在/users中增加一条新的数据。我们可以使用标准HTTP方法HTTP Method中的POST方法来表示。于是注册用户的API就变成了POST /users

类似的,对于订阅专栏而言,我们可以使用/users/{user_id}/subscriptions表示针对某个用户的User-Subscription聚合。其中{user_id}是可以被替换的模板,我们可以将它替换为具体的用户标识符。比如/users/爱学习的鱼玄机/subscriptions,就表示“爱学习的鱼玄机”的所有订阅。

那么订阅专栏也就是在这个聚合中增加一条数据同样我们可以使用标准HTTP方法中的POST方法来表示。于是订阅专栏的API就变成了POST /users/{user_id}/subscriptions

相应的其他的几个RPC风格API也可以被转化为对应的数据风格API

  • 阅读内容:读取Author-Column-Content聚合。GET /authors/{author_id}/columns/{column_id}/content/{content_id}
  • 送朋友专栏:通过User-Subscription聚合,转让订阅。POST /users/{friend_id}/subscriptions
  • 专栏评分:通过Author-Column聚合修改评分。UPDATE /authors/{author_id}/columns/{column_id}

可以看到通过URI与标准HTTP方法我们就从数据角度构造了与RPC等价的API。但是不同于RPC方法我们的URI是直接从领域模型中转化得来的我们与URI沟通的方式是依赖于标准的HTTP方法的。也就是说我们是从领域模型出发驱动出的API设计。

其实这一点也不奇怪。我们在第7节就已经讲过:模型是从数据变化的角度描述业务,具体的业务流程反而是被隐藏的。那么从数据的角度去构造API自然比从行为的角度出发更符合领域模型的特点。因而我们讲领域模型驱动的API设计需要从数据角度去构建API。

看到这里你可能会有一个疑问。这种操作看上去很像是直接暴露对数据库的访问啊难道这真不是直接暴露两个表User、Subscription让别人直接改吗POST /users和INSERT USERS表看起来很像啊

这里关键的不同在于,我们是在富含知识的模型之上去暴露API的。也就是说,并不是单纯对数据进行读写,而是通过对象间的关系,去触发富含于模型之中的逻辑。

特别是如果你仔细看我们上面讲的行为与数据的对应,就会发现大量的逻辑是通过聚合而不是单独对象实现的订阅专栏、阅读内容等。如果换成数据库词汇就是多表间的逻辑而不是单独一张表去完成的。因而我们并不能将从数据角度构造API等同于直接暴露数据库。

说句题外话又到了题外话时间RPC风格的API从根源上讲是面向过程的编程风格也就是数据中无逻辑要通过过程完成对数据的操作。而数据风格的API则更多是面向对象风格逻辑都富集于对象中对对象的访问就可以触发相应的逻辑。因而这种风格成功的关键与难点,就在于构造富含知识的模型

当年微软的ADO.NET Entity Framework和其之上的ADO.NET Data Service就忽略了聚合才是领域模型的核心而不仅仅是实体做了一个莫名其妙的框架出来。的确简化了直接暴露数据表操作的成本但完全没有降低核心复杂度。

好了言归正传。我们前面描述API的方式比如POST/users你看起来一定不陌生。它非常接近我们熟知的RESTful API风格。是的RESTful API是为数不多的从数据角度去描述API的方法之一。因而我们将它作为实现领域驱动API设计的不二法门。

为什么是不二法门呢第一对于API我们希望它易于被其他系统整合。

所谓的RESTful API是指符合REST架构风格的API设计。而REST架构风格是对互联网架构WWW我们使用的互联网本网的总结与提炼。通过RESTful API我们能够获得相当于互联网的开放性和易于整合性。当然前提是需要满足REST架构属性比如无状态、可缓存等等。

第二则是因为没有什么其它数据风格API可选。比如事件驱动APIEvent Driven API对于单体多层架构而言有点大材小用所付出的成本与收益不成比例。

因而目前我们讲在前云时代RESTful API是我们实现领域驱动API设计的不二法门。

如何将模型映射为RESTful API

那么如何将领域模型映射为RESTful API呢总共分为四步

  1. 通过URI表示领域模型。
  2. 根据URI设计API。
  3. 使用分布式超媒体Distributed Hypermedia设计API中涉及的资源。
  4. 使用得到的API去覆盖业务流程验证API的完整性。

今天我们先来讲解前两步,后两步则留待下节课再讲。

通过URI表示领域模型

我们先来看如何通过URI表示领域模型。我们都知道除去scheme、host声明等信息URI主要通过路径Path指示某个资源。而路径可以看作是对某种层次结构遍历的结果。

比如URI中有这样的一个路径“/users/爱学习的鱼玄机/subscriptions”那么我们可以认为它实际是下图所示的图结构中对右侧分支遍历的结果

那么同样地,如果是对左侧分支遍历,就能得到另外一个路径“/users/会聊骚的黄庭坚/subscriptions”。

仔细看这个图,我们会发现它是将User-Subscriptions聚合进行实例化之后,再将实例与领域对象结合在一起,得到的对象图:

那么当我们看到路径/users/爱学习的鱼玄机/subscriptions时,或者它的模板化表示形式/users/{user_id}/subscriptions时,我们就可以还原出路径背后的领域模型,也就是User-Subscription的一对多聚合关系。

通过实例化的方式我们将领域模型展开成了对象图。然后再在这张对象图上遍历就能得到可以表示领域模型的URI路径模板。

当然一旦我们理解了实例化和领域模型之间的关系我们也可以直接从领域模型出发设计URI路径模板。

从领域模型出发设计URI主要依赖的是聚合关系。因此我们需要在领域模型中寻找聚合边界再根据聚合边界内的关系设计URI。

比如在下面这个极客时间专栏的领域模型中,存在两个不同的聚合边界:

  1. 以User为聚合根的User-Subscriptions聚合;
  2. 以Author为聚合根的Author-Column-Content聚合。

然后我们就可以从聚合中依次设计对应的API模板了。比如对于User-Subscription聚合我们可以得到如下的URI模板。

  1. /users User聚合根,表示系统中的全部用户。
  2. /users/{user_id} User的实例化,表示通过user_id标定的某个用户。
  3. /users/{user_id}/subscriptions User-Subscriptions聚合,表示某个用户的全部订阅。
  4. /users/{user_id}/subscriptions/{subscription_id} User和Subscription的实例化,表示通过user_id和subscription_id标定的某个订阅。

这里我们一共得到了四个不同的URI模板其中两个是集合逻辑1和3两个是实体逻辑(2和4)。当URI模板表示集合逻辑时我们无需实例化只需要引用模型中的概念即可。而表示实体逻辑时我们就需要对领域模型进行实例化将它们从概念具象化到某个特定的个体。

类似的我们也可以将Author-Column-Content聚合表示为URI模板

  1. /authors Author聚合根,表示系统中所有的作者。
  2. /authors/{author_id} Author的实例化,表示某个具体的作者。
  3. /authors/{author_id}/columns某个作者所撰写的所有专栏。
  4. /authors/{author_id}/columns/{column_id}作者撰写专栏的实例化,表示某个特定的专栏。
  5. /authors/{author_id}/columns/{column_id}/contents 某个作者的某个专栏的全部内容。
  6. /authors/{author_id}/columns/{column_id}/contents/{content_id} 某个作者的某个专栏内容的实例化,表示某个特定的课程。

看到这里你可能会问,像 /authors/{author_id}/columns/{column_id}/contents/{content_id}这样的URI模板不会太长了吗我们是否需要简化一下

答案是不需要。比起URI模板的长短我们更关心这些模板是否与领域模型的结构一致。在模型中特定的课程是通过Author-Column-Content聚合关系表达的那么我们就需要通过如此冗长的URI模板来表示它。

当然如果你觉得URI模板的长度过长那么重新审视你的领域模型也是十分有必要的看看其中是否具有不恰当的聚合关系。如果聚合关系正常就请坦然接受它的URI表现形式。

根据URI设计API

在根据领域模型得到了URI模板之后我们就需要围绕着URI进行API设计了。这个过程惊人地简单。我们需要做的就是构造这样一张表格表格中包含角色、HTTP 方法、URI、业务场景这样一些条目。如下图所示

然后我们只需填入角色、URI然后按照穷举法填入所有的HTTP方法就可以了。比如对于/users/{user_id}/subscriptions这个URI我们可以将常用的四个HTTP方法填入表格

这里有个我个人的小偏好,就是通过模板化表示角色通常的做法是以统一语言中的角色名加_id。比如第一行实际我的意思是角色是user并且和URI中给出的user_id是同一个人。当然,也可以只统一语言中的角色名。

下一步,我们就需要寻找业务方反馈帮助我们判断对于这些由HTTP方法和URI组成的行为是否存在合理的业务场景。假如业务方确定了业务场景的存在那么表格中的条目就成为了API候选。

当然在上图的表格中我们对表示集合逻辑的URI施加了PUT和DELETE方法。对于集合逻辑而言PUT和DELETE通常没有任何含义。我们只是为了说明一个过程通过这样的枚举不容易遗漏可能的API候选。

最后在这个例子里我们得到的API候选是这样的

这个过程很简单吧。那我们再来看一个例子这个例子里我们引入不同的角色。我们通常会将API候选看作是HTTP方法和URI的组合然而调用方的角色其实也是很重要的。过程我就省略了方法和刚才讲的一样我们着重看一下结果

通常来说,已经订阅的专栏就无法修改了,但是我们假设有赠送功能。于是呢,读者发起的“对于专栏的修改”,那么其实就是赠送;与之相对的,由系统管理员发起的“对于读者订阅专栏的修改”,可能就是处理退订。

看到这里,我不知道你发现没有,**这个设计API的流程和催化剂法的变体角色-目标-实体法如出一辙。其中业务场景是目标而实体则是通过URI表示的领域模型。**所以这样的API建模过程同时也在帮助我们展开业务维度更好地将领域模型作为统一语言。

到此为止我们得到了一系列的API候选接下来就需要使用分布式超媒体设计API中所涉及的资源以完善API的设计。以及使用得到的API去覆盖业务流程验证API的有效性。这两步我们下节课再讲。

小结

这节课我们讲述了实现领域模型驱动API设计需要选择恰当的API风格。也就是不要从行为角度而要从数据角度去构建API。同时我们分析了为什么从行为角度构建的API和模型之间存有隔阂而从数据角度构建的API能够和领域模型结合得更加紧密。

然后我们解释了为什么RESTful API是在前云时代实现领域驱动的API设计的不二法门。一个由于其自身的特点源自互联网架构能提供更好的互联性另一个则因为也没什么其他更好的选择了。

最后我们介绍了如何将领域模型实现为RESTful API。分为四步

  1. 通过URI表示领域模型
  2. 根据URI设计API
  3. 使用分布式超媒体设计API中涉及的资源
  4. 使用得到的API去覆盖业务流程验证API的完整性。

我们讲解了前两步,后两步则要留到下节课继续。

思考题

我们在使用RESTful API时经常会遇到这种情况不同的客户端比如手机或页面需要不同格式的数据。那么我们有没有办法只提供一种格式的数据但能满足所有客户端的需求不需要根据客户端的要求而修改并且还不怎么影响性能

欢迎把你的思考和想法分享在留言区,我会和你交流讨论。我们下节课再见!