使用Domain-Driven建立Hypermedia API

richiezhang發表於2021-01-02

使用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型別,意味著在一個完整的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 
  }

二、設計具有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型別,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消費端在一定程度上減少對文件的依賴。

相關文章