在现实中我们会遇到各种各样的复杂场景,"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的能力


- 实现Booking Domain
实现过程也相当的直接,如果将下面的代码阅读出来,几乎跟之前描述的业务需求是完全匹配的。Booking Domain的实现需要注意下面几点:

  • 所有属性都是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取决于设计;
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能力:

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返回的资源进行第一版建模,一个最初的版本如下:

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");
}

其中 BookingResourceFlightChangeSeatAssignment 分别为对应的API URI地址,使用了ASP.NET Web API提供的 urlHelper.Action("ActionName","ControllerName") 方法来生成一个url。这样的一个方法接受两个字符串来生成一个url地址,但这并不是强类型的玩法,所以马上想到通过解析Lambda expression tree来生成url,在IUrlHelper上扩展一个方法,使得代码更容易支持重构。

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都能划分为两类,CommandQuery(参考CQRS pattern),其中能够改变Domain状态的API都可以认为是API消费者发送了一个Command;另一类API则可以划分到Query,无论API消费者请求多少遍都不会改变Domain的状态,通常指Get请求。

针对TripSelectionResource包含的三个API,我们也可以将其划分为两类:

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消费端应当遵守的契约。

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资源如下:

{
"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得到一些有用的信息:

var startResource = restAirlineApiNavigator.Execute();

第二个回合,从上一个资源中拿到搜索可用航班的API地址,按照契约发送请求:

var searchTripsCommand = startResource.SearchTripsCommand;
searchTripsCommand.SearchCriteria = TripSearchCriteria.DefaultTripSearchCriteria();
var tripAvailabilityResource = restAirlineApiNavigator.PostCommand(searchTripsCommand);

第三个回合,从上面的资源中拿到选择可用航班的API地址,按照契约发送请求:

var selectTripCommand = tripAvailabilityResource.SelectTripCommand;
selectTripCommand.Trip = tripAvailabilityResource.AvailableTrips.First();
var tripSelectonResource = restAirlineApiNavigator.PostCommand(selectTripCommand);

上面是一个C#版本的API消费端,restAirlineApiNavigator是一个Typed api navigator,他拥有下面接口:

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消费过程如下:

getProducts(): Observable<ProductsResource> {
const products = this.apiNavigator
.followLink(start => start.productHome)
.followLink(product => product.products)
.execute();
return products;
}

也许这种设计方式无法满足所有的场景,但是他可以在一定程度上帮助你创建出更具表达力的API,同时也使API消费端在一定程度上减少对文档的依赖。比起理论,本文更多在讨论实践及实现细节,当然很多内容难免有纰漏,欢迎大家指正。

使用Domain-Driven创建Hypermedia API的更多相关文章

  1. DDD:Strategic Domain Driven Design with Context Mapping

    Introduction Many approaches to object oriented modeling tend not to scale well when the application ...

  2. 领域驱动设计(Domain Driven Design)参考架构详解

    摘要 本文将介绍领域驱动设计(Domain Driven Design)的官方参考架构,该架构分成了Interfaces.Applications和Domain三层以及包含各类基础设施的Infrast ...

  3. [译文]Domain Driven Design Reference(二)—— 让模型起作用

    本书是Eric Evans对他自己写的<领域驱动设计-软件核心复杂性应对之道>的一本字典式的参考书,可用于快速查找<领域驱动设计>中的诸多概念及其简明解释. 其它本系列其它文章 ...

  4. [译文]Domain Driven Design Reference(三)—— 模型驱动设计的构建模块

    本书是Eric Evans对他自己写的<领域驱动设计-软件核心复杂性应对之道>的一本字典式的参考书,可用于快速查找<领域驱动设计>中的诸多概念及其简明解释. 其它本系列其它文章 ...

  5. [译文]Domain Driven Design Reference(四)—— 柔性设计

    本书是Eric Evans对他自己写的<领域驱动设计-软件核心复杂性应对之道>的一本字典式的参考书,可用于快速查找<领域驱动设计>中的诸多概念及其简明解释. 其它本系列其它文章 ...

  6. [译文]Domain Driven Design Reference(七)—— 大型战略设计结构

    本书是Eric Evans对他自己写的<领域驱动设计-软件核心复杂性应对之道>的一本字典式的参考书,可用于快速查找<领域驱动设计>中的诸多概念及其简明解释. 上周末电脑硬盘文件 ...

  7. [译文]Domain Driven Design Reference(六)—— 提炼战略设计

    本书是Eric Evans对他自己写的<领域驱动设计-软件核心复杂性应对之道>的一本字典式的参考书,可用于快速查找<领域驱动设计>中的诸多概念及其简明解释. 其它本系列其它文章 ...

  8. [译文]Domain Driven Design Reference(五)—— 为战略设计的上下文映射

    本书是Eric Evans对他自己写的<领域驱动设计-软件核心复杂性应对之道>的一本字典式的参考书,可用于快速查找<领域驱动设计>中的诸多概念及其简明解释. 其它本系列其它文章 ...

  9. [转载]领域驱动设计(Domain Driven Design)参考架构详解

    摘要 本文将介绍领域驱动设计(Domain Driven Design)的官方参考架构,该架构分成了Interfaces.Applications和Domain三层以及包含各类基础设施的Infrast ...

随机推荐

  1. arm linux和windows 使用tftp传文件

    一.在windows 安装tftp客户端 链接:https://pan.baidu.com/s/1sxNciX337DObVmGJmCxICw 提取码:hzvj 在客户端新建一个tftp文件夹 二.关 ...

  2. 2019.03.11 bzoj4813: [Cqoi2017]小Q的棋盘(贪心)

    传送门 考虑最后所有走过的点构成的树,显然除了最长链走一遍以外每条轻链都走两遍. 于是求一波最长链搞一搞就完了. 注意几个小细节特判qwq 代码: #include<bits/stdc++.h& ...

  3. vi/vim 三种模式的操作

    来源:http://www.runoob.com/linux/linux-vim.html ps:刚刚进入vi/vim 是命令模式 一.命令模式 i 切换到输入模式,以输入字符. x 删除当前光标所在 ...

  4. Unity3D中默认函数的执行顺序

    直接用一张图来说明各个默认函数的执行顺序: FixedUpdate以固定的物理时间间隔被调用,不受游戏帧率影响.一个游戏帧可能会调用多次FixedUpdate.比如处理Rigidbody的时候最好用F ...

  5. 公用表表达式 (CTE)、递归、所有子节点、sqlserver

    指定临时命名的结果集,这些结果集称为公用表表达式 (CTE).公用表表达式可以包括对自身的引用.这种表达式称为递归公用表表达式. 对于递归公用表达式来说,实现原理也是相同的,同样需要在语句中定义两部分 ...

  6. Python TypeError: 'module' object is not callable 原因分析

    今天尝试使用pprint进行输出,语句为 >>>import pprint >>>pprint(people) 结果报错,TypeError: 'module' o ...

  7. vs 2017 打开 iis express问题

    问题: 更新vs2017 15.6.4后,首次打开网站 iis express 一直报 无法连接到web服务器. 解决办法: 关闭防火墙,在次启动即可,启动成功后,在次打开防火墙也无影响.

  8. Maven 在新版eclipse报错的解决

    转自Stack Overflow Remove all your failed downloads: Linux: find ~/.m2 -name "*.lastUpdated" ...

  9. 背水一战 Windows 10 (99) - 关联启动: 关联指定的文件类型, 关联指定的协议

    [源码下载] 背水一战 Windows 10 (99) - 关联启动: 关联指定的文件类型, 关联指定的协议 作者:webabcd 介绍背水一战 Windows 10 之 关联启动 关联指定的文件类型 ...

  10. 3.CursorAdapter

    会话页面 点击菜单时编辑的按钮显示,其余的时候gone ConversationUI  public class ConversationUI extends Activity implements ...