gRPC(一)入門:什麼是RPC?

lin鍾一發表於2022-11-09

本文作為Grpc的開篇,透過文件先了解一下rpc。
個人網站:linzyblog.netlify.app/
示例程式碼已經上傳到github:點選跳轉

1、什麼是RPC?

RPC(Remote Procedure Call 遠端過程呼叫)是一種軟體通訊協議,一個程式可以使用該協議向位於網路上另一臺計算機中的程式請求服務,而無需瞭解網路的詳細資訊。RPC 用於呼叫遠端系統上的其他程式,如本地系統。過程呼叫有時也稱為 函式呼叫或 子程式呼叫。

RPC是一種客戶端-伺服器互動形式(呼叫者是客戶端,執行者是伺服器),通常透過請求-響應訊息傳遞系統實現。與本地過程呼叫一樣,RPC 是一種 同步 操作,需要阻塞請求程式,直到返回遠端過程的結果。但是,使用共享相同地址空間的輕量級程式或 執行緒 可以同時執行多個 RPC。

通俗的解釋:
客戶端在不知道呼叫細節的情況下,呼叫存在於遠端計算機上的某個物件,就像呼叫本地應用程式中的物件一樣。

介面定義語言(IDL)——用於描述軟體元件的應用程式程式設計介面(API)的規範語言——通常用於遠端過程呼叫軟體。在這種情況下,IDL 在鏈路兩端的機器之間提供了一座橋樑,這些機器可能使用不同的作業系統 (OS) 和計算機語言。

實際場景:

有兩臺伺服器,分別是伺服器 A、伺服器 B。在 伺服器 A 上的應用 想要呼叫伺服器 B 上的應用,它們可以直接本地呼叫嗎?

答案是不能的,但走 RPC 的話,十分方便。因此常有人稱使用 RPC,就跟本地呼叫一個函式一樣簡單。

在這裡插入圖片描述

2、HTTP和RPC的區別

1)概念區別

RPC是一種方法,而HTTP是一種協議。兩者都常用於實現服務,在這個層面最本質的區別是RPC服務主要工作在TCP協議之上(也可以在HTTP協議),而HTTP服務工作在HTTP協議之上。由於HTTP協議基於TCP協議,所以RPC服務天然比HTTP更輕量,效率更勝一籌。

兩者都是基於網路實現的,從這一點上,都是基於Client/Server架構。

2)從協議上區分

RPC是遠端過程呼叫,其呼叫協議通常包含:傳輸協議序列化協議

  • 傳輸協議:著名的 grpc,它底層使用的是 http2 協議;還有 dubbo 一類的自定義報文的 tcp 協議。
  • 序列化協議:基於文字編碼的 json 協議;也有二進位制編碼的 protobuf、hession 等協議;還有針對 java 高效能、高吞吐量的 kryo 和 ftc 等序列化協議。

HTTP服務工作在HTTP協議之上,而且HTTP協議基於TCP協議。

3、RPC如何工作的?

當呼叫 RPC 時,呼叫環境被掛起,過程引數透過網路傳送到過程執行的環境,然後在該環境中執行過程。

當過程完成時,結果將被傳送回撥用環境,在那裡繼續執行,就像從常規過程呼叫返回一樣。

在 RPC 期間,將執行以下步驟:

  1. 客戶端呼叫客戶端存根。該呼叫是本地過程呼叫,引數以正常方式壓入堆疊。
  2. 客戶端存根將過程引數打包到訊息中並進行系統呼叫以傳送訊息。過程引數的打包稱為編組
  3. 客戶端的本地作業系統將訊息從客戶端機器傳送到遠端伺服器機器。
  4. 伺服器作業系統將傳入的資料包傳遞給伺服器存根。
  5. 伺服器存根從訊息中解包引數——稱為解編組
  6. 當伺服器過程完成時,它返回到伺服器存根,它將返回值編組為一條訊息。然後伺服器 存根將訊息交給傳輸層。
  7. 傳輸層將生成的訊息傳送回客戶端傳輸層,傳輸層將訊息返回給客戶端存根。
  8. 客戶端存根解組返回引數,然後執行返回給呼叫者。

Client (客戶端):服務呼叫方。
Server(服務端):服務提供方。
Client Stub(客戶端存根):存放服務端的地址訊息,負責將客戶端的請求引數打包成網路訊息,然後透過網路傳送給服務提供方。
Server Stub(服務端存根):接收客戶端傳送的訊息,再將客戶端請求引數打包成網路訊息,然後透過網路遠端傳送給服務方。

在這裡插入圖片描述

4、RPC的優缺點

儘管它擁有廣泛的好處,但使用 RPC 的人肯定應該注意一些陷阱。

RPC 為開發人員和應用程式管理人員提供的一些優勢:

  • 幫助客戶端透過傳統使用高階語言中的過程呼叫與伺服器進行通訊。
  • 可以在分散式環境中使用,也可以在本地環境中使用。
  • 支援面向程式和麵向執行緒的模型。
  • 對使用者隱藏內部訊息傳遞機制。
  • 只需極少的努力即可重寫和重新開發程式碼。
  • 提供抽象,即網路通訊的訊息傳遞特性對使用者隱藏。
  • 省略許多協議層以提高效能。

另一方面,RPC 的一些缺點包括:

  • 客戶端和伺服器各自的例程使用不同的執行環境,資源(如檔案)的使用也更加複雜。因此,RPC 系統並不總是適合傳輸大量資料。
  • RPC 極易發生故障,因為它涉及一個通訊系統、另一臺機器和另一個程式。
  • RPC沒有統一的標準;它可以透過多種方式實現。
  • RPC 只是基於互動的,因此它在硬體架構方面沒有提供任何靈活性。

5、常見的RPC框架

1)跟語言繫結框架

  • Dubbo:國內最早開源的 RPC 框架,由阿里巴巴公司開發並於 2011 年末對外開源,僅支援 Java 語言。
  • Motan:微博內部使用的 RPC 框架,於 2016 年對外開源,僅支援 Java 語言。
  • Tars:騰訊內部使用的 RPC 框架,於 2017 年對外開源,僅支援 C++ 語言。
  • Spring Cloud:國外 Pivotal 公司 2014 年對外開源的 RPC 框架,僅支援 Java 語言。

2)跨語言開源框架

  • gRPC:Google 於 2015 年對外開源的跨語言 RPC 框架,支援多種語言。
  • Thrift:最初是由Facebook 開發的內部系統跨語言的 RPC 框架,2007 年貢獻給了 Apache 基金,成為 Apache 開源專案之一,支援多種語言。
  • Rpcx:是一個類似阿里巴巴 Dubbo和微博 Motan的 RPC 框架,開源,支援多種語言。

Go語言標準包(net/rpc)已經提供了對RPC的支援,而且支援三個級別的RPC:TCP、HTTP和JSONRPC。但Go語言的RPC包是獨一無二的RPC,它和傳統的RPC系統不同,它只支援Go語言開發的伺服器與客戶端之間的互動,因為在內部,它們採用了Gob來編碼。

1、簡單的RPC示例

1)服務端實現

我們先構造一個 HelloService 型別,其中的 SayHi方法用於實現列印功能:

type HelloService struct{}

func (h *HelloService) SayHi(request string, response *string) error {
    format := time.Now().Format("2006-01-02 15:04:05")
    *response = "hi " + request + "---" + format
    return nil
}

Go 語言的 RPC 規則:方法只能有兩個可序列化的引數,其中第二個引數是指標型別,並且返回一個 error 型別,同時必須是公開的方法。

將 HelloService 型別的物件註冊為一個 RPC 服務:

func main() {
    //註冊服務
    _ = rpc.RegisterName("HiLinzy", new(HelloService))
    //監聽介面
    lis, err := net.Listen("tcp", ":8888")
    if err != nil {
        log.Fatal(err)
        return
    }
    for {
        //監聽請求
        accept, err := lis.Accept()
        if err != nil {
            log.Fatalf("Accept Error: %s", err)
        }
        //用goroutine為每個TCP連線提供RPC服務
        go rpc.ServeConn(accept)
    }
}

RegisterName類似於Register,但使用提供的名稱作為型別,Register 函式呼叫會將物件型別中所有滿足 RPC 規則的物件方法註冊為 RPC 函式,所有註冊的方法會放在 “HelloService” 服務空間之下。
rpc.ServeConn 函式在該 TCP 連線上為對方提供 RPC 服務。
我們的服務支援多個 TCP 連線,然後為每個 TCP 連線提供 RPC 服務。

2)客戶端實現

在客戶端請求 HelloService 服務的程式碼:

func main() {
    //建立連線
    dial, err := rpc.Dial("tcp", "127.0.0.1:8888")
    if err != nil {
        log.Fatal("Dial error ", err)
    }
    var result string
    for i := 0; i < 5; i++ {
        //發起請求
        _ = dial.Call("HiLinzy.SayHi", "linzy", &result)
        fmt.Println("rpc service result:", result)
        time.Sleep(time.Second)
    }
}

rpc.Dial 撥號 RPC 服務,然後透過 dial.Call 呼叫具體的 RPC 方法。
在呼叫 dial.Call 時,第一個引數是用點號連線的 RPC 服務名字和方法名字,第二和第三個引數分別我們定義 RPC 方法的兩個引數,第一個是客服端傳遞的訊息,第二個是由服務端產生返回的結果。

# 啟動服務
➜ go run  server.go
API server listening at: 127.0.0.1:54096

# 啟動客戶端
➜ go run  client.go
API server listening at: 127.0.0.1:54100
rpc service result: hi linzy---2022-10-30 15:52:39
rpc service result: hi linzy---2022-10-30 15:52:40
rpc service result: hi linzy---2022-10-30 15:52:41
rpc service result: hi linzy---2022-10-30 15:52:42
rpc service result: hi linzy---2022-10-30 15:52:43

2、更安全的RPC介面

在涉及 RPC 的應用中,作為開發人員一般至少有三種角色:首先是服務端實現 RPC 方法的開發人員,其次是客戶端呼叫 RPC 方法的人員,最後也是最重要的是制定服務端和客戶端 RPC 介面規範的設計人員。在前面的例子中我們為了簡化將以上幾種角色的工作全部放到了一起,雖然看似實現簡單,但是不利於後期的維護和工作的切割。

1)服務端重構

如果要重構 HelloService 服務,第一步需要明確服務的名字和介面:

const HelloServiceName = "server/tcp-server/server.HiLinzy"

type HelloServiceInterface interface {
    SayHi(request string, response *string) error
}

//封裝Register
func RegisterHelloService(svc HelloServiceInterface) error {
    return rpc.RegisterName(HelloServiceName, svc)
}

我們將 RPC 服務的介面規範分為三個部分:首先是服務的名字,然後是服務要實現的詳細方法列表,最後是註冊該型別服務的函式。
為了避免名字衝突,我們在 RPC 服務的名字中增加了包路徑字首(這個是 RPC 服務抽象的包路徑,並非完全等價 Go 語言的包路徑)。
RegisterHelloService 註冊服務時,編譯器會要求傳入的物件滿足 HelloServiceInterface 介面。

基於 RPC 介面規範編寫真實的服務端程式碼:

type HelloService struct{}

func (h *HelloService) SayHi(request string, response *string) error {
    format := time.Now().Format("2006-01-02 15:04:05")
    *response = "hi " + request + "---" + format
    return nil
}

func main() {
    //註冊服務
    //_ = rpc.RegisterName("HiLinzy", new(HelloService))
    RegisterHelloService(new(HelloService))
    //監聽介面
    lis, err := net.Listen("tcp", "127.0.0.1:8888")
    if err != nil {
        log.Fatal(err)
        return
    }
    for {
        //監聽請求
        accept, err := lis.Accept()
        if err != nil {
            log.Fatalf("Accept Error: %s", err)
        }
        go rpc.ServeConn(accept)
    }
}

2)客戶端重構

為了簡化客戶端使用者呼叫 RPC 函式,我們在可以在介面規範部分增加對客戶端的簡單包裝:

const HelloServiceName = "server/tcp-server/server.HiLinzy"

type HelloServiceClient struct {
    *rpc.Client
}

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 (h *HelloServiceClient) SayHi(request string, response *string) error {
    //client.Call 的第一個引數用 HelloServiceName+".SayHi" 代替了 "HiLinzy.SayHi"。
    return h.Client.Call(HelloServiceName+".SayHi", request, &response)
}

提供了一個 DialHelloService 方法,直接撥號 HelloService 服務。

基於新的客戶端介面,我們可以簡化客戶端使用者的程式碼:

func main() {
    //建立連線
    //dial, err := rpc.Dial("tcp", "127.0.0.1:8888")
    client, err := DialHelloService("tcp", "127.0.0.1:8888")
    if err != nil {
        log.Fatal("dialing:", err)
    }
    var result string
    for i := 0; i < 5; i++ {
        //發起請求
        //_ = dial.Call("HiLinzy.SayHi", "linzy", &result)
        err = client.SayHi("linzy", &result)
        if err != nil {
            log.Fatal(err)
        }
        fmt.Println("rpc service result:", result)
        time.Sleep(time.Second)
    }
}

現在客戶端使用者不用再擔心 RPC 方法名字或引數型別不匹配等低階錯誤的發生。

# 啟動服務
➜ go run  server.go
API server listening at: 127.0.0.1:56990

# 啟動客戶端
➜ go run  client.go
API server listening at: 127.0.0.1:57188
rpc service result: hi linzy---2022-10-30 16:55:12
rpc service result: hi linzy---2022-10-30 16:55:13
rpc service result: hi linzy---2022-10-30 16:55:14
rpc service result: hi linzy---2022-10-30 16:55:15
rpc service result: hi linzy---2022-10-30 16:55:16

在新的 RPC 服務端實現中,我們用 RegisterHelloService 函式來註冊函式,這樣不僅可以避免命名服務名稱的工作,同時也保證了傳入的服務物件滿足了 RPC 介面的定義。

3、跨語言的 RPC

標準庫的RPC預設採用 Go 語言特有的 gob 編碼,因此從其他語言呼叫 Go 語言實現的 RPC 服務將比較困難。在網際網路的微服務時代,每個 RPC 以及服務的使用者都可能採用不同的程式語言,因此跨語言是網際網路時代 RPC 的一個首要條件。得益於 RPC 的框架設計,Go 語言的 RPC 其實也是很容易實現跨語言支援的。

Go 語言的 RPC 框架有兩個比較有特色的設計:

  • RPC 資料打包時可以透過外掛實現自定義的編碼和解碼。
  • RPC 建立在抽象的 io.ReadWriterCloser 介面之上的,我們可以將 RPC 架設在不同的通訊協議之上。

這裡我們使用Go官方自帶的 net/rpc/jsonrpc 擴充套件實現一個跨語言的rpc。

1)服務端實現

首先是基於 json 編碼重新實現 RPC 服務:

func main() {
    //註冊服務
    //_ = rpc.RegisterName("HiLinzy", new(HelloService))
    RegisterHelloService(new(HelloService))
    //監聽介面
    lis, err := net.Listen("tcp", "127.0.0.1:8888")
    if err != nil {
        log.Fatal(err)
        return
    }
    for {
        //監聽請求
        accept, err := lis.Accept()
        if err != nil {
            log.Fatalf("Accept Error: %s", err)
        }
        //go rpc.ServeConn(accept)
        go rpc.ServeCodec(jsonrpc.NewServerCodec(accept))
    }
}

程式碼中最大的變化是用 rpc.ServeCodec 函式替代了 rpc.ServeConn 函式,傳入的引數是針對服務端的 json 編解碼器。

2)客戶端實現

實現 json 版本的客戶端:

func main() {
    //建立TCP連線
    conn, err := net.Dial("tcp", "127.0.0.1:8888")
    if err != nil {
        log.Fatal("net.Dial:", err)
    }
    //建立針對客戶端的json編解碼器
    client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))

    var result string
    for i := 0; i < 5; i++ {
        //發起請求
        //err = client.SayHi("linzy", &result)
        client.Call(HelloServiceName+".SayHi", "linzy", &result)
        if err != nil {
            log.Fatal(err)
        }
        fmt.Println("rpc service result:", result)
        time.Sleep(time.Second)
    }
}
# 啟動服務
➜ go run  server.go
API server listening at: 127.0.0.1:59409

# 啟動客戶端
➜ go run  client.go
API server listening at: 127.0.0.1:59514
rpc service result: hi linzy---2022-10-30 19:09:52
rpc service result: hi linzy---2022-10-30 19:09:53
rpc service result: hi linzy---2022-10-30 19:09:54
rpc service result: hi linzy---2022-10-30 19:09:55
rpc service result: hi linzy---2022-10-30 19:09:56

我們先手工呼叫 net.Dial 函式建立 TCP 連線,然後基於該連線建立針對客戶端的 json 編解碼器。
在確保客戶端可以正常呼叫 RPC 服務的方法之後,我們用一個普通的 TCP 服務代替 Go 語言版本的 RPC 服務,這樣可以檢視客戶端呼叫時傳送的資料格式。

3)分析資料格式

我們用Wireshark抓個包看看我們直接傳遞的資料格式:
在這裡插入圖片描述
這是一個 json 編碼的資料,其中 method 部分對應要呼叫的 rpc 服務和方法組合成的名字,params 部分的第一個元素為引數,id 是由呼叫端維護的一個唯一的呼叫編號。

{"method":"server/tcp-server/server.HiLinzy.SayHi","params":["linzy"],"id":0}

請求的 json 資料物件在內部對應兩個結構體:客戶端是 clientRequest,服務端是 serverRequest。clientRequest 和 serverRequest 結構體的內容基本是一致的:

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"`
}

我們再來檢視服務端響應的結果的資料結構:
在這裡插入圖片描述
返回的結果也是一個 json 格式的資料:

{"id":0,"result":"hilinzy---2022-10-30 19:09:52","error":null}.

其中 id 對應輸入的 id 引數,result 為返回的結果,error 部分在出問題時表示錯誤資訊。對於順序呼叫來說,id 不是必須的。但是 Go 語言的 RPC 框架支援非同步呼叫,當返回結果的順序和呼叫的順序不一致時,可以透過 id 來識別對應的呼叫。

返回的 json 資料也是對應內部的兩個結構體:客戶端是 clientResponse,服務端是 serverResponse。兩個結構體的內容同樣也是類似的:

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 結構,以同樣的流程就可以和 Go 語言編寫的 RPC 服務進行通訊。這樣我們就實現了跨語言的 RPC。

4、HTTP 上的 RPC

Go 語言內在的 RPC 框架已經支援在 HTTP 協議上提供 RPC 服務。但是框架的 HTTP 服務同樣採用了內建的 gob 協議,並且沒有提供採用其它協議的介面,因此從其它語言依然無法訪問的

在前面的例子中,我們已經實現了在 TCP 協議之上執行 jsonrpc 服務,並且透過Wireshark抓包分析傳遞的資料 json 資料格式。現在我們嘗試在 http 協議上提供 jsonrpc 服務。

新的 RPC 服務其實是一個類似 REST 規範的介面,接收請求並採用相應處理流程:

const HelloServiceName = "server/tcp-server/server.HiLinzy"

type HelloService struct{}

func (h *HelloService) SayHi(request string, response *string) error {
    format := time.Now().Format("2006-01-02 15:04:05")
    *response = "hi " + request + "---" + format
    return nil
}

func main() {
    //註冊服務
    rpc.RegisterName(HelloServiceName, 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(":8888", nil)
}

RPC 的服務架設在 “/jsonrpc” 路徑,在處理函式中基於 http.ResponseWriter 和 http.Request 型別的引數構造一個 io.ReadWriteCloser 型別的 conn 通道。
然後基於 conn 構建針對服務端的 json 編碼解碼器。最後透過 rpc.ServeRequest 函式為每次請求處理一次 RPC 方法呼叫。

用Postman模擬RPC呼叫過程,向連線localhost:8888/jsonrpc傳送一條 json 字串

{"method":"server/tcp-server/server.HiLinzy.SayHi","params":["linzy"],"id":0}

在這裡插入圖片描述
這樣我們就可用很方便的從不同的語言或者不同的方式來訪問RPC服務了。

參考文章:
www.techtarget.com/searchapparchit...

https://mp.weixin.qq.com/s__biz=MzI5MDAzNTAxMQ==&mid=2455917150&idx=1&sn=8a8325b09e6e2a0e34bf86609967f28c&scene=19#wechat_redirect

chai2010.cn/advanced-go-programmin...

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章