原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch5-web/ch5-08-interface-and-web.html
在 Web 項目中經(jīng)常會遇到外部依賴環(huán)境的變化,比如:
嗯,所以你看到了,我們的外部依賴總是為了自己爽而不斷地做升級,且不想做向前兼容,然后來給我們下最后通牒。如果我們的部門工作飽和,領(lǐng)導(dǎo)強(qiáng)勢,那么有時候也可以倒逼依賴方來做兼容。但世事不一定如人愿,即使我們的領(lǐng)導(dǎo)強(qiáng)勢,讀者朋友的領(lǐng)導(dǎo)也還是可能認(rèn)慫的。
我們可以思考一下怎么緩解這個問題。
互聯(lián)網(wǎng)公司只要可以活過三年,工程方面面臨的首要問題就是代碼膨脹。系統(tǒng)的代碼膨脹之后,可以將系統(tǒng)中與業(yè)務(wù)本身流程無關(guān)的部分做拆解和異步化。什么算是業(yè)務(wù)無關(guān)呢,比如一些統(tǒng)計、反作弊、營銷發(fā)券、價格計算、用戶狀態(tài)更新等等需求。這些需求往往依賴于主流程的數(shù)據(jù),但又只是掛在主流程上的旁支,自成體系。
這時候我們就可以把這些旁支拆解出去,作為獨(dú)立的系統(tǒng)來部署、開發(fā)以及維護(hù)。這些旁支流程的時延如若非常敏感,比如用戶在界面上點(diǎn)了按鈕,需要立刻返回(價格計算、支付),那么需要與主流程系統(tǒng)進(jìn)行 RPC 通信,并且在通信失敗時,要將結(jié)果直接返回給用戶。如果時延不敏感,比如抽獎系統(tǒng),結(jié)果稍后公布的這種,或者非實(shí)時的統(tǒng)計類系統(tǒng),那么就沒有必要在主流程里為每一套系統(tǒng)做一套 RPC 流程。我們只要將下游需要的數(shù)據(jù)打包成一條消息,傳入消息隊列,之后的事情與主流程一概無關(guān)(當(dāng)然,與用戶的后續(xù)交互流程還是要做的)。
通過拆解和異步化雖然解決了一部分問題,但并不能解決所有問題。隨著業(yè)務(wù)發(fā)展,單一職責(zé)的模塊也會變得越來越復(fù)雜,這是必然的趨勢。一件事情本身變的復(fù)雜的話,這時候拆解和異步化就不靈了。我們還是要對事情本身進(jìn)行一定程度的封裝抽象。
最基本的封裝過程,我們把相似的行為放在一起,然后打包成一個一個的函數(shù),讓自己雜亂無章的代碼變成下面這個樣子:
func BusinessProcess(ctx context.Context, params Params) (resp, error){
ValidateLogin()
ValidateParams()
AntispamCheck()
GetPrice()
CreateOrder()
UpdateUserStatus()
NotifyDownstreamSystems()
}
不管是多么復(fù)雜的業(yè)務(wù),系統(tǒng)內(nèi)的邏輯都是可以分解為 step1 -> step2 -> step3 ...
這樣的流程的。
每一個步驟內(nèi)部也會有復(fù)雜的流程,比如:
func CreateOrder() {
ValidateDistrict() // 判斷是否是地區(qū)限定商品
ValidateVIPProduct() // 檢查是否是只提供給 vip 的商品
GetUserInfo() // 從用戶系統(tǒng)獲取更詳細(xì)的用戶信息
GetProductDesc() // 從商品系統(tǒng)中獲取商品在該時間點(diǎn)的詳細(xì)信息
DecrementStorage() // 扣減庫存
CreateOrderSnapshot() // 創(chuàng)建訂單快照
return CreateSuccess
}
在閱讀業(yè)務(wù)流程代碼時,我們只要閱讀其函數(shù)名就能知曉在該流程中完成了哪些操作,如果需要修改細(xì)節(jié),那么就繼續(xù)深入到每一個業(yè)務(wù)步驟去看具體的流程。寫得稀爛的業(yè)務(wù)流程代碼則會將所有過程都堆積在少數(shù)的幾個函數(shù)中,從而導(dǎo)致幾百甚至上千行的函數(shù)。這種意大利面條式的代碼閱讀和維護(hù)都會非常痛苦。在開發(fā)的過程中,一旦有條件應(yīng)該立即進(jìn)行類似上面這種方式的簡單封裝。
業(yè)務(wù)發(fā)展的早期,是不適宜引入接口(interface)的,很多時候業(yè)務(wù)流程變化很大,過早引入接口會使業(yè)務(wù)系統(tǒng)本身增加很多不必要的分層,從而導(dǎo)致每次修改幾乎都要全盤否定之前的工作。
當(dāng)業(yè)務(wù)發(fā)展到一定階段,主流程穩(wěn)定之后,就可以適當(dāng)?shù)厥褂媒涌趤磉M(jìn)行抽象了。這里的穩(wěn)定,是指主流程的大部分業(yè)務(wù)步驟已經(jīng)確定,即使再進(jìn)行修改,也不會進(jìn)行大規(guī)模的變動,而只是小修小補(bǔ),或者只是增加或刪除少量業(yè)務(wù)步驟。
如果我們在開發(fā)過程中,已經(jīng)對業(yè)務(wù)步驟進(jìn)行了良好的封裝,這時候進(jìn)行接口抽象化就會變的非常容易,偽代碼:
// OrderCreator 創(chuàng)建訂單流程
type OrderCreator interface {
ValidateDistrict() // 判斷是否是地區(qū)限定商品
ValidateVIPProduct() // 檢查是否是只提供給 vip 的商品
GetUserInfo() // 從用戶系統(tǒng)獲取更詳細(xì)的用戶信息
GetProductDesc() // 從商品系統(tǒng)中獲取商品在該時間點(diǎn)的詳細(xì)信息
DecrementStorage() // 扣減庫存
CreateOrderSnapshot() // 創(chuàng)建訂單快照
}
我們只要把之前寫過的步驟函數(shù)簽名都提到一個接口中,就可以完成抽象了。
在進(jìn)行抽象之前,我們應(yīng)該想明白的一點(diǎn)是,引入接口對我們的系統(tǒng)本身是否有意義,這是要按照場景去進(jìn)行分析的。假如我們的系統(tǒng)只服務(wù)一條產(chǎn)品線,并且內(nèi)部的代碼只是針對很具體的場景進(jìn)行定制化開發(fā),那么引入接口是不會帶來任何收益的。至于說是否方便測試,這一點(diǎn)我們會在之后的章節(jié)來講。
如果我們正在做的是平臺系統(tǒng),需要由平臺來定義統(tǒng)一的業(yè)務(wù)流程和業(yè)務(wù)規(guī)范,那么基于接口的抽象就是有意義的。舉個例子:
圖 5-19 實(shí)現(xiàn)公有的接口
平臺需要服務(wù)多條業(yè)務(wù)線,但數(shù)據(jù)定義需要統(tǒng)一,所以希望都能走平臺定義的流程。作為平臺方,我們可以定義一套類似上文的接口,然后要求接入方的業(yè)務(wù)必須將這些接口都實(shí)現(xiàn)。如果接口中有其不需要的步驟,那么只要返回 nil
,或者忽略就好。
在業(yè)務(wù)進(jìn)行迭代時,平臺的代碼是不用修改的,這樣我們便把這些接入業(yè)務(wù)當(dāng)成了平臺代碼的插件(plugin)引入進(jìn)來了。如果沒有接口的話,我們會怎么做?
import (
"sample.com/travelorder"
"sample.com/marketorder"
)
func CreateOrder() {
switch businessType {
case TravelBusiness:
travelorder.CreateOrder()
case MarketBusiness:
marketorder.CreateOrderForMarket()
default:
return errors.New("not supported business")
}
}
func ValidateUser() {
switch businessType {
case TravelBusiness:
travelorder.ValidateUserVIP()
case MarketBusiness:
marketorder.ValidateUserRegistered()
default:
return errors.New("not supported business")
}
}
// ...
switch ...
switch ...
switch ...
沒錯,就是無窮無盡的 switch
,和沒完沒了的垃圾代碼。引入了接口之后,我們的 switch
只需要在業(yè)務(wù)入口做一次。
type BusinessInstance interface {
ValidateLogin()
ValidateParams()
AntispamCheck()
GetPrice()
CreateOrder()
UpdateUserStatus()
NotifyDownstreamSystems()
}
func entry() {
var bi BusinessInstance
switch businessType {
case TravelBusiness:
bi = travelorder.New()
case MarketBusiness:
bi = marketorder.New()
default:
return errors.New("not supported business")
}
}
func BusinessProcess(bi BusinessInstance) {
bi.ValidateLogin()
bi.ValidateParams()
bi.AntispamCheck()
bi.GetPrice()
bi.CreateOrder()
bi.UpdateUserStatus()
bi.NotifyDownstreamSystems()
}
面向接口編程,不用關(guān)心具體的實(shí)現(xiàn)。如果對應(yīng)的業(yè)務(wù)在迭代中發(fā)生了修改,所有的邏輯對平臺方來說也是完全透明的。
Go 被人稱道的最多的地方是其接口設(shè)計的正交性,模塊之間不需要知曉相互的存在,A 模塊定義接口,B 模塊實(shí)現(xiàn)這個接口就可以。如果接口中沒有 A 模塊中定義的數(shù)據(jù)類型,那 B 模塊中甚至都不用 import A
。比如標(biāo)準(zhǔn)庫中的 io.Writer
:
type Writer interface {
Write(p []byte) (n int, err error)
}
我們可以在自己的模塊中實(shí)現(xiàn) io.Writer
接口:
type MyType struct {}
func (m MyType) Write(p []byte) (n int, err error) {
return 0, nil
}
那么我們就可以把我們自己的 MyType
傳給任何使用 io.Writer
作為參數(shù)的函數(shù)來使用了,比如:
package log
func SetOutput(w io.Writer) {
output = w
}
然后:
package my-business
import "xy.com/log"
func init() {
log.SetOutput(MyType)
}
在 MyType
定義的地方,不需要 import "io"
就可以直接實(shí)現(xiàn) io.Writer
接口,我們還可以隨意地組合很多函數(shù),以實(shí)現(xiàn)各種類型的接口,同時接口實(shí)現(xiàn)方和接口定義方都不用建立 import 產(chǎn)生的依賴關(guān)系。因此很多人認(rèn)為 Go 的這種正交是一種很優(yōu)秀的設(shè)計。
但這種 “正交” 性也會給我們帶來一些麻煩。當(dāng)我們接手了一個幾十萬行的系統(tǒng)時,如果看到定義了很多接口,例如訂單流程的接口,我們希望能直接找到這些接口都被哪些對象實(shí)現(xiàn)了。但直到現(xiàn)在,這個簡單的需求也就只有 Goland 實(shí)現(xiàn)了,并且體驗(yàn)尚可。Visual Studio Code 則需要對項目進(jìn)行全局掃描,來看到底有哪些結(jié)構(gòu)體實(shí)現(xiàn)了該接口的全部函數(shù)。那些顯式實(shí)現(xiàn)接口的語言,對于 IDE 的接口查找來說就友好多了。另一方面,我們看到一個結(jié)構(gòu)體,也希望能夠立刻知道這個結(jié)構(gòu)體實(shí)現(xiàn)了哪些接口,但也有著和前面提到的相同的問題。
雖有不便,接口帶給我們的好處也是不言而喻的:一是依賴反轉(zhuǎn),這是接口在大多數(shù)語言中對軟件項目所能產(chǎn)生的影響,在 Go 的正交接口的設(shè)計場景下甚至可以去除依賴;二是由編譯器來幫助我們在編譯期就能檢查到類似 “未完全實(shí)現(xiàn)接口” 這樣的錯誤,如果業(yè)務(wù)未實(shí)現(xiàn)某個流程,但又將其實(shí)例作為接口強(qiáng)行來使用的話:
package main
type OrderCreator interface {
ValidateUser()
CreateOrder()
}
type BookOrderCreator struct{}
func (boc BookOrderCreator) ValidateUser() {}
func createOrder(oc OrderCreator) {
oc.ValidateUser()
oc.CreateOrder()
}
func main() {
createOrder(BookOrderCreator{})
}
會報出下述錯誤。
# command-line-arguments
./a.go:18:30: cannot use BookOrderCreator literal (type BookOrderCreator) as type OrderCreator in argument to createOrder:
BookOrderCreator does not implement OrderCreator (missing CreateOrder method)
所以接口也可以認(rèn)為是一種編譯期進(jìn)行檢查的保證類型安全的手段。
熟悉開源 lint 工具的同學(xué)應(yīng)該見到過圈復(fù)雜度的說法,在函數(shù)中如果有 if
和 switch
的話,會使函數(shù)的圈復(fù)雜度上升,所以有強(qiáng)迫癥的同學(xué)即使在入口一個函數(shù)中有 switch
,還是想要干掉這個 switch
,有沒有什么辦法呢?當(dāng)然有,用表驅(qū)動的方式來存儲我們需要實(shí)例:
func entry() {
var bi BusinessInstance
switch businessType {
case TravelBusiness:
bi = travelorder.New()
case MarketBusiness:
bi = marketorder.New()
default:
return errors.New("not supported business")
}
}
可以修改為:
var businessInstanceMap = map[int]BusinessInstance {
TravelBusiness : travelorder.New(),
MarketBusiness : marketorder.New(),
}
func entry() {
bi := businessInstanceMap[businessType]
}
表驅(qū)動的設(shè)計方式,很多設(shè)計模式相關(guān)的書籍并沒有把它作為一種設(shè)計模式來講,但我認(rèn)為這依然是一種非常重要的幫助我們來簡化代碼的手段。在日常的開發(fā)工作中可以多多思考,哪些不必要的 switch case
可以用一個字典和一行代碼就可以輕松搞定。
當(dāng)然,表驅(qū)動也不是沒有缺點(diǎn),因?yàn)樾枰獙斎?nbsp;?key
?計算哈希,在性能敏感的場合,需要多加斟酌。
![]() | ![]() |
更多建議: