Go實戰 22 | 網路程式設計:通過 RPC 實現跨平臺服務

Swenson1992 發表於 2021-03-23
Go

什麼是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 架構如下圖所示:

Go實戰 22 | 網路程式設計:通過 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 個引數,它們的作用分別如下所示:

  1. 呼叫的遠端方法的名字,這裡是MathService.Add,點前面的部分是註冊的服務的名稱,點後面的部分是該服務的方法;
  2. 客戶端為了呼叫遠端方法提供的引數,示例中是args;
  3. 為了接收遠端方法返回的結果,必須是一個指標,也就是示例中的& 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 服務,以及每個服務的方法,如下圖所示:

Go實戰 22 | 網路程式設計:通過 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 協議》,轉載必須註明作者和本文連結
golang