基於 Go-Kit 的 Golang 整潔架構實踐
如何用 Golang 實現簡潔架構?本文介紹了基於 Go-Kit 實現簡潔架構的嘗試,通過示例介紹了簡潔架構的具體實現。原文: Why is Go-Kit Perfect For Clean Architecture in Golang?[1]
簡介
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(服務)
Transport
傳輸層是唯一熟悉交付機制 (HTTP、gRPC、CLI…) 的組件,這一點非常強大,因爲我們可以通過提供不同的傳輸層來同時支持 HTTP 和 CLI。
稍後我們將看到傳輸層是如何對應於上圖中的controller
和presenter
的。
Endpoint
type Endpoint func(ctx context.Context, request interface{}) (response interface{}, err error)
端點層表示應用程序中的單個 RPC,將交付連接到 BL。這是根據輸入和輸出實際定義用例的地方,在整潔架構術語中是Request Model
和Response Model
。
注意,端點是接收請求並返回響應的函數,都是
interface{}
,是RequestModel
和ResponseModel
。理論上也可以用類型參數 (泛型) 來實現。
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 數據層。
總結
簡潔架構只是一種方法,並不會告訴你如何構建源代碼,其實現藝術在於瞭解所用語言的使用慣例和工具。希望這篇文章對你有所幫助,重要的是要意識到,那些爭論設計問題解決方案的文章並不總是對的,當然也包括這篇😀
你好,我是俞凡,在 Motorola 做過研發,現在在 Mavenir 做技術工作,對通信、網絡、後端架構、雲原生、DevOps、CICD、區塊鏈、AI 等技術始終保持着濃厚的興趣,平時喜歡閱讀、思考,相信持續學習、終身成長,歡迎一起交流學習。爲了方便大家以後能第一時間看到文章,請朋友們關注公衆號 "DeepNoMind",並設個星標吧,如果能一鍵三連 (轉發、點贊、在看),則能給我帶來更多的支持和動力,激勵我持續寫下去,和大家共同成長進步!
參考資料
[1]
Why is Go-Kit Perfect For Clean Architecture in Golang?: https://orenrose.medium.com/clean-architecture-in-golang-with-go-kit-e5b716a3b881
[2]
Package names: https://go.dev/blog/package-names
[3]
shipping example in go-kit: https://github.com/go-kit/examples/tree/master/shipping
[4]
分離關注點: https://en.wikipedia.org/wiki/Separation_of_concerns
[5]
Go kit: https://gokit.io/faq/#what-is-go-kit
[6]
發貨示例: https://github.com/go-kit/examples/tree/master/shipping
[7]
gokit-example: https://github.com/OrenRosen/gokit-example
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/1IJ9_bqipG3waLJ_rD0A-g