在現(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洞見