go-micro整合鏈路跟蹤的方法和中介軟體原理

波斯馬發表於2022-05-05

前幾天有個同學想了解下如何在go-micro中做鏈路跟蹤,這幾天正好看到wrapper這塊,wrapper這個東西在某些框架中也稱為中介軟體,裡邊有個opentracing的外掛,正好用來做鏈路追蹤。opentracing是個規範,還需要搭配一個具體的實現,比如zipkin、jeager等,這裡選擇zipkin。

image-20220504113509350

鏈路跟蹤實戰

安裝zipkin

通過docker快速啟動一個zipkin服務端:

docker run -d -p 9411:9411 openzipkin/zipkin

程式結構

為了方便演示,這裡把客戶端和服務端放到了一個專案中,程式的目錄結構是這樣的:

image-20220504105323472

  • main.go 服務端程式。
  • client/main.go 客戶端程式。
  • config/config.go 程式用到的一些配置,比如服務的名稱和監聽埠、zipkin的訪問地址等。
  • zipkin/ot-zipkin.go opentracing和zipkin相關的函式。

安裝依賴包

需要安裝go-micro、opentracing、zipkin相關的包:

go get go-micro.dev/v4@latest
go get github.com/go-micro/plugins/v4/wrapper/trace/opentracing
go get -u github.com/openzipkin-contrib/zipkin-go-opentracing

編寫服務端

首先定義一個服務端業務處理程式:

type Hello struct {
}

func (h *Hello) Say(ctx context.Context, name *string, resp *string) error {
	*resp = "Hello " + *name
	return nil
}

這個程式只有一個方法Say,輸入name,返回 "Hello " + name。

然後使用go-micro編寫服務端框架程式:

func main() {
	tracer := zipkin.GetTracer(config.SERVICE_NAME, config.SERVICE_HOST)
	defer zipkin.Close()
	tracerHandler := opentracing.NewHandlerWrapper(tracer)

	service := micro.NewService(
		micro.Name(config.SERVICE_NAME),
		micro.Address(config.SERVICE_HOST),
		micro.WrapHandler(tracerHandler),
	)

	service.Init()

	micro.RegisterHandler(service.Server(), &Hello{})

	if err := service.Run(); err != nil {
		log.Println(err)
	}
}

這裡NewService的時候除了指定服務的名稱和訪問地址,還通過micro.WrapHandler設定了一個用於鏈路跟蹤的HandlerWrapper。

這個HandlerWrapper是通過go-micro的opentracing外掛提供的,這個外掛需要傳入一個tracer。這個tracer可以通過前邊安裝的 zipkin-go-opentracing 包來建立,我們把建立邏輯封裝在了config.go中:

func GetTracer(serviceName string, host string) opentracing.Tracer {
	// set up a span reporter
	zipkinReporter = zipkinhttp.NewReporter(config.ZIPKIN_SERVER_URL)

	// create our local service endpoint
	endpoint, err := zipkin.NewEndpoint(serviceName, host)
	if err != nil {
		log.Fatalf("unable to create local endpoint: %+v\n", err)
	}

	// initialize our tracer
	nativeTracer, err := zipkin.NewTracer(zipkinReporter, zipkin.WithLocalEndpoint(endpoint))
	if err != nil {
		log.Fatalf("unable to create tracer: %+v\n", err)
	}

	// use zipkin-go-opentracing to wrap our tracer
	tracer := zipkinot.Wrap(nativeTracer)
	opentracing.InitGlobalTracer(tracer)
	return tracer
}

service建立完畢之後,還要通過 micro.RegisterHandler 來註冊前邊編寫的業務處理程式。

最後通過 service.Run 讓服務執行起來。

編寫客戶端

再來看一下客戶端的處理邏輯:

func main() {
	tracer := zipkin.GetTracer(config.CLIENT_NAME, config.CLIENT_HOST)
	defer zipkin.Close()
	tracerClient := opentracing.NewClientWrapper(tracer)

	service := micro.NewService(
		micro.Name(config.CLIENT_NAME),
		micro.Address(config.CLIENT_HOST),
		micro.WrapClient(tracerClient),
	)

	client := service.Client()

	go func() {
		for {
			<-time.After(time.Second)
			result := new(string)
			request := client.NewRequest(config.SERVICE_NAME, "Hello.Say", "FireflySoft")
			err := client.Call(context.TODO(), request, result)
			if err != nil {
				log.Println(err)
				continue
			}
			log.Println(*result)
		}
	}()

	service.Run()
}

這段程式碼開始也是先NewService,設定客戶端程式的名稱和監聽地址,然後通過micro.WrapClient注入鏈路跟蹤,這裡注入的是一個ClientWrapper,也是由opentracing外掛提供的。這裡用的tracer和服務端tracer是一樣的,都是通過config.go中GetTracer函式獲取的。

然後為了方便演示,啟動一個go routine,客戶端每隔一秒發起一次RPC請求,並將返回結果列印出來。執行效果如圖所示:

image-20220504113324121

zipkin中跟蹤到的訪問日誌:

go-micro zipkin

Wrap原理分析

Wrap從字面意思上理解就是封裝、巢狀,在很多的框架中也稱為中介軟體,比如gin中,再比如ASP.NET Core中。這個部分就來分析下go-micro中Wrap的原理。

服務端Wrap

在go-micro中服務端處理請求的邏輯封裝稱為Handler,它的具體形式是一個func,定義為:

func(ctx context.Context, req Request, rsp interface{}) error

這個部分就來看一下服務端Handler是怎麼被Wrap的。

HandlerWrapper

要想Wrap一個Handler,必須建立一個HandlerWrapper型別,這其實是一個func,其定義如下:

type HandlerWrapper func(HandlerFunc) HandlerFunc

它的引數和返回值都是HandlerFunc型別,其實就是上面提到的Handler的func定義。

以本文鏈路跟蹤中使用的 tracerHandler 為例,看一下HandlerWrapper是如何實現的:

	func(h server.HandlerFunc) server.HandlerFunc {
		return func(ctx context.Context, req server.Request, rsp interface{}) error {
			...
			if err = h(ctx, req, rsp); err != nil {
			...
		}
	}

從中可以看出,Wrap一個Hander就是定義一個新Handler,在它的的內部呼叫傳入的原Handler。

Wrap Handler

建立了一個HandlerWrapper之後,還需要把它加入到服務端的處理過程中。

go-micro在NewService的時候通過呼叫 micro.WrapHandler 設定這些 HandlerWrapper:

service := micro.NewService(
		...
		micro.WrapHandler(tracerHandler),
	)

WrapHandler的實現是這樣的:

func WrapHandler(w ...server.HandlerWrapper) Option {
	return func(o *Options) {
		var wrappers []server.Option

		for _, wrap := range w {
			wrappers = append(wrappers, server.WrapHandler(wrap))
		}

		o.Server.Init(wrappers...)
	}
}

它返回的是一個函式,這個函式會將我們傳入的HandlerWrapper通過server.WrapHandler轉化為一個server.Option,然後交給Server.Init進行初始化處理。

這裡的server.Option其實還是一個func,看一下WrapHandler的原始碼:

func WrapHandler(w HandlerWrapper) Option {
	return func(o *Options) {
		o.HdlrWrappers = append(o.HdlrWrappers, w)
	}
}

這個func將我們傳入的HandlerWrapper新增到了一個切片中。

那麼這個函式什麼時候執行呢?就在Server.Init中。看一下Server.Init中的原始碼:

 func (s *rpcServer) Init(opts ...Option) error {
	...

	for _, opt := range opts {
		opt(&s.opts)
	}
	
	if s.opts.Router == nil {
		r := newRpcRouter()
		r.hdlrWrappers = s.opts.HdlrWrappers
		...
		s.router = r
	}

	...
}

它會遍歷傳入的所有server.Option,也就是執行每一個func(o *Options)。這樣Options的切片HdlrWrappers中就新增了我們設定的HandlerWrapper,同時還把這個切片傳遞到了rpcServer的router中。

可以看到這裡的Options就是rpcServer.opts,HandlerWrapper切片同時設定到了rpcServer.router和rpcServer.opts中。

還有一個問題:WrapHandler返回的func什麼時候執行呢?

這個在micro.NewService -> newService -> newOptions中:

func newOptions(opts ...Option) Options {
	opt := Options{
	...
		Server:    server.DefaultServer,
	...
	}

	for _, o := range opts {
		o(&opt)
	}

	...
}

遍歷opts就是執行每一個設定func,最終執行到rpcServer.Init。

到NewService執行完畢為止,我們設定的WrapHandler全部新增到了一個名為HdlrWrappers的切片中。

再來看一下服務端Wrapper的執行過程是什麼樣的?

執行Handler的這段程式碼在rpc_router.go中:

func (s *service) call(ctx context.Context, router *router, sending *sync.Mutex, mtype *methodType, req *request, argv, replyv reflect.Value, cc codec.Writer) error {
	defer router.freeRequest(req)

	...

	for i := len(router.hdlrWrappers); i > 0; i-- {
		fn = router.hdlrWrappers[i-1](fn)
	}

	...

	// execute handler
	return fn(ctx, r, rawStream)
}

根據前面的分析,可以知道router.hdlrWrappers中記錄的就是所有的HandlerWrapper,這裡通過遍歷router.hdlrWrappers實現了HandlerWrapper的巢狀,注意這裡遍歷時索引採用了從大到小的順序,後新增的先被Wrap,先新增在外層。

實際執行時就是先呼叫到最先新增的HandlerWrapper,然後一層層向裡呼叫,最終呼叫到我們註冊的業務Handler,然後再一層層的返回,每個HandlerWrapper都可以在呼叫下一層前後做些自己的工作,比如鏈路跟蹤這裡的檢測執行時間。

客戶端Wrap

在客戶端中遠端呼叫的定義在Client中,它是一個介面,定義了若干方法:

type Client interface {
	...
	Call(ctx context.Context, req Request, rsp interface{}, opts ...CallOption) error
	...
}

我們這裡為了講解方便,只關注Call方法,其它的先省略。

下面來看一下Client是怎麼被Wrap的。

XXXWrapper

要想Wrap一個Client,需要通過struct巢狀這個Client,並實現Client介面的方法。至於這個struct的名字無法強制要求,一般以XXXWrapper命名。

這裡以鏈路跟蹤使用的 otWrapper 為例,它的定義如下:

type otWrapper struct {
	ot opentracing.Tracer
	client.Client
}

func (o *otWrapper) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
	...
	if err = o.Client.Call(ctx, req, rsp, opts...); err != nil {
	...
}

...

注意XXXWrapper實現的介面方法中都去呼叫了被巢狀Client的對應介面方法,這是能夠巢狀執行的關鍵。

Wrap Client

有了上面的 XXXWrapper,還需要把它注入到程式的執行流程中。

go-micro在NewService的時候通過呼叫 micro.WrapClient 設定這些 XXXWrapper:

service := micro.NewService(
		...
		micro.WrapClient(tracerClient),
	)

和WrapHandler差不多,WrapClient的引數不是直接傳入XXXWrapper的例項,而是一個func,定義如下:

type Wrapper func(Client) Client

這個func需要將傳入的的Client包裝到 XXXWrapper 中,並返回 XXXWrapper 的例項。這裡傳入的 tracerClient 就是這樣一個func:

return func(c client.Client) client.Client {
  if ot == nil {
  	ot = opentracing.GlobalTracer()
  }
  return &otWrapper{ot, c}
}

要實現Client的巢狀,可以給定一個初始的Client例項作為第一個此類func的輸入,然後前一個func的輸出作為後一個func的輸入,依次執行,最終形成業務程式碼中要使用的Client例項,這很像俄羅斯套娃,它有很多層Client。

那麼這個俄羅斯套娃是什麼時候建立的呢?

在 micro.NewService -> newService -> newOptions中:

func newOptions(opts ...Option) Options {
	opt := Options{
		...
		Client:    client.DefaultClient,
		...
	}

	for _, o := range opts {
		o(&opt)
	}

	return opt
}

可以看到這裡給Client設定了一個初始值,然後遍歷這些NewService時傳入的Option(WrapClient返回的也是Option),這些Option其實都是func,所以就是遍歷執行這些func,執行這些func的時候會傳入一些初始預設值,包括Client的初始值。

那麼前一個func的輸出怎麼作為後一個func的輸入的呢?再來看下WrapClient的原始碼:

func WrapClient(w ...client.Wrapper) Option {
	return func(o *Options) {
		for i := len(w); i > 0; i-- {
			o.Client = w[i-1](o.Client)
		}
	}
}

可以看到Wrap方法從Options中獲取到當前的Client例項,把它傳給Wrap func,然後新生成的例項又被設定到Options的Client欄位中。

正是這樣形成了前文所說的俄羅斯套娃。

再來看一下客戶端呼叫的執行流程是什麼樣的?

通過service的Client()方法獲取到Client例項,然後通過這個例項的Call()方法執行RPC呼叫。

client:=service.Client()
client.Call()

這個Client例項就是前文描述的套娃例項:

func (s *service) Client() client.Client {
	return s.opts.Client
}

前文提到過:XXXWrapper實現的介面方法中呼叫了被巢狀Client的對應介面方法。這就是能夠巢狀執行的關鍵。

這裡給一張圖,讓大家方便理解Wrap Client進行RPC呼叫的執行流程:

go-micro wrap client call

客戶端Wrap和服務端Wrap的區別

一個重要的區別是:對於多次WrapClient,後新增的先被呼叫;對於多次WrapHandler,先新增的先被呼叫。

有一個比較怪異的地方是,WrapClient時如果傳遞了多個Wrapper例項,WrapClient會把順序調整過來,這多個例項中前邊的先被呼叫,這個處理和多次WrapClient處理的順序相反,不是很理解。

func WrapClient(w ...client.Wrapper) Option {
	return func(o *Options) {
		// apply in reverse
		for i := len(w); i > 0; i-- {
			o.Client = w[i-1](o.Client)
		}
	}
}

客戶端Wrap還提供了更低層級的CallWrapper,它的執行順序和服務端HandlerWrapper的執行順序一致,都是先新增的先被呼叫。

	// wrap the call in reverse
	for i := len(callOpts.CallWrappers); i > 0; i-- {
		rcall = callOpts.CallWrappers[i-1](rcall)
	}

還有一個比較大的區別是,服務端的Wrap是呼叫某個業務Handler之前臨時加上的,客戶端的Wrap則是在呼叫Client.Call時就已經建立好。這樣做的原因是什麼呢?這個可能是因為在服務端,業務Handler和HandlerWrapper是分別註冊的,註冊業務Handler時HandlerWrapper可能還不存在,只好採用動態Wrap的方式。而在客戶端,通過Client.Call發起呼叫時,Client是發起呼叫的主體,使用者有很多獲取Client的方式,無法要求使用者在每次呼叫前都臨時Wrap。

Http服務的鏈路跟蹤

關於Http或者說是Restful服務的鏈路跟蹤,go-micro的httpClient支援CallWrapper,可以用WrapCall來新增鏈路跟蹤的CallWrapper;但是其httpServer實現的比較簡單,把http內部的Handler處理完全交出去了,不能用WrapHandler,只能自己在http的框架中來做這件事,比如go-micro+gin開發的Restful服務可以使用gin的中介軟體機制來做鏈路追蹤。


以上就是本文的主要內容,如有錯漏歡迎指正。

程式碼已經上傳到Github,歡迎訪問:https://github.com/bosima/go-demo/tree/main/go-micro-opentracing

收穫更多架構知識,請關注微信公眾號 螢火架構。原創內容,轉載請註明出處。
掃描二維碼關注公眾號

相關文章