簡介
Go是整潔架構(Clean Architecture)的完美選擇。整潔架構本身只是一種方法,并沒有告訴我們如何構建源代碼,在嘗試用新語言實現時,認識到這點非常重要。
自從我有了使用Ruby on RAIls的經驗后,嘗試了好幾次編寫第一個服務,而且我讀過的大多數關于Go的整潔架構的文章都以一種非Go慣用的方式介紹結構布局。部分原因是這些例子中的包是根據層命名的——controller、model、service等等……如果你有這些類型的包,這是第一個危險信號,告訴你應用程序需要重新設計。在Go中,包名[2]應該描述包提供了什么,而不是包含了什么。
然后我開始了解go-kit,特別是它提供的發貨示例[3],并決定在應用程序中實現相同的結構。后來,當我深入研究整潔架構(Clean Architecture)時,驚喜的發現go-kit方法是多么完美。
本文將介紹使用Go-Kit方法編寫服務是如何符合整潔架構理念的。
整潔架構(Clean Architecture)
整潔架構(Clean Architecture)是由Bob大叔(Robert Martin)創建的一種軟件架構設計。目標是分離關注點[4],允許開發人員封裝業務邏輯,并使其獨立于交付和框架機制。許多架構范例(如Onion和Hexagon架構)也有相同的目標,都是通過將軟件劃分成層來實現解耦。
圓圈中的箭頭表示依賴規則。如果在外部循環中聲明了某些內容,則不得在內部循環代碼中引用。它既適用于實際的源代碼依賴關系,也適用于命名。內層不依賴于任何外層。
外層包含低級組件,如UI、DB、傳輸或任何第三方服務,都可以被認為是應用程序的細節或插件。其思想是,外層的變化一定不會引起內層的任何變化。
不同模塊/組件之間的依賴關系可以描述如下:
請注意,跨越邊界的箭頭只指向一個方向,邊界后面的組件屬于外層,包括controller、presenter和database。Interactor是實現BL的地方,可以將其視為用例層。
請注意Request Model和Response Model。這些對象分別描述了內層需要和返回的數據。controller將請求(在web的情況下是HTTP請求)轉換為請求模型(Request Model),presenter將響應模型(Response Model)格式化為可以由視圖模型(View Model)呈現的數據。
還要注意接口,用于反轉控制流以與依賴規則相對應。Interactor通過Boundary接口與presenter對話,并通過Entity Gateway接口與數據層對話。
這是整潔架構的主要思想,通過依賴注入分離不同的層,使用依賴反轉反轉控制流。Interactor(BL)和實體對傳輸和數據層一無所知。這一點很重要,因為如果我們改變了外層細節,內層就不會發生級聯變化。
什么是Go-Kit?
Go kit[5]是包的集合,可以幫助我們構建健壯、可靠、可維護的微服務。
對于來自Ruby on Rails的我來說,重要的是Go-Kit不是MVC框架。相反,它將應用程序分為三層:
- Transport(傳輸)
- Endpoint(端點)
- Service(服務)
1.Transport
傳輸層是唯一熟悉交付機制(HTTP、gRPC、CLI…)的組件,這一點非常強大,因為我們可以通過提供不同的傳輸層來同時支持HTTP和CLI。
稍后我們將看到傳輸層是如何對應于上圖中的controller和presenter的。
2.Endpoint
type Endpoint func(ctx context.Context, request interface{}) (response interface{}, err error)
端點層表示應用程序中的單個RPC,將交付連接到BL。這是根據輸入和輸出實際定義用例的地方,在整潔架構術語中是Request Model和Response Model。
注意,端點是接收請求并返回響應的函數,都是interface{},是RequestModel和ResponseModel。理論上也可以用類型參數(泛型)來實現。
3.Service
服務層(interactor)是實現BL的地方。服務層不知道端點層,服務層和端點層都不知道傳輸域(比如HTTP)。
Go-Kit提供了創建服務器(HTTP服務器/gRPC服務器等)的功能。例如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請求轉換為Request Model,并且
- EncodeResponseFunc格式化Response Model并將其編碼到HTTP響應中。
- 返回的*server實現http.Server(有ServeHTTP方法)。
傳輸層使用這個函數來創建http.Server,解碼器和編碼器在傳輸中定義,端點在運行時初始化。
簡短示例:(基于發貨示例[6])
簡易服務
我們將描述一個具有兩個API的簡單服務,用于從數據層創建和讀取文章,傳輸層是HTTP,數據層只是一個內存映射。可以在這里找到Github源代碼[7]。
注意文件結構:
- inmem
- articlerepo.go
- publishing
- transport.go
- endpoint.go
- service.go
- formatter.go
- article
- article.go
我們看看如何表示整潔架構的不同層。
- article —— 這是實體層,不包含BL、數據層或傳輸層的知識。
- inmem —— 這是數據層。
- transport —— 這是傳輸層。
- endpoint+service —— 組成了邊界+交互器。
從服務開始:
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
}
服務對交付和數據層一無所知,它不從外層(HTTP、inmem…)導入任何東西。BL就在這里,你可能會說這里沒有真正的BL,這里的服務可能是冗余的,但需要記住這只是一個簡單示例。
實體
package article
type Article struct {
ID string
Title string
Text string
}
實體只是一個DTO,如果有業務策略或行為,可以添加到這里。
端點
endpoint.go定義了服務接口:
type Service interface {
GetArticle(ctx context.Context, id string) (article.Article, error)
CreateArticle(ctx context.Context, thing article.Article) (id string, err error)
}
然后為每個用例(RPC)定義一個端點。例如,對于獲取文章::
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的輸入/輸出。其思想是,可以看到所需數據(輸入)和返回數據(輸出),甚至無需讀取端點本身的實現,因此我認為端點代表單個RPC。服務具有實際觸發BL的方法,但是端點是RPC的應用定義。理論上,一個端點可以觸發多個BL方法。
傳輸
transport.go注冊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)
}
傳輸層通過MakeEndpoint函數在運行時創建端點,并提供用于反序列化請求的解碼器和用于格式化和編碼響應的編碼器。
例如:
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,
},
},
}
}
你可能會問,為什么要使用另一個函數來格式化article,而不是在article實體上添加JSON標記?
這是個非常重要的問題。在article實體上添加JSON標記意味著article知道它是如何格式化的。雖然沒有顯式導入到HTTP,但打破了抽象,使實體包依賴于傳輸層。
例如,假設你想將對客戶端的響應從"title"更改為"header",此更改僅涉及傳輸層。但是,如果此需求導致需要更改實體,則意味著該實體依賴于傳輸層,這就破壞了簡潔架構原則。
我們看看這個簡單應用的依賴關系圖:
哇,你一定注意到了它們的相似性!article實體沒有依賴關系(只有向內箭頭)。外層,transport和inmem,只有指向BL和實體內層的箭頭。
一切都和轉換有關
跨界就是不同層次語言之間的轉換。
BL層只使用應用語言,也就是說,只知道實體(沒有HTTP請求或SQL查詢)。為了跨越邊界,流中的某個組件必須將應用語言轉換為外層語言。
在傳輸層,有解碼器(將HTTP請求轉換為RequestModel的應用語言)和編碼器(將應用語言ResponseModel轉換為HTTP響應)。
數據層實現了repo,在我們的例子中是inmem。在另一種情況下,我們可能會讓sql包負責將應用語言轉換為SQL語言(查詢和原始結果)。
"ing"包
你可能會說傳輸和服務不應該在同一個包中,因為它們位于不同的層,這是一個正確的論點。我從go-kit的shipping例子中取了一個例子,含有這種設計,ing包包含了傳輸/端點/服務,我發現從長遠來看非常方便。話雖如此,如果我現在寫的話,可能會用不同的包。
最后關于"尖叫架構(Screaming Architecture)"的一句話
Go非常適合簡潔架構的另一個原因是包的命名及其思想。尖叫架構(Screaming Architecture) 和構建應用程序有關,以便應用程序的意圖顯而易見。在Ruby On Rails中,當查看結構時,就知道它是用Ruby On Rails框架編寫的(控制器、模型、視圖……)。在我們的應用程序中,當查看結構時,可以看出這是一個關于文章的應用程序,有發布用例,并使用inmem數據層。
總結
簡潔架構只是一種方法,并不會告訴你如何構建源代碼,其實現藝術在于了解所用語言的使用慣例和工具。希望這篇文章對你有所幫助,重要的是要意識到,那些爭論設計問題解決方案的文章并不總是對的,當然也包括這篇