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

熊紀元發表於2019-03-24

前言

到目前為止我們的框架已經有了一部分服務治理的功能,這次我們在之前的基礎上實現一些其他功能。篇幅所限這裡只列舉部分實現,完整程式碼參考:github

zookeeper註冊中心

實現我們之前的註冊中心的介面即可,這裡使用了docker的libkv而不是直接用zk客戶端(從rpcx那學的),libkv封裝了對於幾種儲存服務的操作,包括Consul、Etcd、Zookeeper和BoltDB,後續如果要支援其他型別的儲存就得自己寫客戶端了。基於zk的註冊中心的定義如下:

type ZookeeperRegistry struct {
	AppKey         string //一個ZookeeperRegistry例項和一個appkey關聯
	ServicePath    string //資料儲存的基本路徑位置,比如/service/providers
	UpdateInterval time.Duration //定時拉取資料的時間間隔
	kv store.Store //封裝過的zk客戶端
	providersMu sync.RWMutex
	providers   []registry.Provider //本地快取的列表
	watchersMu sync.Mutex
	watchers   []*Watcher //watcher列表
}
複製程式碼

初始化部分邏輯如下:

func NewZookeeperRegistry(AppKey string, ServicePath string, zkAddrs []string,
	updateInterval time.Duration, cfg *store.Config) registry.Registry {
	zk := new(ZookeeperRegistry)
	zk.AppKey = AppKey
	zk.ServicePath = ServicePath
	zk.UpdateInterval = updateInterval
	kv, err := libkv.NewStore(store.ZK, zkAddrs, cfg)
	if err != nil {
		log.Fatalf("cannot create zk registry: %v", err)
	}
	zk.kv = kv
	basePath := zk.ServicePath
	if basePath[0] == '/' { //路徑不能以"/"開頭
		basePath = basePath[1:]
		zk.ServicePath = basePath
	}
	//先建立基本路徑
	err = zk.kv.Put(basePath, []byte("base path"), &store.WriteOptions{IsDir: true})
	if err != nil {
		log.Fatalf("cannot create zk path %s: %v", zk.ServicePath, err)
	}
	//顯式拉取第一次資料
	zk.doGetServiceList()
	go func() {
		t := time.NewTicker(updateInterval)
		for range t.C {
			//定時拉取資料
			zk.doGetServiceList()
		}
	}()
	go func() {
		//後臺watch資料
		zk.watch()
	}()
	return zk
}
複製程式碼

我們在初始化註冊中心時執行兩個後臺任務:定時拉取和監聽資料,相當於推拉結合的方式。同時監聽獲得的資料是全量資料,因為實現起來簡單一些,後續如果服務列表越來越大時,可能需要加上基於版本號的機制或者只傳輸增量資料。這裡額外指出幾個要點:

  1. 後臺定時拉取資料並快取起來
  2. 查詢時直接返回快取
  3. 註冊時在zk新增節點,登出時在zk刪除節點
  4. 監聽時並不監聽每個服務提供者,而是監聽其父級目錄,有變更時再統一拉取服務提供者列表,這樣可以減少watcher的數目,邏輯也更簡單一些
  5. 因為第4點,所以註冊和登出時需要更改父級目錄的內容(lastUpdate)來觸發監聽

具體的註冊登出邏輯這裡不再列舉,參考:github

客戶端心跳

如果我們使用zk作為註冊中心,更簡單的做法可能是直接將服務提供者作為臨時節點新增到zk上,這樣就可以利用臨時節點的特性實現動態的服務發現。但是我們使用的libkv庫並不支援臨時節點的功能,而且除了zk其他儲存服務比如etcd等可能也不支援臨時節點的特性,所以我們註冊到註冊中心的都是持久節點。在這種情況下,可能某些由於特殊情況無法訪問的服務提供者並沒有及時地將自身從註冊中心登出掉,所以客戶端需要額外的能力來判斷一個服務提供者是否可用,而不是完全依賴註冊中心。

所以我們需要增加客戶端心跳的支援,客戶端可以定時向服務端傳送心跳請求,服務端收到心跳請求時可以直接返回,只要通知客戶端自身仍然可用就行。客戶端可以根據設定的閾值,對心跳失敗的服務提供者進行降級處理,直到心跳恢復或者服務提供者被登出掉。客戶端傳送心跳邏輯如下:

func (c *sgClient) heartbeat() {
	if c.option.HeartbeatInterval <= 0 {
		return
	}
	//根據指定的時間間隔傳送心跳
	t := time.NewTicker(c.option.HeartbeatInterval)
	for range t.C {
		if c.shutdown {
			t.Stop()
			return
		}
		//遍歷每個RPCClient進行心跳檢查
		c.clients.Range(func(k, v interface{}) bool {
			err := v.(RPCClient).Call(context.Background(), "", "", nil)
			c.mu.Lock()
			if err != nil {
				//心跳失敗進行計數
				if fail, ok := c.clientsHeartbeatFail[k.(string)]; ok {
					fail++
					c.clientsHeartbeatFail[k.(string)] = fail
				} else {
					c.clientsHeartbeatFail[k.(string)] = 1
				}
			} else {
				//心跳成功則進行恢復
				c.clientsHeartbeatFail[k.(string)] = 0
				c.serversMu.Lock()
				for i, p := range c.servers {
					if p.ProviderKey == k {
						delete(c.servers[i].Meta, protocol.ProviderDegradeKey)
					}
				}
				c.serversMu.Unlock()
			}
			c.mu.Unlock()
			//心跳失敗次數超過閾值則進行降級
			if c.clientsHeartbeatFail[k.(string)] > c.option.HeartbeatDegradeThreshold {
				c.serversMu.Lock()
				for i, p := range c.servers {
					if p.ProviderKey == k {
						c.servers[i].Meta[protocol.ProviderDegradeKey] = true
					}
				}
				c.serversMu.Unlock()
			}
			return true
		})
	}
}
複製程式碼

鑑權

鑑權的實現比較簡單,客戶端可以在後設資料中攜帶鑑權相關的資訊,而服務端可以通過指定的Wrapper進行鑑權。服務端Wrapper的程式碼如下:

type AuthFunc func(key string) bool
type ServerAuthInterceptor struct {
	authFunc AuthFunc
}
func NewAuthInterceptor(authFunc AuthFunc) Wrapper {
	return &ServerAuthInterceptor{authFunc}
}
func (sai *ServerAuthInterceptor) WrapHandleRequest(s *SGServer, requestFunc HandleRequestFunc) HandleRequestFunc {
	return func(ctx context.Context, request *protocol.Message, response *protocol.Message, tr transport.Transport) {
		if auth, ok := ctx.Value(protocol.AuthKey).(string); ok {
			//鑑權通過則執行業務邏輯
			if sai.authFunc(auth) {
				requestFunc(ctx, response, response, tr)
				return
			}
		}
		//鑑權失敗則返回異常
		s.writeErrorResponse(response, tr, "auth failed")
	}
}
複製程式碼

熔斷降級

暫時實現了簡單的基於時間視窗的熔斷器,實現如下:

type CircuitBreaker interface {
	AllowRequest() bool
	Success()
	Fail(err error)
}
type DefaultCircuitBreaker struct {
	lastFail  time.Time
	fails     uint64
	threshold uint64
	window    time.Duration
}
func NewDefaultCircuitBreaker(threshold uint64, window time.Duration) *DefaultCircuitBreaker {
	return &DefaultCircuitBreaker{
		threshold: threshold,
		window:    window,
	}
}
func (cb *DefaultCircuitBreaker) AllowRequest() bool {
	if time.Since(cb.lastFail) > cb.window {
		cb.reset()
		return true
	}

	failures := atomic.LoadUint64(&cb.fails)
	return failures < cb.threshold
}
func (cb *DefaultCircuitBreaker) Success() {
	cb.reset()
}
func (cb *DefaultCircuitBreaker) Fail() {
	atomic.AddUint64(&cb.fails, 1)
	cb.lastFail = time.Now()
}
func (cb *DefaultCircuitBreaker) reset() {
	atomic.StoreUint64(&cb.fails, 0)
	cb.lastFail = time.Now()
}
複製程式碼

結語

這次的內容就到此為止,有任何意見或者建議歡迎指正。

歷史連結

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

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

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

相關文章