原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch4-rpc/ch4-01-rpc-intro.html
RPC 是遠程過程調(diào)用的簡稱,是分布式系統(tǒng)中不同節(jié)點間流行的通信方式。在互聯(lián)網(wǎng)時代,RPC 已經(jīng)和 IPC 一樣成為一個不可或缺的基礎(chǔ)構(gòu)件。因此 Go 語言的標準庫也提供了一個簡單的 RPC 實現(xiàn),我們將以此為入口學習 RPC 的各種用法。
Go 語言的 RPC 包的路徑為 net/rpc,也就是放在了 net 包目錄下面。因此我們可以猜測該 RPC 包是建立在 net 包基礎(chǔ)之上的。在第一章 “Hello, World” 革命一節(jié)最后,我們基于 http 實現(xiàn)了一個打印例子。下面我們嘗試基于 rpc 實現(xiàn)一個類似的例子。
我們先構(gòu)造一個 HelloService 類型,其中的 Hello 方法用于實現(xiàn)打印功能:
type HelloService struct {}
func (p *HelloService) Hello(request string, reply *string) error {
*reply = "hello:" + request
return nil
}
其中 Hello 方法必須滿足 Go 語言的 RPC 規(guī)則:方法只能有兩個可序列化的參數(shù),其中第二個參數(shù)是指針類型,并且返回一個 error 類型,同時必須是公開的方法。
然后就可以將 HelloService 類型的對象注冊為一個 RPC 服務(wù):
func main() {
rpc.RegisterName("HelloService", new(HelloService))
listener, err := net.Listen("tcp", ":1234")
if err != nil {
log.Fatal("ListenTCP error:", err)
}
conn, err := listener.Accept()
if err != nil {
log.Fatal("Accept error:", err)
}
rpc.ServeConn(conn)
}
其中 rpc.Register 函數(shù)調(diào)用會將對象類型中所有滿足 RPC 規(guī)則的對象方法注冊為 RPC 函數(shù),所有注冊的方法會放在 “HelloService” 服務(wù)空間之下。然后我們建立一個唯一的 TCP 連接,并且通過 rpc.ServeConn 函數(shù)在該 TCP 連接上為對方提供 RPC 服務(wù)。
下面是客戶端請求 HelloService 服務(wù)的代碼:
func main() {
client, err := rpc.Dial("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing:", err)
}
var reply string
err = client.Call("HelloService.Hello", "hello", &reply)
if err != nil {
log.Fatal(err)
}
fmt.Println(reply)
}
首先是通過 rpc.Dial 撥號 RPC 服務(wù),然后通過 client.Call 調(diào)用具體的 RPC 方法。在調(diào)用 client.Call 時,第一個參數(shù)是用點號連接的 RPC 服務(wù)名字和方法名字,第二和第三個參數(shù)分別我們定義 RPC 方法的兩個參數(shù)。
由這個例子可以看出 RPC 的使用其實非常簡單。
在涉及 RPC 的應(yīng)用中,作為開發(fā)人員一般至少有三種角色:首先是服務(wù)端實現(xiàn) RPC 方法的開發(fā)人員,其次是客戶端調(diào)用 RPC 方法的人員,最后也是最重要的是制定服務(wù)端和客戶端 RPC 接口規(guī)范的設(shè)計人員。在前面的例子中我們?yōu)榱撕喕瘜⒁陨蠋追N角色的工作全部放到了一起,雖然看似實現(xiàn)簡單,但是不利于后期的維護和工作的切割。
如果要重構(gòu) HelloService 服務(wù),第一步需要明確服務(wù)的名字和接口:
const HelloServiceName = "path/to/pkg.HelloService"
type HelloServiceInterface interface {
Hello(request string, reply *string) error
}
func RegisterHelloService(svc HelloServiceInterface) error {
return rpc.RegisterName(HelloServiceName, svc)
}
我們將 RPC 服務(wù)的接口規(guī)范分為三個部分:首先是服務(wù)的名字,然后是服務(wù)要實現(xiàn)的詳細方法列表,最后是注冊該類型服務(wù)的函數(shù)。為了避免名字沖突,我們在 RPC 服務(wù)的名字中增加了包路徑前綴(這個是 RPC 服務(wù)抽象的包路徑,并非完全等價 Go 語言的包路徑)。RegisterHelloService 注冊服務(wù)時,編譯器會要求傳入的對象滿足 HelloServiceInterface 接口。
在定義了 RPC 服務(wù)接口規(guī)范之后,客戶端就可以根據(jù)規(guī)范編寫 RPC 調(diào)用的代碼了:
func main() {
client, err := rpc.Dial("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing:", err)
}
var reply string
err = client.Call(HelloServiceName+".Hello", "hello", &reply)
if err != nil {
log.Fatal(err)
}
}
其中唯一的變化是 client.Call 的第一個參數(shù)用 HelloServiceName+".Hello" 代替了 "HelloService.Hello"。然而通過 client.Call 函數(shù)調(diào)用 RPC 方法依然比較繁瑣,同時參數(shù)的類型依然無法得到編譯器提供的安全保障。
為了簡化客戶端用戶調(diào)用 RPC 函數(shù),我們在可以在接口規(guī)范部分增加對客戶端的簡單包裝:
type HelloServiceClient struct {
*rpc.Client
}
var _ HelloServiceInterface = (*HelloServiceClient)(nil)
func DialHelloService(network, address string) (*HelloServiceClient, error) {
c, err := rpc.Dial(network, address)
if err != nil {
return nil, err
}
return &HelloServiceClient{Client: c}, nil
}
func (p *HelloServiceClient) Hello(request string, reply *string) error {
return p.Client.Call(HelloServiceName+".Hello", request, reply)
}
我們在接口規(guī)范中針對客戶端新增加了 HelloServiceClient 類型,該類型也必須滿足 HelloServiceInterface 接口,這樣客戶端用戶就可以直接通過接口對應(yīng)的方法調(diào)用 RPC 函數(shù)。同時提供了一個 DialHelloService 方法,直接撥號 HelloService 服務(wù)。
基于新的客戶端接口,我們可以簡化客戶端用戶的代碼:
func main() {
client, err := DialHelloService("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing:", err)
}
var reply string
err = client.Hello("hello", &reply)
if err != nil {
log.Fatal(err)
}
}
現(xiàn)在客戶端用戶不用再擔心 RPC 方法名字或參數(shù)類型不匹配等低級錯誤的發(fā)生。
最后是基于 RPC 接口規(guī)范編寫真實的服務(wù)端代碼:
type HelloService struct {}
func (p *HelloService) Hello(request string, reply *string) error {
*reply = "hello:" + request
return nil
}
func main() {
RegisterHelloService(new(HelloService))
listener, err := net.Listen("tcp", ":1234")
if err != nil {
log.Fatal("ListenTCP error:", err)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Fatal("Accept error:", err)
}
go rpc.ServeConn(conn)
}
}
在新的 RPC 服務(wù)端實現(xiàn)中,我們用 RegisterHelloService 函數(shù)來注冊函數(shù),這樣不僅可以避免命名服務(wù)名稱的工作,同時也保證了傳入的服務(wù)對象滿足了 RPC 接口的定義。最后我們新的服務(wù)改為支持多個 TCP 連接,然后為每個 TCP 連接提供 RPC 服務(wù)。
標準庫的 RPC 默認采用 Go 語言特有的 gob 編碼,因此從其它語言調(diào)用 Go 語言實現(xiàn)的 RPC 服務(wù)將比較困難。在互聯(lián)網(wǎng)的微服務(wù)時代,每個 RPC 以及服務(wù)的使用者都可能采用不同的編程語言,因此跨語言是互聯(lián)網(wǎng)時代 RPC 的一個首要條件。得益于 RPC 的框架設(shè)計,Go 語言的 RPC 其實也是很容易實現(xiàn)跨語言支持的。
Go 語言的 RPC 框架有兩個比較有特色的設(shè)計:一個是 RPC 數(shù)據(jù)打包時可以通過插件實現(xiàn)自定義的編碼和解碼;另一個是 RPC 建立在抽象的 io.ReadWriteCloser 接口之上的,我們可以將 RPC 架設(shè)在不同的通訊協(xié)議之上。這里我們將嘗試通過官方自帶的 net/rpc/jsonrpc 擴展實現(xiàn)一個跨語言的 RPC。
首先是基于 json 編碼重新實現(xiàn) RPC 服務(wù):
func main() {
rpc.RegisterName("HelloService", new(HelloService))
listener, err := net.Listen("tcp", ":1234")
if err != nil {
log.Fatal("ListenTCP error:", err)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Fatal("Accept error:", err)
}
go rpc.ServeCodec(jsonrpc.NewServerCodec(conn))
}
}
代碼中最大的變化是用 rpc.ServeCodec 函數(shù)替代了 rpc.ServeConn 函數(shù),傳入的參數(shù)是針對服務(wù)端的 json 編解碼器。
然后是實現(xiàn) json 版本的客戶端:
func main() {
conn, err := net.Dial("tcp", "localhost:1234")
if err != nil {
log.Fatal("net.Dial:", err)
}
client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))
var reply string
err = client.Call("HelloService.Hello", "hello", &reply)
if err != nil {
log.Fatal(err)
}
fmt.Println(reply)
}
先手工調(diào)用 net.Dial 函數(shù)建立 TCP 連接,然后基于該連接建立針對客戶端的 json 編解碼器。
在確保客戶端可以正常調(diào)用 RPC 服務(wù)的方法之后,我們用一個普通的 TCP 服務(wù)代替 Go 語言版本的 RPC 服務(wù),這樣可以查看客戶端調(diào)用時發(fā)送的數(shù)據(jù)格式。比如通過 nc 命令 nc -l 1234
在同樣的端口啟動一個 TCP 服務(wù)。然后再次執(zhí)行一次 RPC 調(diào)用將會發(fā)現(xiàn) nc 輸出了以下的信息:
{"method":"HelloService.Hello","params":["hello"],"id":0}
這是一個 json 編碼的數(shù)據(jù),其中 method 部分對應(yīng)要調(diào)用的 rpc 服務(wù)和方法組合成的名字,params 部分的第一個元素為參數(shù),id 是由調(diào)用端維護的一個唯一的調(diào)用編號。
請求的 json 數(shù)據(jù)對象在內(nèi)部對應(yīng)兩個結(jié)構(gòu)體:客戶端是 clientRequest,服務(wù)端是 serverRequest。clientRequest 和 serverRequest 結(jié)構(gòu)體的內(nèi)容基本是一致的:
type clientRequest struct {
Method string `json:"method"`
Params [1]interface{} `json:"params"`
Id uint64 `json:"id"`
}
type serverRequest struct {
Method string `json:"method"`
Params *json.RawMessage `json:"params"`
Id *json.RawMessage `json:"id"`
}
在獲取到 RPC 調(diào)用對應(yīng)的 json 數(shù)據(jù)后,我們可以通過直接向架設(shè)了 RPC 服務(wù)的 TCP 服務(wù)器發(fā)送 json 數(shù)據(jù)模擬 RPC 方法調(diào)用:
$ echo -e '{"method":"HelloService.Hello","params":["hello"],"id":1}' | nc localhost 1234
返回的結(jié)果也是一個 json 格式的數(shù)據(jù):
{"id":1,"result":"hello:hello","error":null}
其中 id 對應(yīng)輸入的 id 參數(shù),result 為返回的結(jié)果,error 部分在出問題時表示錯誤信息。對于順序調(diào)用來說,id 不是必須的。但是 Go 語言的 RPC 框架支持異步調(diào)用,當返回結(jié)果的順序和調(diào)用的順序不一致時,可以通過 id 來識別對應(yīng)的調(diào)用。
返回的 json 數(shù)據(jù)也是對應(yīng)內(nèi)部的兩個結(jié)構(gòu)體:客戶端是 clientResponse,服務(wù)端是 serverResponse。兩個結(jié)構(gòu)體的內(nèi)容同樣也是類似的:
type clientResponse struct {
Id uint64 `json:"id"`
Result *json.RawMessage `json:"result"`
Error interface{} `json:"error"`
}
type serverResponse struct {
Id *json.RawMessage `json:"id"`
Result interface{} `json:"result"`
Error interface{} `json:"error"`
}
因此無論采用何種語言,只要遵循同樣的 json 結(jié)構(gòu),以同樣的流程就可以和 Go 語言編寫的 RPC 服務(wù)進行通信。這樣我們就實現(xiàn)了跨語言的 RPC。
Go 語言內(nèi)在的 RPC 框架已經(jīng)支持在 Http 協(xié)議上提供 RPC 服務(wù)。但是框架的 http 服務(wù)同樣采用了內(nèi)置的 gob 協(xié)議,并且沒有提供采用其它協(xié)議的接口,因此從其它語言依然無法訪問的。在前面的例子中,我們已經(jīng)實現(xiàn)了在 TCP 協(xié)議之上運行 jsonrpc 服務(wù),并且通過 nc 命令行工具成功實現(xiàn)了 RPC 方法調(diào)用。現(xiàn)在我們嘗試在 http 協(xié)議上提供 jsonrpc 服務(wù)。
新的 RPC 服務(wù)其實是一個類似 REST 規(guī)范的接口,接收請求并采用相應(yīng)處理流程:
func main() {
rpc.RegisterName("HelloService", new(HelloService))
http.HandleFunc("/jsonrpc", func(w http.ResponseWriter, r *http.Request) {
var conn io.ReadWriteCloser = struct {
io.Writer
io.ReadCloser
}{
ReadCloser: r.Body,
Writer: w,
}
rpc.ServeRequest(jsonrpc.NewServerCodec(conn))
})
http.ListenAndServe(":1234", nil)
}
RPC 的服務(wù)架設(shè)在 “/jsonrpc” 路徑,在處理函數(shù)中基于 http.ResponseWriter 和 http.Request 類型的參數(shù)構(gòu)造一個 io.ReadWriteCloser 類型的 conn 通道。然后基于 conn 構(gòu)建針對服務(wù)端的 json 編碼解碼器。最后通過 rpc.ServeRequest 函數(shù)為每次請求處理一次 RPC 方法調(diào)用。
模擬一次 RPC 調(diào)用的過程就是向該連接發(fā)送一個 json 字符串:
$ curl localhost:1234/jsonrpc -X POST \
--data '{"method":"HelloService.Hello","params":["hello"],"id":0}'
返回的結(jié)果依然是 json 字符串:
{"id":0,"result":"hello:hello","error":null}
這樣就可以很方便地從不同語言中訪問 RPC 服務(wù)了。
更多建議: