什麼是RPC 服務
RPC,也就是遠端過程呼叫,是分散式系統中不同節點呼叫的方式(程式間通訊),屬於 C/S 模式。RPC 由客戶端發起,呼叫服務端的方法進行通訊,然後服務端把結果返回給客戶端。
RPC的核心有兩個:通訊協議和序列化。在 HTTP 2 之前,一般採用自定義 TCP 協議的方式進行通訊,HTTP 2 出來後,也有采用該協議的,比如流行的gRPC。
序列化和反序列化是一種把傳輸內容編碼和解碼的方式,常見的編解碼方式有 JSON、Protobuf 等。
在大多數 RPC的架構設計中,都有Client、Client Stub、Server、Server Stub這四個元件,Client 和 Server 之間通過 Socket 進行通訊。RPC 架構如下圖所示:
下面總結下 RPC 呼叫的流程:
- 客戶端(Client)呼叫客戶端存根(Client Stub),同時把引數傳給客戶端存根;
- 客戶端存根將引數打包編碼,並通過系統呼叫傳送到服務端;
- 客戶端本地系統傳送資訊到伺服器;
- 伺服器系統將資訊傳送到服務端存根(Server Stub);
- 服務端存根解析資訊,也就是解碼;
- 服務端存根呼叫真正的服務端程式(Sever);
- 服務端(Server)處理後,通過同樣的方式,把結果再返回給客戶端(Client)。
RPC 呼叫常用於大型專案,也就是我們現在常說的微服務,而且還會包含服務註冊、治理、監控等功能,是一套完整的體系。
Go 語言 RPC 簡單入門
RPC這麼流行,Go 語言當然不會錯過,在 Go SDK 中,已經內建了 net/rpc 包來幫助開發者實現 RPC。簡單來說,net/rpc 包提供了通過網路訪問服務端物件方法的能力。
現在通過一個加法運算來演示 RPC的使用,它的服務端程式碼如下所示:
package server
type MathService struct {
}
type Args struct {
A, B int
}
func (m *MathService) Add(args Args, reply *int) error {
*reply = args.A + args.B
return nil
}
在以上程式碼中:
- 定義了MathService,用於表示一個遠端服務物件;
- Args 結構體用於表示引數;
- Add 這個方法實現了加法的功能,加法的結果通過 replay這個指標變數返回。
有了這個定義好的服務物件,就可以把它註冊到暴露的服務列表中,以供其他客戶端使用了。在Go 語言中,要註冊一個一個RPC 服務物件還是比較簡單的,通過 RegisterName 方法即可,示例程式碼如下所示:
package main
import (
"tools/server"
"log"
"net"
"net/rpc"
)
func main() {
rpc.RegisterName("MathService",new(server.MathService))
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
rpc.Accept(l)
}
以上示例程式碼中,通過 RegisterName 函式註冊了一個服務物件,該函式接收兩個引數:
- 服務名稱(MathService);
- 具體的服務物件,也就是我剛剛定義好的MathService 這個結構體。
然後通過 net.Listen 函式建立一個TCP 連結,在 1234 埠進行監聽,最後通過 rpc.Accept 函式在該 TCP 連結上提供 MathService 這個 RPC 服務。現在客戶端就可以看到MathService這個服務以及它的Add 方法了。
任何一個框架都有自己的規則,net/rpc 這個 Go 語言提供的RPC 框架也不例外。要想把一個物件註冊為 RPC 服務,可以讓客戶端遠端訪問,那麼該物件(型別)的方法必須滿足如下條件:
- 方法的型別是可匯出的(公開的);
- 方法本身也是可匯出的;
- 方法必須有 2 個引數,並且引數型別是可匯出或者內建的;
- 方法必須返回一個 error 型別。
總結下來,該方法的格式如下所示:
func (t *T) MethodName(argType T1, replyType *T2) error
這裡面的 T1、T2都是可以被 encoding/gob 序列化的。
- 第一個引數 argType 是呼叫者(客戶端)提供的;
- 第二個引數 replyType是返回給呼叫者結果,必須是指標型別。
有了提供好的RPC 服務,現在再來看下客戶端如何呼叫,它的程式碼如下所示:
package main
import (
"fmt"
"tools/server"
"log"
"net/rpc"
)
func main() {
client, err := rpc.Dial("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing:", err)
}
args := server.Args{A:7,B:8}
var reply int
err = client.Call("MathService.Add", args, &reply)
if err != nil {
log.Fatal("MathService.Add error:", err)
}
fmt.Printf("MathService.Add: %d+%d=%d", args.A, args.B, reply)
}
在以上例項程式碼中,首先通過 rpc.Dial 函式建立 TCP 連結,需要注意的是這裡的 IP、埠要和RPC 服務提供的一致,確保可以建立 RCP 連結。
TCP 連結建立成功後,就需要準備遠端方法需要的引數,也就是示例中的args 和 reply。引數準備好之後,就可以通過 Call 方法呼叫遠端的RPC 服務了。Call 方法有 3 個引數,它們的作用分別如下所示:
- 呼叫的遠端方法的名字,這裡是MathService.Add,點前面的部分是註冊的服務的名稱,點後面的部分是該服務的方法;
- 客戶端為了呼叫遠端方法提供的引數,示例中是args;
- 為了接收遠端方法返回的結果,必須是一個指標,也就是示例中的& replay,這樣客戶端就可以獲得服務端返回的結果了。
服務端和客戶端的程式碼都寫好了,現在就可以執行它們,測試 RPC呼叫的效果了。
首先執行服務端的程式碼,提供 RPC 服務,執行命令如下所示:
➜ go run lib/server_main.go
然後執行客戶端程式碼,測試呼叫 RPC的結果,執行命令如下所示:
➜ go run lib/client_main.go
如果看到了 MathService.Add: 7+8=15的結果,那麼恭喜,完成了一個完整的RPC 呼叫。
基於 HTTP的RPC
RPC 除了可以通過 TCP 協議呼叫之外,還可以通過HTTP 協議進行呼叫,而且內建的net/rpc 包已經支援,現在修改以上示例程式碼,支援 HTTP 協議的呼叫,服務端程式碼如下所示:
func main() {
rpc.RegisterName("MathService", new(server.MathService))
rpc.HandleHTTP()//新增的
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
http.Serve(l, nil)//換成http的服務
}
以上是服務端程式碼的修改,只需修改兩處,已經在程式碼中標註出來了,很容易理解。
服務端修改的程式碼不算多,客戶端修改的程式碼就更少了,只需要修改一處即可,修改的部分如下所示:
func main() {
client, err := rpc.DialHTTP("tcp", "localhost:1234")
//省略了其他沒有修改的程式碼
}
從以上程式碼可以看到,只需要把建立連結的方法從 Dial 換成 DialHTTP 即可。
現在分別執行服務端和客戶端程式碼,就可以看到輸出的結果了,和上面使用TCP 連結時是一樣的。
此外,Go 語言 net/rpc 包提供的 HTTP 協議的 RPC 還有一個除錯的 URL,執行服務端程式碼後,在瀏覽器中輸入 localhost:1234/debug/rpc 回車,即可看到服務端註冊的RPC 服務,以及每個服務的方法,如下圖所示:
如上圖所示,註冊的 RPC 服務、方法的簽名、已經被呼叫的次數都可以看到。
JSON RPC 跨平臺通訊
以上實現的RPC 服務是基於 gob 編碼的,這種編碼在跨語言呼叫的時候比較困難,而當前在微服務架構中,RPC 服務的實現者和呼叫者都可能是不同的程式語言,因此實現的 RPC 服務要支援多語言的呼叫。
基於 TCP 的 JSON RPC
實現跨語言 RPC 服務的核心在於選擇一個通用的編碼,這樣大多數語言都支援,比如常用的JSON。在 Go 語言中,實現一個 JSON RPC 服務非常簡單,只需要使用 net/rpc/jsonrpc 包即可。
同樣以上面的示例為例,我把它改造成支援 JSON的RPC 服務,服務端程式碼如下所示:
func main() {
rpc.RegisterName("MathService", new(server.MathService))
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
for {
conn, err := l.Accept()
if err != nil {
log.Println("jsonrpc.Serve: accept:", err.Error())
return
}
//json rpc
go jsonrpc.ServeConn(conn)
}
}
從以上程式碼可以看到,相比 gob 編碼的RPC 服務,JSON 的 RPC 服務是把連結交給了jsonrpc.ServeConn這個函式處理,達到了基於 JSON 進行 RPC 呼叫的目的。
JSON RPC 的客戶端程式碼也非常少,只需要修改一處,修改的部分如下所示:
func main() {
client, err := jsonrpc.Dial("tcp", "localhost:1234")
//省略了其他沒有修改的程式碼
}
從以上程式碼可以看到,只需要把建立連結的 Dial方法換成 jsonrpc 包中的即可。
以上是使用 Go 語言作為客戶端呼叫 RPC 服務的示例,其他程式語言也是類似的,只需要遵守 JSON-RPC 規範即可。
基於 HTTP的JSON RPC
相比基於 TCP 呼叫的RPC 來說,使用 HTTP肯定會更方便,也更通用。Go 語言內建的jsonrpc 並沒有實現基於 HTTP的傳輸,所以就需要自己來實現,這裡我參考 gob 編碼的HTTP RPC 實現方式,來實現基於 HTTP的JSON RPC 服務。
還是上面的示例,改造下讓其支援 HTTP 協議,RPC 服務端程式碼如下所示:
func main() {
rpc.RegisterName("MathService", new(server.MathService))
//註冊一個path,用於提供基於http的json rpc服務
http.HandleFunc(rpc.DefaultRPCPath, func(rw http.ResponseWriter, r *http.Request) {
conn, _, err := rw.(http.Hijacker).Hijack()
if err != nil {
log.Print("rpc hijacking ", r.RemoteAddr, ": ", err.Error())
return
}
var connected = "200 Connected to JSON RPC"
io.WriteString(conn, "HTTP/1.0 "+connected+"\n\n")
jsonrpc.ServeConn(conn)
})
l, e := net.Listen("tcp", ":1234")
if e != nil {
log.Fatal("listen error:", e)
}
http.Serve(l, nil)//換成http的服務
}
以上程式碼的實現基於 HTTP 協議的核心,即使用 http.HandleFunc 註冊了一個 path,對外提供基於 HTTP 的 JSON RPC 服務。在這個 HTTP 服務的實現中,通過Hijack方法劫持連結,然後轉交給 jsonrpc 處理,這樣就實現了基於 HTTP 協議的 JSON RPC 服務。
實現了服務端的程式碼後,現在開始實現客戶端呼叫,它的程式碼如下所示:
func main() {
client, err := DialHTTP("tcp", "localhost:1234")
if err != nil {
log.Fatal("dialing:", err)
}
args := server.Args{A:7,B:8}
var reply int
err = client.Call("MathService.Add", args, &reply)
if err != nil {
log.Fatal("MathService.Add error:", err)
}
fmt.Printf("MathService.Add: %d+%d=%d", args.A, args.B, reply)
}
// DialHTTP connects to an HTTP RPC server at the specified network address
// listening on the default HTTP RPC path.
func DialHTTP(network, address string) (*rpc.Client, error) {
return DialHTTPPath(network, address, rpc.DefaultRPCPath)
}
// DialHTTPPath connects to an HTTP RPC server
// at the specified network address and path.
func DialHTTPPath(network, address, path string) (*rpc.Client, error) {
var err error
conn, err := net.Dial(network, address)
if err != nil {
return nil, err
}
io.WriteString(conn, "GET "+path+" HTTP/1.0\n\n")
// Require successful HTTP response
// before switching to RPC protocol.
resp, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: "GET"})
connected := "200 Connected to JSON RPC"
if err == nil && resp.Status == connected {
return jsonrpc.NewClient(conn), nil
}
if err == nil {
err = errors.New("unexpected HTTP response: " + resp.Status)
}
conn.Close()
return nil, &net.OpError{
Op: "dial-http",
Net: network + " " + address,
Addr: nil,
Err: err,
}
}
以上這段程式碼的核心在於通過建立好的TCP 連結,傳送 HTTP 請求呼叫遠端的HTTP JSON RPC 服務,這裡使用的是 HTTP GET 方法。
分別執行服務端和客戶端,就可以看到正確的HTTP JSON RPC 呼叫結果了。
總結
基於 Go 語言自帶的RPC 框架,講解了 RPC 服務的實現以及呼叫。通過學習可以很好地瞭解什麼是 RPC 服務,基於 TCP 和 HTTP 實現的RPC 服務有什麼不同,它們是如何實現的等等。
不過在實際的專案開發中,使用Go 語言自帶的 RPC 框架並不多,但是這裡還是以自帶的框架為例進行講解,這樣可以更好地理解 RPC 的使用以及實現原理。如果可以很好地掌握它們,那麼使用第三方的 RPC 框架也可以很快上手。
在實際的專案中,比較常用的是Google的gRPC 框架,它是通過Protobuf 序列化的,是基於 HTTP/2 協議的二進位制傳輸,並且支援很多程式語言,效率也比較高。關於 gRPC的使用可以看官網的文件,入門是很容易的。
本作品採用《CC 協議》,轉載必須註明作者和本文連結