一文吃透 Go 內建 RPC 原理

2023-03-02 21:01:00

hello 大家好呀,我是小樓,這是系列文《Go底層原理剖析》的第三篇,依舊分析 Http 模組。我們今天來看 Go內建的 RPC。說起 RPC 大家想到的一般是框架,Go 作為程式語言竟然還內建了 RPC,著實讓我有些吃鯨。

從一個 Demo 入手

為了快速進入狀態,我們先搞一個 Demo,當然這個 Demo 是參考 Go 原始碼 src/net/rpc/server.go,做了一丟丟的修改。

  • 首先定義請求的入參和出參:
package common

type Args struct {
	A, B int
}

type Quotient struct {
	Quo, Rem int
}
  • 接著在定義一個物件,並給這個物件寫兩個方法
type Arith struct{}

func (t *Arith) Multiply(args *common.Args, reply *int) error {
	*reply = args.A * args.B
	return nil
}

func (t *Arith) Divide(args *common.Args, quo *common.Quotient) error {
	if args.B == 0 {
		return errors.New("divide by zero")
	}
	quo.Quo = args.A / args.B
	quo.Rem = args.A % args.B
	return nil
}
  • 然後起一個 RPC server:
func main() {
	arith := new(Arith)
	rpc.Register(arith)
	rpc.HandleHTTP()
	l, e := net.Listen("tcp", ":9876")
	if e != nil {
		panic(e)
	}

	go http.Serve(l, nil)

	var wg sync.WaitGroup
	wg.Add(1)
	wg.Wait()
}
  • 最後初始化 RPC Client,並行起呼叫:
func main() {
	client, err := rpc.DialHTTP("tcp", "127.0.0.1:9876")
	if err != nil {
		panic(err)
	}

	args := common.Args{A: 7, B: 8}
	var reply int
  // 同步呼叫
	err = client.Call("Arith.Multiply", &args, &reply)
	if err != nil {
		panic(err)
	}
	fmt.Printf("Call Arith: %d * %d = %d\n", args.A, args.B, reply)

  // 非同步呼叫
	quotient := new(common.Quotient)
	divCall := client.Go("Arith.Divide", args, quotient, nil)
	replyCall := <-divCall.Done

	fmt.Printf("Go Divide: %d divide %d = %+v %+v\n", args.A, args.B, replyCall.Reply, quotient)
}

如果不出意外,RPC 呼叫成功

這 RPC 嗎

在剖析原理之前,我們先想想什麼是 RPC?

RPC 是 Remote Procedure Call 的縮寫,一般翻譯為遠端過程呼叫,不過我覺得這個翻譯有點難懂,啥叫過程?如果查一下 Procedure,就能發現它就是應用程式的意思。

所以翻譯過來應該是呼叫遠端程式,說人話就是呼叫的方法不在本地,不能通過記憶體定址找到,只能通過遠端通訊來呼叫。

一般來說 RPC 框架存在的意義是讓你呼叫遠端方法像呼叫本地方法一樣方便,也就是將複雜的編解碼、通訊過程都封裝起來,讓程式碼寫起來更簡單。

說到這裡其實我想吐槽一下,網上經常有文章說,既然有 Http,為什麼還要有 RPC?如果你理解 RPC,我相信你不會問出這樣的問題,他們是兩個維度的東西,RPC 關注的是遠端呼叫的封裝,Http 是一種協定,RPC 沒有規定通訊協定,RPC 也可以使用 Http,這不矛盾。這種問法就好像在問既然有了蘋果手機,為什麼還要有中國移動?

扯遠了,我們回頭看一下上述的例子是否符合我們對 RPC 的定義。

  • 首先是遠端呼叫,我們是開了一個 Server,監聽了9876埠,然後 Client 與之通訊,將這兩個程式部署在兩臺機器上,只要網路是通的,照樣可以正常工作
  • 其次它符合呼叫遠端方法像呼叫本地方法一樣方便,程式碼中沒有處理編解碼,也沒有處理通訊,只不過方法名以引數的形式傳入,和一般的 RPC 稍有不同,倒是很像 Dubbo 的泛化呼叫

綜上兩點,這很 RPC。

下面我將用兩段內容分別剖析 Go 內建的 RPC Server 與 Client 的原理,來看看 Go 是如何實現一個 RPC 的。

RPC Server 原理

註冊服務

這裡的服務指的是一個具有公開方法的物件,比如上面 Demo 中的 Arith,只需要呼叫 Register 就能註冊

rpc.Register(arith)

註冊完成了以下動作:

  • 利用反射獲取這個物件的型別、類名、值、以及公開方法
  • 將其包裝為 service 物件,並存在 server 的 serviceMap 中,serviceMap 的 key 預設為類名,比如這裡是Arith,也可以呼叫另一個註冊方法 RegisterName 來自定義名稱

註冊 Http Handle

這裡你可能會問,為啥 RPC 要註冊 Http Handle。沒錯,Go 內建的 RPC 通訊是基於 Http 協定的,所以需要註冊。只需要一行程式碼:

rpc.HandleHTTP()

它呼叫的是 Http 的 Handle 方法,也就是 HandleFunc 的底層實現,這塊如果不清楚,可以看我之前的文章《一文讀懂 Go Http Server 原理》

它註冊了兩個特殊的 Path:/_goRPC_/debug/rpc,其中有一個是 Debug 專用,當然也可以自定義。

邏輯處理

註冊時傳入了 RPC 的 server 物件,這個物件必須實現 Handler 的 ServeHTTP 介面,也就是 RPC 的處理邏輯入口在這個 ServeHTTP 中:

type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

我們看 RPC Server 是如何實現這個介面的:

// ServeHTTP implements an http.Handler that answers RPC requests.
func (server *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	// ①
  if req.Method != "CONNECT" {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		w.WriteHeader(http.StatusMethodNotAllowed)
		io.WriteString(w, "405 must CONNECT\n")
		return
	}
  // ②
	conn, _, err := w.(http.Hijacker).Hijack()
	if err != nil {
		log.Print("rpc hijacking ", req.RemoteAddr, ": ", err.Error())
		return
	}
  // ③
	io.WriteString(conn, "HTTP/1.0 "+connected+"\n\n")
	// ④
	server.ServeConn(conn)
}

我對這段程式碼標了號,逐一看:

  • ①:限制了請求的 Method 必須是 CONNECT,如果不是則直接返回錯誤,這麼做是為什麼?看下 Method 欄位的註釋就恍然大悟:Go 的 Http Client 是發不出 CONNECT 的請求,也就是 RPC 的 Server 是沒辦法通過 Go 的 Http Client 存取,限制必須得使用 RPC Client
type Request struct {
	// Method specifies the HTTP method (GET, POST, PUT, etc.).
	// For client requests, an empty string means GET.
	//
	// Go's HTTP client does not support sending a request with
	// the CONNECT method. See the documentation on Transport for
	// details.
	Method string
}

  • ②:Hijack 是劫持 Http 的連線,劫持後需要手動處理連線的關閉,這個操作是為了複用連線
  • ③:先寫一行響應:
"HTTP/1.0 200 Connected to Go RPC \n\n"
  • ④:開始真正的處理,這裡段比較長,大致做了如下幾點事情:

    • 準備好資料、編解碼器
    • 在一個大回圈裡處理每一個請求,處理流程是:
      • 讀出請求,包括要呼叫的service,引數等
      • 通過反射非同步地呼叫對應的方法
      • 將執行結果編碼寫回連線

說到這裡,程式碼中有個物件池的設計挺巧妙,這裡展開說說。

在高並行下,Server 端的 Request 物件和 Response 物件會頻繁地建立,這裡用了佇列來實現了物件池。以 Request 物件池做個介紹,在 Server 物件中有一個 Request 指標,Request 中有個 next 指標

type Server struct {
	...
	freeReq    *Request
	..
}

type Request struct {
	ServiceMethod string 
	Seq           uint64
	next          *Request
}

在讀取請求時需要這個物件,如果池中沒有物件,則 new 一個出來,有的話就拿到,並將 Server 中的指標指向 next:

func (server *Server) getRequest() *Request {
	server.reqLock.Lock()
	req := server.freeReq
	if req == nil {
		req = new(Request)
	} else {
		server.freeReq = req.next
		*req = Request{}
	}
	server.reqLock.Unlock()
	return req
}

請求處理完成時,釋放這個物件,插入到連結串列的頭部

func (server *Server) freeRequest(req *Request) {
	server.reqLock.Lock()
	req.next = server.freeReq
	server.freeReq = req
	server.reqLock.Unlock()
}

畫個圖整體感受下:

回到正題,Client 和 Server 之間只有一條連線,如果是非同步執行,怎麼保證返回的資料是正確的呢?這裡先不說,如果一次性說完了,下一節的 Client 就沒啥可說的了,你說是吧?

RPC Client 原理

Client 使用第一步是 New 一個 Client 物件,在這一步,它偷偷起了一個協程,幹什麼呢?用來讀取 Server 端的返回,這也是 Go 慣用的伎倆。

每一次 Client 的呼叫都被封裝為一個 Call 物件,包含了呼叫的方法、引數、響應、錯誤、是否完成。

同時 Client 物件有一個 pending map,key 為請求的遞增序號,當 Client 發起呼叫時,將序號自增,並把當前的 Call 物件放到 pending map 中,然後再向連線寫入請求。

寫入的請求先後分別為 Request 和引數,可以理解為 header 和 body,其中 Request 就包含了 Client 的請求自增序號。

Server 端響應時把這個序號帶回去,Client 接收響應時讀出返回資料,再去 pending map 裡找到對應的請求,通知給對應的阻塞協程。

這不就能把請求和響應串到一起了嗎?這一招很多 RPC 框架也是這麼玩的。

Client 、Server 流程都走完,但我們忽略了編解碼細節,Go RPC 預設使用 gob 編解碼器,這裡也稍微介紹下 gob。

gob 編解碼

gob 是 Go 實現的一個 Go 親和的協定,可以簡單理解這個協定只能在 Go 中用。Go Client RPC 對編解碼介面的定義如下:

type ClientCodec interface {
	WriteRequest(*Request, interface{}) error
	ReadResponseHeader(*Response) error
	ReadResponseBody(interface{}) error

	Close() error
}

同理,Server 端也有一個定義:

type ServerCodec interface {
	ReadRequestHeader(*Request) error
	ReadRequestBody(interface{}) error
	WriteResponse(*Response, interface{}) error
  
	Close() error
}

gob 是其一個實現,這裡只看 Client:

func (c *gobClientCodec) WriteRequest(r *Request, body interface{}) (err error) {
	if err = c.enc.Encode(r); err != nil {
		return
	}
	if err = c.enc.Encode(body); err != nil {
		return
	}
	return c.encBuf.Flush()
}

func (c *gobClientCodec) ReadResponseHeader(r *Response) error {
	return c.dec.Decode(r)
}

func (c *gobClientCodec) ReadResponseBody(body interface{}) error {
	return c.dec.Decode(body)
}

追蹤到底層就是 Encoder 的 EncodeValue 和 DecodeValue 方法,Encode 的細節我不打算寫,因為我也不想看這一塊,最終結果就是把結構體編碼成了二進位制資料,呼叫 writeMessage。

總結

本文介紹了 Go 內建的 RPC Client 和 Server 端原理,能窺探出一點點 RPC 的設計,如果讓你實現一個 RPC 是不是有些可以參考呢?

本來草稿中貼了很多程式碼,但我覺得那樣解讀很難讀下去,於是就刪了又刪。

不過還有一點是我想寫但沒有寫出來的,本文只講了 Go 內建 RPC 是什麼,怎麼實現的,至於它的優缺點,能不能在生產中使用,倒是沒有講,下次寫一篇文章專門講一下,有興趣可以持續關注,我們下期再見,歡迎轉發、收藏、點贊。

往期回顧

  • 搜尋關注微信公眾號"捉蟲大師",後端技術分享,架構設計、效能優化、原始碼閱讀、問題排查、踩坑實踐