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