1. 程式人生 > >【劉文彬】以太坊RPC機制與API例項

【劉文彬】以太坊RPC機制與API例項

原文連結:醒者呆的部落格園,https://www.cnblogs.com/Evsward/p/eth-rpc.html

上一篇文章(【劉文彬】探路以太坊)介紹了以太坊的基礎知識,我們瞭解了web3.js的呼叫方式是通過以太坊RPC技術,本篇文章旨在研究如何開發、編譯、執行與使用以太坊RPC介面。

關鍵字:以太坊,RPC,JSON-RPC,client,server,api,web3.js,api例項,Postman

rpc簡介

RPC(remote process call),名曰遠端過程呼叫。意思就是兩臺物理位置不同的伺服器,其中一臺伺服器的應用想呼叫另一臺伺服器上某個應用的函式或者方法,由於不在同一個記憶體空間不能直接呼叫,因此需要通過網路來表達語義以及傳入的引數。RPC是跨作業系統,跨程式語言的網路通訊方式。

RMI(remote method invocation),遠端方法呼叫,只能被Java呼叫,可返回Java物件和基本型別,可以說是Java版的RPC。它需要一個RMI Registry來註冊服務地址。

JMS(java message service),java訊息服務,網路傳輸的是Java物件,而RMI還是遠端呼叫方法,網路傳輸的是請求引數和返回值。

SOAP(simple object access protocol),簡單物件訪問協議,是一種交換資訊的服務,通過WSDL(Web Services Description Language)方法描述檔案來指導我們去呼叫遠端服務,獲得自己想要的物件,傳輸格式為xml。

RPC規定在網路傳輸中引數和返回值均被序列化為二進位制資料,這個過程被稱為序列化(Serialize)或編組(marshal)。通過定址和傳輸將序列化的二進位制傳送給另一臺伺服器。另一臺伺服器收到二進位制資料以後會反序列化,恢復為記憶體中的表達方式,然後找到對應方法呼叫將返回值仍舊以二進位制形式返回給第一臺伺服器,然後再反序列化讀取返回值。

netty

Netty框架不侷限於RPC,更多的是作為一種網路協議的實現框架。通過上面的rpc介紹,我們指定rpc需要:

  • 通訊方式,TCP/IP socket, HTTP
  • 高效的序列化框架,protobuf, thrift
  • 定址方式,registry,endpoint URI, UDDI,另外服務註冊可以選擇redis,zookeeper等技術。
  • 會話和狀態保持 session

netty提供了一種事件驅動的,責任鏈式的,流水線式的網路協議實現方式。Netty對NIO有很好的實現,解決了眾多的RPC的後顧之憂並且開發效率也有了很大程度的提高,很多RPC框架是基於Netty來構建的。

希望未來可以有更多的使用到netty的具體工作場景。

rpc框架

rpc框架簡直不要太多。

  • dubbo 阿里出品,基於Netty,java實現的分散式開源RPC框架,但於12年年底已停止維護升級
  • motan 新浪出品,微博使用的rpc框架。
  • rpcx
  • gRPC Google出品,強大新潮,基於HTTP2,以及protobuf構建並且支援多語言。
  • thrift
  • protobuf 序列化框架

以太坊rpc客戶端機制研究

geth命令中rpc相關api

之前介紹過這些API都可以在geth console中呼叫,而在實際應用中,純正完整的RPC的呼叫方式,

geth --rpc --rpcapi "db,eth,net,web3,personal"

這個命令可以啟動http的rpc服務,當然他們都是geth命令下的,仍舊可以拼接成一個多功能的命令串,可以瞭解一下上一篇介紹的geth的使用情況。下面介紹一下api相關的選項引數:

API AND CONSOLE OPTIONS:
  --rpc                  啟動HTTP-RPC服務(基於HTTP的)
  --rpcaddr value        HTTP-RPC伺服器監聽地址(default: "localhost")
  --rpcport value        HTTP-RPC伺服器監聽埠(default: 8545)
  --rpcapi value         指定需要呼叫的HTTP-RPC API介面,預設只有eth,net,web3
  --ws                   啟動WS-RPC服務(基於WebService的)
  --wsaddr value         WS-RPC伺服器監聽地址(default: "localhost")
  --wsport value         WS-RPC伺服器監聽埠(default: 8546)
  --wsapi value          指定需要呼叫的WS-RPC API介面,預設只有eth,net,web3
  --wsorigins value      指定接收websocket請求的來源
  --ipcdisable           禁掉IPC-RPC服務
  --ipcpath              指定IPC socket/pipe檔案目錄(明確指定路徑)
  --rpccorsdomain value  指定一個可以接收請求來源的以逗號間隔的域名列表(瀏覽器訪問的話,要強制指定該選項)
  --jspath loadScript    JavaScript根目錄用來載入指令碼 (default: ".")
  --exec value           執行JavaScript宣告
  --preload value        指定一個可以預載入到控制檯的JavaScript檔案,其中包含一個以逗號分隔的列表

我們在執行以上啟動rpc命令時可以同時指定網路,指定節點,指定埠,指定可接收域名,甚至可以同時開啟一個console,這也並不產生衝突。

geth --rpc --rpcaddr <ip> --rpcport <portnumber>

我們可以指定監聽地址以及埠,如果不謝rpcaddr和rpcport的話,就是預設的http://localhost:8545。

geth --rpc --rpccorsdomain "http://localhost:3000"

如果你要使用瀏覽器來訪問的話,就要強制指定rpccorsdomain選項,否則的話由於JavaScript呼叫的同源限制,請求會失敗。

admin.startRPC(addr, port)

如果已進入geth console,也可以通過這條命令新增地址和埠。

rpc客戶端:Postman模擬HTTP請求api

Postman是一個可以用來測試各種http請求的客戶端工具,它還有其他很多用途,但這裡只用它來測試上面的HTTP-RPC服務。

在這裡插入圖片描述

看圖說話,我們指定了請求地址埠,指定了HTTP POST請求方式,設定好請求為原始Json文字,請求內容為:

{"jsonrpc":"2.0","method":"web3_clientVersion","params":[],"id":67}

是用來請求伺服器當前web3客戶端版本的,然後點選"Send",得到請求結果為:

{
    "jsonrpc": "2.0",
    "id": 67,
    "result": "Geth/v0.0.1-stable-930fa051/linux-amd64/go1.9.2"
}

rpc客戶端的研究:Go原始碼呼叫rpc

這種rpc客戶端可以有兩種,一種是通過j呼叫web3.js來實現,另一種是在geth consol中通過manageAPI來實現,但是它們的內部執行機制是一樣的,包括上面的Postman模擬瀏覽器發起HTTP請求也是一樣,下面我們通過一個完整的客戶端呼叫例子來研究整個以太坊原始碼中對於客戶端這塊是如何處理的。這裡我們就以最常用的api:eth_getBalance為例,它的引數要求為:

Parameters
- DATA, 20 Bytes - address to check for balance.
- QUANTITY|TAG - integer block number, or the string "latest", "earliest" or "pending", see the default block parameter

該api要求的引數:

  • 第一個引數為需檢查餘額的地址
  • 第二個引數為整數區塊號,或者是字串“latest","earliest"以及"pending"指代某個特殊的區塊。

在go-ethereum專案中查詢到使用位置ethclient/ethclient.go:

func (ec *Client) BalanceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (*big.Int, error) {
    var result hexutil.Big
    err := ec.c.CallContext(ctx, &result, "eth_getBalance", account, toBlockNumArg(blockNumber))
    return (*big.Int)(&result), err
}
func (ec *Client) PendingBalanceAt(ctx context.Context, account common.Address) (*big.Int, error) {
    var result hexutil.Big
    err := ec.c.CallContext(ctx, &result, "eth_getBalance", account, "pending")
    return (*big.Int)(&result), err
}

結合上面的RPC API和下面的go原始碼的呼叫,可以看到在go語言中的呼叫方式:要使用客戶端指標型別變數呼叫到上下文Call的方法,傳入第一個引數為上下文例項,第二個引數為一個hexutil.Big型別的結果接收變數的指標,第三個引數為呼叫的rpc的api介面名稱,第四個和第五個為該api的引數,如上所述。

  • 跟蹤到ec.c.CallContext,CallContext方法是ec.c物件的。
// Client defines typed wrappers for the Ethereum RPC API.
type Client struct {
    c *rpc.Client
}

可以看到ethclient/ethclient.go檔案中將原rpc/client.go的Client結構體進行了一層包裹,這樣就可以區分出來屬於ethclient的方法和底層rpc/client的方法。下面貼出原始的rpc.client的結構體定義:

// Client represents a connection to an RPC server.
type Client struct {
    idCounter   uint32
    connectFunc func(ctx context.Context) (net.Conn, error)
    isHTTP      bool

    // writeConn is only safe to access outside dispatch, with the
    // write lock held. The write lock is taken by sending on
    // requestOp and released by sending on sendDone.
    writeConn net.Conn

    // for dispatch
    close       chan struct{}
    didQuit     chan struct{}                  // closed when client quits
    reconnected chan net.Conn                  // where write/reconnect sends the new connection
    readErr     chan error                     // errors from read
    readResp    chan []*jsonrpcMessage         // valid messages from read
    requestOp   chan *requestOp                // for registering response IDs
    sendDone    chan error                     // signals write completion, releases write lock
    respWait    map[string]*requestOp          // active requests
    subs        map[string]*ClientSubscription // active subscriptions
}

ethclient經過包裹以後,可以使用本地Client變數呼叫rpc.client的指標變數c,從而呼叫其CallContext方法:

func (c *Client) CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error {
    msg, err := c.newMessage(method, args...) // 看來CallContext還不是終點,TODO:進到newMessage方法內再看看。
    // 結果處理
    if err != nil {
        return err
    }
    // requestOp又一個結構體,封裝響應引數的,包括原始請求訊息,響應資訊jsonrpcMessage,jsonrpcMessage也是一個結構體,封裝了響應訊息標準內容結構,包括版本,ID,方法,引數,錯誤,返回值,其中RawMessage在go原始碼位置json/stream.go又是一個自定義型別,屬於go本身封裝好的,型別是位元組陣列[]byte,也有自己的各種功能的方法。
    op := &requestOp{ids: []json.RawMessage{msg.ID}, resp: make(chan *jsonrpcMessage, 1)}

    // 通過rpc不同的渠道傳送響應訊息:這些渠道在上面命令部分已經介紹過,有HTTP,WebService等。
    if c.isHTTP {
        err = c.sendHTTP(ctx, op, msg)
    } else {
        err = c.send(ctx, op, msg)
    }
    if err != nil {
        return err
    }

    // TODO:對wait方法的研究
    // 對wait方法返回結果的處理
    switch resp, err := op.wait(ctx); {
    case err != nil:
        return err
    case resp.Error != nil:
        return resp.Error
    case len(resp.Result) == 0:
        return ErrNoResult
    default:
        return json.Unmarshal(resp.Result, &result)// 順利將結果資料編出
    }
}

先看wait方法,它仍舊在rpc/client.go中:

func (op *requestOp) wait(ctx context.Context) (*jsonrpcMessage, error) {
    select {
    case <-ctx.Done():
        return nil, ctx.Err()
    case resp := <-op.resp:
        return resp, op.err
    }
}

select的使用請參考這裡。繼續正題,進入ctx.Done(),Done屬於Go原始碼context/context.go:

// See https://blog.golang.org/pipelines for more examples of how to use
// a Done channel for cancelation.
Done() <-chan struct{}

想知道Done()咋回事,請轉到我寫的另一篇博文Go併發模式:管道與取消,那裡仔細分析了這一部分內容。

從上面的原始碼分析我感覺go語言就是一個網狀結構,從一個結構體跳進另一個結構體,它們之間誰也不屬於誰,誰呼叫了誰就可以使用,沒有顯式繼承extends和顯式實現implements,go就是不斷的封裝結構體,然後增加該結構體的方法,有時候你甚至都忘記了自己程式的結構體和Go原始碼封裝的結構體之間的界限。這就類似於面向物件分析的類,定義一個類,定義它的成員屬性,寫它的成員方法。

web3與rpc的關係

這裡再多囉嗦一句,重申一下web3和rpc的關係:

To make your app work on Ethereum, you can use the web3 object provided by the web3.js library. Under the hood it communicates to a local node through RPC calls. web3.js works with any Ethereum node, which exposes an RPC layer.

翻譯過來就是為了讓你的api工作在以太坊,你可以使用由web3.js庫提供的web3物件。底層通過RPC呼叫本地節點進行通訊。web3.js可以與以太坊任何一個節點通訊,這一層就是暴露出來的RPC層。

以太坊rpc服務端機制研究

以上介紹了各種客戶端的呼叫方式,包括通過web3提供的介面,從頁面js呼叫,或者從ethclient呼叫,或者直接通過頁面發起Json請求(Postman),無論什麼形式,最終都是通過JSON-RPC框架傳輸到以太坊服務端。那麼下面我們開發一個自己的api,首先從服務端介面開發開始:

服務端原始碼都在ethapi包下,首先它有一個Backend結構體,這個結構體是一個介面,提供了一套基本API服務的描述。然後就通過函式GetAPIs返回一個數組,其中包括各種不同名稱空間,版本,服務地址,以及公開私密許可權的API物件。下面進到其中的一個API類PublicBlockChainAPI,它包含一個backend屬性,代表了它具備後臺所有API的能力。這個類有很多方法,其中就包括上面我們講到的關於eth_getBalance介面的服務端實現方法。

// GetBalance returns the amount of wei for the given address in the state of the
// given block number. The rpc.LatestBlockNumber and rpc.PendingBlockNumber meta
// block numbers are also allowed.
func (s *PublicBlockChainAPI) GetBalance(ctx context.Context, address common.Address, blockNr rpc.BlockNumber) (*big.Int, error) {
    state, _, err := s.b.StateAndHeaderByNumber(ctx, blockNr)
    if state == nil || err != nil {
        return nil, err
    }
    b := state.GetBalance(address)
    return b, state.Error()
}

可以看到GetBalance方法是PublicBlockChainAPI的方法,並且方法名首字母大寫,說明是外部可訪問的。它通過Backend的方法s.b.StateAndHeaderByNumber獲取餘額,上面提到了Backend只是描述了API的介面,那麼具體實現還得繼續探索,

StateAndHeaderByNumber(ctx context.Context, blockNr rpc.BlockNumber) (*state.StateDB, *types.Header, error)

通過檢視Backend中對於該方法的定義,得到該方法的返回值為StateDB的指標物件。而這個物件正是下面真正獲取餘額所需要的物件。

b := state.GetBalance(address)

然後我們進入到該物件中,找到GetBalance方法。

// Retrieve the balance from the given address or 0 if object not found
func (self *StateDB) GetBalance(addr common.Address) *big.Int {
    stateObject := self.getStateObject(addr)
    if stateObject != nil {
        return stateObject.Balance()
    }
    return common.Big0
}

首先通過StateDB的getStateObject方法獲得一個stateObject結構體的物件,其中該物件的一個屬性是Account型別的data成員變數,進到Account中可以發現它的結構為:

type Account struct {
    Nonce    uint64
    Balance  *big.Int
    Root     common.Hash // merkle root of the storage trie
    CodeHash []byte
}

一切瞭然,這裡存了Balance資訊,那麼外部stateObject通過一個方法Balance()

func (self *stateObject) Balance() *big.Int {
    return self.data.Balance
}

這個方法可以取出上面Accout中的Balance資訊。stateObject是資料儲存的一致性物件。

leveldb

以太坊geth是採用leveldb作為資料庫,它是:

  • 過程資料庫,沒有sql,只支援api呼叫
  • Go自帶實現
  • 鍵值對儲存方式
  • 多層磁碟儲存,後臺有調理
  • 記錄可追蹤可證明

go-ethereum引入了 github.com/syndtr/goleveldb/leveldb/db.go ,檢視此原始檔,可以看到它定義了一個DB結構體,包含很多欄位以及方法,此外該檔案還包含一些函式。其中就有func openDB(s *session) (*DB, error)函式,該函式可以開啟一個數據庫會話並返回一個數據庫例項。

上面的stateObject結構體的欄位,它按照錢包address為key儲存了賬戶物件,通過key可以取出賬戶物件,進而取出賬戶餘額資訊。

以太坊的儲存結構

以太坊並不存在中心伺服器,而是基於p2p協議連線起來的平等節點,在眾多節點中儲存了全部資料。當用戶發起一筆交易,會通過廣播通知每一個節點,礦工對此進行驗證打包並進一步廣播,在區塊鏈確認以後,此操作即認為是不可更改的。

在每個節點上,資料是以區塊鏈來儲存的。區塊鏈由一個個塊串起來而組成。以太坊被描述為一個交易驅動的狀態機。它在某個狀態下接收一些輸入,會確定的轉移到另一個狀態。

狀態機,包含一組狀態集(states)、一個起始狀態(start state)、一組輸入符號集(alphabet)、一個對映輸入符號和當前狀態到下一狀態的轉換函式(transition function)的計算模型。

在以太坊的一個狀態下,每個賬戶都有確定的餘額和狀態資訊,當他接收到新的交易以後就會進入一個新的狀態,從創世塊開始,不斷的收到新的交易,由此能進入到一系列新的狀態。

以太坊每隔一段時間就會把一批交易資訊打包儲存到一個塊裡,這個塊中包含的資訊有:

1. ParentHash:父塊的雜湊值
2. Number:塊編號
3. Timestamp:塊產生的時間戳
4. GasUsed:交易消耗的Gas
5. GasLimit:Gas限制
6. Difficulty:POW的難度值
7. Beneficiary:塊打包手續費的受益人,也稱礦工
8. Nonce: (可選引數) 整型數字,可以通過使用相同nonce值來複寫你的pending狀態的交易(注意與ethash挖礦隨機數做區分)。

這些欄位我們在上面的web3介面中都可以獲取得到。

nonce,整數型別,允許使用相同隨機數覆蓋自己傳送的處於pending狀態的交易。

為了防止交易的重播攻擊,每筆交易必須有一個nonce隨機數,針對每一個賬戶nonce都是從0開始,當nonce為0的交易處理完之後,才會處理nonce為1的交易,並依次加1的交易才會被處理。以下是nonce使用的幾條規則:

  • 當nonce太小,交易會被直接拒絕。
  • 當nonce太大,交易會一直處於佇列之中,這也就是導致我們上面描述的問題的原因;
  • 當傳送一個比較大的nonce值,然後補齊開始nonce到那個值之間的nonce,那麼交易依舊可以被執行。
  • 當交易處於queue中時停止geth客戶端,那麼交易queue中的交易會被清除掉。

以太坊RPC例項:開發自己的api

設定一個小需求:就是將餘額數值乘以指定乘數,這個乘數是由另一個介面的引數來指定的。

在ethapi中加入

var rateFlag uint64 = 1
// Start forking command.
// Rate is the fork coin's exchange rate.
func (s *PublicBlockChainAPI) Forking(ctx context.Context, rate uint64) (uint64) {
    // attempt: store the rate info in context.
    // context.WithValue(ctx, "rate", rate)
    rateFlag = rate
    rate = rate + 1
    return rate
}

然後在ethclient中加入

// Forking tool's client for the Ethereum RPC API
func (ec *Client) ForkingAt(ctx context.Context, account common.Address, rate uint64)(uint64, error){
    var result hexutil.Uint64
    err := ec.c.CallContext(ctx, &result, "eth_forking", account, rate)
    return uint64(result), err
}

儲存,make geth編譯,然後在節點目錄下啟動

geth --testnet --rpc console --datadir node0

然後進入到Postman中測試,可以看到

在這裡插入圖片描述

乘數已經改為3(輸出4是為了測試,實際上已在區域性變數rateFlag儲存了乘數3)
然後我們再發送請求餘額測試,

在這裡插入圖片描述

可以看到返回值為一串16進位制數,通過轉換結果為:417093750000000000000,我們原始餘額為:139031250000000000000,正好三倍。

rpc客戶端

我們上面已經在rpc服務端對api進行了增加,而客戶端呼叫採用的是Postman傳送Post請求。而rpc客戶端在以太坊實際上有兩種:一個是剛才我們實驗的,在網頁中呼叫JSON-RPC;另一種則是geth console的形式,而關於這種形式,我還沒真正搞清楚它部署的流程,只是看到了在原始碼根目錄下build/_workspace會在每一次make geth被copy進去所有的原始碼作為編譯後環境,而我修改了原始碼檔案,_workspace下檔案,均未生效,可能還存在一層執行環境,我並沒有修改到。但這無所謂了,因為實際應用中,我們很少去該console的內容,直接修改web3.js引入到網頁即可。下面介紹一下配合上面自己的api,如何修改web3.js檔案:

上面講過了web3.js的結構,是一個node.js的module結構,因此我們先決定將這個api放到eth物件下,檢查eth對應的id為38,找到物件體,在methods中增加對應api呼叫操作,

var forking = new Method({
    name: 'forking',
    call: 'eth_forking',
    params: 1,
    inputFormatter: [null],
    outputFormatter: formatters.outputBigNumberFormatter
});

然後在物件體返回值部分將我們新構建的method新增進去,

return [
    forking,
    ...

改好以後,我們將該檔案引用到頁面中去,即可通過web3.eth.forking(3)進行呼叫了。

總結

本文介紹了rpc的概念,rpc的流行框架,以太坊使用的rpc框架為JSON-RPC。接著描述瞭如何啟動JSON-RPC服務端,然後使用Postman來請求JSON-RPC服務端api。通過這一流程,我們仔細分析並跟蹤了原始碼中的實現,抽絲剝繭,從最外層的JSON-RPC的呼叫規範到原始碼中外層封裝的引用,到內部具體實現,期間對各種自定義結構體進行了跟蹤研究,直到Go原始碼庫中的結構體,研究了服務端從接收客戶端請求到傳送響應的過程。最後我們仔細研究了web3.js檔案的結構並且做了一個小實驗,從服務端到客戶端模仿者增加了一個自定義的api。希望本文對您有所幫助。


相關文章和視訊推薦

圓方圓學院彙集大批區塊鏈名師,打造精品的區塊鏈技術課程。 在各大平臺都長期有優質免費公開課,歡迎報名收看。

公開課地址:https://ke.qq.com/course/345101