從 Kratos 設計看 Go 微服務工程實踐
導讀
github.com/go-kratos/kratos(以下簡稱 Kratos)是一套輕量級 Go 微服務框架,致力於提供完整的微服務研發體驗,整合相關框架及周邊工具後,微服務治理相關部分可對整體業務開發週期無感,從而更加聚焦於業務交付。Kratos 在設計之初就考慮到了高可擴展性,組件化,工程化,規範化等。對每位開發者而言,整套 Kratos 框架也是不錯的學習倉庫,可以瞭解和參考微服務的技術積累和經驗。
接下來我們從 Protobuf、開放性、規範、依賴注入這 4 個點了解一下 Kratos 在 Go 微服務工程領域的實踐。
** 曹國樑 **
6 年 Go 微服務研發經歷
騰訊雲高級研發工程師
Kratos Maintainer,gRPC-go contributor
基於 Protocol Buffers(Protobuf) 的生態
在 Kratos 中,API 定義、gRPC Service、HTTP Service、請求參數校驗、錯誤定義、Swagger API json、應用服務模版等都是基於 Protobuf IDL 來構建的:
舉一個簡單的 helloworld.proto 例子:
syntax = "proto3";
package helloworld;
import "google/api/annotations.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
import "errors/errors.proto";
option go_package = "github.com/go-kratos/kratos/examples/helloworld/helloworld";
// The greeting service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {
option (google.api.http) = {
// 定義一個HTTP GET 接口,並且把 name 映射到 HelloRequest
get: "/helloworld/{name}",
};
// 添加API接口描述(swagger api)
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
description: "這是SayHello接口";
};
}
}
// The request message containing the user's name.
message HelloRequest {
// 增加name字段參數校驗,字符數需在1到16之間
string name = 1 [(validate.rules).string = {min_len: 1, max_len: 16}];
}
// The response message containing the greetings
message HelloReply {
string message = 1;
}
enum ErrorReason {
// 設置缺省錯誤碼
option (errors.default_code) = 500;
// 爲某個錯誤枚舉單獨設置錯誤碼
USER_NOT_FOUND = 0 [(errors.code) = 404];
CONTENT_MISSING = 1 [(errors.code) = 400];;
}
以上是一個簡單的 helloworld 服務定義的例子,這裏我們定義了一個 Service 叫 Greeter,給 Greeter 添加了一個 SayHello 的接口,並根據 googleapis 規範給這個接口添加了 Restful 風格的 HTTP 接口定義,然後還利用 openapiv2 添加了接口的 Swagger API 描述,同時還給請求消息結構體 HelloRequest 中的 name 字段加上了參數校驗,最後我們在文件的末尾還定義了這個服務可能返回的錯誤碼。
這時我們在終端中執行: kratos proto client api/helloworld/ helloworld.proto 便可以生成以下文件:
由上,我們看到 Kraots 腳手架工具幫我們一鍵生成了上面提到的能力。從這個例子中,我們可以直觀感受到使用使用 Protobuf 帶來的開發效率的提升,除此之外 Kratos 還有以下優點:
-
清晰:做到了定義即文檔,定義即代碼
-
收斂,統一:將邏輯都收斂統一到一起,通過代碼生成工具來保證 HTTP Service、grpc Service 等功能具有一致的行爲
-
跨語言:衆所周知 Protobuf 是跨語言的,java、go、python、php、js、c 等等主流語言都支持
-
擁抱開源生態:比如 Kratos 複用了 google.http.api、protoc-gen-openapiv2、protoc-gen-validate 等等一些犀利的 Protobuf 周邊生態工具或規範,這比起自己造一個 IDL 的輪子要容易維護得多,同時老的使用這些輪子的 gRPC 項目遷移成本也更低
開放性
一個基礎框架在設計的時候就要考慮未來的可擴展性,那 Kratos 是怎麼做的呢?
1. Server Transport
我們先看下服務協議層的代碼:
上面是 Kratos RPC 服務協議層的接口定義,這裏我們可以看到如果想要給 Kratos 新增一個新的服務協議,只要實現 Start()、Stop()、Endpoint() 這幾個方法即可。這樣的設計解耦了應用和服務協議層的實現,使得擴展服務協議更加方便。
從上圖中我們可以看到 App 層無需關心底層服務協議的實現,只是一個容器管理好應用配置、服務生命週期、加載順序即可。
2. Log
我們再看一個 Kratos 日誌模塊的設計:
這裏 Kratos 定義了一個日誌輸出接口 Logger,它的設計的非常簡單 - 只用了一個方法、兩個輸入、一個輸出。我們知道一個包暴露的接口越少,越容易維護,同時對使用和實現方的心智負擔更小,擴展日誌實現會變得更容易。但問題來了,這個接口從功能上來講似乎只能輸出日誌 level 和固定的 kv paris,如何能支持更高級的功能?比如輸出 caller stack、實時 timestamp、 context traceID ?這裏我們定義了一個回調接口 Valuer:
這個 Valuer 可以被當作 key/value pairs 中的 value 被 Append 到日誌裏,並被實時調用。
我們看一下如何給日誌加時間戳的 Valuer 實現:
使用時只要在原始的 logger 上再 append 一個固定的 key 和一個動態的 valuer 即可:
這裏的 With 是一個 Helper function,裏面 new 了一個新的 logger(也實現了 Logger 接口),並將 key\value pairs 暫存在新的 logger 裏,等到 Log 方法被調用時再通過斷言.(Valuer) 的方式獲取值並輸出給底層原始的 logger。
所以我們可以看到僅僅通過兩個簡單的接口 + 一個 Helper function 的組合我們就實現了日誌的大多數功能,這樣大大提高了可擴展性。實際上還有日誌過濾、多日誌源輸出等功能也是通過組合使用這兩接口來實現,這裏待下次分享再展開細講。
3. Tracing
最後我們來看下 Kratos 的 Tracing 組件,這裏 Kratos 採用的是 CNCF 項目 OpenTelemetry。
OpenTelemetry 在設計之初就考慮到了組件化和高可擴展性,其實現了 OpenTracing 和 W3C Trace Context 的規範,可以無縫對接 zipkin、jaeger 等主流開源 tracing 系統,並且可以自定義 Propagator 和 TraceProvider。通過 otel.SetTracerProvider() 我們可以輕易得替換 Span 的落地協議和格式,從而兼容老系統中的 trace 採集 agent;通過 otel.SetTextMapPropagtor() 我們可以替換 Span 在 RPC 中的 Encoding 協議,從而可以和老系統中的服務互相調用時也能兼容。
工程流程
我們知道在工程實踐的時候,強規範和約束往往比自由和更多的選擇更有優勢,那麼在 Go 工程規範這塊我這裏主要介紹三塊:
1. 面向包的設計規範
Go 是一個面向包名設計的語言,Package 在 Go 程序中主要起到功能隔離的作用,標準庫就是很好的設計範例。Kratos 也是可以按包進行組織代碼結構,這裏我們抽取 Kratos 根目錄下主要幾個 Package 包來看下:
/cmd: 可以通過 go install 一鍵安裝生成工具,使用戶更加方便地使用框架。
/api: Kratos 框架本身的暴露的接口定義
/errors: 統一的業務錯誤封裝,方便返回錯誤碼和業務原因。
/config: 支持多數據源方式,進行配置合併鋪平,通過 Atomic 方式支熱更配置。
/internal:存放對外不可見或者不穩定的接口。
/transport: 服務協議層(HTTP/gRPC)的抽象封裝,可以方便獲取對應的接口信息。
/middleware: 中間件抽象接口,主要跟 transport 和 service 之間的橋樑適配器。
/third_party: 第三方外部的依賴
可以看到 Kratos 的包命名清晰簡短,按功能進行劃分,每個包具有唯一的職責。
在設計包時我們還需要考慮到以下幾點:
-
包的設計必須以使用者爲中心,直觀且易於使用,包的命名必須旨在描述它提供的內容,如果包的名稱不能立即暗示這一點,則它可能包含一組零散的功能。
-
包的目的是爲特定問題域而提供的,爲了有目的,包必須提供,而不是包含。包不能成爲不同問題域的聚合地,隨着時間的推移,它將影響項目的簡潔和重構、適應、擴展和分離的能力。
-
高便攜性,儘量減少依賴其他代碼庫,一個包與其它包依賴越少,一個包的可重用性就越高。
-
不能成爲單點依賴,當包被單一的依賴點時,就像一個公共包(common),會給項目帶來很高的耦合性。
2. 配置
首先,我們來看下常見的基礎框架是怎麼初始化配置的:
這是 Go 標準庫 HTTP Server 配置初始化的例子,但是這樣做會有如下幾個問題:
-
&http.Server{} 由於是一個取址引用,裏面的參數可能會被外部運行時修改,這種運行時修改帶來的危害是不可把控的。
-
無法區分 nil 和 0 值,當裏面的參數值爲 0 的時候,不知道是用戶未設置還是就是被設置成了 0。
-
難以分辨必傳和選傳參數,只能通過文檔說明來隱式約定,沒有強約束力。
那麼 Kraots 是怎麼解決這些問題的呢?答案就是 Functional Options 。我們看下 transport/http/client.go 的代碼:
Client.go 中定義了一個回調函數 ClientOption,該函數接受一個定義了一個存放實際配置的未導出結構體 clientOptions 的指針,然後我們在 NewClient 的時候,使用可變參數進行傳遞,然後再初始化函數內部通過 for 循環調用修改相關的配置。
這麼做有這麼幾個好處:
-
由於 clientOptions 結構體是未導出的,那麼就不存在被外部修改的可能。
-
可以區分 0 值和未設置,首先我們在 new clientOptions 時會設置默認參數,那麼如果外部沒有傳遞相應的 Option 就不會修改這個默認參數。
-
必選參數顯示定義,可選值則通過 Go 可變參數進行傳遞,很好的區分必傳和選傳。
3. Error 規範
Kratos 爲微服務提供了統一的 Error 模型:
-
Code 用作外部展示和初步判斷,服務端無需定義大量全局唯一的 XXX_NOT_FOUND,而是使用一個標準 Code.NOT_FOUND 錯誤代碼並告訴客戶端找不到某個資源。錯誤空間變小降低了文檔的複雜性,在客戶端庫中提供了更好的慣用映射,並降低了客戶端的邏輯複雜性。同時這種標準的大類 Code 的存在也對外部的觀測系統更友好,比如可以通過分析 Nginx Access Log 中的 HTTP StatusCode 來做服務端監控和告警。
-
Reason 是具體的錯誤原因,可以用來更詳細的錯誤判定。每個微服務都會定義自己 Reason,那麼要保持全局唯一就需要加上領域前綴,比如 User_XXX。
-
Message 錯誤信息可以幫助用戶輕鬆快捷地理解和解決 API 錯誤
-
Metadata 中則可以存放一些標準的錯誤詳情,比如 retryInfo、error stack 等
-
這種強制規範,避免了開發人員直接透傳 Go 的 error 從而導致一些敏感信息泄露。
接下來我們看下 Error 結構體還實現了哪些接口:
-
實現了 GRPCStatus () *status.Status 接口,這樣就實現了從 http status code 到 grpc status code 的轉換,這樣 Kratos Error 可以被 gRPC 直接轉成 google.rpc.Status 傳遞出去。
-
實現了標準庫 errors 包的 Is (error) bool 接口,這樣使用者可以直接調用 errors.Is() 來比較兩個 erorr 中的 reason 是否相等,避免了使用 == 來直接判斷 error 是否相等這種錯誤姿勢。
依賴注入
依賴注入 (Dependency Injection) 可以理解爲一種代碼的構造模式,按照這樣的方式來寫,能夠讓你的代碼更加容易維護,一般在 Java 的項目中見到的比較多。
依賴注入初看起來比較違反直覺,那麼爲什麼 Go 也需要依賴注入?假設我們要實現一個用戶訪問計數的功能。我們先看看不使用依賴注入的項目代碼:
type Service struct {
redisCli *redis.Client
}
func (s *Service) AddUserCount(ctx context.Context) {
//do some business logic
s.redisCli.Incr(ctx, "user_count")
}
func NewService(cfg *redis.Options) *Service {
return &Service{redisCli: redis.NewClient(cfg)}
}
這種方式比較常見,在項目剛開始或者規模小的時候沒什麼問題,但我們如果考慮下面這些因素:
-
Redis 是基礎組件,往往會在項目的很多地方被依賴,那麼如果哪天我們想整體修改 redis sdk 的甚至想把 redis 整體替換成 mysql 時,需要在每個被用到的地方都進行修改,耗時耗力還容易出錯。
-
很難對 App 這個類寫單元測試,因爲我們需要創建一個真實的 redis.Client。
使用依賴注入改造後的 Service:
type DataSource interface{
Incr(context.Context, string)
}
type Service struct {
dataSource DataSource
}
func (s *Service) AddUserCount(ctx context.Context) {
//do some business logic
s.dataSource.Incr(ctx, "user_count")
}
func NewService(ds DataSource) *Service {
return &Service{dataSource: ds}
}
上面代碼中我們把 * redis.Client 實體替換成了一個 DataSource 接口,同時不控制 dataSource 的創建和銷燬,把 dataSource 生命週期控制權交給了上層來處理,以上操作有三個主要原因:
-
因爲 Service 層已不再關心 dataSource 的創建和銷燬,這樣當我們需要修改 dataSource 實現的時候,只要在上層統一修改即可,無需在各個被依賴的地方一一修改。
-
因爲依賴的是一個接口,我們寫單元測試的時候只要傳遞一個 mock 後的 Datasource 實現即可 。
-
這裏 dataSource 這個基礎組件不再被會到處創建,可以做到複用一個單例節省資源開銷。
Go 的依賴注入框架有兩類,一類是通過反射在運行時進行依賴注入,典型代表是 uber 開源的 dig,另外一類是通過 generate 進行代碼生成,典型代表是 Google 開源的 wire。使用 dig 功能會強大一些,但是缺點就是錯誤只能在運行時才能發現,這樣如果不小心的話可能會導致一些隱藏的 bug 出現。使用 wire 的缺點就是功能限制多一些,但是好處就是編譯的時候就可以發現問題,並且生成的代碼其實和我們自己手寫相關代碼差不太多,更符合直覺,心智負擔更小。所以 Kratos 更加推薦 wire,Kratos 的默認項目模板中 kratos-layout 也正是使用了 google/wire 進行依賴注入。
我們來看下 wire 使用方式:
我們首先要定義一個 ProviderSet,這個 Set 會返回構建依賴關係所需的組件 Provider。如下所示,Provider 往往是一些簡單的工廠函數,這些函數不會太複雜:
type RedisSource struct {
redisCli *redis.Client
}
// RedisSource實現了Datasource的Incr接口
func (ds *RedisSource) Incr(ctx context.Context, key string) {
ds.redisCli.Incr(ctx, key)
}
// 構建實現了DataSource接口的Provider
func NewRedisSource(db *redis.Client) *RedisSource {
return &RedisSource{redisCli: db}
}
// 構建*redis.Client的Provider
func NewRedis(cfg *redis.Options) *redis.Client {
return redis.NewClient(cfg)
}
// 這是一個Provider的集合,告訴wire這個包提供了哪些Provider
var ProviderSet = wire.NewSet(NewRedis, NewRedisSource)
接着我們要在應用啓動處新建一個 wire.go 文件並定義 Injector,Injctor 會分析依賴關係並將 Provider 串聯起來構建出最終的 Service:
// +build wireinject
func initService(cfg *redis.Options) *service.Service {
panic(wire.Build(
redisSource.ProviderSet,
//使用 wire.Bind 將 Struct 和接口進行綁定了,表示這個結構體實現了這個接口,
wire.Bind(new(data.DataSource), new(*redisSource.RedisSource)),
service.NewService),
)
}
最後執行 wire . 後自動生成的代碼如下:
//go:generate go run github.com/google/wire/cmd/wire
//+build !wireinject
func initService(cfg *redis.Options) *service.Service {
client := redis2.NewRedis(cfg)
redisSource := redis2.NewRedisSource(client)
serviceService := service.NewService(redisSource)
return serviceService
}
由此我們可以看到只要定義好組件初始化的 Provider 函數,還有把這些 Provider 組裝在一起的 Injector 就可以直接生成初始化鏈路代碼了,上手還是相對簡單的,生成的代碼所見即所得,容易 Debug。
綜上可見,Kraots 是一款凝結了開源社區力量以及 Go 同學們大量微服務工程實踐後誕生的一款微服務框架。現在騰訊雲微服務治理治理平臺 (微服務平臺 TSF) 也已支持 Kratos 框架,給 Kratos 賦予了更多企業級服務治理能力、提供多維度服務,如:應用生命週期託管、一鍵上雲、私有化部署、多語言發佈。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/D8py9Ia1KVoPZP-RCfwkgQ