從 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 還有以下優點:

開放性

一個基礎框架在設計的時候就要考慮未來的可擴展性,那 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 的包命名清晰簡短,按功能進行劃分,每個包具有唯一的職責。

在設計包時我們還需要考慮到以下幾點:

2. 配置

首先,我們來看下常見的基礎框架是怎麼初始化配置的:

這是 Go 標準庫 HTTP Server 配置初始化的例子,但是這樣做會有如下幾個問題:

那麼 Kraots 是怎麼解決這些問題的呢?答案就是 Functional Options 。我們看下 transport/http/client.go 的代碼:

Client.go 中定義了一個回調函數 ClientOption,該函數接受一個定義了一個存放實際配置的未導出結構體 clientOptions 的指針,然後我們在 NewClient 的時候,使用可變參數進行傳遞,然後再初始化函數內部通過 for 循環調用修改相關的配置。

這麼做有這麼幾個好處:

3. Error 規範

Kratos 爲微服務提供了統一的 Error 模型:

接下來我們看下 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)}
}

這種方式比較常見,在項目剛開始或者規模小的時候沒什麼問題,但我們如果考慮下面這些因素:

使用依賴注入改造後的 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 生命週期控制權交給了上層來處理,以上操作有三個主要原因:

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