API 本身的含義指應(yīng)用程序接口,包括所依賴的庫(kù)、平臺(tái)、操作系統(tǒng)提供的能力都可以叫做 API。我們?cè)谟懻撐⒎?wù)場(chǎng)景下的 API 設(shè)計(jì)都是指 WEB API,一般的實(shí)現(xiàn)有 RESTful、RPC等。API 代表了一個(gè)微服務(wù)實(shí)例對(duì)外提供的能力,因此 API 的傳輸格式(XML、JSON)對(duì)我們?cè)谠O(shè)計(jì) API 時(shí)的影響并不大。
API 設(shè)計(jì)是微服務(wù)設(shè)計(jì)中非常重要的環(huán)節(jié),代表服務(wù)之間交互的方式,會(huì)影響服務(wù)之間的集成。 通常來(lái)說(shuō),一個(gè)好的 API 設(shè)計(jì)需要滿足兩個(gè)主要的目的:
- 平臺(tái)獨(dú)立性。 任何客戶端都能消費(fèi) API,而不需要關(guān)注系統(tǒng)內(nèi)部實(shí)現(xiàn)。API 應(yīng)該使用標(biāo)準(zhǔn)的協(xié)議和消息格式對(duì)外部提供服務(wù)。傳輸協(xié)議和傳輸格式不應(yīng)該侵入到業(yè)務(wù)邏輯中,也就是系統(tǒng)應(yīng)該具備隨時(shí)支持不同傳輸協(xié)議和消息格式的能力。
- 系統(tǒng)可靠性。 在 API 已經(jīng)被發(fā)布和非 API 版本改變的情況下,API 應(yīng)該對(duì)契約負(fù)責(zé),不應(yīng)該導(dǎo)致數(shù)據(jù)格式發(fā)生破壞性的修改。在 API 需要重大更新時(shí),使用版本升級(jí)的方式修改,并對(duì)舊版本預(yù)留下線時(shí)間窗口。
實(shí)踐中發(fā)現(xiàn),API 設(shè)計(jì)是一件很難的事情,同時(shí)也很難衡量設(shè)計(jì)是否優(yōu)秀。根據(jù)系統(tǒng)設(shè)計(jì)和消費(fèi)者的角度,給出了一些簡(jiǎn)單的設(shè)計(jì)原則。
使用成熟度合適的 RESTful API
RESTful 風(fēng)格的 API 具有一些天然的優(yōu)勢(shì),例如通過(guò) HTTP 協(xié)議降低了客戶端的耦合,具有極好的開(kāi)放性。因此越來(lái)越多的開(kāi)發(fā)者使用 RESTful 這種風(fēng)格設(shè)計(jì) API,但是 RESTful 只能算是一個(gè)設(shè)計(jì)思想或理念,不是一個(gè) API 規(guī)范,沒(méi)有一些具體的約束條件。
因此在設(shè)計(jì) RESTful 風(fēng)格的 API 時(shí)候,需要參考 RESTful 成熟度模型。
RESTful 成熟度模型。
根據(jù)自己的應(yīng)用場(chǎng)景選擇對(duì)應(yīng)的成熟度模型,一般來(lái)說(shuō)系統(tǒng)成熟度模型在 Level 2左右。
避免簡(jiǎn)單封裝
API應(yīng)該服務(wù)業(yè)務(wù)能力的封裝,避免簡(jiǎn)單封裝讓API徹底變成了數(shù)據(jù)庫(kù)操作接口。例如標(biāo)記訂單狀態(tài)為已支付,應(yīng)該提供形如POST /orders/1/pay這樣的API。而非PATCH /orders/1,然后通過(guò)具體的字段更新訂單。
因?yàn)橛唵沃Ц妒怯芯唧w的業(yè)務(wù)邏輯,可能涉及到大量復(fù)雜的操作,使用簡(jiǎn)單的更新操作將業(yè)務(wù)邏輯泄漏到系統(tǒng)之外。同時(shí)系統(tǒng)外也需要知道訂單狀態(tài) 這個(gè)內(nèi)部使用的字段。
更重要的是,破壞了業(yè)務(wù)邏輯的封裝,同時(shí)也會(huì)影響其他非功能需求。例如,權(quán)限控制、日志記錄、通知等。
關(guān)注點(diǎn)分離
好的接口應(yīng)該做到不多東西,不少東西。 怎么理解呢?在用戶修改密碼和修改個(gè)人資料的場(chǎng)景中,這兩個(gè)操作看起來(lái)很類似,然后設(shè)計(jì)API的時(shí)候使用了一個(gè)通用的/users/1/udpateURI。
然后定義了一個(gè)對(duì)象,這個(gè)對(duì)象可能直接使用了User這個(gè)類:
{
"username": "用戶名",
"password": "密碼"
}
這個(gè)對(duì)象在修改用戶名的時(shí)候, password是不必要的,但是在修改密碼的操作中,一個(gè)password字段卻不夠用了,可能還需要
confirmPassword。
于是這個(gè)接口變成:
{
"username": "用戶名",
"password":"密碼",
"confirmPassword":"重復(fù)密碼"
}
這種類的復(fù)用會(huì)給后續(xù)維護(hù)的開(kāi)發(fā)者帶來(lái)困惑,同時(shí)對(duì)消費(fèi)者也非常不友好。合理的設(shè)計(jì)應(yīng)該是兩個(gè)分離的 API:
// POST /users/{userId}/password
{
"password":"密碼",
"confirmPassword":"重復(fù)密碼"
}
// PATCH /users/{userId}
{
"username":"用戶名",
"xxxx":"其他可更新的字段"
}
對(duì)應(yīng)的實(shí)現(xiàn),在 JAVA 中需要定義兩個(gè) DTO,分別處理不同的接口。這也體現(xiàn)了面向?qū)ο笏枷胫械年P(guān)注點(diǎn)分離。
完全窮盡,彼此獨(dú)立
API 之間盡量遵守完全窮盡,彼此獨(dú)立 (MECE) 原則,不應(yīng)該提供相互疊加的 API。例如訂單和訂單項(xiàng)這兩個(gè)資源,如果提供了形如 PUT /orders/1/order-items/1 這樣的接口去修改訂單項(xiàng),接口 PUT /orders/1 就不應(yīng)該具備處理某一個(gè) order-item 的能力。
這樣的好處是不會(huì)存在重復(fù)的 API,造成維護(hù)和理解上的復(fù)雜性。如何做到完全窮盡和彼此獨(dú)立呢?
簡(jiǎn)單的方法是使用一個(gè)表格設(shè)計(jì) API,標(biāo)出每個(gè) URI 具備的能力。
API設(shè)計(jì)表格
資源 URL 設(shè)計(jì)來(lái)源于 DDD 領(lǐng)域建模就非常簡(jiǎn)單了,聚合根作為根 URL,實(shí)體作為二級(jí) URI 設(shè)計(jì)。聚合根之間應(yīng)該徹底沒(méi)有任何聯(lián)系,實(shí)體和聚合根之間的責(zé)任應(yīng)該明確。
產(chǎn)生這類問(wèn)題的根源還是缺乏合理的抽象。如果存在 API 中可以通過(guò)用戶組操作用戶,通過(guò)用戶的 URI 操作用戶屬于的用戶組,這其中的問(wèn)題是缺少了成員這一概念。用戶組下面的本質(zhì)上并不是用戶,而是用戶和用戶組的關(guān)系,即成員。
版本化
一個(gè)對(duì)外開(kāi)放的服務(wù),極大的概率會(huì)發(fā)生變化。業(yè)務(wù)變化可能修改 API 參數(shù)或響應(yīng)數(shù)據(jù)結(jié)構(gòu),以及資源之間的關(guān)系。一般來(lái)說(shuō),字段的增加不會(huì)影響舊的客戶端運(yùn)行。但是當(dāng)存在一些破壞性修改時(shí),就需要使用新的版本將數(shù)據(jù)導(dǎo)向到新的資源地址。
版本信息的傳輸,可以通過(guò)下面幾種方式
- URI 前綴
- Header
- Query
比較推薦的做法是使用 URI 前綴,例如/v1/users/表達(dá)獲取 v1 版本下的用戶列表。
常見(jiàn)的反模式是通過(guò)增加 URI 后綴來(lái)實(shí)現(xiàn)的,例如/users/1/updateV2。這樣做的缺陷是版本信息侵入到業(yè)務(wù)邏輯中,對(duì)路由的統(tǒng)一管理帶來(lái)不便。
使用 Header 和 Query 發(fā)送版本信息則較為相似,不同之處在于,使用 URI 前綴在 MVC 框架中實(shí)現(xiàn)相對(duì)簡(jiǎn)單,只需要定義好路由即可。使用 Header 和 Query 還需要編寫額外的攔截器。
合理命名
設(shè)計(jì) API 時(shí)候的命名涉及多個(gè)地方:URI、請(qǐng)求參數(shù)、響應(yīng)數(shù)據(jù)等。通常來(lái)說(shuō)最主要,也是最難的一個(gè)是全局命名統(tǒng)一。
其次,命名需要注意這些:
- 盡可能和領(lǐng)域名詞保持一致,例如聚合根、實(shí)體、事件等
- RESTful 設(shè)計(jì)的 URI 中使用名詞復(fù)數(shù)
- 盡可能不要過(guò)度簡(jiǎn)寫,例如將 user 簡(jiǎn)寫成usr
- 盡可能使用不需要編碼的字符
用領(lǐng)域名詞來(lái)對(duì) API 設(shè)計(jì)命名不是一件特別難的事情。識(shí)別出的領(lǐng)域名詞可以直接作為 URI 來(lái)使用。如果存在多個(gè)單詞的連接可以使用中橫線,例如/orders/1/order-items
安全
安全是任何一項(xiàng)軟件設(shè)計(jì)都必須要考慮的事情,對(duì)于 API 設(shè)計(jì)來(lái)說(shuō),暴露給內(nèi)部系統(tǒng)的 API 和開(kāi)放給外部系統(tǒng)的 API 略有不同。
內(nèi)部系統(tǒng),更多的是考慮是否足夠健壯。對(duì)接收的數(shù)據(jù)有足夠的驗(yàn)證,并給出錯(cuò)誤信息,而不是什么信息都接收,然后內(nèi)部業(yè)務(wù)邏輯應(yīng)該邊界值的影響變得莫名其妙。
而對(duì)于外部系統(tǒng)的 API 則有更多的挑戰(zhàn)。
- 錯(cuò)誤的調(diào)用方式
- 接口濫用
- 瀏覽器消費(fèi) API 時(shí)因安全漏洞導(dǎo)致的非法訪問(wèn)
所以設(shè)計(jì) API 時(shí)應(yīng)該考慮響應(yīng)的應(yīng)對(duì)措施。針對(duì)錯(cuò)誤的調(diào)用方式,API 不應(yīng)該進(jìn)入業(yè)務(wù)處理流程,及時(shí)給出錯(cuò)誤信息;對(duì)于接口濫用的情況,需要做一些限速的方案;對(duì)于一些瀏覽器消費(fèi)者的問(wèn)題,可以在讓 API 返回一些安全增強(qiáng)頭部,例如:X-XSS-Protection、Content-Security-Policy 等。
API 設(shè)計(jì)評(píng)審清單
- URI 命名是否通過(guò)聚合根和實(shí)體統(tǒng)一
- URI 命名是否采用名詞復(fù)數(shù)和連接線
- URI 命名是否都是單詞小寫
- URI 是否暴露了不必要的信息,例如/cgi-bin
- URI 規(guī)則是否統(tǒng)一
- 資源提供的能力是否彼此獨(dú)立
- URI 是否存在需要編碼的字符
- 請(qǐng)求和返回的參數(shù)是否不多不少
- 資源的 ID 參數(shù)是否通過(guò) PATH 參數(shù)傳遞
- 認(rèn)證和授權(quán)信息是否暴露到 query 參數(shù)中
- 參數(shù)是否使用奇怪的縮寫
- 參數(shù)和響應(yīng)數(shù)據(jù)中的字段命名統(tǒng)一
- 是否存在無(wú)意義的對(duì)象包裝 例如{"data":{}'}
- 出錯(cuò)時(shí)是否破壞約定的數(shù)據(jù)結(jié)構(gòu)
- 是否使用合適的狀態(tài)碼
- 是否使用合適的媒體類型
- 響應(yīng)數(shù)據(jù)的單復(fù)是否和數(shù)據(jù)內(nèi)容一致
- 響應(yīng)頭中是否有緩存信息
- 是否進(jìn)行了版本管理
- 版本信息是否作為 URI 的前綴存在
- 是否提供 API 服務(wù)期限
- 是否提供了 API 返回所有 API 的索引
- 是否進(jìn)行了認(rèn)證和授權(quán)
- 是否采用 HTTPS
- 是否檢查了非法參數(shù)
- 是否增加安全性的頭部
- 是否有限流策略
- 是否支持 CORS
- 響應(yīng)中的時(shí)間格式是否采用ISO 8601標(biāo)準(zhǔn)
- 是否存在越權(quán)訪問(wèn)
更多精彩洞見(jiàn),請(qǐng)關(guān)注微信公眾號(hào):ThoughtWorks洞見(jiàn)
文/ThoughtWorks少個(gè)分號(hào)