從零開始實現一個RPC框架(四)

熊紀元發表於2019-03-31

前言

到目前為止我們已經支援了基本的RPC呼叫,也支援基於zk的服務註冊和發現,還支援鑑權和熔斷等等。雖然實現得都非常簡單,但是這些功能都是基於可替換的介面實現的,所以我們後續可以很方便的替換成更加完善成熟的實現。

這次我們繼續服務治理方面的功能,包括註冊中心優化、限流的支援、鏈路追蹤的支援,同時增加了一種路由策略。

具體程式碼參考:github

支援多種資料來源的註冊中心

在上一篇文章裡我們藉助libkv實現了基於zookeeper的服務註冊與發現,這次我們更進一步,將我們的ZookeeperRegistry改造成支援多種資料來源的Registry。實際上的改造也比較簡單,最重要的註冊、發現以及通知等都已經完成了,我們只需要將底層的資料來源型別改造為可配置的即可。程式碼如下:

//Registry的定義,就是從上一篇的ZookeeperRegistry改過來的
type KVRegistry struct {
	AppKey         string        //KVRegistry
	ServicePath    string        //資料儲存的基本路徑位置,比如/service/providers
	UpdateInterval time.Duration //定時拉取資料的時間間隔
	kv store.Store //store例項是一個封裝過的客戶端
	providersMu sync.RWMutex
	providers   []registry.Provider //本地快取的列表
	watchersMu sync.Mutex
	watchers   []*Watcher //watcher列表
}

//初始化邏輯,根據backend引數的不同支援不同的底層資料來源
func NewKVRegistry(backend store.Backend,addrs []string,AppKey string,cfg *store.Config,ServicePath string,updateInterval time.Duration) registry.Registry {
    //libkv中需要顯式初始化資料來源
	switch backend {
	case store.ZK:
		zookeeper.Register()
	case store.ETCD:
		etcd.Register()
	case store.CONSUL:
		consul.Register()
	case store.BOLTDB:
		boltdb.Register()
	}
	r := new(KVRegistry)
	r.AppKey = AppKey
	r.ServicePath = ServicePath
	r.UpdateInterval = updateInterval
    //生成實際的資料來源
	kv, err := libkv.NewStore(backend, addrs, cfg)
	if err != nil {
		log.Fatalf("cannot create kv registry: %v", err)
	}
	r.kv = kv

	//省略了後面的初始化邏輯,因為和之前沒有改動
	return r
}
複製程式碼

這裡實際上是偷懶了,可以看出來這裡完全就是對libkv的包裝,所以能夠支援的資料來源也僅限libkv支援的幾種,包括:boltdb、etcd、consul、zookeeper。後續如果要支援其他的註冊中西比如eureka或者narcos,就得自己寫接入程式碼了。

限流

當前的限流是基於Ticker實現的,同時支援服務端和客戶端的限流,具體的邏輯參考了https://gobyexample.com/rate-limiting裡的實現。

首先列舉限流器介面的定義:

type RateLimiter interface {
        //獲取許可,會阻塞直到獲得許可
        Acquire() 
	//嘗試獲取許可,如果不成功會立即返回false,而不是一直阻塞
	TryAcquire() bool 
	//獲取許可,會阻塞直到獲得許可或者超時,超時時會返回一個超時異常,成功時返回nil
	AcquireWithTimeout(duration time.Duration) error 
}
複製程式碼

客戶端的實現如下(基於Wrapper):

type RateLimitInterceptor struct {
    //內嵌了defaultClientInterceptor,defaultClientInterceptor類實現了Wrapper的所有方法,我們只需要覆蓋自己需要實現的方法即可
	defaultClientInterceptor
	Limit ratelimit.RateLimiter
}
var ErrRateLimited = errors.New("request limited")
func (r *RateLimitInterceptor) WrapCall(option *SGOption, callFunc CallFunc) CallFunc {
	return func(ctx context.Context, ServiceMethod string, arg interface{}, reply interface{}) error {
		if r.Limit != nil {
		        //進行嘗試獲取,獲取失敗時直接返回限流異常
			if r.Limit.TryAcquire() { 
				return callFunc(ctx, ServiceMethod, arg, reply)
			} else {
				return ErrRateLimited
			}
		} else {//若限流器為nil則不進行限流
			return callFunc(ctx, ServiceMethod, arg, reply)
		}
	}
}
func (r *RateLimitInterceptor) WrapGo(option *SGOption, goFunc GoFunc) GoFunc {
	return func(ctx context.Context, ServiceMethod string, arg interface{}, reply interface{}, done chan *Call) *Call {
		if r.Limit != nil {
		        //進行嘗試獲取,獲取失敗時直接返回限流異常
			if r.Limit.TryAcquire() {
				return goFunc(ctx, ServiceMethod, arg, reply, done)
			} else {
				call := &Call{
					ServiceMethod: ServiceMethod,
					Args:arg,
					Reply: nil,
					Error: ErrRateLimited,
					Done: done,
				}
				done <- call
				return call
			}
		} else {//若限流器為nil則不進行限流
			return goFunc(ctx, ServiceMethod, arg, reply, done)
		}
	}
}
複製程式碼

服務端的限流實現如下(基於Wrapper):

type RequestRateLimitInterceptor struct {
    //這裡內嵌了defaultServerInterceptor,defaultServerInterceptor類實現了Wrapper的所有方法,我們只需要覆蓋自己需要實現的方法即可
	defaultServerInterceptor
	Limiter ratelimit.RateLimiter
}

func (rl *RequestRateLimitInterceptor) WrapHandleRequest(s *SGServer, requestFunc HandleRequestFunc) HandleRequestFunc {
	return func(ctx context.Context, request *protocol.Message, response *protocol.Message, tr transport.Transport) {
		if rl.Limiter != nil {
		        //進行嘗試獲取,獲取失敗時直接返回限流異常
			if rl.Limiter.TryAcquire() {
				requestFunc(ctx, request, response, tr)
			} else {
				s.writeErrorResponse(response, tr, "request limited")
			}
		} else {//如果限流器為nil則直接返回
			requestFunc(ctx, request, response, tr)
		}
	}
}
複製程式碼

可以看到這裡的限流邏輯非常簡單,只支援全侷限流,沒有區分各個方法,但要支援也很簡單,在Wrapper裡維護一個方法到限流器的map,在限流時根據具體的方法名獲取不同的限流器進行限流判斷即可;同時這裡限流也是基於單機的,不支援叢集限流,要支援叢集級別的限流需要獨立的資料來源進行次數統計等等,這裡暫時不涉及了。

鏈路追蹤

鏈路追蹤在大型分散式系統中可以有效地幫助我們進行故障排查、效能分析等等。鏈路追蹤通常包括三部分工作:資料埋點、資料收集和資料展示,而到RPC框架這裡實際上就只涉及資料埋點了。目前業界有許多鏈路追蹤的產品,而他們各自的api和實現都不一樣,要支援不同的產品需要做很多額外的相容改造工作,於是就有了opentracing規範。opentracing旨在統一各個不同的追蹤產品的api,提供標準的接入層。而我們這裡就直接整合opentracing,使用者可以在使用時繫結到不同的opentracing實現,比較主流的opentracing實現有zipkinjaeger

客戶端鏈路追蹤的實現(同樣基於Wrapper):

//目前只做了同步呼叫支援
type OpenTracingInterceptor struct {
	defaultClientInterceptor
}
func (*OpenTracingInterceptor) WrapCall(option *SGOption, callFunc CallFunc) CallFunc {
	return func(ctx context.Context, ServiceMethod string, arg interface{}, reply interface{}) error {
		var clientSpan opentracing.Span
		if ServiceMethod != "" { //不是心跳的請求才進行追蹤
		        //先從當前context獲取已存在的追蹤資訊
			var parentCtx opentracing.SpanContext
			if parent := opentracing.SpanFromContext(ctx); parent != nil {
				parentCtx = parent.Context()
			}
			//開始埋點
			clientSpan := opentracing.StartSpan(
				ServiceMethod,
				opentracing.ChildOf(parentCtx),
				ext.SpanKindRPCClient)
			defer clientSpan.Finish()

			meta := metadata.FromContext(ctx)
			writer := &trace.MetaDataCarrier{&meta}
                        //將追蹤資訊注入到metadata中,通過rpc傳遞到下游
			injectErr := opentracing.GlobalTracer().Inject(clientSpan.Context(), opentracing.TextMap, writer)
			if injectErr != nil {
				log.Printf("inject trace error: %v", injectErr)
			}
			ctx = metadata.WithMeta(ctx, meta)
		}

		err := callFunc(ctx, ServiceMethod, arg, reply)
		if err != nil && clientSpan != nil {
			clientSpan.LogFields(opentracingLog.String("error", err.Error()))
		}
		return err
	}
}
複製程式碼

服務端鏈路追蹤的實現(同樣基於Wrapper):

type OpenTracingInterceptor struct {
	defaultServerInterceptor
}
func (*OpenTracingInterceptor) WrapHandleRequest(s *SGServer, requestFunc HandleRequestFunc) HandleRequestFunc {
	return func(ctx context.Context, request *protocol.Message, response *protocol.Message, tr transport.Transport) {
		if protocol.MessageTypeHeartbeat != request.MessageType {
			meta := metadata.FromContext(ctx)
			//從metadata中提取追蹤資訊
			spanContext, err := opentracing.GlobalTracer().Extract(opentracing.TextMap, &trace.MetaDataCarrier{&meta})
			if err != nil && err != opentracing.ErrSpanContextNotFound {
				log.Printf("extract span from meta error: %v", err)
			}
                        //開始服務端埋點
			serverSpan := opentracing.StartSpan(
				request.ServiceName + "." + request.MethodName,
				ext.RPCServerOption(spanContext),
				ext.SpanKindRPCServer)
			defer serverSpan.Finish()
			ctx = opentracing.ContextWithSpan(ctx, serverSpan)

		}
		requestFunc(ctx, request, response, tr)
	}
}
複製程式碼

可以看到我們實現鏈路追蹤的邏輯主要就是兩部分:

  1. 根據請求方法名等資訊生成鏈路資訊
  2. 通過rpc metadata傳遞追蹤資訊

前面也提到了,RPC框架的工作也僅限於資料埋點而已,剩下的資料收集和資料展示部分需要依賴具體的產品。使用者需要在程式裡設定具體的實現,類似這樣:

//mocktracker是mock的追蹤,只限於測試目的使用
opentracing.SetGlobalTracer(mocktracer.New())

//或者使用jaeger
import (
    "github.com/uber/jaeger-client-go/config"
    "github.com/uber/jaeger-lib/metrics/prometheus"
)

    metricsFactory := prometheus.New()
    tracer, closer, err := config.Configuration{
        ServiceName: "your-service-name",
    }.NewTracer(
        config.Metrics(metricsFactory),
    )
    //設定tracer
    opentracing.SetGlobalTracer(tracer)
複製程式碼

基於標籤的路由策略

最後我們來實現基於服務端後設資料的規則路由,使用者在實際使用過程中,肯定有一些特殊的路由要求,比如“我們的服務執行在不同的idc,我希望能夠儘量保證同idc相互呼叫”,或者“我希望能夠在執行時切斷某個服務提供者的流量”,這些需求都可以抽象成基於標籤的路由。我們給每個服務提供者都打上不同的標籤,客戶端在呼叫時會根據自己的需要過濾出符合某些標籤的服務提供者。

而標籤的具體實現就是將標籤放到服務提供者的後設資料裡,這些後設資料會被註冊到註冊中心,也會被客戶端服務發現時獲取到,客戶端在呼叫前進行過濾即可。

程式碼實現:

//服務端註冊時,將我們設定的tags作為後設資料註冊到註冊中心
func (w *DefaultServerWrapper) WrapServe(s *SGServer, serveFunc ServeFunc) ServeFunc {
	return func(network string, addr string, meta map[string]interface{}) error {
		//省略註冊shutdownHook的邏輯
		...
		
		if meta == nil {
			meta = make(map[string]interface{})
		}
		//注入tags
		if len(s.Option.Tags) > 0 {
			meta["tags"] = s.Option.Tags
		}
		meta["services"] = s.Services()
		provider := registry.Provider{
			ProviderKey: network + "@" + addr,
			Network:     network,
			Addr:        addr,
			Meta:        meta,
		}
		r := s.Option.Registry
		rOpt := s.Option.RegisterOption

		r.Register(rOpt, provider)
		log.Printf("registered provider %v for app %s", provider, rOpt)

		return serveFunc(network, addr, meta)
	}
}

//客戶端實現,基於tags進行過濾
func TaggedProviderFilter(tags map[string]string) Filter {
	return func(provider registry.Provider, ctx context.Context, ServiceMethod string, arg interface{}) bool {
		if tags == nil {
			return true
		}
		if provider.Meta == nil {
			return false
		}
		providerTags, ok := provider.Meta["tags"].(map[string]string)
		if !ok || len(providerTags) <= 0{
			return false
		}
		for k, v := range tags {
			if tag, ok := providerTags[k];ok {
				if tag != v {
					return false
				}
			} else {
				return false
			}
		}
		return true
	}
}
複製程式碼

這裡的實現當中,服務端和客戶端的標籤在註冊前就已經設定好了,只能滿足比較簡單的策略,後續再考慮實現執行時修改標籤的支援了。

結語

今天的內容就到此為止了,實際上我們的很多功能都是基於最開始定義的Wrapper實現的攔截器來完成的。這樣設計的好處就是能保證對擴充套件開放,對修改關閉,也就是開閉原則,我們在擴充時可以完全不影響之前的邏輯。但是這種基於高階函式的實現有個不方便的地方就是debug時比較困難,不容易找到具體的實現邏輯,不知道有沒有更好的解決方式。

歷史連結

從零開始實現一個RPC框架(零)

從零開始實現一個RPC框架(一)

從零開始實現一個RPC框架(二)

從零開始實現一個RPC框架(三)

相關文章