使用Domain-Driven建立Hypermedia API

使用Domain-Driven建立Hypermedia API

在現實世界中咱們會遇到各類各樣的複雜場景,沒有一種API設計方式能夠應對全部的場景。區別於」Consumer-Driven Contract」,本文將描述另一種設計API的方式:Domain-Driven API。這不是API設計的標準方法,可是也許他能夠給你靈感,幫助你設計出更具備表達力的API。json

POST /api/customer
POST /api/customer/order
PUT /api/customer
POST /api/customer/notification

上圖是一個API文檔片斷,他們經過HTTP動做加上統一資源標識符(URI)來描述本身的意圖,也許還須要一份不錯的文檔來描述他的參數,返回類型等,就能被消費端調用和使用。市面上也有相似Swager這樣高效的產品,用起來也很方便。可是這樣的API或多或少有一些設計方面的小問題:api

1. 沒法經過API描述上下文

縱然HTTP動詞加上描述API資源的名詞基本可以描述其意圖,可是在使用過程當中,一份API文檔彷佛仍是少不了。在過去的若干年裏,我去掉了給代碼寫註釋的壞毛病,由於我認識到良好的組織結構和代碼是自描述的。然而當咱們設計API的時候,你們不約而同的接受了編寫文檔的事實。在」Consumer-Driven Contract」過程當中還要編寫一份契約測試來驅動服務端保證契約的一致性。有沒有可能讓API資源包含這一份契約,同時讓消費者去遵照契約呢?dom

2. API消費端知道的太多

在上面的API文檔片斷中,你知道應該在何時調用下面的API嗎?函數

POST /api/customer/notification

你可能不知道,也許是當用戶下了訂單,也或者是用戶支付了訂單,這取決於需求。彷佛看起來合情合理,可是這樣的場景預示着一部分領域邏輯有轉移到消費端的嫌疑。打個比方,你去飯店吃飯,服務員拿來了一個菜單,當你點了一份湯的時候,服務員告訴你這個菜單有本身的規則,只有你先點一份蛋炒飯,你纔可以點這份湯。這時候你只有一種選擇,那就是記住這個規則,下次先點蛋炒飯。有沒有可能不要把這個規則強加在消費端呢?測試

3. 易碎的設計

API以提供URI的方式來提供服務,而URI在本質上就是一個字符串,做爲一個強類型玩家,我不但願這樣的字符串分散在各個角落,試想我重命名了一個URI,我不得不搜索並修改全部曾經使用過這個資源的代碼。ui

1、設計領域模型

咱們在實踐領域驅動設計時咱們在作什麼?找出領域邊界,根據領域的能力作出抽象並設計良好的模型。而領域模型在提供業務需求的過程就是領域模型狀態發生變化的過程。this

一樣的道理,咱們設計API是爲了達到什麼目的?我但願個人API不但可以完成增刪改查,還可以更具表達力。每個API不是獨立存在的,他們是領域模型在某一時刻狀態和能力的體現,每個API資源在告知消費者目前領域模型狀態的同時,還能夠告訴消費者當前領域模型具有了什麼樣的能力,消費者接下來可以作什麼,也即消費者可以請求哪個API資源。url

這麼說來API的設計實際上跟領域模型能力的設計有千絲萬縷的關係,我決定用航空公司的賣票業務來舉例說明。設計

業務需求:rest

  • 一個叫作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類型,意味着在一個完整的Booking中,能夠不選擇接送機服務(TransferService);對於Trip屬性而言,即* 便從語言層面上來說他是引用類型,能夠爲null,可是一個包含空Trip的Booking是不存在的,因此一個完整的Booking領域模型中,* 一旦一個非Maybe類型的屬性爲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 
  }

2、設計具備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。

3、實現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類型,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還包含了契約內容。

4、 如何優雅的消費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消費端在必定程度上減小對文檔的依賴。

相關文章
相關標籤/搜索