使用Domain-Driven创建Hypermedia API
目录
一、设计Domain
二、 设计具有Domain能力的API
三、实现Hypermedia API
四、如何优雅的消费Hypermedia API
在现实中我们会遇到各种各样的复杂场景,"There is not a right way" 用来描述API的设计方法再合适不过了,没有一种API设计方式可以应对所有的场景。区别于"Consumer-Driven Contract",本文将描述另外一种设计API的方式:Domain-Driven API。这不是API设计的标准方法,但是他也许可以给你灵感,帮助你设计出更加具有表达力的API。
POST /api/customer
POST /api/customer/order
PUT /api/customer
POST /api/customer/notification
上图是一个API文档片段,他们通过HTTP动作加上统一资源标识符(URI)来描述自己的意图,也许还需要一份不错的文档来描述他的参数,返回类型等,就能被客户端调用和使用。市面上也有类似Swager这样高效的产品,用起来也很方便。但是这样的API或多或少有一些设计方面的小问题:
- 无法通过API描述上下文
纵然一个HTTP动词加上一个描述API资源的名词基本可以能够描述其意图,但是在使用过程中一份API文档似乎还是少不了。在过去的若干年里我去掉了给代码写注释的坏毛病,因为我认识到良好的组织结构和代码是自描述的。然而当我们设计API的时候,大家不约而同的接受了编写文档的事实。在"Consumer-Driven Contract"过程中还要编写一份契约测试来驱动服务端保证契约的一致性。有没有可能让API资源包含这一份契约,同时让消费者去遵守契约呢?
- API消费端知道的太多
在上图的API文档片段中,你知道应该在什么时候调用下面的API吗?
POST /api/customer/notification
你可能不知道,也许是当用户下了订单,也或者是用户支付了订单,这取决于需求。似乎看起来合情合理,但是这样的场景预示着一部分领域逻辑有转移到消费端的嫌疑。打个比方,你去饭店吃饭,服务员拿过来了一个菜单,当你点了一分汤的时候服务员告诉你这个菜单有自己的规则,只有你先点一份蛋炒饭,你才能够点这份汤。这时候你只有一种选择那就是记住这个规则,下次先点蛋炒饭。有没有可能不要把这个规则强加在消费端呢?
- 易碎的设计
API以提供URI的方式来提供服务,而URI在本质上就是一个字符串,作为一个强类型玩家,我不希望这样的字符串分散在各个角落,试想我重命名了一个URI,我不得不搜索并修改所有曾经使用过这个资源的代码。
一、设计Domain
我们在实践领域驱动设计时我们在做什么?找出领域边界、找到聚合根、划分Domain、根据Domain的能力做出抽象并设计良好的模型。而Domain在提供业务需求的过程就是Domain模型状态发生变化的过程。
同样的道理,我们设计API是为了达到什么目的?我希望我的API不但能够完成增删改查,还能够更具表达力。每一个API不是独立存在的,他们是Domain模型在某一时刻状态和能力的体现,每一个API资源在告知消费者目前Domain状态的同时,还可以告诉消费者当前Domain具备了什么样的能力,消费者接下来能够做什么,也即消费者能够请求哪一个API资源。
这么说来API的设计实际上跟Domain能力的设计有千丝万缕的关系,我决定用航空公司的卖票业务来举例说明。
业务需求:
- 一个叫做RestAirline的航空公司提供在线机票出售业务,用户可以按照搜索条件搜索到所有可用的航班(trip)
- 当乘客选中一条可用的航班(trip)就开始了整个预定(booking)流程
- 一旦乘客选择了一条可用的航班就可以修改航班(change trip)和选择座位(seat)
- 当乘客选择完座位还可以添加一些额外的服务,如:接送机服务(transfer service)等, 最后完成支付(payment)
- 乘客在飞机起飞前还可以做在线登机手续(checkin)并打印登机牌(boardingpass),在Checkin的过程中还可以重新选择座位
注意: 括号中的英文术语可以理解为该公司的Domain language, 我们在领域建模的时候也会使用相同的术语,从而减少跟领域专家的沟通成本。
就上面的需求我们可以很容易的分析出若干个Domain: Booking, Payment, Trip Avalability
- 设计Booking Domain
我们以Booking Domain为例来描述设计过程,下面的交互图清晰的描述出了Booking的能力

- 所有属性都是private set,意味着Domain的内部属性是靠自己维护的;
- AirportTransfer为
Maybe<T>
类型,意味着在一个完整的Booking中,可以不选择接送机服务(TransferService);反之对于Trip属性而言,即便从语言层面上来讲他也是引用类型,也可以为null,但是一个包含空Trip的Booking是不存在的,所以一个完整的Domain里,一旦一个非Maybe<T>
类型的属性为null,那我们就可以认为这个Booking就是无效的; - 该类的构造函数被修饰为private,意味着Booking只能通过选择可用的航班来创建,代码的含义诠释了业务需求
- Checkin被设计为Sub-Domain,因为Checkin的实现过程略微复杂,是否是一个Sub-Domain取决于设计;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | public class Booking { public Guid Id { get ; } public IReadOnlyList<Passenger> Passengers => _passengers.AsReadOnly(); public Trip Trip { get ; } public IReadOnlyList<Maybe<Seat>> Seats => _passengers.Select(p => p.SelectedSeat).ToList().AsReadOnly(); public Maybe<AirportTransfer> AirportTransfer { get ; private set ; } private readonly List<Passenger> _passengers; private readonly CheckinProcess _checkinProcess; private Booking(Trip trip, List<Passenger> passengers) { Id = Guid.NewGuid(); _checkinProcess = CheckinProcess.CreateCheckinProcess( this ); Trip = trip; _passengers = passengers; } public static Booking SelectTrip(Trip trip, List<Passenger> passengers) { //Validation for trip and passengers in here var booking = new Booking(trip, passengers); return booking; } public void ChangeFlight(Trip.Journey journey, Flight flight) { // Checking is it eligible for changing flight; Trip.ChangeFlight(journey.Id, flight); } public void AssignSeat(Seat seat, Passenger passenger) { //Validation in here var p = _passengers.Single(s => s.Name.Equals(passenger.Name)); p.AssignSeat(seat); } //... Other capabilities } |
二、 设计具有Domain能力的API
根据上面设计好的Domain,我们可以轻松设计出第一个表达Domain能力的API: tripselection:
POST /api/booking/tripselection
实际上这一API的实现方式就是直接调用对应的Domain能力:
1 | var booking = Booking.SelectTrip(trip, passengers) |
- 站在Domain的角度,这一能力创建了一个Booking,同时还将一个可用的航班(Trip)和乘客列表添加到了Booking中,
此时的Booking就拥有了一些初始状态,同时还具备了一定的能力:分配座位(seat)和修改航班(flight)。 - 站在API消费者的角度,在消费者消费完毕tripselection这个API之后,除了能够得到一些必要的返回值,还拥有了调用下面三个API的能力:
GET api/booking/{id}
PUT api/booking/{id}/seatassignment
PUT api/booking/{id}/changeflight
这三个API跟Domain在此时拥有的能力是一致的。Hypermedia API的思想在于:API资源除了包含必要的返回值,还能告诉API消费者下一步Domain拥有的能力和此时Domain的状态,也就是API消费者接下来可以请求什么样的API。
三、实现Hypermedia API
根据上面的分析,我们尝试对tripselection API返回的资源进行第一版建模,一个最初的版本如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 | public class TripSelectionResource { private readonly IUrlHelper _urlHelper; public TripSelectionResource(IUrlHelper urlHelper) { _urlHelper = urlHelper; } public Guid BookingId { get ; set ; } public string BookingResource => _urlHelper.Action( "GetBooking" , "Booking" ); public string FlightChange => _urlHelper.Action( "ChangeFlight" , "Booking" ); public string SeatAssignment => _urlHelper.Action( "AssignSeat" , "Booking" ); } |
其中 BookingResource
,FlightChange
,SeatAssignment
分别为对应的API URI地址,使用了ASP.NET Web API提供的 urlHelper.Action("ActionName","ControllerName")
方法来生成一个url。这样的一个方法接受两个字符串来生成一个url地址,但这并不是强类型的玩法,所以马上想到通过解析Lambda expression tree来生成url,在IUrlHelper上扩展一个方法,使得代码更容易支持重构。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | public class TripSelectionResource { private readonly IUrlHelper _urlHelper; public TripSelectionResource(IUrlHelper urlHelper) { _urlHelper = urlHelper; } public Guid BookingId { get ; set ; } public string BookingResource => _urlHelper.Link((BookingController c) => c.GetBooking(BookingId)); public string FlightChange => _urlHelper.Link((BookingController c) => c.ChangeFlight()); public string SeatAssignment => _urlHelper.Link((BookingController c) => c.AssignSeat()); } |
理论上所有的API都能划分为两类,Command
和Query
(参考CQRS pattern),其中能够改变Domain状态的API都可以认为是API消费者发送了一个Command
;另一类API则可以划分到Query,无论API消费者请求多少遍都不会改变Domain的状态,通常指Get请求。
针对TripSelectionResource包含的三个API,我们也可以将其划分为两类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | public class TripSelectionResource { private readonly IUrlHelper _urlHelper; public TripSelectionResource(IUrlHelper urlHelper) { _urlHelper = urlHelper; } public Guid BookingId { get ; set ; } public Link<BookingResource> Booking => _urlHelper.Link((BookingController c) => c.GetBooking(BookingId)); public ChangeFlightCommand ChangeFlight => new ChangeFlightCommand(_urlHelper); public AssignSeatCommand AssignSeat => new AssignSeatCommand(_urlHelper); } |
Query类的API被抽象为 Link<T>
类型,Command
类的API如 ChangeFlightCommand
,他不但告诉了API消费端该API的URI,还告诉了API消费端应当遵守的契约。
1 2 3 4 5 6 7 | public class ChangeFlightCommand : HypermediaCommand<FlightChangeResource> { public ChangeFlightCommand(IUrlHelper urlHelper) : base (urlHelper.Link((BookingController c) => c.ChangeFlight( null ))) { } public Trip.Journey Journey { get ; set ; } public Flight Flight { get ; set ; } } |
一个按照上面建模方式返回的tripselection资源如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | { "BookingId" : "6cedc5fc-afed-4e34-8906-2ddc4b8cac6f" , "Booking" : { "Uri" : "localhost:3000/api/booking/6cedc5fc-afed-4e34-8906-2ddc4b8cac6f" }, "ChangeFlight" : { "BookingId" : "6cedc5fc-afed-4e34-8906-2ddc4b8cac6f" , "Journey" : { "Id" : "00000000-0000-0000-0000-000000000000" , // Ignore other fields }, "Flight" : { "Number" : null , // Ignore other fields }, "PostUrl" : { "Uri" : "localhost:3000/api/booking/6cedc5fc-afed-4e34-8906-2ddc4b8cac6f/flightchange" } }, "AssignSeat" : { "BookingId" : "6cedc5fc-afed-4e34-8906-2ddc4b8cac6f" , "Seat" : { "Number" : null , "SeatType" : 0 }, "Passenger" : { "Name" : null , "PassengerType" : 0, "Age" : 0, "Email" : null }, "PostUrl" : { "Uri" : "localhost:3000/api/booking/6cedc5fc-afed-4e34-8906-2ddc4b8cac6f/seatassignment" } } } |
这一份资源包含了服务端返回值BookingId, 同时还返回了此时API消费端接下来能够使用的API列表,其中Command类型的API还包含了契约内容。
四、如何优雅的消费Hypermedia API
按照本文提供的设计思路,因为我们设计好的API总能够返回下次可用的API列表,所以我们可以认为所有的API是有层级关系的,那么服务端一定会提供一个最顶端的API供消费者使用。试想一个消费端如何消费这样的API呢?
第一个回合,一定是API消费端拿到了最顶端的API地址,我们期望消费端能够通过这个API得到一些有用的信息:
1 | var startResource = restAirlineApiNavigator.Execute(); |
第二个回合,从上一个资源中拿到搜索可用航班
的API地址,按照契约发送请求:
1 2 3 | var searchTripsCommand = startResource.SearchTripsCommand; searchTripsCommand.SearchCriteria = TripSearchCriteria.DefaultTripSearchCriteria(); var tripAvailabilityResource = restAirlineApiNavigator.PostCommand(searchTripsCommand); |
第三个回合,从上面的资源中拿到选择可用航班
的API地址,按照契约发送请求:
1 2 3 | var selectTripCommand = tripAvailabilityResource.SelectTripCommand; selectTripCommand.Trip = tripAvailabilityResource.AvailableTrips.First(); var tripSelectonResource = restAirlineApiNavigator.PostCommand(selectTripCommand); |
上面是一个C#版本的API消费端,restAirlineApiNavigator是一个Typed api navigator,他拥有下面接口:
1 2 3 4 5 6 7 8 9 10 | public interface IApiNavigator<TResource> { TResource Execute(); TResourceToFetch PostCommand<TResourceToFetch> (HypermediaCommand<TResourceToFetch> command); SubApiNavigator<TTargetResource, TResource> FollowLink<TTargetResource>( Func<TResource, Link<TTargetResource>> navigator); } |
当然,如果你API消费端是Javascript,你应该没法写出这样的API Navigator来帮你做类型保证,不过你可以写一个TypeScript版本的API navigator,一个典型的Hypermedia消费过程如下:
1 2 3 4 5 6 7 | getProducts(): Observable<ProductsResource> { const products = this .apiNavigator .followLink(start => start.productHome) .followLink(product => product.products) .execute(); return products; } |
也许这种设计方式无法满足所有的场景,但是他可以在一定程度上帮助你创建出更具表达力的API,同时也使API消费端在一定程度上减少对文档的依赖。比起理论,本文更多在讨论实践及实现细节,当然很多内容难免有纰漏,欢迎大家指正。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· [AI/GPT/综述] AI Agent的设计模式综述