Kratos技术系列|从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 greetingrpc 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 logics.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 logics.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 wireinjectfunc 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 !wireinjectfunc initService(cfg *redis.Options) *service.Service {client := redis2.NewRedis(cfg)redisSource := redis2.NewRedisSource(client)serviceService := service.NewService(redisSource)return serviceService }由此我們可以看到只要定義好組件初始化的Provider函數,還有把這些Provider組裝在一起的Injector就可以直接生成初始化鏈路代碼了,上手還是相對簡單的,生成的代碼所見即所得,容易Debug。
綜上可見,Kratos是一款凝結了開源社區力量以及Go同學們大量微服務工程實踐后誕生的一款微服務框架?,F在騰訊云微服務治理治理平臺(微服務平臺TSF)也已支持Kratos框架,給Kratos賦予了更多企業級服務治理能力、提供多維度服務,如:應用生命周期托管、一鍵上云、私有化部署、多語言發布。
(掃描二維碼查看Go接入TSF騰訊云文檔)
免費體驗館
消息隊列CKafka
分布式、高吞吐量、高可擴展性的消息服務,具備數據壓縮、同時支持離線和實時數據處理等優點。
掃碼即可免費體驗
免費體驗路徑:云產品體驗->基礎->消息隊列CKafka
消息隊列TDMQ
一款基于 Apache 頂級開源項目 Pulsar 自研的金融級分布式消息中間件。其計算與存儲分離的架構設計,使得它具備極好的云原生和 Serverless 特性,用戶按量使用,無需關心底層資源。
掃碼點擊“立即使用”,即可免費體驗
微服務平臺TSF
穩定、高性能的技術中臺。一個圍繞著應用和微服務的 PaaS 平臺,提供應用全生命周期管理、數據化運營、立體化監控和服務治理等功能。TSF 擁抱 Spring Cloud 、Service Mesh 微服務框架,幫助企業客戶解決傳統集中式架構轉型的困難,打造大規模高可用的分布式系統架構,實現業務、產品的快速落地。
掃碼點擊“免費體驗”,即可免費體驗
微服務引擎TSE
高效、穩定的注冊中心托管,助力您快速實現微服務架構轉型。
掃碼點擊“立即申請”,即可免費體驗
彈性微服務TEM
面向微服務應用的 Serverless PaaS 平臺,實現資源 Serverless 化與微服務架構的完美結合,提供一整套開箱即用的微服務解決方案。彈性微服務幫助用戶創建和管理云資源,并提供秒級彈性伸縮,用戶可按需使用、按量付費,極大程度上幫用戶節約運維和資源成本。讓用戶充分聚焦企業核心業務本身,助力業務成功。
掃碼點擊“立即申請”,即可免費體驗
往期
推薦
《【陣容擴大】三位騰訊Maintainer加入Apache Pulsar生態項目RocketMQ-on-Palsar》
《Apache Pulsar事務機制原理解析|Apache Pulsar 技術系列》
《騰訊云中間件月報(2021年第六期)》
掃描下方二維碼關注本公眾號,
了解更多微服務、消息隊列的相關信息!
解鎖超多鵝廠周邊!
戳原文,了解更多騰訊微服務平臺相關信息
總結
以上是生活随笔為你收集整理的Kratos技术系列|从Kratos设计看Go微服务工程实践的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 数据上报痛点解决方案
- 下一篇: 大牛书单 | 云原生技术领域好书推荐