使用Domain-Driven創(chuàng)建Hypermedia API

在現(xiàn)實(shí)世界中我們會(huì)遇到各種各樣的復(fù)雜場(chǎng)景锄码,沒有一種API設(shè)計(jì)方式可以應(yīng)對(duì)所有的場(chǎng)景夺英。區(qū)別于”Consumer-Driven Contract”,本文將描述另外一種設(shè)計(jì)API的方式:Domain-Driven API滋捶。這不是API設(shè)計(jì)的標(biāo)準(zhǔn)方法秋麸,但是也許他可以給你靈感,幫助你設(shè)計(jì)出更具有表達(dá)力的API炬太。

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

上圖是一個(gè)API文檔片段灸蟆,他們通過(guò)HTTP動(dòng)作加上統(tǒng)一資源標(biāo)識(shí)符(URI)來(lái)描述自己的意圖,也許還需要一份不錯(cuò)的文檔來(lái)描述他的參數(shù)亲族,返回類型等,就能被消費(fèi)端調(diào)用和使用霎迫。市面上也有類似Swager這樣高效的產(chǎn)品斋枢,用起來(lái)也很方便。但是這樣的API或多或少有一些設(shè)計(jì)方面的小問(wèn)題:

1. 無(wú)法通過(guò)API描述上下文

縱然HTTP動(dòng)詞加上描述API資源的名詞基本能夠描述其意圖知给,但是在使用過(guò)程中瓤帚,一份API文檔似乎還是少不了。在過(guò)去的若干年里涩赢,我去掉了給代碼寫注釋的壞毛病戈次,因?yàn)槲艺J(rèn)識(shí)到良好的組織結(jié)構(gòu)和代碼是自描述的。然而當(dāng)我們?cè)O(shè)計(jì)API的時(shí)候筒扒,大家不約而同的接受了編寫文檔的事實(shí)怯邪。在”Consumer-Driven Contract”過(guò)程中還要編寫一份契約測(cè)試來(lái)驅(qū)動(dòng)服務(wù)端保證契約的一致性。有沒有可能讓API資源包含這一份契約花墩,同時(shí)讓消費(fèi)者去遵守契約呢悬秉?

2. API消費(fèi)端知道的太多

在上面的API文檔片段中澄步,你知道應(yīng)該在什么時(shí)候調(diào)用下面的API嗎?

POST /api/customer/notification

你可能不知道和泌,也許是當(dāng)用戶下了訂單村缸,也或者是用戶支付了訂單,這取決于需求武氓。似乎看起來(lái)合情合理梯皿,但是這樣的場(chǎng)景預(yù)示著一部分領(lǐng)域邏輯有轉(zhuǎn)移到消費(fèi)端的嫌疑。打個(gè)比方聋丝,你去飯店吃飯索烹,服務(wù)員拿來(lái)了一個(gè)菜單,當(dāng)你點(diǎn)了一份湯的時(shí)候弱睦,服務(wù)員告訴你這個(gè)菜單有自己的規(guī)則百姓,只有你先點(diǎn)一份蛋炒飯,你才能夠點(diǎn)這份湯况木。這時(shí)候你只有一種選擇垒拢,那就是記住這個(gè)規(guī)則,下次先點(diǎn)蛋炒飯火惊。有沒有可能不要把這個(gè)規(guī)則強(qiáng)加在消費(fèi)端呢求类?

3. 易碎的設(shè)計(jì)

API以提供URI的方式來(lái)提供服務(wù),而URI在本質(zhì)上就是一個(gè)字符串屹耐,作為一個(gè)強(qiáng)類型玩家尸疆,我不希望這樣的字符串分散在各個(gè)角落,試想我重命名了一個(gè)URI惶岭,我不得不搜索并修改所有曾經(jīng)使用過(guò)這個(gè)資源的代碼寿弱。

一、設(shè)計(jì)領(lǐng)域模型

我們?cè)趯?shí)踐領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)時(shí)我們?cè)谧鍪裁窗丛睿空页鲱I(lǐng)域邊界症革,根據(jù)領(lǐng)域的能力做出抽象并設(shè)計(jì)良好的模型。而領(lǐng)域模型在提供業(yè)務(wù)需求的過(guò)程就是領(lǐng)域模型狀態(tài)發(fā)生變化的過(guò)程鸯旁。

同樣的道理噪矛,我們?cè)O(shè)計(jì)API是為了達(dá)到什么目的?我希望我的API不但能夠完成增刪改查铺罢,還能夠更具表達(dá)力艇挨。每一個(gè)API不是獨(dú)立存在的,他們是領(lǐng)域模型在某一時(shí)刻狀態(tài)和能力的體現(xiàn)畏铆,每一個(gè)API資源在告知消費(fèi)者目前領(lǐng)域模型狀態(tài)的同時(shí)雷袋,還可以告訴消費(fèi)者當(dāng)前領(lǐng)域模型具備了什么樣的能力,消費(fèi)者接下來(lái)能夠做什么辞居,也即消費(fèi)者能夠請(qǐng)求哪一個(gè)API資源楷怒。

這么說(shuō)來(lái)API的設(shè)計(jì)實(shí)際上跟領(lǐng)域模型能力的設(shè)計(jì)有千絲萬(wàn)縷的關(guān)系,我決定用航空公司的賣票業(yè)務(wù)來(lái)舉例說(shuō)明。

業(yè)務(wù)需求:

  • 一個(gè)叫做RestAirline的航空公司提供在線機(jī)票出售業(yè)務(wù)瓦灶,用戶可以按照搜索條件搜索到所有可用的航班(trip)
  • 當(dāng)乘客選中一條可用的航班(trip)就開始了整個(gè)預(yù)定(booking)流程
  • 一旦乘客選擇了一條可用的航班就可以修改航班(change trip)和選擇座位(seat)
  • 當(dāng)乘客選擇完座位還可以添加一些額外的服務(wù)鸠删,如:接送機(jī)服務(wù)(transfer service)等, 最后通過(guò)不同的支付方式完成支付(payment)
  • 乘客在飛機(jī)起飛前,還可以做在線登機(jī)手續(xù)(checkin)并打印登機(jī)牌(boardingpass)贼陶,在Checkin的過(guò)程中還可以重新選擇座位

注意: 括號(hào)中的英文術(shù)語(yǔ)可以理解為該公司的領(lǐng)域術(shù)語(yǔ), 我們?cè)陬I(lǐng)域建模的時(shí)候也會(huì)使用相同的術(shù)語(yǔ)刃泡,從而減少跟領(lǐng)域?qū)<业臏贤ǔ杀尽?br> 就上面的需求我們可以很容易的分析出若干個(gè)領(lǐng)域: Booking, Payment, Trip Avalability

1. 設(shè)計(jì)Booking領(lǐng)域模型

我們以Booking領(lǐng)域模型為例來(lái)描述設(shè)計(jì)過(guò)程,下面的交互圖清晰的描述出了Booking的能力:

2. 實(shí)現(xiàn)Booking Domain

實(shí)現(xiàn)過(guò)程也相當(dāng)?shù)闹苯拥镎绻麑⑾旅娴拇a閱讀出來(lái),幾乎跟之前描述的業(yè)務(wù)需求是完全匹配的。Booking領(lǐng)域模型的實(shí)現(xiàn)需要注意下面幾點(diǎn):

  • 所有屬性都是private set早像,意味著領(lǐng)域模型內(nèi)部屬性是靠自己維護(hù)的讥巡;
  • AirportTransfer為Maybe<t>類型,意味著在一個(gè)完整的Booking中芹啥,可以不選擇接送機(jī)服務(wù)(TransferService)锻离;對(duì)于Trip屬性而言,即便從語(yǔ)言層面上來(lái)講他是引用類型墓怀,可以為null汽纠,但是一個(gè)包含空Trip的Booking是不存在的,所以一個(gè)完整的Booking領(lǐng)域模型中傀履,一旦一個(gè)非Maybe</t><t>類型的屬性為null虱朵,那我們就可以認(rèn)為這個(gè)Booking就是無(wú)效的;
  • 該類的構(gòu)造函數(shù)被修飾為private钓账,意味著Booking領(lǐng)域模型只能通過(guò)選擇可用的航班來(lái)創(chuàng)建碴犬,代碼的含義詮釋了業(yè)務(wù)需求;
 
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 
  }

二官扣、設(shè)計(jì)具有Domain能力的API

根據(jù)上面設(shè)計(jì)好的領(lǐng)域模型翅敌,我們可以輕松設(shè)計(jì)出第一個(gè)表達(dá)領(lǐng)域能力的API: trip:

POST /api/booking/trip

實(shí)際上這一API的實(shí)現(xiàn)方式就是直接調(diào)用對(duì)應(yīng)的領(lǐng)域模型能力:

var booking = Booking.SelectTrip(trip, passengers)
  • 站在領(lǐng)域模型的角度,這一能力創(chuàng)建了一個(gè)Booking惕蹄,同時(shí)還將一個(gè)可用的航班(Trip)和乘客列表添加到了Booking領(lǐng)域模型中蚯涮,
    此時(shí)的Booking就擁有了一些初始狀態(tài),同時(shí)還具備了一定的能力:分配座位(seat)和修改航班(flight)卖陵。
  • 站在API消費(fèi)者的角度遭顶,在消費(fèi)者消費(fèi)完畢trip這個(gè)API之后,除了能夠得到一些必要的返回值泪蔫,還擁有了調(diào)用下面三個(gè)API的能力:
GET api/booking/{id}
PUT api/booking/{id}/seat
PUT api/booking/{id}/flight

這三個(gè)API跟Booking領(lǐng)域模型在此時(shí)擁有的能力是一致的棒旗。Hypermedia API的思想在于:API資源除了包含必要的返回值,還能告訴API消費(fèi)者下一步領(lǐng)域模型擁有的能力和此時(shí)領(lǐng)域模型的狀態(tài),也就是API消費(fèi)者接下來(lái)可以請(qǐng)求什么樣的API铣揉。

三饶深、實(shí)現(xiàn)Hypermedia API

根據(jù)上面的分析,我們嘗試對(duì)trip API返回的資源進(jìn)行第一版建模逛拱,一個(gè)最初的版本如下:

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敌厘,F(xiàn)lightChange,SeatAssignment 為對(duì)應(yīng)的API URI地址朽合,使用了ASP.NET Web API提供的 urlHelper.Action(“ActionName”,”ControllerName”) 方法來(lái)生成一個(gè)url俱两。這樣的一個(gè)方法接受兩個(gè)字符串來(lái)生成一個(gè)url地址,但這并不是強(qiáng)類型的玩法曹步,所以馬上想到通過(guò)解析表達(dá)式樹的方式生成URI宪彩,在IUrlHelper上擴(kuò)展一個(gè)方法,使得代碼更容易支持重構(gòu)讲婚。

 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),其中能夠改變領(lǐng)域模型狀態(tài)的API都可以認(rèn)為是API消費(fèi)者發(fā)送了一個(gè)Command磺樱;另一類API則可以劃分到Query纳猫,無(wú)論API消費(fèi)者請(qǐng)求多少遍都不會(huì)改變領(lǐng)域模型的狀態(tài),通常指Get請(qǐng)求竹捉。
針對(duì)TripResource包含的三個(gè)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被抽象為L(zhǎng)ink</t><t>類型,Command類的API如 ChangeFlightCommand块差。一個(gè)按照上面建模方式返回的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"
        }
    }
 }

這一份資源包含了服務(wù)端返回值BookingId侵续, 同時(shí)還返回了此時(shí)API消費(fèi)端接下來(lái)能夠使用的API列表,其中Command類型的API還包含了契約內(nèi)容憨闰。

四状蜗、 如何優(yōu)雅的消費(fèi)Hypermedia API

按照本文提供的設(shè)計(jì)思路,因?yàn)槲覀冊(cè)O(shè)計(jì)好的API總能夠返回下次可用的API列表鹉动,所以我們可以認(rèn)為整個(gè)API列表是有層級(jí)關(guān)系的轧坎,服務(wù)端只需要提供一個(gè)最頂端的API URI給消費(fèi)者即可。試想一個(gè)消費(fèi)端如何消費(fèi)這樣的API呢泽示?
第一個(gè)回合缸血,一定是API消費(fèi)端拿到了最頂端的API地址,我們期望消費(fèi)端能夠通過(guò)這個(gè)API得到一些有用的信息:

var homeResource = restAirlineApiNavigator.Execute();

第二個(gè)回合械筛,從上一個(gè)資源中拿到搜索可用航班的API地址捎泻,按照契約發(fā)送請(qǐng)求:

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

第三個(gè)回合,從上面的資源中拿到”選擇可用航班”的API地址埋哟,按照契約發(fā)送請(qǐng)求:

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

上面是一個(gè)C#版本的API消費(fèi)端笆豁,restAirlineApiNavigator是一個(gè)強(qiáng)類型API Navigator,他擁有下面接口:

 public interface IApiNavigator<TResource>
    {
        TResource Execute();
 
        TResourceToFetch PostCommand<TResourceToFetch>(HypermediaCommand<TResourceToFetch> command);
 
        SubApiNavigator<TTargetResource, TResource> FollowLink<TTargetResource>(
            Func<TResource, Link<TTargetResource>> navigator);
    }

當(dāng)然,如果你API消費(fèi)端是Javascript闯狱,你應(yīng)該沒法寫出這樣的API Navigator來(lái)幫你做類型保證煞赢,不過(guò)你可以寫一個(gè)TypeScript版本的API navigator,一個(gè)典型的Hypermedia消費(fèi)過(guò)程如下:

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

本文從領(lǐng)域建模出發(fā)扩氢,描述了Hypermedia API的創(chuàng)建耕驰、實(shí)現(xiàn)以及消費(fèi)過(guò)程爷辱,也許這種設(shè)計(jì)方式無(wú)法滿足所有的場(chǎng)景录豺,但是他可以在一定程度上幫助你創(chuàng)建出更具表達(dá)力的API,同時(shí)也使API消費(fèi)端在一定程度上減少對(duì)文檔的依賴饭弓。


文/ThoughtWorks張陽(yáng)

更多精彩洞見双饥,請(qǐng)關(guān)注微信公眾號(hào):ThoughtWorks洞見

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市弟断,隨后出現(xiàn)的幾起案子咏花,更是在濱河造成了極大的恐慌,老刑警劉巖阀趴,帶你破解...
    沈念sama閱讀 218,941評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件昏翰,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡刘急,警方通過(guò)查閱死者的電腦和手機(jī)棚菊,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)叔汁,“玉大人统求,你說(shuō)我怎么就攤上這事【菘椋” “怎么了码邻?”我有些...
    開封第一講書人閱讀 165,345評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)另假。 經(jīng)常有香客問(wèn)我像屋,道長(zhǎng),這世上最難降的妖魔是什么边篮? 我笑而不...
    開封第一講書人閱讀 58,851評(píng)論 1 295
  • 正文 為了忘掉前任己莺,我火速辦了婚禮,結(jié)果婚禮上苟耻,老公的妹妹穿的比我還像新娘篇恒。我一直安慰自己,他們只是感情好凶杖,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,868評(píng)論 6 392
  • 文/花漫 我一把揭開白布胁艰。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪腾么。 梳的紋絲不亂的頭發(fā)上奈梳,一...
    開封第一講書人閱讀 51,688評(píng)論 1 305
  • 那天,我揣著相機(jī)與錄音解虱,去河邊找鬼攘须。 笑死,一個(gè)胖子當(dāng)著我的面吹牛殴泰,可吹牛的內(nèi)容都是我干的于宙。 我是一名探鬼主播,決...
    沈念sama閱讀 40,414評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼悍汛,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼捞魁!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起离咐,我...
    開封第一講書人閱讀 39,319評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤谱俭,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后宵蛀,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體昆著,經(jīng)...
    沈念sama閱讀 45,775評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,945評(píng)論 3 336
  • 正文 我和宋清朗相戀三年术陶,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了凑懂。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,096評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡瞳别,死狀恐怖征候,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情祟敛,我是刑警寧澤疤坝,帶...
    沈念sama閱讀 35,789評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站馆铁,受9級(jí)特大地震影響跑揉,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜埠巨,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,437評(píng)論 3 331
  • 文/蒙蒙 一历谍、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧辣垒,春花似錦望侈、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)侥猬。三九已至,卻和暖如春捐韩,著一層夾襖步出監(jiān)牢的瞬間退唠,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評(píng)論 1 271
  • 我被黑心中介騙來(lái)泰國(guó)打工荤胁, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留瞧预,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,308評(píng)論 3 372
  • 正文 我出身青樓仅政,卻偏偏與公主長(zhǎng)得像垢油,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子已旧,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,037評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容