hello 大家好呀,我是小樓,這是系列文《Go底層原理剖析》的第三篇,依舊分析 Http 模塊。我們今天來看 Go內置的 RPC。說起 RPC 大家想到的一般是框架,Go 作為編程語言竟然還內置了 RPC,著實讓我有些吃鯨。 從一個 Demo 入手 為了快速進入狀態,我們先搞一個 Demo,當然這 ...
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 是什麼,怎麼實現的,至於它的優缺點,能不能在生產中使用,倒是沒有講,下次寫一篇文章專門講一下,有興趣可以持續關註,我們下期再見,歡迎轉發、收藏、點贊。
往期回顧
- 搜索關註微信公眾號"捉蟲大師",後端技術分享,架構設計、性能優化、源碼閱讀、問題排查、踩坑實踐