在现实中我们会遇到各种各样的复杂场景,"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. MySQL远程连接失败(错误码:2003)

    一 环境信息 服务器系统:Ubuntu 18.04 服务器MySQL版本:14.14 Distrib 5.7.25 本地系统:Kali Linux 本地客户端:python3交互模式 本地开发环境:p ...

  2. 理解存储引擎MyISAM与InnoDB

    1.MyISAM:默认表类型,它是基于传统的ISAM类型,ISAM是Indexed Sequential Access Method (有索引的顺序访问方法) 的缩写,它是存储记录和文件的标准方法.不 ...

  3. 20155205 郝博雅 Exp9 Web安全基础

    20155205 郝博雅 Exp9 Web安全基础 一.实验内容 一共做了13个题目. 1.WebGoat 输入java -jar webgoat-container-7.1-exec.jar 在浏览 ...

  4. JMS Cluster modules

    是GeoServer实现集群还是在数据库实现集群? Hadoop.Spark.HBase与Redis的适用性见解:https://blog.csdn.net/cuiyaonan2000/article ...

  5. EF学习笔记(九):异步处理和存储过程

    总目录:ASP.NET MVC5 及 EF6 学习笔记 - (目录整理) 上一篇:EF学习笔记(八):更新关联数据 本篇原文:Async and Stored Procedures 为何要采用异步? ...

  6. leetcode第一天-merge two binary trees

    有段时间没有写代码了,脑子都生锈了,今后争取笔耕不辍(立flag,以后打脸) 随机一道Leecode题, Merge Two Binary Trees,题目基本描述如下: Given two bina ...

  7. Python 日常技巧

    jupyter notebook 本地开启jupyter,画图需打开限制:jupyter notebook --NotebookApp.iopub_data_rate_limit=2147483647 ...

  8. python学习总结(一)

    1.编码格式发展历史 ASCII 255 Ibytes --> 1980 gb2312  7000+ -->1995 GBK1.0 2W+ -->2000 GB18030 2.7W+ ...

  9. 搭建node js的运行环境。

    第一步:首先安装一个NVM,就是一个node的版本管理器. nvm的下载地址::https://github.com/coreybutler/nvm-windows/releases,我选择下载的是n ...

  10. Linux下Oracle数据库的安装

    记录详细过程以备使用 一.准备安装 为了确保Oracle数据库11g能够成功安装,您需要做好准备工作,例如检查网络配置.更改Linux内核参数.创建用户Oracle.创建安装目录.设置用户Oracle ...