一個輕量級RPC的實現

山頂上的垂釣者發表於2018-01-26

前言

最近公司在做分散式相關的東西,需要用到RPC。以前對RPC不是很瞭解,網上也看了很多文章,發現看過之後並沒有加深我的理解,仍然雲裡霧裡,只知道是遠端過程呼叫,一個計算機上的程式可以呼叫另一個計算機上的服務,或一個程式呼叫另一個程式提供的服務,僅此而已。也用了golang官方的net/rpc庫實現了這個目的,但是對net/rpc的底層不瞭解(只是會用),本著一顆“鑽牛角尖”的心,我決定深入研究一下RPC的原理,並自己實現一個RPC,故有了這篇文章。

什麼是RPC

維基百科對RPC是這樣解釋的:

In distributed computing, a remote procedure call (RPC) is when a computer program causes a procedure (subroutine) to execute in a different address space (commonly on another computer on a shared network), which is coded as if it were a normal (local) procedure call, without the programmer explicitly coding the details for the remote interaction.

我就不翻譯了,大體是說程式A呼叫程式B,A跟B不在一個地址空間(通常A跟B也不在同一臺電腦上),但是A呼叫B就跟呼叫本地的程式是一樣的,程式設計師無需對這個遠端的互動細節進行額外的程式設計。

這裡有兩個關鍵詞,我用黑體標出來了。 分散式:表明了RPC的應用場景,一般是用在多臺計算機,而不是單臺計算機上。 在不同的地址空間:說明了至少有兩個程式,一個服務端程式,一個客戶端程式。服務端程式提供服務(暴露出某些介面),客戶端程式呼叫服務。當然測試的時候服務端和客戶端程式寫在一個檔案裡面也行,在主執行緒裡面寫服務端程式,提供介面,然後新開一個執行緒寫客戶端程式,呼叫介面也是可以的。

RPC原理圖

一個輕量級RPC的實現

實現一個RPC需要解決哪些問題

從RPC原理圖中我們可以看出,server端是服務提供方,client端是服務呼叫方。既然server端可以提供服務,那麼它要先實現服務的註冊,只有註冊過的服務才能被client端呼叫。前文也說過,client端和server端一般不在一個程式內,甚至不在一個計算機內,那麼他們之間的通訊必然是通過網路傳輸,這樣就涉及到了網路傳輸協議,說的更直白的,就是如何將client端的資料(一般是要呼叫的服務名和相應的引數)安全傳輸到server端,而server端也能完整的接收到。在client端和server端,資料一般是以物件的形式存在,而物件是無法進行網路傳輸的,在網路傳輸之前,我們需要先把物件序列化成位元組流,然後傳輸這些位元組流,server端在接收到這些位元組流之後,再反序列化得到原始物件,這就是序列化與反序列化。總結一下,要實現一個RPC就必須解決這三個問題,即

  1. 服務的註冊
  2. 網路傳輸
  3. 序列化與反序列化

呼叫形式

在講解具體的實現之前,我們先看一下我們的testrpc是如何使用的。 server.go

package main

import (
	"log"
	"net"
	"testrpc"
)

type Args struct {
	A, B int
}

type Arith int

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

func main() {
	// 建立一個rpc server物件
	newServer := testrpc.NewServer()

	// 向rpc server物件註冊一個Arith物件,註冊後,client就可以呼叫Arith的Multiply方法
	arith := new(Arith)
	newServer.Register(arith)

	// 監聽本機的1234埠
	l, e := net.Listen("tcp", "127.0.0.1:1234")
	if e != nil {
		log.Fatalf("net.Listen tcp :0: %v", e)
	}

	for {
		// 阻塞直到從1234埠收到一個網路連線
		conn, e := l.Accept()
		if e != nil {
			log.Fatalf("l.Accept: %v", e)
		}

		//開始工作
		go newServer.ServeConn(conn)
	}
}
複製程式碼

程式碼比較簡單,也有註釋,這裡簡單說明一下流程:我們先是new出來了一個rpc server物件,然後向這個server註冊了Arith物件,註冊後,client就可呼叫Arith暴露出來的所有方法,這裡只有Multiply。然後我們監聽了本機的1234埠,並且在for迴圈中等待來自1234埠的連線,等來了一個連線我們就呼叫ServeConn方法在一個新的goroutine中處理這個連線,然後我們繼續等待新的連線,如此反覆。

client.go

package main

import (
	"log"
	"net"
	"os"
	"testrpc"
)

type Args struct {
	A, B int
}

func main() {

	// 連線本機的1234埠,返回一個net.Conn物件
	conn, err := net.Dial("tcp", "127.0.0.1:1234")
	if err != nil {
		log.Println(err.Error())
		os.Exit(-1)
	}

	// main函式退出時關閉該網路連線
	defer conn.Close()

	// 建立一個rpc client物件
	client := testrpc.NewClient(conn)
	// main函式退出時關閉該client
	defer client.Close()

	// 呼叫遠端Arith.Multiply函式
	args := Args{7, 8}
	var reply int
	err = client.Call("Arith.Multiply", args, &reply)
	if err != nil {
		log.Fatal("arith error:", err)
	}
	log.Println(reply)
}
複製程式碼

我們先連線本機的1234埠(這個埠server.go在監聽),得到一個net.Conn物件,然後用這個物件new出來了一個rpc client,然後再通過這個client呼叫服務端提供的方法Multiply,計算完後把結果存到reply中。程式碼很簡單,就不多說了。

這個用法參考了golang官方的net/rpc庫,有興趣的讀者也可以去學習一下net/rpc的使用方法。

實現原理

Server的定義

前面說過,server端要解決的問題有服務的註冊,也就是Register方法,那麼server端必須要能夠儲存這些服務,所以server的定義可以如下:

type Service struct {
	Method    reflect.Method
	ArgType   reflect.Type
	ReplyType reflect.Type
}

type Server struct {
	ServiceMap  map[string]map[string]*Service
	serviceLock sync.Mutex
}
複製程式碼

一個Service物件就對應一個服務,一個服務包括方法、引數型別和返回值型別。Server有兩個屬性:ServiceMap和serviceLock,ServiceMap是一系列service的集合,之所以要以Map的形式是為了方便查詢,serviceLock是為了保護ServiceMap,確保同一時刻只有一個goroutine能夠寫ServiceMap。

服務的註冊

func (server *Server) Register(obj interface{}) error {
	server.serviceLock.Lock()
	defer server.serviceLock.Unlock()

	//通過obj得到其各個方法,儲存在servicesMap中
	tp := reflect.TypeOf(obj)
	val := reflect.ValueOf(obj)
	serviceName := reflect.Indirect(val).Type().Name()
	if _, ok := server.ServiceMap[serviceName]; ok {
		return errors.New(serviceName + " already registed.")
	}

	s := make(map[string]*Service)
	numMethod := tp.NumMethod()
	for m := 0; m < numMethod; m++ {
		service := new(Service)
		method := tp.Method(m)
		mtype := method.Type
		mname := method.Name

		service.ArgType = mtype.In(1)
		service.ReplyType = mtype.In(2)
		service.Method = method
		s[mname] = service
	}
	server.ServiceMap[serviceName] = s
	server.ServerType = reflect.TypeOf(obj)
	return nil
}
複製程式碼

這裡把前面的呼叫Register的程式碼放出來一起看可能會更清楚一些。

type Arith int

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

...
newServer := testrpc.NewServer()
newServer.Register(new(Arith))
...
複製程式碼

Register的大概邏輯就是拿到obj(Register的引數)的各個暴露出來的方法(這裡只有一個Multiply),然後存到server的ServiceMap中。這裡主要用到了golang的reflect,如果對reflect不瞭解的話看Register程式碼還是比較吃力的。網上有很多講解reflect的文章,建議不瞭解reflect的讀者先去看看,這裡就不講了。註冊之後,ServiceMap大概是這個樣子

{
	"Arith": {"Multiply":&{Method:Multiply, ArgType:main.Args, ReplyType:*int}}	
}
複製程式碼

網路傳輸

testrpc的網路傳輸是基於golang提供的net.Conn,這個net.Conn提供了兩個方法:Read和Write。Read表示從網路連線中讀取資料,Write表示向網路連線中寫資料。我們就基於這兩個方法來實現我們的網路傳輸,程式碼如下:

const (
	EachReadBytes = 500
)

type Transfer struct {
	conn net.Conn
}

func NewTransfer(conn net.Conn) *Transfer {
	return &Transfer{conn: conn}
}

func (trans *Transfer) ReadData() ([]byte, error) {
	finalData := make([]byte, 0)
	for {
		data := make([]byte, EachReadBytes)
		i, err := trans.conn.Read(data)
		if err != nil {
			return nil, err
		}
		finalData = append(finalData, data[:i]...)
		if i < EachReadBytes {
			break
		}
	}
	return finalData, nil
}

func (trans *Transfer) WriteData(data []byte) (int, error) {
	num, err := trans.conn.Write(data)
	return num, err
}
複製程式碼

ReadData是從網路連線中讀取資料,每次讀500位元組(由EachReadBytes指定),直到讀完為止,然後把讀到的資料返回;WriteData是向網路連線中寫資料

序列化與反序列化

上節講到了網路傳輸,我們知道,傳輸的物件是位元組流。序列化就是負責把物件變成位元組流的,相反的,反序列化就是負責將位元組流變成程式中的物件的。在網路傳輸之前我們要先進行序列化,testrpc採用了json做為序列化方式,未來可能會加入其它的序列化方式,如gob、xml、protobuf等。我們先來看下采用json做為序列化的程式碼:

type EdCode int

func (edcode EdCode) encode(v interface{}) ([]byte, error) {
	return json.Marshal(v)
}

func (edcode EdCode) decode(data []byte, v interface{}) error {
	return json.Unmarshal(data, v)
}
複製程式碼

這裡採用了golang官方提供的json庫,程式碼很簡單,就不解釋了。

Server端處理網路連線

  1. 構造一個Transfer物件,代表一個網路傳輸
  2. 呼叫Transfer的ReadData方法從網路連線中讀資料
  3. 呼叫EdCode的decode方法將資料反序列化成普通物件
  4. 獲取反序列化後資料,如方法名、引數
  5. 根據方法名查詢ServiceMap,拿到對應的service
  6. 呼叫對應的service,得到結果
  7. 將結果序列化
  8. 使用Transfer的WriteData方法的寫回到網路連線中

以上就是Server端在拿到Client端請求後的處理過程,我們把它封裝在了ServeConn方法裡。程式碼比較長,就不在這裡貼了,有興趣的可以去github裡面看。

Client端發起網路連線

  1. 構造一個Transfer物件,代表一個網路傳輸
  2. 將要呼叫的服務名和引數序列化
  3. 使用Transfer的WriteData方法將序列化後的資料的寫入到網路連線中
  4. 阻塞直到Server端計算完
  5. 呼叫Transfer的ReadData方法從網路連線中讀取計算後的結果
  6. 將結果反序列化後返回給client 程式碼如下:
func (client *Client) Call(methodName string, req interface{}, reply interface{}) error {

	// 構造一個Request
	request := NewRequest(methodName, req)

	// encode
	var edcode EdCode
	data, err := edcode.encode(request)
	if err != nil {
		return err
	}

	// write
	// 構造一個Transfer
	trans := NewTransfer(client.conn)
	_, err = trans.WriteData(data)
	if err != nil {
		log.Println(err.Error())
		return err
	}

	// read
	data2, err := trans.ReadData()
	if err != nil {
		log.Println(err.Error())
		return err
	}

	// decode and assin to reply
	edcode.decode(data2, reply)

	// return
	return nil
}
複製程式碼

Client的定義很簡單,就一個代表網路連線的conn,程式碼如下:

type Client struct {
	conn net.Conn
}
複製程式碼

為什麼不用Python來實現

由於我平常寫Python程式碼寫得比較多,如果用Python來實現這個testrpc的話確實要更快。那麼為什麼要使用golang呢?因為我們公司最近做的一個專案是基於golang的net/rpc來實現的,工作之便,瞭解了net/rpc的使用,也稍微看了下net/rpc的底層程式碼,猜出了其大概原理,於是就想自己也寫一個,就這樣。當然,以後有機會的話再用Python實現一遍。

總結

本文主要講述了RPC的原理,以及實現了一個輕量級的RPC。這裡說一下我在除錯的時候遇到的問題,由於golang是強型別語言,那麼我必須要面對的問題是,怎麼在一個物件經過序列化和反序列化之後依然保持其原來的型別。比如一個Args型別的物件(讀者可以翻到最前面看看Args是如何定義的)在序列化和反序列化之後變成了map[string]interface{}型別,用map[string]interface{}型別當做Args型別傳參是會報錯的,這部分感興趣的讀者可以去看看程式碼。讀者可能看過這篇文章沒什麼感覺,看過一遍也就看過了,感覺有收穫又感覺沒收穫,我建議把程式碼下載下來,然後在自己的電腦上跑一下,除錯一下,我相信你會有很深的理解,不管是對RPC,還是對golang的reflect等。比如說我,我以前對reflect掌握的不是很好,寫完這個testrpc之後基本掌握了reflect的使用。

程式碼在github上,歡迎給個star和提交issue,也歡迎你提出自己的看法,比如這段程式碼寫得不好,可以這麼優化等等,都可以與我交流,多謝!

github地址:https://github.com/TanLian/testrpc

另也可關注下我個人的技術公眾號,期待一起進步。

一個輕量級RPC的實現

相關文章