使用Domain-Driven创建Hypermedia 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或多或少有一些设计方面的小问题:

1. 无法通过API描述上下文

纵然HTTP动词加上描述API资源的名词基本能够描述其意图,但是在使用过程中,一份API文档似乎还是少不了。在过去的若干年里,我去掉了给代码写注释的坏毛病,因为我认识到良好的组织结构和代码是自描述的。然而当我们设计API的时候,大家不约而同的接受了编写文档的事实。在”Consumer-Driven Contract”过程中还要编写一份契约测试来驱动服务端保证契约的一致性。有没有可能让API资源包含这一份契约,同时让消费者去遵守契约呢?

2. API消费端知道的太多

在上面的API文档片段中,你知道应该在什么时候调用下面的API吗?

POST /api/customer/notification

你可能不知道,也许是当用户下了订单,也或者是用户支付了订单,这取决于需求。似乎看起来合情合理,但是这样的场景预示着一部分领域逻辑有转移到消费端的嫌疑。打个比方,你去饭店吃饭,服务员拿来了一个菜单,当你点了一份汤的时候,服务员告诉你这个菜单有自己的规则,只有你先点一份蛋炒饭,你才能够点这份汤。这时候你只有一种选择,那就是记住这个规则,下次先点蛋炒饭。有没有可能不要把这个规则强加在消费端呢?

3. 易碎的设计

API以提供URI的方式来提供服务,而URI在本质上就是一个字符串,作为一个强类型玩家,我不希望这样的字符串分散在各个角落,试想我重命名了一个URI,我不得不搜索并修改所有曾经使用过这个资源的代码。

一、设计领域模型

我们在实践领域驱动设计时我们在做什么?找出领域边界,根据领域的能力做出抽象并设计良好的模型。而领域模型在提供业务需求的过程就是领域模型状态发生变化的过程。

同样的道理,我们设计API是为了达到什么目的?我希望我的API不但能够完成增删改查,还能够更具表达力。每一个API不是独立存在的,他们是领域模型在某一时刻状态和能力的体现,每一个API资源在告知消费者目前领域模型状态的同时,还可以告诉消费者当前领域模型具备了什么样的能力,消费者接下来能够做什么,也即消费者能够请求哪一个API资源。

这么说来API的设计实际上跟领域模型能力的设计有千丝万缕的关系,我决定用航空公司的卖票业务来举例说明。

业务需求:

  • 一个叫做RestAirline的航空公司提供在线机票出售业务,用户可以按照搜索条件搜索到所有可用的航班(trip)
  • 当乘客选中一条可用的航班(trip)就开始了整个预定(booking)流程
  • 一旦乘客选择了一条可用的航班就可以修改航班(change trip)和选择座位(seat)
  • 当乘客选择完座位还可以添加一些额外的服务,如:接送机服务(transfer service)等, 最后通过不同的支付方式完成支付(payment)
  • 乘客在飞机起飞前,还可以做在线登机手续(checkin)并打印登机牌(boardingpass),在Checkin的过程中还可以重新选择座位

注意: 括号中的英文术语可以理解为该公司的领域术语, 我们在领域建模的时候也会使用相同的术语,从而减少跟领域专家的沟通成本。
就上面的需求我们可以很容易的分析出若干个领域: Booking, Payment, Trip Avalability

1. 设计Booking领域模型

我们以Booking领域模型为例来描述设计过程,下面的交互图清晰的描述出了Booking的能力:

2. 实现Booking Domain

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

  • 所有属性都是private set,意味着领域模型内部属性是靠自己维护的;
  • AirportTransfer为Maybe<t>类型,意味着在一个完整的Booking中,可以不选择接送机服务(TransferService);对于Trip属性而言,即便从语言层面上来讲他是引用类型,可以为null,但是一个包含空Trip的Booking是不存在的,所以一个完整的Booking领域模型中,一旦一个非Maybe</t><t>类型的属性为null,那我们就可以认为这个Booking就是无效的;
  • 该类的构造函数被修饰为private,意味着Booking领域模型只能通过选择可用的航班来创建,代码的含义诠释了业务需求;
 
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(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

根据上面设计好的领域模型,我们可以轻松设计出第一个表达领域能力的API: trip:

POST /api/booking/trip

实际上这一API的实现方式就是直接调用对应的领域模型能力:

var booking = Booking.SelectTrip(trip, passengers)
  • 站在领域模型的角度,这一能力创建了一个Booking,同时还将一个可用的航班(Trip)和乘客列表添加到了Booking领域模型中,
    此时的Booking就拥有了一些初始状态,同时还具备了一定的能力:分配座位(seat)和修改航班(flight)。
  • 站在API消费者的角度,在消费者消费完毕trip这个API之后,除了能够得到一些必要的返回值,还拥有了调用下面三个API的能力:
GET api/booking/{id}
PUT api/booking/{id}/seat
PUT api/booking/{id}/flight

这三个API跟Booking领域模型在此时拥有的能力是一致的。Hypermedia API的思想在于:API资源除了包含必要的返回值,还能告诉API消费者下一步领域模型拥有的能力和此时领域模型的状态,也就是API消费者接下来可以请求什么样的API。

三、实现Hypermedia API

根据上面的分析,我们尝试对trip API返回的资源进行第一版建模,一个最初的版本如下:

public class TripResource
    {
        private readonly IUrlHelper _urlHelper;

        public TripResource(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地址,但这并不是强类型的玩法,所以马上想到通过解析表达式树的方式生成URI,在IUrlHelper上扩展一个方法,使得代码更容易支持重构。

 public class TripResource
    {
        private readonly IUrlHelper _urlHelper;

        public TripResource(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),其中能够改变领域模型状态的API都可以认为是API消费者发送了一个Command;另一类API则可以划分到Query,无论API消费者请求多少遍都不会改变领域模型的状态,通常指Get请求。
针对TripResource包含的三个API,我们也可以将其划分为两类:

 public class TripResource
    {
        private readonly IUrlHelper _urlHelper;

        public Trip(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><t>类型,Command类的API如 ChangeFlightCommand。一个按照上面建模方式返回的trip资源如下:

{
    "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 URI给消费者即可。试想一个消费端如何消费这样的API呢?
第一个回合,一定是API消费端拿到了最顶端的API地址,我们期望消费端能够通过这个API得到一些有用的信息:

var homeResource = restAirlineApiNavigator.Execute();

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

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

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

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

上面是一个C#版本的API消费端,restAirlineApiNavigator是一个强类型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;
    }

本文从领域建模出发,描述了Hypermedia API的创建、实现以及消费过程,也许这种设计方式无法满足所有的场景,但是他可以在一定程度上帮助你创建出更具表达力的API,同时也使API消费端在一定程度上减少对文档的依赖。


文/ThoughtWorks张阳

更多精彩洞见,请关注微信公众号:ThoughtWorks洞见

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 198,154评论 5 464
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,252评论 2 375
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 145,107评论 0 327
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,985评论 1 268
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,905评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,256评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,978评论 3 388
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,611评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,891评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,910评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,736评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,516评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,995评论 3 301
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,132评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,447评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,034评论 2 343
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,242评论 2 339

推荐阅读更多精彩内容