前言
在上一篇文章中我們先列舉了大致的需求,定義了訊息協議。這次我們著手搭建基本的RPC框架,首先實現基礎的方法呼叫功能。
功能設計
RPC呼叫的第一步,就是在服務端定義要對外暴露的方法,在grpc或者是thrift中,這一步我們需要編寫語言無關的idl檔案,然後通過idl檔案生成對應語言的程式碼。而在我們的框架裡,出於簡單起見,我們不採用idl的方式,直接在程式碼裡定義介面和方法。這裡先規定對外的方法必須遵守以下幾個條件:
- 對外暴露的方法,其所屬的型別和其自身必須是對外可見(Exported)的,也就是首字母必須是大寫的
- 方法的引數必須為三個,而且第一個必須是context.Context型別
- 第三個方法引數必須是指標型別
- 方法返回值必須是error型別
- 客戶端通過"Type.Method"的形式來引用服務方法,其中Type是方法實現類的全類名,Method就是方法名稱
為什麼要有這幾個規定呢,具體的原因是這樣的:因為java中的RPC框架場用到的動態代理在go語言中並不支援,所以我們需要顯式地定義方法的統一格式,這樣在RPC框架中才能統一地處理不同的方法。所以我們規定了方法的格式:
- 方法的第一個引數固定為Context,用於傳遞上下文資訊
- 第二個引數是真正的方法引數
- 第三個參數列示方法的返回值,呼叫完成後它的值就會被改變為服務端執行的結
- 方法的返回值固定為error型別,表示方法呼叫過程中發生的錯。
這裡我們需要注意的是,服務提供者在對外暴露時並不需要以介面的形式暴露,只要服務提供者有符合規則的方法即可;而客戶端在呼叫方法時指定的是服務提供者的具體型別,不能指定介面的名稱,即使服務提供者實現了這個介面。
contet.Context
context是go語言提供的關於請求上下文的抽象,它攜帶了請求deadline、cancel訊號的資訊,還可以傳遞一些上下文資訊,非常適合作為RPC請求的上下文,我們可以在context中設定超時時間,還可以將一些引數無關的後設資料通過context傳遞到服務端。
實際上,方法的固定格式以及用Call和Go來表示同步和非同步呼叫都是go自帶的rpc裡的規則,只是在引數裡增加了context.Context。不得不說go自帶的rpc設計確實十分優秀,值得好好學習理解。
介面定義
client和server
首先是面向使用者的RPC框架中的客戶端和服務端介面:
type RPCServer interface {
//註冊服務例項,rcvr是receiver的意思,它是我們對外暴露的方法的實現者,metaData是註冊服務時攜帶的額外的後設資料,它描述了rcvr的其他資訊
Register(rcvr interface{}, metaData map[string]string) error
//開始對外提供服務
Serve(network string, addr string) error
}
type RPCClient interface {
//Go表示非同步呼叫
Go(ctx context.Context, serviceMethod string, arg interface{}, reply interface{}, done chan *Call) *Call
//Call表示非同步呼叫
Call(ctx context.Context, serviceMethod string, arg interface{}, reply interface{}) error
Close() error
}
type Call struct {
ServiceMethod string // 服務名.方法名
Args interface{} // 引數
Reply interface{} // 返回值(指標型別)
Error error // 錯誤資訊
Done chan *Call // 在呼叫結束時啟用
}
複製程式碼
selector和registery
這次先實現RPC呼叫部分,這兩層暫時忽略,後續再實現。
codec
接下來我們需要選擇一個序列化協議,這裡就選之前使用過的messagepack。之前設計的通訊協議分為兩個部分:head和body,這兩個部分都需要進行序列化和反序列化。head部分是後設資料,可以直接採用messagepack序列化,而body部分是方法的引數或者響應,其序列化由head中的SerializeType決定,這樣的好處就是為了後續擴充套件方便,目前也使用messagepack序列化,後續也可以採用其他的方式序列化。
序列化的邏輯也定義為介面:
type Codec interface {
Encode(value interface{}) ([]byte, error)
Decode(data []byte, value interface{}) error
}
複製程式碼
protocol
確定好了序列化協議之後,我們就可以定義訊息協議相關的介面了。協議的設計參考上一篇文章:從零開始實現一個RPC框架(零)
接下來就是協議的介面定義:
//Messagge表示一個訊息體
type Message struct {
*Header //head部分, Header的定義參考上一篇文章
Data []byte //body部分
}
//Protocol定義瞭如何構造和序列化一個完整的訊息體
type Protocol interface {
NewMessage() *Message
DecodeMessage(r io.Reader) (*Message, error)
EncodeMessage(message *Message) []byte
}
複製程式碼
根據之前的設計,所以互動都通過介面進行,這樣方便擴充套件和替換。
transport
協議的介面定義好了之後,接下來就是網路傳輸層的定義:
//傳輸層的定義,用於讀取資料
type Transport interface {
Dial(network, addr string) error
//這裡直接內嵌了ReadWriteCloser介面,包含Read、Write和Close方法
io.ReadWriteCloser
RemoteAddr() net.Addr
LocalAddr() net.Addr
}
//服務端監聽器定義,用於監聽埠和建立連線
type Listener interface {
Listen(network, addr string) error
Accept() (Transport, error)
//這裡直接內嵌了Closer介面,包含Close方法
io.Closer
}
複製程式碼
具體實現
各個層次的介面定義好了之後,就可以開始搭建基礎的框架了,這裡不附上具體的程式碼了,具體程式碼可以參考github連結 ,這裡大致描述一下各個部分的實現思路。
Client
客戶端的功能比較簡單,就是將引數序列化之後,組裝成一個完整的訊息體傳送出去。請求傳送出去的同時,將未完成的請求都快取起來,每收到一個響應就和未完成的請求進行匹配。
傳送請求的核心在Go
和send
方法,Go
的功能是組裝引數,send
方法是將引數序列化並通過傳輸層的介面傳送出去,同時將請求快取到pendingCalls
中。而Call
方法則是直接呼叫Go
方法並阻塞等待知道返回或者超時。
接收響應的核心在input
方法,input
方法在client初始化完成時通過go input()
執行。input
方法包含一個無限迴圈,在無限迴圈中讀取傳輸層的資料並將其反序列化,並將反序列化得到的響應與快取的請求進行匹配。
注:send
和input
方法的命名也是從go自帶的rpc裡學來的。
Server
服務端在接受註冊時,會過濾服務提供者的各個方法,將合法的方法快取起來。
服務端的核心邏輯是serveTransport
方法,它接收一個Transport
物件,然後在一個無限迴圈中從Transport
讀取資料並反序列化成請求,根據請求指定的方法查詢自身快取的方法,找到對應 的方法後通過反射執行對應的實現並返。執行完成後再根據返回結果或者是執行發生的異常組裝成一個完整的訊息,通過Transport
傳送出去。
服務端在反射執行方法時,需要將實現者作為執行的第一個引數,所以引數比方法定義中的引數多一個。
codec和protocol
這兩個部分就比較簡單了,codec基本上就是使用messagepack實現了對應的介面;protocol的實現就是根據我們定義的協議進行解析。
執行緒模型
在執行過程中,除了客戶端的使用者執行緒和服務端用來執行方法的服務執行緒,還分別增加了客戶端輪詢執行緒和服務端監聽執行緒,大致的示意圖如下:
這裡的執行緒模型比較簡單,服務端針對每個建立的連線都會建立一個執行緒(goroutine),雖說goroutine很輕量,但是也不是完全沒有消耗的,後續可以再進一步進行優化,比如把讀取資料反序列化和執行方法拆分到不同的執行緒執行,或者把goroutine池化等等。結語
到此我們的RPC框架已經具備了雛形,能夠支援基礎的RPC呼叫了。實際上整個框架就是參考go自帶的rpc的結構,客戶端和服務端的執行緒模型和go自帶的rpc一樣,只是自己定義了序列化和訊息協議,而且實現的過程中保留了擴充套件的介面,方便後續進行完善和擴充套件。下一步的規劃是實現過濾器鏈,以便後續實現服務治理相關的功能。