以太坊源码分析 控制台

本文分析以太坊控制台的工作原理和流程。

当我们通过 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 的业务流程参见下图: 输入一笔交易,内部会转换成一个Message对象,传入EVM执行。如果是一笔普通转账交易,那么直接修改StateDB中对应的账户 ...