基于Go-Kit的Golang整潔架構(gòu)實(shí)踐
簡(jiǎn)介
Go是整潔架構(gòu)(Clean Architecture)的完美選擇。整潔架構(gòu)本身只是一種方法,并沒(méi)有告訴我們?nèi)绾螛?gòu)建源代碼,在嘗試用新語(yǔ)言實(shí)現(xiàn)時(shí),認(rèn)識(shí)到這點(diǎn)非常重要。
自從我有了使用Ruby on Rails的經(jīng)驗(yàn)后,嘗試了好幾次編寫第一個(gè)服務(wù),而且我讀過(guò)的大多數(shù)關(guān)于Go的整潔架構(gòu)的文章都以一種非Go慣用的方式介紹結(jié)構(gòu)布局。部分原因是這些例子中的包是根據(jù)層命名的——controller、model、service等等……如果你有這些類型的包,這是第一個(gè)危險(xiǎn)信號(hào),告訴你應(yīng)用程序需要重新設(shè)計(jì)。在Go中,包名[2]應(yīng)該描述包提供了什么,而不是包含了什么。
然后我開始了解go-kit,特別是它提供的發(fā)貨示例[3],并決定在應(yīng)用程序中實(shí)現(xiàn)相同的結(jié)構(gòu)。后來(lái),當(dāng)我深入研究整潔架構(gòu)(Clean Architecture)時(shí),驚喜的發(fā)現(xiàn)go-kit方法是多么完美。
本文將介紹使用Go-Kit方法編寫服務(wù)是如何符合整潔架構(gòu)理念的。
整潔架構(gòu)(Clean Architecture)
整潔架構(gòu)(Clean Architecture)是由Bob大叔(Robert Martin)創(chuàng)建的一種軟件架構(gòu)設(shè)計(jì)。目標(biāo)是分離關(guān)注點(diǎn)[4],允許開發(fā)人員封裝業(yè)務(wù)邏輯,并使其獨(dú)立于交付和框架機(jī)制。許多架構(gòu)范例(如Onion和Hexagon架構(gòu))也有相同的目標(biāo),都是通過(guò)將軟件劃分成層來(lái)實(shí)現(xiàn)解耦。
圓圈中的箭頭表示依賴規(guī)則。如果在外部循環(huán)中聲明了某些內(nèi)容,則不得在內(nèi)部循環(huán)代碼中引用。它既適用于實(shí)際的源代碼依賴關(guān)系,也適用于命名。內(nèi)層不依賴于任何外層。
外層包含低級(jí)組件,如UI、DB、傳輸或任何第三方服務(wù),都可以被認(rèn)為是應(yīng)用程序的細(xì)節(jié)或插件。其思想是,外層的變化一定不會(huì)引起內(nèi)層的任何變化。
不同模塊/組件之間的依賴關(guān)系可以描述如下:
請(qǐng)注意,跨越邊界的箭頭只指向一個(gè)方向,邊界后面的組件屬于外層,包括controller、presenter和database。Interactor是實(shí)現(xiàn)BL的地方,可以將其視為用例層。
請(qǐng)注意Request Model和Response Model。這些對(duì)象分別描述了內(nèi)層需要和返回的數(shù)據(jù)。controller將請(qǐng)求(在web的情況下是HTTP請(qǐng)求)轉(zhuǎn)換為請(qǐng)求模型(Request Model),presenter將響應(yīng)模型(Response Model)格式化為可以由視圖模型(View Model)呈現(xiàn)的數(shù)據(jù)。
還要注意接口,用于反轉(zhuǎn)控制流以與依賴規(guī)則相對(duì)應(yīng)。Interactor通過(guò)Boundary接口與presenter對(duì)話,并通過(guò)Entity Gateway接口與數(shù)據(jù)層對(duì)話。
這是整潔架構(gòu)的主要思想,通過(guò)依賴注入分離不同的層,使用依賴反轉(zhuǎn)反轉(zhuǎn)控制流。Interactor(BL)和實(shí)體對(duì)傳輸和數(shù)據(jù)層一無(wú)所知。這一點(diǎn)很重要,因?yàn)槿绻覀兏淖兞送鈱蛹?xì)節(jié),內(nèi)層就不會(huì)發(fā)生級(jí)聯(lián)變化。
什么是Go-Kit?
Go kit[5]是包的集合,可以幫助我們構(gòu)建健壯、可靠、可維護(hù)的微服務(wù)。
對(duì)于來(lái)自Ruby on Rails的我來(lái)說(shuō),重要的是Go-Kit不是MVC框架。相反,它將應(yīng)用程序分為三層:
- Transport(傳輸)
- Endpoint(端點(diǎn))
- Service(服務(wù))
1.Transport
傳輸層是唯一熟悉交付機(jī)制(HTTP、gRPC、CLI…)的組件,這一點(diǎn)非常強(qiáng)大,因?yàn)槲覀兛梢酝ㄟ^(guò)提供不同的傳輸層來(lái)同時(shí)支持HTTP和CLI。
稍后我們將看到傳輸層是如何對(duì)應(yīng)于上圖中的controller和presenter的。
2.Endpoint
type Endpoint func(ctx context.Context, request interface{}) (response interface{}, err error)
端點(diǎn)層表示應(yīng)用程序中的單個(gè)RPC,將交付連接到BL。這是根據(jù)輸入和輸出實(shí)際定義用例的地方,在整潔架構(gòu)術(shù)語(yǔ)中是Request Model和Response Model。
注意,端點(diǎn)是接收請(qǐng)求并返回響應(yīng)的函數(shù),都是interface{},是RequestModel和ResponseModel。理論上也可以用類型參數(shù)(泛型)來(lái)實(shí)現(xiàn)。
3.Service
服務(wù)層(interactor)是實(shí)現(xiàn)BL的地方。服務(wù)層不知道端點(diǎn)層,服務(wù)層和端點(diǎn)層都不知道傳輸域(比如HTTP)。
Go-Kit提供了創(chuàng)建服務(wù)器(HTTP服務(wù)器/gRPC服務(wù)器等)的功能。例如HTTP:
package http // under go-kit/kit/transport/http
type DecodeRequestFunc func(context.Context, *http.Request) (request interface{}, err error)
type EncodeResponseFunc func(context.Context, http.ResponseWriter, interface{}) error
func NewServer(
e endpoint.Endpoint,
dec DecodeRequestFunc,
enc EncodeResponseFunc,
options ...ServerOption,
) *Server
- DecodeRequestFunc將HTTP請(qǐng)求轉(zhuǎn)換為Request Model,并且
- EncodeResponseFunc格式化Response Model并將其編碼到HTTP響應(yīng)中。
- 返回的*server實(shí)現(xiàn)http.Server(有ServeHTTP方法)。
傳輸層使用這個(gè)函數(shù)來(lái)創(chuàng)建http.Server,解碼器和編碼器在傳輸中定義,端點(diǎn)在運(yùn)行時(shí)初始化。
簡(jiǎn)短示例:(基于發(fā)貨示例[6])
簡(jiǎn)易服務(wù)
我們將描述一個(gè)具有兩個(gè)API的簡(jiǎn)單服務(wù),用于從數(shù)據(jù)層創(chuàng)建和讀取文章,傳輸層是HTTP,數(shù)據(jù)層只是一個(gè)內(nèi)存映射。可以在這里找到GitHub源代碼[7]。
注意文件結(jié)構(gòu):
- inmem
- articlerepo.go
- publishing
- transport.go
- endpoint.go
- service.go
- formatter.go
- article
- article.go
我們看看如何表示整潔架構(gòu)的不同層。
- article —— 這是實(shí)體層,不包含BL、數(shù)據(jù)層或傳輸層的知識(shí)。
- inmem —— 這是數(shù)據(jù)層。
- transport —— 這是傳輸層。
- endpoint+service —— 組成了邊界+交互器。
從服務(wù)開始:
import (
"context"
"fmt"
"math/rand"
"github.com/OrenRosen/gokit-example/article"
)
type ArticlesRepository interface {
GetArticle(ctx context.Context, id string) (article.Article, error)
InsertArticle(ctx context.Context, thing article.Article) error
}
type service struct {
repo ArticlesRepository
}
func NewService(repo ArticlesRepository) *service {
return &service{
repo: repo,
}
}
func (s *service) GetArticle(ctx context.Context, id string) (article.Article, error) {
return s.repo.GetArticle(ctx, id)
}
func (s *service) CreateArticle(ctx context.Context, artcle article.Article) (id string, err error) {
artcle.ID = generateID()
if err := s.repo.InsertArticle(ctx, artcle); err != nil {
return "", fmt.Errorf("publishing.CreateArticle: %w", err)
}
return artcle.ID, nil
}
func generateID() string {
// code emitted
}
服務(wù)對(duì)交付和數(shù)據(jù)層一無(wú)所知,它不從外層(HTTP、inmem…)導(dǎo)入任何東西。BL就在這里,你可能會(huì)說(shuō)這里沒(méi)有真正的BL,這里的服務(wù)可能是冗余的,但需要記住這只是一個(gè)簡(jiǎn)單示例。
實(shí)體
package article
type Article struct {
ID string
Title string
Text string
}
實(shí)體只是一個(gè)DTO,如果有業(yè)務(wù)策略或行為,可以添加到這里。
端點(diǎn)
endpoint.go定義了服務(wù)接口:
type Service interface {
GetArticle(ctx context.Context, id string) (article.Article, error)
CreateArticle(ctx context.Context, thing article.Article) (id string, err error)
}
然后為每個(gè)用例(RPC)定義一個(gè)端點(diǎn)。例如,對(duì)于獲取文章::
type GetArticleRequestModel struct {
ID string
}
type GetArticleResponseModel struct {
Article article.Article
}
func MakeEndpointGetArticle(s Service) endpoint.Endpoint {
return func(ctx context.Context, request interface{}) (response interface{}, err error) {
req, ok := request.(GetArticleRequestModel)
if !ok {
return nil, fmt.Errorf("MakeEndpointGetArticle failed cast request")
}
a, err := s.GetArticle(ctx, req.ID)
if err != nil {
return nil, fmt.Errorf("MakeEndpointGetArticle: %w", err)
}
return GetArticleResponseModel{
Article: a,
}, nil
}
}
注意如何定義RequestModel和ResponseModel,這是RPC的輸入/輸出。其思想是,可以看到所需數(shù)據(jù)(輸入)和返回?cái)?shù)據(jù)(輸出),甚至無(wú)需讀取端點(diǎn)本身的實(shí)現(xiàn),因此我認(rèn)為端點(diǎn)代表單個(gè)RPC。服務(wù)具有實(shí)際觸發(fā)BL的方法,但是端點(diǎn)是RPC的應(yīng)用定義。理論上,一個(gè)端點(diǎn)可以觸發(fā)多個(gè)BL方法。
傳輸
transport.go注冊(cè)HTTP路由:
type Router interface {
Handle(method, path string, handler http.Handler)
}
func RegisterRoutes(router *httprouter.Router, s Service) {
getArticleHandler := kithttp.NewServer(
MakeEndpointGetArticle(s),
decodeGetArticleRequest,
encodeGetArticleResponse,
)
createArticleHandler := kithttp.NewServer(
MakeEndpointCreateArticle(s),
decodeCreateArticleRequest,
encodeCreateArticleResponse,
)
router.Handler(http.MethodGet, "/articles/:id", getArticleHandler)
router.Handler(http.MethodPost, "/articles", createArticleHandler)
}
傳輸層通過(guò)MakeEndpoint函數(shù)在運(yùn)行時(shí)創(chuàng)建端點(diǎn),并提供用于反序列化請(qǐng)求的解碼器和用于格式化和編碼響應(yīng)的編碼器。
例如:
func decodeGetArticleRequest(ctx context.Context, r *http.Request) (request interface{}, err error) {
params := httprouter.ParamsFromContext(ctx)
return GetArticleRequestModel{
ID: params.ByName("id"),
}, nil
}
func encodeGetArticleResponse(ctx context.Context, w http.ResponseWriter, response interface{}) error {
res, ok := response.(GetArticleResponseModel)
if !ok {
return fmt.Errorf("encodeGetArticleResponse failed cast response")
}
formatted := formatGetArticleResponse(res)
w.Header().Set("Content-Type", "application/json")
return json.NewEncoder(w).Encode(formatted)
}
func formatGetArticleResponse(res GetArticleResponseModel) map[string]interface{} {
return map[string]interface{}{
"data": map[string]interface{}{
"article": map[string]interface{}{
"id": res.Article.ID,
"title": res.Article.Title,
"text": res.Article.Text,
},
},
}
}
你可能會(huì)問(wèn),為什么要使用另一個(gè)函數(shù)來(lái)格式化article,而不是在article實(shí)體上添加JSON標(biāo)記?
這是個(gè)非常重要的問(wèn)題。在article實(shí)體上添加JSON標(biāo)記意味著article知道它是如何格式化的。雖然沒(méi)有顯式導(dǎo)入到HTTP,但打破了抽象,使實(shí)體包依賴于傳輸層。
例如,假設(shè)你想將對(duì)客戶端的響應(yīng)從"title"更改為"header",此更改僅涉及傳輸層。但是,如果此需求導(dǎo)致需要更改實(shí)體,則意味著該實(shí)體依賴于傳輸層,這就破壞了簡(jiǎn)潔架構(gòu)原則。
我們看看這個(gè)簡(jiǎn)單應(yīng)用的依賴關(guān)系圖:
哇,你一定注意到了它們的相似性!article實(shí)體沒(méi)有依賴關(guān)系(只有向內(nèi)箭頭)。外層,transport和inmem,只有指向BL和實(shí)體內(nèi)層的箭頭。
一切都和轉(zhuǎn)換有關(guān)
跨界就是不同層次語(yǔ)言之間的轉(zhuǎn)換。
BL層只使用應(yīng)用語(yǔ)言,也就是說(shuō),只知道實(shí)體(沒(méi)有HTTP請(qǐng)求或SQL查詢)。為了跨越邊界,流中的某個(gè)組件必須將應(yīng)用語(yǔ)言轉(zhuǎn)換為外層語(yǔ)言。
在傳輸層,有解碼器(將HTTP請(qǐng)求轉(zhuǎn)換為RequestModel的應(yīng)用語(yǔ)言)和編碼器(將應(yīng)用語(yǔ)言ResponseModel轉(zhuǎn)換為HTTP響應(yīng))。
數(shù)據(jù)層實(shí)現(xiàn)了repo,在我們的例子中是inmem。在另一種情況下,我們可能會(huì)讓sql包負(fù)責(zé)將應(yīng)用語(yǔ)言轉(zhuǎn)換為SQL語(yǔ)言(查詢和原始結(jié)果)。
"ing"包
你可能會(huì)說(shuō)傳輸和服務(wù)不應(yīng)該在同一個(gè)包中,因?yàn)樗鼈兾挥诓煌膶樱@是一個(gè)正確的論點(diǎn)。我從go-kit的shipping例子中取了一個(gè)例子,含有這種設(shè)計(jì),ing包包含了傳輸/端點(diǎn)/服務(wù),我發(fā)現(xiàn)從長(zhǎng)遠(yuǎn)來(lái)看非常方便。話雖如此,如果我現(xiàn)在寫的話,可能會(huì)用不同的包。
最后關(guān)于"尖叫架構(gòu)(Screaming Architecture)"的一句話
Go非常適合簡(jiǎn)潔架構(gòu)的另一個(gè)原因是包的命名及其思想。尖叫架構(gòu)(Screaming Architecture) 和構(gòu)建應(yīng)用程序有關(guān),以便應(yīng)用程序的意圖顯而易見(jiàn)。在Ruby On Rails中,當(dāng)查看結(jié)構(gòu)時(shí),就知道它是用Ruby On Rails框架編寫的(控制器、模型、視圖……)。在我們的應(yīng)用程序中,當(dāng)查看結(jié)構(gòu)時(shí),可以看出這是一個(gè)關(guān)于文章的應(yīng)用程序,有發(fā)布用例,并使用inmem數(shù)據(jù)層。
總結(jié)
簡(jiǎn)潔架構(gòu)只是一種方法,并不會(huì)告訴你如何構(gòu)建源代碼,其實(shí)現(xiàn)藝術(shù)在于了解所用語(yǔ)言的使用慣例和工具。希望這篇文章對(duì)你有所幫助,重要的是要意識(shí)到,那些爭(zhēng)論設(shè)計(jì)問(wèn)題解決方案的文章并不總是對(duì)的,當(dāng)然也包括這篇