事實上,阿里這種復雜的業務中如果不劃分清楚 ?DO、BO、DTO、VO 的領域模型,其內部代碼很容易就混亂了,內部的 RPC 在 service 層的基礎上又增加了 manager 層,從而實現內部的規范統一化。但是,如果只是單獨的域又沒有太多外部依賴,那么,完全不要設計這么復雜,除非預期到可能會變得龐大和復雜化。對此,設計過程中因地制宜就顯得特別重要了。

另外一個規范的例子是 RESTful API。在 REST 架構風格中,每一個 URI 代表一種資源。因此,URI 是每一個資源的地址的唯一資源定位符。所謂資源,實際上就是一個信息實體,它可以是服務器上的一段文本、一個文件、一張圖片、一首歌曲,或者是一種服務。RESTful API 規定了通過 GET、 POST、 PUT、 PATCH、 DELETE 等方式對服務端的資源進行操作。

  1. 【GET】 /users # 查詢用戶信息列表
  2. 【GET】 /users/1001 # 查看某個用戶信息
  3. 【POST】 /users # 新建用戶信息
  4. 【PUT】 /users/1001 # 更新用戶信息(全部字段)
  5. 【PATCH】 /users/1001 # 更新用戶信息(部分字段)
  6. 【DELETE】 /users/1001 # 刪除用戶信息

事實上,RESTful API 的實現分了四個層級。第一層次(Level 0)的 Web API 服務只是使用 HTTP 作為傳輸方式。第二層次(Level 1)的 Web API 服務引入了資源的概念。每個資源有對應的標識符和表達。第三層次(Level 2)的 Web API 服務使用不同的 HTTP 方法來進行不同的操作,并且使用 HTTP 狀態碼來表示不同的結果。第四層次(Level 3)的 Web API 服務使用 HATEOAS。在資源的表達中包含了鏈接信息??蛻舳丝梢愿鶕溄觼戆l現可以執行的動作。通常情況下,偽 RESTful API 都是基于第一層次與第二層次設計的。例如,我們的 Web API 中使用各種動詞,例如?get_menu?和?save_menu?,而真正意義上的 RESTful API 需要滿足第三層級以上。如果我們遵守了這套規范,我們就很可能就設計出通俗易懂的 API。

注意的是,定義好的規范,我們已經成功了一大半。如果這套規范是業內標準,那么我們可以大膽實踐,不要擔心別人不會用,只要把業界標準丟給他好好學習一下就可以啦。例如,Spring 已經在 Java 的生態中舉足輕重,如果一個新人不懂 Spring 就有點說不過去了。但是,很多時候因為業務的限制和公司的技術,我們可能使用基于第一層次與第二層次設計的偽 RESTful API,但是它不一定就是落后的,不好的,只要團隊內部形成規范,降低大家的學習成本即可。很多時候,我們試圖改變團隊的習慣去學習一個新的規范,所帶來的收益(投入產出比)甚微,那就得不償失了。

總結一下,定義好的規范的目的在于,降低學習成本,使得 API 盡可能通俗易懂。當然,設計的 API 通俗易懂還有其他方式,例如我們定義的 API 的名字易于理解,API 的實現盡可能通用等。

二、探討 API 接口的兼容性

API 接口都是不斷演進的。因此,我們需要在一定程度上適應變化。在 RESTful API 中,API 接口應該盡量兼容之前的版本。但是,在實際業務開發場景中,可能隨著業務需求的不斷迭代,現有的 API 接口無法支持舊版本的適配,此時如果強制升級服務端的 API 接口將導致客戶端舊有功能出現故障。實際上,Web 端是部署在服務器,因此它可以很容易為了適配服務端的新的 API 接口進行版本升級,然而像 Android 端、IOS 端、PC 端等其他客戶端是運行在用戶的機器上,因此當前產品很難做到適配新的服務端的 API 接口,從而出現功能故障,這種情況下,用戶必須升級產品到最新的版本才能正常使用。為了解決這個版本不兼容問題,在設計 RESTful API 的一種實用的做法是使用版本號。一般情況下,我們會在 url 中保留版本號,并同時兼容多個版本。


GET


/
v1
/
users
/{
user_id
}

// 版本 v1 的查詢用戶列表的 API 接口


GET


/
v2
/
users
/{
user_id
}

// 版本 v2 的查詢用戶列表的 API 接口

現在,我們可以不改變版本 v1 的查詢用戶列表的 API 接口的情況下,新增版本 v2 的查詢用戶列表的 API 接口以滿足新的業務需求,此時,客戶端的產品的新功能將請求新的服務端的 API 接口地址。雖然服務端會同時兼容多個版本,但是同時維護太多版本對于服務端而言是個不小的負擔,因為服務端要維護多套代碼。這種情況下,常見的做法不是維護所有的兼容版本,而是只維護最新的幾個兼容版本,例如維護最新的三個兼容版本。在一段時間后,當絕大多數用戶升級到較新的版本后,廢棄一些使用量較少的服務端的老版本API 接口版本,并要求使用產品的非常舊的版本的用戶強制升級。注意的是,“不改變版本 v1 的查詢用戶列表的 API 接口”主要指的是對于客戶端的調用者而言它看起來是沒有改變。而實際上,如果業務變化太大,服務端的開發人員需要對舊版本的 API 接口使用適配器模式將請求適配到新的API 接口上。

有趣的是,GraphQL 提供不同的思路。GraphQL 為了解決服務 API 接口爆炸的問題,以及將多個 HTTP 請求聚合成了一個請求,提出只暴露單個服務 API 接口,并且在單個請求中可以進行多個查詢。GraphQL 定義了 API 接口,我們可以在前端更加靈活調用,例如,我們可以根據不同的業務選擇并加載需要渲染的字段。因此,服務端提供的全量字段,前端可以按需獲取。GraphQL 可以通過增加新類型和基于這些類型的新字段添加新功能,而不會造成兼容性問題。

此外,在使用 RPC API 過程中,我們特別需要注意兼容性問題,二方庫不能依賴 parent,此外,本地開發可以使用 SNAPSHOT,而線上環境禁止使用,避免發生變更,導致版本不兼容問題。我們需要為每個接口都應定義版本號,保證后續不兼容的情況下可以升級版本。例如,Dubbo 建議第三位版本號通常表示兼容升級,只有不兼容時才需要變更服務版本。

關于規范的案例,我們可以看看 k8s 和 github,其中 k8s 采用了 RESTful API,而 github 部分采用了 GraphQL。

三、提供清晰的思維模型

所謂思維模型,我的理解是針對問題域抽象模型,對域模型的功能有統一認知,構建某個問題的現實映射,并劃分好模型的邊界,而域模型的價值之一就是統一思想,明確邊界。假設,大家沒有清晰的思維模型,那么也不存在對 API 的統一認知,那么就很可能出現下面圖片中的現實問題。

四、以抽象的方式屏蔽業務實現

我認為好的 API 接口具有抽象性,因此需要盡可能的屏蔽業務實現。那么,問題來了,我們怎么理解抽象性?對此,我們可以思考 java.sql.Driver 的設計。這里,java.sql.Driver 是一個規范接口,而 com.mysql.jdbc.Driver
則是 mysql-connector-java-xxx.jar 對這個規范的實現接口。那么,切換成 Oracle 的成本就非常低了。

一般情況下,我們會通過 API 對外提供服務。這里,API 提供服務的接口的邏輯是固定的,換句話說,它具有通用性。但是,但我們遇到具有類似的業務邏輯的場景時,即核心的主干邏輯相同,而細節的實現略有不同,那我們該何去何從?很多時候,我們會選擇提供多個 API 接口給不同的業務方使用。事實上,我們可以通過 SPI 擴展點來實現的更加優雅。什么是 SPI?SPI 的英文全稱是 Serivce Provider Interface,即服務提供者接口,它是一種動態發現機制,可以在程序執行的過程中去動態的發現某個擴展點的實現類。因此,當 API 被調用時會動態加載并調用 SPI 的特定實現方法。

此時,你是不是聯想到了模版方法模式。模板方法模式的核心思想是定義骨架,轉移實現,換句話說,它通過定義一個流程的框架,而將一些步驟的具體實現延遲到子類中。事實上,在微服務的落地過程中,這種思想也給我們提供了非常好的理論基礎。

現在,我們來看一個案例:電商業務場景中的未發貨僅退款。這種情況在電商業務中非常場景,用戶下單付款后由于各種原因可能就申請退款了。此時,因為不涉及退貨,所以只需要用戶申請退款并填寫退款原因,然后讓賣家審核退款。那么,由于不同平臺的退款原因可能不同,我們可以考慮通過 SPI 擴展點來實現。

此外,我們還經常使用工廠方法+策略模式來屏蔽內部的復雜性。例如,我們對外暴露一個 API 接口 getTask(int operation),那么我們就可以通過工廠方法來創建實例,通過策略方法來定義不同的實現。其中,operation?就是具體的指令。

@Component

public

class

TaskManager

{

private

static

final

Logger
logger
=

LoggerFactory
.
getLogger
(
TaskManager
.
class
);

private

static

TaskManager
instance
;

public

Map
<
Integer
,

ITask
>
taskMap
=

new

HashMap
<
Integer
,

ITask
>();

public

static

TaskManager
getInstance
()

{

return
instance
;

}

public

ITask
getTask
(
int
operation
)

{

return
taskMap
.
get
(
operation
);

}

/**

* 初始化處理過程

*/

@PostConstruct

private

void
init
()

{

logger
.
info
(
"init task manager"
);

instance
=

new

TaskManager
();

// 單聊消息任務

instance
.
taskMap
.
put
(
EventEnum
.
CHAT_REQ
.
getValue
(),

new

ChatTask
());

// 群聊消息任務

instance
.
taskMap
.
put
(
EventEnum
.
GROUP_CHAT_REQ
.
getValue
(),

new

GroupChatTask
());

// 心跳任務

instance
.
taskMap
.
put
(
EventEnum
.
HEART_BEAT_REQ
.
getValue
(),

new

HeatBeatTask
());

}

}

還有一種屏蔽內部復雜性設計就是外觀接口,它是將多個服務的接口進行業務封裝與整合并提供一個簡單的調用接口給客戶端使用。這種設計的好處在于,客戶端不再需要知道那么多服務的接口,只需要調用這個外觀接口即可。但是,壞處也是顯而易見的,即增加了服務端的業務復雜度,接口性能不高,并且復用性不高。因此,因地制宜,盡可能保證職責單一,而在客戶端進行“樂高式”組裝。如果存在 SEO 優化的產品,需要被類似于百度這樣的搜索引擎收錄,可以當首屏的時候,通過服務端渲染生成 HTML,使之讓搜索引擎收錄,若不是首屏的時候,可以通過客戶端調用服務端 RESTful API 接口進行頁面渲染。

此外,隨著微服務的普及,我們的服務越來越多,許多較小的服務有更多的跨服務調用。因此,微服務體系結構使得這個問題更加普遍。為了解決這個問題,我們可以考慮引入一個“聚合服務”,它是一個組合服務,可以將多個微服務的數據進行組合。這樣設計的好處在于,通過一個“聚合服務”將一些信息整合完后再返回給調用方。注意的是,“聚合服務”也可以有自己的緩存和數據庫。事實上,聚合服務的思想無處不在,例如 Serverless 架構。我們可以在實踐的過程中采用 AWS Lambda 作為 Serverless 服務背后的計算引擎,而 AWS Lambda 是一種函數即服務(Function-as-a-Servcie,FaaS)的計算服務,我們直接編寫運行在云上的函數。那么,這個函數可以組裝現有能力做服務聚合。

當然,還有很多很好的設計,我也會在陸續在公眾號中以續補的方式進行補充與探討。

五、考慮背后的性能

我們需要考慮入參字段的各種組合導致數據庫的性能問題。有的時候,我們可能暴露太多字段給外部組合使用,導致數據庫沒有相應的索引而發生全表掃描。事實上,這種情況在查詢的場景特別常見。因此,我們可以只提供存在索引的字段組合給外部調用,或者在下面的案例中,要求調用方必填 taskId 和 caseId 來保證我們數據庫合理使用索引,進一步保證服務提供方的服務性能。

Result
<
Void
>
agree
(
Long
taskId
,

Long
caseId
,

Configger
configger
);

同時,對于報表操作、批量操作、冷數據查詢等 API 應該可以考慮異步能力。

GraphQL 雖然解決將多個 HTTP 請求聚合成了一個請求,但是 schema 會逐層解析方式遞歸獲取全部數據。例如分頁查詢的統計總條數,原本 1 次可以搞定的查詢,演變成了 N + 1 次對數據庫查詢。此外,如果寫得不合理還會導致惡劣的性能問題,因此,我們在設計的過程中特別需要注意。

六、異常響應與錯誤機制

業內對 RPC API 拋出異常,還是拋出錯誤碼已經有太多的爭論?!栋⒗锇桶?Java 開發手冊》建議:跨應用 RPC 調用優先考慮使用 isSuccess() 方法、“錯誤碼”、“錯誤簡短信息”。關于 RPC 方法返回方式使用 Result 方式的理由 : 1)使用拋異常返回方式,調用方如果沒有捕獲到,就會產生運行時錯誤。2)如果不加棧信息,只是 new 自定義異常,加入自己的理解的 error message,對于調用端解決問題的幫助不會太多。如果加了棧信息,在頻繁調用出錯的情況下,數據序列化和傳輸的性能損耗也是問題。當然,我也支持這個論點的實踐擁護者。

public

Result
<
XxxDTO
>
getXxx
(
String
param
)

{

try

{

// ...

return

Result
.
create
(
xxxDTO
);

}

catch

(
BizException
e
)

{

log
.
error
(
"..."
,
e
);

return

Result
.
createErrorResult
(
e
.
getErrorCode
(),
e
.
getErrorInfo
(),

true
);

}

}

Web API 設計過程中,我們會使用 ControllerAdvice 統一包裝錯誤信息。而在微服務復雜的鏈式調用中,我們會比單體架構更難以追蹤與定位問題。因此,在設計的時候,需要特別注意。一種比較好的方案是,當 RESTful API 接口出現非 2xx 的 HTTP 錯誤碼響應時,采用全局的異常結構響應信息。其中,code 字段用來表示某類錯誤的錯誤碼,在微服務中應該加上“{bizname}/”前綴以便于定位錯誤發生在哪個業務系統上。我們來看一個案例,假設“用戶中心”某個接口沒有權限獲取資源而出現錯誤,我們的業務系統可以響應“UC/AUTHDENIED”,并且通過自動生成的 UUID 值的 request_id 字段,在日志系統中獲得錯誤的詳細信息。

HTTP
/
1.1

400

Bad

Request

Content
-
Type
:
application
/
json

{

"code"
:

"INVALID_ARGUMENT"
,

"message"
:

"{error message}"
,

"cause"
:

"{cause message}"
,

"request_id"
:

"01234567-89ab-cdef-0123-456789abcdef"
,

"host_id"
:

"{server identity}"
,

"server_time"
:

"2014-01-01T12:00:00Z"

}

七、思考 API 的冪等性

冪等機制的核心是保證資源唯一性,例如客戶端重復提交或服務端的多次重試只會產生一份結果。支付場景、退款場景,涉及金錢的交易不能出現多次扣款等問題。事實上,查詢接口用于獲取資源,因為它只是查詢數據而不會影響到資源的變化,因此不管調用多少次接口,資源都不會改變,所以是它是冪等的。而新增接口是非冪等的,因為調用接口多次,它都將會產生資源的變化。因此,我們需要在出現重復提交時進行冪等處理。那么,如何保證冪等機制呢?事實上,我們有很多實現方案。其中,一種方案就是常見的創建唯一索引。在數據庫中針對我們需要約束的資源字段創建唯一索引,可以防止插入重復的數據。但是,遇到分庫分表的情況是,唯一索引也就不那么好使了,此時,我們可以先查詢一次數據庫,然后判斷是否約束的資源字段存在重復,沒有的重復時再進行插入操作。注意的是,為了避免并發場景,我們可以通過鎖機制,例如悲觀鎖與樂觀鎖保證數據的唯一性。這里,分布式鎖是一種經常使用的方案,它通常情況下是一種悲觀鎖的實現。但是,很多人經常把悲觀鎖、樂觀鎖、分布式鎖當作冪等機制的解決方案,這個是不正確的。除此之外,我們還可以引入狀態機,通過狀態機進行狀態的約束以及狀態跳轉,確保同一個業務的流程化執行,從而實現數據冪等。事實上,并不是所有的接口都要保證冪等,換句話說,是否需要冪等機制可以通過考量需不需要確保資源唯一性,例如行為日志可以不考慮冪等性。當然,還有一種設計方案是接口不考慮冪等機制,而是在業務實現的時候通過業務層面來保證,例如允許存在多份數據,但是在業務處理的時候獲取最新的版本進行處理。

本文章轉載微信公眾號@服務端思維

上一篇:

如何在 React 中獲取 API 數據

下一篇:

掌握如何搭建高效的大模型任務流(一):LangChain任務流構建
#你可能也喜歡這些API文章!

我們有何不同?

API服務商零注冊

多API并行試用

數據驅動選型,提升決策效率

查看全部API→
??

熱門場景實測,選對API

#AI文本生成大模型API

對比大模型API的內容創意新穎性、情感共鳴力、商業轉化潛力

25個渠道
一鍵對比試用API 限時免費

#AI深度推理大模型API

對比大模型API的邏輯推理準確性、分析深度、可視化建議合理性

10個渠道
一鍵對比試用API 限時免費