以太坊源码分析 控制台
本文分析以太坊控制台的工作原理和流程。
当我们通过 geth console 或者 geth attach 与节点交互的时候,输入的命令是如何被处理的呢?
- 命令行编辑器 Liner 等待用户输入命令
- JSRE 使用一个名为 scheduler 的通道(chan)接收命令
- JSRE 把命令发送给 Javascript 解释器 goja 处理
- goja 中预加载了web3.js,执行对应的函数并通过 provider 发送RPC请求
- Web3 provider 被设置为一个 Bridge 模块,接收请求并转发给 RCP Client
- RPC Client 通过全双工管道和 RPC Server 通信,完成 RPC 调用
- 将 RPC 调用结果输出到命令行
可以看到,流程还是很清晰的,但是涉及到很多模块。实际上,这些模块都被包含在 Console 的数据结构之中:
type Console struct { client *rpc.Client jsre *jsre.JSRE prompt string prompter UserPrompter histPath string history []string printer io.Writer }
下面会对这些模块一一进行介绍。
1. Liner:带历史记录的命令行编辑器
既然是控制台,那么显然需要一个命令行编辑器来输入命令并打印结果。
以太坊使用的是一个开源的命令编辑器Liner,github地址:https://github.com/peterh/liner
这个命令编辑器还是挺强大的,除了基本的交互以外,还支持历史记录和自动补全。我们来看一个最简单的使用示例:
// 创建liner实例 line := liner.NewLiner() defer line.Close() // 设置自动补全处理函数 line.SetCompleter(func(line string) { }) // 打印提示,接收用户输入 name, err := line.Prompt("What is your name? ") if err == nil { log.Print("Got: ", name) // 添加历史记录 line.AppendHistory(name) }
当然,为了可扩展性,以太坊在外面做了一层封装。默认情况下会创建一个terminalPrompter,内部其实还是直接调用 liner,具体可以参见console/prompter.go。
另外,思考一个问题:当我们在控制台输入 eth.getT 然后按 Tab 键时,会自动帮我们补全为 eth.getTransaction,这是怎么做到的?
实际上,可以通过调用 Javascript 的 getOwnPropertyNames() 函数获取对象的所有属性和方法,然后选出匹配项加入自动补全列表中。
具体代码实现参见 internal/jsre/completion.go 以及 internal/jsre/pretty.go。
2. goja:JavaScript解释器
为了方便理解,我们先介绍一下 goja,稍后再介绍 JSRE。
goja 是一个 Go 语言实现的 JavaScript 解释器,并且可以很方便地实现 Javascript 和 Go 之间的相互调用。我们来看一下具体用法,非常简单:
- 创建 goja 实例:
import ( "github.com/dop251/goja" ) vm := goja.New()
- 设置一个Javascript变量的值:
vm.Set("a", 88) vm.Set("b", "hello")
- 获取一个Javascript变量值:
value, err := vm.Get("a") { value, _ := value.ToInteger() }
- 执行一段Javascript代码:
vm.Run(` console.log(b + a); // hello88 `)
- 执行一个Javascript表达式并获取返回值:
value, _ := vm.Run("b.length") { value, _ := value.ToInteger() }
- 执行一个Javascript函数并获取返回值:
value, _ := vm.Call(`[ 1, 2, 3 ].concat`, nil, 4, 5, 6, "abc") { value, _ := value.Export() // [ 1, 2, 3, 4, 5, 6, "abc" ] }
- 设置一个Go函数(可以在Javascript中调用):
vm.Set("twoPlus", func(call goja.FunctionCall) goja.Value { right, _ := call.Argument(0).ToInteger() result, _ := vm.ToValue(2 + right) return result })
- 在Javascript中调用Go函数:
result, _ = vm.Run(` result = twoPlus(2.0); // 4 `) { result, _ := result.ToInteger() }
- 编译执行.js文件:
code, _ := ioutil.ReadFile("./test.js") script, _ := vm.Compile("test.js", code) vm.Run(script)
但是,goja 没有提供 Web 开发中经常使用到的 setTimeout() 和 setInterval() 等函数,它的文档里提到这是因为这些函数不是 ECMA-262 标准的一部分,并且需要增加事件循环。如果你想使用这些函数,需要自己实现。实际上,以太坊中使用 time.AfterFunc() 实现了这些函数,并通过 vm.Set() 设置到了Javascript 中。具体可以参见 internal/jsre/jsre.go。
3. JSRE:实现事件循环
所谓事件循环,其实就是一个消息队列,在 Go 中一般是通过通道(chan)来实现。
命令行接收到用户输入的命令后,会调用 JSRE 的 Evaluate() 函数,我们来看看该函数的具体实现:
func (re *JSRE) Evaluate(code string, w io.Writer) error { var fail error re.Do(func(vm *goja.Runtime) { val, err := vm.Run(code) if err != nil { prettyError(vm, err, w) } else { prettyPrint(vm, val, w) } fmt.Fprintln(w) }) return fail }
可以发现,会调用 Do() 方法把该命令送入事件循环。同时还需要传入一个回调函数,当事件循环执行到该命令时,会调用该函数。在回调函数中,通过 goja 的Run()函数执行该命令,然后把执行结果打印到命令行中。
我们再来看一下 Do() 的具体实现:
func (re *JSRE) Do(fn func(*goja.Runtime)) { done := make(chan bool) req := &evalReq{fn, done} re.evalQueue <- req <-done }
代码很简单,先往 evalQueue 通道中送入一个请求,然后等待被调度执行。
接下来我们就来看看事件循环的实现,也就是 JSRE 中最为核心的 runEventLoop() 函数:
func (re *JSRE) runEventLoop() { vm := goja.New() ... vm.Set("_setTimeout", setTimeout) vm.Set("_setInterval", setInterval) ... for { select { case timer := <-ready: ... _, err := vm.Call(`Function.call.call`, nil, arguments...) ... case req := <-re.evalQueue: req.fn(vm) close(req.done) ... case waitForCallbacks = <-re.stopEventLoop: ... } } ... }
首先创建 goja 实例,然后把 setTimeout()/setInterval() 这些函数设置进去。上一节我们提到过,goja 默认没有提供这些函数,需要自己实现。接着就是一个 for-select 循环了,主要就是监听3个通道:
- timer:处理延时请求,时间到了以后通过 goja 的 Call() 函数执行命令
- evalQueue:处理非延时请求,调用回调函数立即执行
- stopEventLoop:退出事件循环
4. web3.js和bridge
web3.js 是一个 Javascript 库,提供了一些方便的 API 供前端开发使用,代码位于 internal/jsre/deps/web3.js。
需要注意的是,如果你想修改 web3.js,直接修改该文件的内容是不生效的,需要先通过 go-bindata 生成一个 bindata.go 文件,然后再编译以太坊。具体来说需要使用下面两行命令:
go-bindata -nometadata -pkg deps -o bindata.go bignumber.js web3.js gofmt -w -s bindata.go
创建 Web3 对象时需要提供一个 provider,通过 provider 的 send() 或者 sendAsync() 函数可以发起 RPC 请求。在控制台应用场景下,我们不需要真正发起 HTTP 请求,只需要在进程内(InProc)通信就可以了。因此,JSRE 中设置了一个名为 jeth 的 provider,同时把它的 send() 和 sendAsync() 函数绑定到一个 bridge 对象的 Send() 函数上。
那么,web3.js 是怎么被加载进 JSRE 中的呢?又是如何跟 bridge 对象完成绑定的呢?实际上,这是在 Console 模块的 init() 函数中完成的,参见 console/console.go:
func (c *Console) init(preload []string) error { // 创建bridge对象 bridge := newBridge(c.client, c.prompter, c.printer) // 创建jeth对象 c.jsre.Set("jeth", struct{}{}) jethObj, _ := c.jsre.Get("jeth") // 绑定send()/sendAsync()到bridge.Send() jethObj.Object().Set("send", bridge.Send) jethObj.Object().Set("sendAsync", bridge.Send) // 替换console的打印函数 consoleObj, _ := c.jsre.Get("console") consoleObj.Object().Set("log", c.consoleOutput) consoleObj.Object().Set("error", c.consoleOutput) // 加载bignumber.js c.jsre.Compile("bignumber.js", jsre.BigNumber_JS) // 加载web3.js c.jsre.Compile("web3.js", jsre.Web3_JS) c.jsre.Run("var Web3 = require('web3');") // 创建Web3对象,设置jeth为provider c.jsre.Run("var web3 = new Web3(jeth);") ... // 创建我们熟悉的eth和personal对象 flatten := "var eth = web3.eth; var personal = web3.personal; " ... c.jsre.Run(flatten) ... }
可以看到,这里会编译加载 bignumber.js 和 web3.js,创建 Web3 对象,设置 jeth 为 provider,同时把 send()/sendAsync() 绑定到 bridge 的 Send() 函数上。另外,还会创建我们熟悉的 eth 和 personal 对象,并替换掉 console 对象的 log() 和 error() 函数(输出到命令行中)。
接下来,我们就来看看 bridge 对象是如何发起 RPC 请求的,代码位于 console/bridge.go:
func (b *bridge) Send(call goja.FunctionCall) (response goja.Value) { // 获取Javascript请求参数 JSON, _ := call.goja.Object("JSON") reqVal, err := JSON.Call("stringify", call.Argument(0)) ... // 生成Go中的请求对象 dec = json.NewDecoder(strings.NewReader(rawReq)) reqs = make([]jsonrpcCall, 1) dec.Decode(&reqs[0]) ... // 通过RPC Client发起RPC请求 var result json.RawMessage err = b.client.Call(&result, req.Method, req.Params...) ... // 解析执行结果 resultVal, err := JSON.Call("parse", string(result)) ... // 返回执行结果 response, _ = resps.Get("0") return response }
可以发现,主要就是通过调用 RPC Client 的 Call() 函数完成 RPC 请求,然后解析并返回执行结果。
另外,上面的reqs是一个数组,实际上是可以支持批量发送请求的,不过这个不是重点,在此略过。
5. RPC Client
RPC Client是真正发起 RPC 调用的模块,对端的 RPC Server 会处理请求并返回执行结果。
我们来看一看 RPC Client 的创建过程,代码位于 rpc/inproc.go中:
func DialInProc(handler *Server) *Client { initctx := context.Background() c, _ := newClient(initctx, func(context.Context) (net.Conn, error) { p1, p2 := net.Pipe() go handler.ServeCodec(NewJSONCodec(p1), OptionMethodInvocation|OptionSubscriptions) return p2, nil }) return c }
可以看出,关键之处在于创建了一对全双工管道p1和p2。然后启动了一个线程作为RPC Server,通过管道通信,服务端使用p1,客户端使用p2。
Go语言中的net库提供了全双工管道的支持,具体来说,每对管道中包含10个通道(chan),参见下面的示意图:大概解释一下:Rx表示接收数据,Tx表示发送数据。
当我们需要发起请求时,往wrTx中写入请求数据,然后从wrRx中读取执行结果。
当我们需要处理请求是,从rdRx中读取请求数据,处理完毕后,把执行结果写入rdTx。
如果需要关闭本地管道,则向done通道中写入数据,同时也可以查询对端的管道是否关闭。
6. RPC Server
RPC Server 是真正处理 RPC 请求的模块,内部通过 ServerCodec 对象完成具体的处理工作。
ServerCodec 是一个接口,由于需要处理 JSON RPC,上一节我们通过 NewJSONCodec() 创建了它的一个实例,代码位于 rpc/json.go。
不知道大家有没有过这样一个疑问:我们发起JSON RPC的时候指定的函数名是eth_sendTransaction,但是以太坊源码中好像搜不到这个函数啊?那么是怎么找到对应的处理函数的呢?
实际上,RPC Server 在读取请求参数的时候偷偷做了处理,把 eth_sendTransaction 一分为二,eth 作为 namespace,sendTransaction 作为 method,具体代码参见rpc/server.go 和 rpc/json.go:
func (s *Server) readRequest(codec ServerCodec) ([]*serverRequest, bool, Error) { reqs, batch, err := codec.ReadRequestHeaders() ... } func (c *jsonCodec) ReadRequestHeaders() ([]rpcRequest, bool, Error) { ... return parseRequest(incomingMsg) } func parseRequest(incomingMsg json.RawMessage) ([]rpcRequest, bool, Error) { ... // 把请求的Method一分为二 elems := strings.Split(in.Method, serviceMethodSeparator) ... if len(in.Payload) == 0 { return []rpcRequest{{service: elems[0], method: elems[1], id: &in.Id}}, false, nil } return []rpcRequest{{service: elems[0], method: elems[1], id: &in.Id, params: in.Payload}}, false, nil }
到这里,读过我之前写的以太坊RPC源码分析的朋友应该都明白了,接下来就是根据namespace和method调用对应的API就可以了。以eth_sendTransaction为例,对应的配置位于internal/ethapi/backend.go:
{ Namespace: "eth", Version: "1.0", Service: NewPublicTransactionPoolAPI(apiBackend, nonceLock), Public: true, }
对应的API函数位于internal/ethapi/api.go:
func (s *PublicTransactionPoolAPI) SendTransaction(ctx context.Context, args SendTxArgs) (common.Hash, error) { ... }
这里还有一个疑问:eth_sendTransaction中的函数名的首字母是小写的s,这里的API函数的首字母是大写的S,这是怎么匹配上的呢?
实际上,在注册系统API的时候完成了这项映射工作,具体参见rpc/server.go:
func (s *Server) RegisterName(name string, rcvr interface{}) error { ... methods, subscriptions := suitableCallbacks(rcvrVal, svc.typ) ... } func suitableCallbacks(rcvr reflect.Value, typ reflect.Type) (callbacks, subscriptions) { ... for m := 0; m < typ.NumMethod(); m++ { ... mname := formatName(method.Name) ... } // formatName will convert to first character to lower case func formatName(name string) string { ret := []rune(name) if len(ret) > 0 { ret[0] = unicode.ToLower(ret[0]) } return string(ret) }
7. 把所有知识串联到一起
看到这里,相信大家应该对控制台的整个流程有了一个非常清晰的把握。本文之所以没有一上来就分析入口代码,然后一路向下,主要是担心大家会湮没在代码的细节中,无法在更高的维度上看清各个模块之间的关联。
当然,出于完整性考虑,我们也在这里分析一下入口代码,方便大家把所有知识串联到一起。
当我们在运行geth console命令时,会执行cmd/geth/main.go中的consoleCommand:
func init() { ... consoleCommand ... }
该命令对应的处理函数是cmd/geth/consolecmd.go的localConsole():
func localConsole(ctx *cli.Context) error { node := makeFullNode(ctx) startNode(ctx, node) defer node.Stop() client, err := node.Attach() ... console, err := console.New(config) defer console.Stop(false) ... console.Welcome() console.Interactive() return nil }
主要做了下面4件事情:
- 启动一个新节点并attach上去
- 创建console实例
- 打印欢迎信息
- 进入交互模式
首先看一下Attach()函数,代码位于node/node.go:
func (n *Node) Attach() (*rpc.Client, error) { ... return rpc.DialInProc(n.inprocHandler), nil }
这个函数之前分析过,会创建一个RPC Client。
第二步就是创建Console实例,在第一节我们看过Console的数据结构,其中包含了RPC Client、JSRE、命令行编辑器、history等实例。
第三步打印欢迎信息,这个没啥说的。
最后一步执行console.Interactive(),等待和处理用户输入。我们来看一下这个函数:
func (c *Console) Interactive() { ... go func() { for { // 接收用户输入 line, err := c.prompter.PromptInput(<-scheduler) ... // 把命令送入scheduler通道 scheduler <- line } } ... for { ... select { // 从scheduler通道取出命令 case line, ok := <-scheduler: ... // 送入JSRE执行 c.Evaluate(input) } ... }
首先会启动一个新线程,通过Liner获取用户输入。当用户输入一条命令后,将命令送入scheduler通道。
在当前线程中,通过for-select不断从scheduler通道中取出命令,然后送入JSRE执行。
下一章:以太坊虚拟机 EVM
以太坊虚拟机,简称EVM,用来执行以太坊上的交易。EVM 的业务流程参见下图: 输入一笔交易,内部会转换成一个Message对象,传入EVM执行。如果是一笔普通转账交易,那么直接修改StateDB中对应的账户 ...