把Go項目從單體擴展成微服務要做哪些工作?
微服務該怎么拆
使用微服務架構首先要考慮的一點是,業務里到底應該有哪些服務或者說我有一個單體服務我該怎么把它拆成微服務呢?
這一點要搞不清楚,大概率拆了也白拆,或者是純純給自己增加工作量。這幾年我見過有很多走極端的拆法,有的恨不得一個兩個接口就給你整個微服務。有的則是從一個單體變成了幾個小一點的單體,也不管服務注冊發現什么的,直接上域名給你相互調用。
那么微服務到底該怎么拆呢?其實沒有特別統一的標準,還是靠大家對業務的理解,一個比較常用的方法是對業務進行領域分析后按照子域進行拆分。
這一聊就又聊到領域驅動設計了,很多人看到DDD就嗤之以鼻,不過嗤之以鼻的應該是開發階段的繁瑣,設計階段它的一些思路還是很值得借鑒的,或者說它的思路應該是符合事物發展規律的,你在完全不了解領域驅動設計這個概念前大概率也是按著類似的思路做軟件設計的,只不過老外把方法論提煉出來宣傳的早,所以他們拿到了命名權。
如果我們把子域按照價值維度劃分,可劃分為:核心域、通用域和支撐域
下面是一個敏捷項目管理產品的領域劃分(圖片取自 IDDD 一書)
圖片
- 核心域:決定產品和公司核心競爭力的子域,它是業務成功的主要因素和公司的核心競爭力。舉個例子來說:****公司是賣貨的,那賣貨就是你們與其它競爭對手的關鍵競爭環節。這就是核心域,就是核心業務。
- 支撐域:公司在線賣貨,但是用戶在線支付不靈,那公司也發展不起來,所以支付子領域、庫存管理子領域就是支撐域
- 通用域:沒有太多個性化的訴求,同時被多個子域使用的通用功能的子域-- 比如身份認證角色管理子域、(各種基礎服務?)。如果上升到整個公司的大業務領域,通用域會被每個業務的領域使用。
我們可以通過DDD的方式定義子域,并把子域對應為每一個服務。
圖片
也就是說每個服務應該有自己的領域模型,不能是一個接口就給整個微服務,一個業務整上幾百個服務,這里我不是開玩笑,我真見過,五個人維護上百個服務,我們有的公司的屎山的難度真是太高了。
以上內容參考自實現領域驅動設計(IDDD)和微服務架構設置模式兩本書,也是我推薦大家有時間拿來看的,只看第二本也夠,第一本IDDD翻譯過于晦澀。兩本設計到編程設計的部分都用Java語言寫的,不過大家看起來應該沒有難度。
微服務的技術架構
微服務的技術架構一般是什么樣的呢?一般是業務網關負責與客戶端進行對接,網關層會把請求轉發給內部的服務,此外因為拆分了微服務就不可避免地需要使用消息隊列進行業務事件的同步從而達到數據的最終一致性。
針對Go技術棧來說最簡單的實現微服務架構的方式是使用 Gin / Echo 之類的Web框架做業務網關,內部的各個業務子域的服務使用gRPC,服務發現和注冊可以使用gRPC自帶的 Etcd naming 組件通過Etcd完成微服務的注冊和發現。
我一般喜好在微服務中有一個專門處理接收事件消息然后根據事件類型進行內部服務調用的event服務,這樣做的好處是其他RPC服務在啟動時會更簡單,不用監聽消息隊列。當然這么做肯定是比每個服務都監聽消息要多一次RPC請求的,所以這一條不構成建議,大家可以按自己的偏好來。
把項目擴展成微服務要做哪些工作
下面說一下假如要做微服務,我們專欄中學到的技能要做哪些更新才能讓它適配微服務。
日志組件增加服務名
從單體架構變成微服務架構,日志如果不能串聯和歸因那維護起來肯定會比使用單體架構時還要難上幾倍。我們的項目里封裝的 logger 已經做了日志的trace 和 span 的埋點,直接用到微服務架構下肯定是沒問題的。
不過還是需要增加一些服務標示,這些標識主要是收集日志的日志組件對日志做分類用,做運維的同學應該會比較關注這些字段。
這個不難改,我們只需要在項目logger 的初始化方法中加上它即可。
func New(ctx context.Context) *logger {
var traceId, spanId, pSpanId string
if ctx.Value("traceid") != nil {
traceId = ctx.Value("traceid").(string)
}
if ctx.Value("spanid") != nil {
spanId = ctx.Value("spanid").(string)
}
if ctx.Value("psapnid") != nil {
pSpanId = ctx.Value("pspanid").(string)
}
return &logger{
ctx: ctx,
traceId: traceId,
spanId: spanId,
pSpanId: pSpanId,
perfix: config.App.Name // 增加日志的服務名前綴
_logger: _logger,
}
}
gRPC 服務間怎么傳遞追蹤參數
大家都知道我們日志里的trace_id, span_id 這些追蹤參數在Http 的API接口調用中是通過 Header 往下繼續傳遞的,那如果是網關調內部的RPC服務該怎么把它們繼續傳遞下去呢?
其實跟發HTTP請求可以配置HTTP客戶端攜帶 Header 和 Context 一樣,RPC客戶端也支持類似功能。以 gRPC 服務為例,客戶端調用RPC 方法時,在可以攜帶的元數據里設置這些追蹤參數。
traceID := ctx.Value("trace-id").(string)
traceID := ctx.Value("span-id").(string)
md := metadata.Pairs("xx-traceid", traceID, "xx-spanid", spanID)
// 新建一個有 metadata 的 context
ctx := metadata.NewOutgoingContext(context.Background(), md)
// 單向的 Unary RPC
response, err := client.SomeRPCMethod(ctx, someRequest)
gRPC 的服務端的處理方法里,可以再通過 metadata 把元數據里存儲的追蹤參數取出來。
func (s server) SomeRPCMethod(ctx context.Context, req *xx.someRequest) (reply *xx.SomeReply, err error) {
remote, _ := peer.FromContext(ctx)
remoteAddr := remote.Addr.String()
// 生成本次請求在當前服務的 spanId
spanID := utils.GenerateSpanID(remoteAddr)
traceID, pSpanID := "", ""
md, _ := metadata.FromIncomingContext(ctx)
if arr := md["xx-tranceid"]; len(arr) > 0 {
traceID = arr[0]
}
if arr := md["xx-spanid"]; len(arr) > 0 {
pSpanID = arr[0]
}
return
}
當然如果我們每個客戶端調用和RPC 服務方法里都這么搞一遍得累死,gRPC 里也有類似全局路由中間件的概念,叫攔截器,我們可以把追蹤參數傳遞這部分邏輯封裝在客戶端和服務端的攔截器里。
gRPC客戶端攔截器
func UnaryClientInterceptor(ctx context.Context, ... , opts ...grpc.CallOption) error {
md := metadata.Pairs("xx-traceid", traceID, "xx-spanid", spanID)
mdOld, _ := metadata.FromIncomingContext(ctx)
md = metadata.Join(mdOld, md)
ctx = metadata.NewOutgoingContext(ctx, md)
err := invoker(ctx, method, req, reply, cc, opts...)
return err
}
// 調用gRPC服務
conn, err := grpc.Dial(*address, grpc.WithInsecure(),grpc.WithUnaryInterceptor(UnaryClientInterceptor))
gRPC服務端攔截器
func UnaryServerInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
remote, _ := peer.FromContext(ctx)
remoteAddr := remote.Addr.String()
spanID := utils.GenerateSpanID(remoteAddr)
// set tracing span id
traceID, pSpanID := "", ""
md, _ := metadata.FromIncomingContext(ctx)
if arr := md["xx-traceid"]; len(arr) > 0 {
traceID = arr[0]
}
if arr := md["xx-spanid"]; len(arr) > 0 {
pSpanID = arr[0]
}
// 把 這些ID再搞到 ctx 里,其他兩個就省略了
ctx := Context.WithValue(ctx, "traceId", traceId)
resp, err = handler(ctx, req)
return
}
微服務如何保持數據一致性
上面聊怎么做微服務拆分時我們聊到過,業務領域中的每個子域可以映射為一個服務,每個服務有都自己的領域模型。
微服務架構的一個關鍵特性是每個服務之間都是松耦合的,僅通過 API 或者消息進行通信,這就要求每個服務都擁有自己的私有數據庫,不能是一個服務能連多個庫,如果那樣就當于把自己的領域全都暴露出去了。
那業務規則要求一個操作修改多個庫內的數據實例該怎么辦?
一個合格的微服務,不能在一個業務邏輯中修改自己服務的數據庫的同時又去調用API修改其他服務的數據庫,而應該借助事件消息,完成數據的最終一致性。
上面這一條呢,說是這么說,據我觀察很多時候不是這樣的,所以大家可以把它視做一個參照物,但不用把它奉為圭臬,必須完全遵守。尤其是一些有歷史的老業務,數據庫本就不好按領域做拆分。
那上面聊了如果一個業務操作對A服務的數據做了變更后還需要對B服務里的數據做變更,這個時候我們應該通過事件消息完成服務B的變更。
事件消息也就是發消息隊列對吧,雖然微服務保證的是最終一致性,但我們還是要保證服務內的變更和發送事件消息這兩個操作的原子性的。這個該怎么做呢?
這個就涉及到事件的存儲和發布方式了,通常的做法是首先在本地有一個Events 表,記錄所有領域對象發布的領域事件。
CREATE TABLE `tbl_events` (
`event_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`event_type` varhcar(100) NOT NULL COMMENT '事件名稱',
`entity_id` bigint(20) unsigned NOT NULL COMMENT '聚合的標識,userId orderId 等數據'
`entity_type` varhcar(100) NOT NULL COMMENT '聚合類型',
`event_data` varchar(50000) NOT NULL COMMENT '事件Body'
`occurred_on` datetime NOT NULL COMMENT '事件發布時間',
`publis_state` tinyint(3) unsinged NOT NULL COMMENT '發布狀態'
)
圖片
我們在向消息隊列發布事件消息時,要在同一個數據庫事務中先寫Events表再把消息發送給隊列,如果隊列發布失敗,在表中記錄發布失敗后再退出事務。
這里也可以所有服務公用一個事件庫,Events表按照某些維度做垂直拆表,因為Events設計地是通用的不會暴露服務的領域,所以我個人認為多個服務公用一個也沒啥關系。
這樣做的話除了能保證數據的最終一致性外,還有利于我們做好事件溯源,比如一個訂單的狀態在訂單表中只能體現它當前的狀態,它經過了哪些狀態變更是沒法查到的。
有了這個Events表后,我們做事件溯源或者審計類的需求時就會好做很多。比如類似 “給7 天前簽到的用戶發消息”、“把某個商品添加到購物車又刪除的用戶發促銷紅包”等功能,通過分析事件流里的數據會很容易實現。