
理解 Composition API Vue 的基礎(chǔ)與模式
事實上,阿里這種復(fù)雜的業(yè)務(wù)中如果不劃分清楚 ?DO、BO、DTO、VO 的領(lǐng)域模型,其內(nèi)部代碼很容易就混亂了,內(nèi)部的 RPC 在 service 層的基礎(chǔ)上又增加了 manager 層,從而實現(xiàn)內(nèi)部的規(guī)范統(tǒng)一化。但是,如果只是單獨的域又沒有太多外部依賴,那么,完全不要設(shè)計這么復(fù)雜,除非預(yù)期到可能會變得龐大和復(fù)雜化。對此,設(shè)計過程中因地制宜就顯得特別重要了。
另外一個規(guī)范的例子是 RESTful API。在 REST 架構(gòu)風(fēng)格中,每一個 URI 代表一種資源。因此,URI 是每一個資源的地址的唯一資源定位符。所謂資源,實際上就是一個信息實體,它可以是服務(wù)器上的一段文本、一個文件、一張圖片、一首歌曲,或者是一種服務(wù)。RESTful API 規(guī)定了通過 GET、 POST、 PUT、 PATCH、 DELETE 等方式對服務(wù)端的資源進(jìn)行操作。
【GET】 /users # 查詢用戶信息列表
【GET】 /users/1001 # 查看某個用戶信息
【POST】 /users # 新建用戶信息
【PUT】 /users/1001 # 更新用戶信息(全部字段)
【PATCH】 /users/1001 # 更新用戶信息(部分字段)
【DELETE】 /users/1001 # 刪除用戶信息
事實上,RESTful API 的實現(xiàn)分了四個層級。第一層次(Level 0)的 Web API 服務(wù)只是使用 HTTP 作為傳輸方式。第二層次(Level 1)的 Web API 服務(wù)引入了資源的概念。每個資源有對應(yīng)的標(biāo)識符和表達(dá)。第三層次(Level 2)的 Web API 服務(wù)使用不同的 HTTP 方法來進(jìn)行不同的操作,并且使用 HTTP 狀態(tài)碼來表示不同的結(jié)果。第四層次(Level 3)的 Web API 服務(wù)使用 HATEOAS。在資源的表達(dá)中包含了鏈接信息。客戶端可以根據(jù)鏈接來發(fā)現(xiàn)可以執(zhí)行的動作。通常情況下,偽 RESTful API 都是基于第一層次與第二層次設(shè)計的。例如,我們的 Web API 中使用各種動詞,例如?get_menu
?和?save_menu
?,而真正意義上的 RESTful API 需要滿足第三層級以上。如果我們遵守了這套規(guī)范,我們就很可能就設(shè)計出通俗易懂的 API。
注意的是,定義好的規(guī)范,我們已經(jīng)成功了一大半。如果這套規(guī)范是業(yè)內(nèi)標(biāo)準(zhǔn),那么我們可以大膽實踐,不要擔(dān)心別人不會用,只要把業(yè)界標(biāo)準(zhǔn)丟給他好好學(xué)習(xí)一下就可以啦。例如,Spring 已經(jīng)在 Java 的生態(tài)中舉足輕重,如果一個新人不懂 Spring 就有點說不過去了。但是,很多時候因為業(yè)務(wù)的限制和公司的技術(shù),我們可能使用基于第一層次與第二層次設(shè)計的偽 RESTful API,但是它不一定就是落后的,不好的,只要團(tuán)隊內(nèi)部形成規(guī)范,降低大家的學(xué)習(xí)成本即可。很多時候,我們試圖改變團(tuán)隊的習(xí)慣去學(xué)習(xí)一個新的規(guī)范,所帶來的收益(投入產(chǎn)出比)甚微,那就得不償失了。
總結(jié)一下,定義好的規(guī)范的目的在于,降低學(xué)習(xí)成本,使得 API 盡可能通俗易懂。當(dāng)然,設(shè)計的 API 通俗易懂還有其他方式,例如我們定義的 API 的名字易于理解,API 的實現(xiàn)盡可能通用等。
API 接口都是不斷演進(jìn)的。因此,我們需要在一定程度上適應(yīng)變化。在 RESTful API 中,API 接口應(yīng)該盡量兼容之前的版本。但是,在實際業(yè)務(wù)開發(fā)場景中,可能隨著業(yè)務(wù)需求的不斷迭代,現(xiàn)有的 API 接口無法支持舊版本的適配,此時如果強(qiáng)制升級服務(wù)端的 API 接口將導(dǎo)致客戶端舊有功能出現(xiàn)故障。實際上,Web 端是部署在服務(wù)器,因此它可以很容易為了適配服務(wù)端的新的 API 接口進(jìn)行版本升級,然而像 Android 端、IOS 端、PC 端等其他客戶端是運(yùn)行在用戶的機(jī)器上,因此當(dāng)前產(chǎn)品很難做到適配新的服務(wù)端的 API 接口,從而出現(xiàn)功能故障,這種情況下,用戶必須升級產(chǎn)品到最新的版本才能正常使用。為了解決這個版本不兼容問題,在設(shè)計 RESTful API 的一種實用的做法是使用版本號。一般情況下,我們會在 url 中保留版本號,并同時兼容多個版本。
【
GET
】
/
v1
/
users
/{
user_id
}
// 版本 v1 的查詢用戶列表的 API 接口
【
GET
】
/
v2
/
users
/{
user_id
}
// 版本 v2 的查詢用戶列表的 API 接口
現(xiàn)在,我們可以不改變版本 v1 的查詢用戶列表的 API 接口的情況下,新增版本 v2 的查詢用戶列表的 API 接口以滿足新的業(yè)務(wù)需求,此時,客戶端的產(chǎn)品的新功能將請求新的服務(wù)端的 API 接口地址。雖然服務(wù)端會同時兼容多個版本,但是同時維護(hù)太多版本對于服務(wù)端而言是個不小的負(fù)擔(dān),因為服務(wù)端要維護(hù)多套代碼。這種情況下,常見的做法不是維護(hù)所有的兼容版本,而是只維護(hù)最新的幾個兼容版本,例如維護(hù)最新的三個兼容版本。在一段時間后,當(dāng)絕大多數(shù)用戶升級到較新的版本后,廢棄一些使用量較少的服務(wù)端的老版本API 接口版本,并要求使用產(chǎn)品的非常舊的版本的用戶強(qiáng)制升級。注意的是,“不改變版本 v1 的查詢用戶列表的 API 接口”主要指的是對于客戶端的調(diào)用者而言它看起來是沒有改變。而實際上,如果業(yè)務(wù)變化太大,服務(wù)端的開發(fā)人員需要對舊版本的 API 接口使用適配器模式將請求適配到新的API 接口上。
有趣的是,GraphQL 提供不同的思路。GraphQL 為了解決服務(wù) API 接口爆炸的問題,以及將多個 HTTP 請求聚合成了一個請求,提出只暴露單個服務(wù) API 接口,并且在單個請求中可以進(jìn)行多個查詢。GraphQL 定義了 API 接口,我們可以在前端更加靈活調(diào)用,例如,我們可以根據(jù)不同的業(yè)務(wù)選擇并加載需要渲染的字段。因此,服務(wù)端提供的全量字段,前端可以按需獲取。GraphQL 可以通過增加新類型和基于這些類型的新字段添加新功能,而不會造成兼容性問題。
此外,在使用 RPC API 過程中,我們特別需要注意兼容性問題,二方庫不能依賴 parent,此外,本地開發(fā)可以使用 SNAPSHOT,而線上環(huán)境禁止使用,避免發(fā)生變更,導(dǎo)致版本不兼容問題。我們需要為每個接口都應(yīng)定義版本號,保證后續(xù)不兼容的情況下可以升級版本。例如,Dubbo 建議第三位版本號通常表示兼容升級,只有不兼容時才需要變更服務(wù)版本。
關(guān)于規(guī)范的案例,我們可以看看 k8s 和 github,其中 k8s 采用了 RESTful API,而 github 部分采用了 GraphQL。
所謂思維模型,我的理解是針對問題域抽象模型,對域模型的功能有統(tǒng)一認(rèn)知,構(gòu)建某個問題的現(xiàn)實映射,并劃分好模型的邊界,而域模型的價值之一就是統(tǒng)一思想,明確邊界。假設(shè),大家沒有清晰的思維模型,那么也不存在對 API 的統(tǒng)一認(rèn)知,那么就很可能出現(xiàn)下面圖片中的現(xiàn)實問題。
我認(rèn)為好的 API 接口具有抽象性,因此需要盡可能的屏蔽業(yè)務(wù)實現(xiàn)。那么,問題來了,我們怎么理解抽象性?對此,我們可以思考 java.sql.Driver 的設(shè)計。這里,java.sql.Driver 是一個規(guī)范接口,而 com.mysql.jdbc.Driver
則是 mysql-connector-java-xxx.jar 對這個規(guī)范的實現(xiàn)接口。那么,切換成 Oracle 的成本就非常低了。
一般情況下,我們會通過 API 對外提供服務(wù)。這里,API 提供服務(wù)的接口的邏輯是固定的,換句話說,它具有通用性。但是,但我們遇到具有類似的業(yè)務(wù)邏輯的場景時,即核心的主干邏輯相同,而細(xì)節(jié)的實現(xiàn)略有不同,那我們該何去何從?很多時候,我們會選擇提供多個 API 接口給不同的業(yè)務(wù)方使用。事實上,我們可以通過 SPI 擴(kuò)展點來實現(xiàn)的更加優(yōu)雅。什么是 SPI?SPI 的英文全稱是 Serivce Provider Interface,即服務(wù)提供者接口,它是一種動態(tài)發(fā)現(xiàn)機(jī)制,可以在程序執(zhí)行的過程中去動態(tài)的發(fā)現(xiàn)某個擴(kuò)展點的實現(xiàn)類。因此,當(dāng) API 被調(diào)用時會動態(tài)加載并調(diào)用 SPI 的特定實現(xiàn)方法。
此時,你是不是聯(lián)想到了模版方法模式。模板方法模式的核心思想是定義骨架,轉(zhuǎn)移實現(xiàn),換句話說,它通過定義一個流程的框架,而將一些步驟的具體實現(xiàn)延遲到子類中。事實上,在微服務(wù)的落地過程中,這種思想也給我們提供了非常好的理論基礎(chǔ)。
現(xiàn)在,我們來看一個案例:電商業(yè)務(wù)場景中的未發(fā)貨僅退款。這種情況在電商業(yè)務(wù)中非常場景,用戶下單付款后由于各種原因可能就申請退款了。此時,因為不涉及退貨,所以只需要用戶申請退款并填寫退款原因,然后讓賣家審核退款。那么,由于不同平臺的退款原因可能不同,我們可以考慮通過 SPI 擴(kuò)展點來實現(xiàn)。
此外,我們還經(jīng)常使用工廠方法+策略模式來屏蔽內(nèi)部的復(fù)雜性。例如,我們對外暴露一個 API 接口 getTask(int operation),那么我們就可以通過工廠方法來創(chuàng)建實例,通過策略方法來定義不同的實現(xiàn)。其中,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
();
// 單聊消息任務(wù)
instance
.
taskMap
.
put
(
EventEnum
.
CHAT_REQ
.
getValue
(),
new
ChatTask
());
// 群聊消息任務(wù)
instance
.
taskMap
.
put
(
EventEnum
.
GROUP_CHAT_REQ
.
getValue
(),
new
GroupChatTask
());
// 心跳任務(wù)
instance
.
taskMap
.
put
(
EventEnum
.
HEART_BEAT_REQ
.
getValue
(),
new
HeatBeatTask
());
}
}
還有一種屏蔽內(nèi)部復(fù)雜性設(shè)計就是外觀接口,它是將多個服務(wù)的接口進(jìn)行業(yè)務(wù)封裝與整合并提供一個簡單的調(diào)用接口給客戶端使用。這種設(shè)計的好處在于,客戶端不再需要知道那么多服務(wù)的接口,只需要調(diào)用這個外觀接口即可。但是,壞處也是顯而易見的,即增加了服務(wù)端的業(yè)務(wù)復(fù)雜度,接口性能不高,并且復(fù)用性不高。因此,因地制宜,盡可能保證職責(zé)單一,而在客戶端進(jìn)行“樂高式”組裝。如果存在 SEO 優(yōu)化的產(chǎn)品,需要被類似于百度這樣的搜索引擎收錄,可以當(dāng)首屏的時候,通過服務(wù)端渲染生成 HTML,使之讓搜索引擎收錄,若不是首屏的時候,可以通過客戶端調(diào)用服務(wù)端 RESTful API 接口進(jìn)行頁面渲染。
此外,隨著微服務(wù)的普及,我們的服務(wù)越來越多,許多較小的服務(wù)有更多的跨服務(wù)調(diào)用。因此,微服務(wù)體系結(jié)構(gòu)使得這個問題更加普遍。為了解決這個問題,我們可以考慮引入一個“聚合服務(wù)”,它是一個組合服務(wù),可以將多個微服務(wù)的數(shù)據(jù)進(jìn)行組合。這樣設(shè)計的好處在于,通過一個“聚合服務(wù)”將一些信息整合完后再返回給調(diào)用方。注意的是,“聚合服務(wù)”也可以有自己的緩存和數(shù)據(jù)庫。事實上,聚合服務(wù)的思想無處不在,例如 Serverless 架構(gòu)。我們可以在實踐的過程中采用 AWS Lambda 作為 Serverless 服務(wù)背后的計算引擎,而 AWS Lambda 是一種函數(shù)即服務(wù)(Function-as-a-Servcie,F(xiàn)aaS)的計算服務(wù),我們直接編寫運(yùn)行在云上的函數(shù)。那么,這個函數(shù)可以組裝現(xiàn)有能力做服務(wù)聚合。
當(dāng)然,還有很多很好的設(shè)計,我也會在陸續(xù)在公眾號中以續(xù)補(bǔ)的方式進(jìn)行補(bǔ)充與探討。
我們需要考慮入?yún)⒆侄蔚母鞣N組合導(dǎo)致數(shù)據(jù)庫的性能問題。有的時候,我們可能暴露太多字段給外部組合使用,導(dǎo)致數(shù)據(jù)庫沒有相應(yīng)的索引而發(fā)生全表掃描。事實上,這種情況在查詢的場景特別常見。因此,我們可以只提供存在索引的字段組合給外部調(diào)用,或者在下面的案例中,要求調(diào)用方必填 taskId 和 caseId 來保證我們數(shù)據(jù)庫合理使用索引,進(jìn)一步保證服務(wù)提供方的服務(wù)性能。
Result
<
Void
>
agree
(
Long
taskId
,
Long
caseId
,
Configger
configger
);
同時,對于報表操作、批量操作、冷數(shù)據(jù)查詢等 API 應(yīng)該可以考慮異步能力。
GraphQL 雖然解決將多個 HTTP 請求聚合成了一個請求,但是 schema 會逐層解析方式遞歸獲取全部數(shù)據(jù)。例如分頁查詢的統(tǒng)計總條數(shù),原本 1 次可以搞定的查詢,演變成了 N + 1 次對數(shù)據(jù)庫查詢。此外,如果寫得不合理還會導(dǎo)致惡劣的性能問題,因此,我們在設(shè)計的過程中特別需要注意。
業(yè)內(nèi)對 RPC API 拋出異常,還是拋出錯誤碼已經(jīng)有太多的爭論。《阿里巴巴 Java 開發(fā)手冊》建議:跨應(yīng)用 RPC 調(diào)用優(yōu)先考慮使用 isSuccess() 方法、“錯誤碼”、“錯誤簡短信息”。關(guān)于 RPC 方法返回方式使用 Result 方式的理由 : 1)使用拋異常返回方式,調(diào)用方如果沒有捕獲到,就會產(chǎn)生運(yùn)行時錯誤。2)如果不加棧信息,只是 new 自定義異常,加入自己的理解的 error message,對于調(diào)用端解決問題的幫助不會太多。如果加了棧信息,在頻繁調(diào)用出錯的情況下,數(shù)據(jù)序列化和傳輸?shù)男阅軗p耗也是問題。當(dāng)然,我也支持這個論點的實踐擁護(hù)者。
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 設(shè)計過程中,我們會使用 ControllerAdvice 統(tǒng)一包裝錯誤信息。而在微服務(wù)復(fù)雜的鏈?zhǔn)秸{(diào)用中,我們會比單體架構(gòu)更難以追蹤與定位問題。因此,在設(shè)計的時候,需要特別注意。一種比較好的方案是,當(dāng) RESTful API 接口出現(xiàn)非 2xx 的 HTTP 錯誤碼響應(yīng)時,采用全局的異常結(jié)構(gòu)響應(yīng)信息。其中,code 字段用來表示某類錯誤的錯誤碼,在微服務(wù)中應(yīng)該加上“{bizname}/”前綴以便于定位錯誤發(fā)生在哪個業(yè)務(wù)系統(tǒng)上。我們來看一個案例,假設(shè)“用戶中心”某個接口沒有權(quán)限獲取資源而出現(xiàn)錯誤,我們的業(yè)務(wù)系統(tǒng)可以響應(yīng)“UC/AUTHDENIED”,并且通過自動生成的 UUID 值的 request_id 字段,在日志系統(tǒng)中獲得錯誤的詳細(xì)信息。
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"
}
冪等機(jī)制的核心是保證資源唯一性,例如客戶端重復(fù)提交或服務(wù)端的多次重試只會產(chǎn)生一份結(jié)果。支付場景、退款場景,涉及金錢的交易不能出現(xiàn)多次扣款等問題。事實上,查詢接口用于獲取資源,因為它只是查詢數(shù)據(jù)而不會影響到資源的變化,因此不管調(diào)用多少次接口,資源都不會改變,所以是它是冪等的。而新增接口是非冪等的,因為調(diào)用接口多次,它都將會產(chǎn)生資源的變化。因此,我們需要在出現(xiàn)重復(fù)提交時進(jìn)行冪等處理。那么,如何保證冪等機(jī)制呢?事實上,我們有很多實現(xiàn)方案。其中,一種方案就是常見的創(chuàng)建唯一索引。在數(shù)據(jù)庫中針對我們需要約束的資源字段創(chuàng)建唯一索引,可以防止插入重復(fù)的數(shù)據(jù)。但是,遇到分庫分表的情況是,唯一索引也就不那么好使了,此時,我們可以先查詢一次數(shù)據(jù)庫,然后判斷是否約束的資源字段存在重復(fù),沒有的重復(fù)時再進(jìn)行插入操作。注意的是,為了避免并發(fā)場景,我們可以通過鎖機(jī)制,例如悲觀鎖與樂觀鎖保證數(shù)據(jù)的唯一性。這里,分布式鎖是一種經(jīng)常使用的方案,它通常情況下是一種悲觀鎖的實現(xiàn)。但是,很多人經(jīng)常把悲觀鎖、樂觀鎖、分布式鎖當(dāng)作冪等機(jī)制的解決方案,這個是不正確的。除此之外,我們還可以引入狀態(tài)機(jī),通過狀態(tài)機(jī)進(jìn)行狀態(tài)的約束以及狀態(tài)跳轉(zhuǎn),確保同一個業(yè)務(wù)的流程化執(zhí)行,從而實現(xiàn)數(shù)據(jù)冪等。事實上,并不是所有的接口都要保證冪等,換句話說,是否需要冪等機(jī)制可以通過考量需不需要確保資源唯一性,例如行為日志可以不考慮冪等性。當(dāng)然,還有一種設(shè)計方案是接口不考慮冪等機(jī)制,而是在業(yè)務(wù)實現(xiàn)的時候通過業(yè)務(wù)層面來保證,例如允許存在多份數(shù)據(jù),但是在業(yè)務(wù)處理的時候獲取最新的版本進(jìn)行處理。
本文章轉(zhuǎn)載微信公眾號@服務(wù)端思維