一文吃透 Go 內置 RPC 原理

来源:https://www.cnblogs.com/zhuochongdashi/archive/2023/03/02/17173143.html
-Advertisement-
Play Games

hello 大家好呀,我是小樓,這是系列文《Go底層原理剖析》的第三篇,依舊分析 Http 模塊。我們今天來看 Go內置的 RPC。說起 RPC 大家想到的一般是框架,Go 作為編程語言竟然還內置了 RPC,著實讓我有些吃鯨。 從一個 Demo 入手 為了快速進入狀態,我們先搞一個 Demo,當然這 ...


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

image

從一個 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 調用成功

image

這 RPC 嗎

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

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

image

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

一般來說 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
}

image

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

    • 準備好數據、編解碼器
    • 在一個大迴圈里處理每一個請求,處理流程是:
      • 讀出請求,包括要調用的service,參數等
      • 通過反射非同步地調用對應的方法
      • 將執行結果編碼寫回連接

說到這裡,代碼中有個對象池的設計挺巧妙,這裡展開說說。

image

在高併發下,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()
}

畫個圖整體感受下:

image

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

image

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 框架也是這麼玩的。

image

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 是什麼,怎麼實現的,至於它的優缺點,能不能在生產中使用,倒是沒有講,下次寫一篇文章專門講一下,有興趣可以持續關註,我們下期再見,歡迎轉發、收藏、點贊。

往期回顧

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

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 1. 概覽 1.1. 即時編譯器是Java虛擬機的核心 1.1.1. just-in-time compiler,簡稱JIT compiler 1.1.2. 即時編譯器會頻繁地使用寄存器 1.2. 編譯型語言 1.2.1. 程式是以二進位(編譯後的)代碼的形式發佈的 1.2.1.1. 彙編代碼是針對 ...
  • 最近演唱會還挺多的,都是大家喜歡的那些知名歌手,所以特地出一手教程給大家助力(主要是 表弟想追女神,所以教他自己搶票) 知識點 selenium 淘寶滑塊處理 搶購邏輯實現 必備環境 python 3.8 pycharm 專業版 谷歌瀏覽器+谷歌驅動+selenium3.141.0 stealth. ...
  • 一、寫在前面 又有很久沒更文了,真的是被催婚搞的整個人情緒特別不好,如果硬要形容的話,那就是沒法跟人正常溝通,一點就著,做什麼都沒耐心,看什麼都煩,簡直沒救了... 也是偶然發現的,自己居然沒寫關於Playwright的元素定位,這不是自動化測試的重中之重,怎麼可以忘,馬上安排! 二、元素定位 主要 ...
  • ​ 遞歸函數 在函數內部調用自身本身 計算階乘: def fact(n): if n == 1: return 1 return n * fact(n - 1) 註意:使用遞歸函數需要防止棧溢出。 在電腦中,函數調用是通過棧(stack)實現,每當進入一個函數調用,棧就會加一層棧幀,每當函數返回, ...
  • docker實戰筆記 一、安裝docker 下麵以ubuntu系統舉例: 卸載已有的舊版本docker $ sudo apt-get remove docker \ docker-engine \ docker.io 使用apt安裝最新版docker $ sudo apt-get update $ ...
  • 實現Controller的三種方式分析 每種實現的方式對應的HanderAdapter都不同。 實現Controller介面 該介面對應的HanderAdapter為SimpleControllerHandlerAdapter。 使用案列: public class LeController imp ...
  • 用GoRoutines高性能同時進行多個Api調用 轉載請註明來源:https://janrs.com/2023/03/%E7%94%A8goroutines%E5%90%8C%E6%97%B6%E8%BF%9B%E8%A1%8C%E5%A4%9A%E4%B8%AAapi%E8%B0%83%E7%9 ...
  • 前言 今天看到一個程式,用到了智能指針, virtual tmp<volScalarField> rho() const; 藉此機會把有關智能指針的知識體系重新梳理一遍 智能指針autoPtr的由來: 首先要說明智能指針本質上是模板類,是對原有指針的改進,相比更安全, of對autoPtr的描述如下 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...