以太坊控制台源码分析

飞久  2019-04-14  以太坊/以太坊(Ethereum)栏目  

  最近有网友提到以太坊控制台的代码看不太明白,抽了点时间整理了一下。

  当我们通过gETH console或者geth attach与节点交互的时候,输入的命令是如何被处理的呢?看下面这张流程图就明白了:

  

  命令行编辑器Liner等待用户输入命令

  JSRE使用一个名为scheduler的通道(chan)接收命令

  JSRE把命令发送给Javascript解释器Otto处理

  Otto中预加载了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
}

  下面会对这些模块一一进行介绍。

01Liner:带历史记录的命令行编辑器

  既然是控制台,那么显然需要一个命令行编辑器来输入命令并打印结果。以太坊使用的是一个开源的命令编辑器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。

02Otto:JavaScript解释器

  为了方便理解,我们先介绍一下Otto,稍后再介绍JSRE。

  Otto是一个Go语言实现的JavaScript解释器,并且可以很方便地实现Javascript和Go之间的相互调用。我们来看一下具体用法,非常简单:

  创建otto实例:

import (
   "github.com/robertkrimen/otto"
)

vm := otto.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 otto.FunctionCall) otto.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)

  但是,Otto没有提供Web开发中经常使用到的setTimeout()和setInterval()等函数,它的文档里提到这是因为这些函数不是ECMA-262标准的一部分,并且需要增加事件循环。如果你想使用这些函数,需要自己实现。实际上,以太坊中使用time.AfterFunc()实现了这些函数,并通过vm.Set()设置到了Javascript中,具体可以参见internal/jsre/jsre.go。

03JSRE:实现事件循环

  所谓事件循环,其实就是一个消息队列,在Go中一般是通过通道(chan)来实现。

  命令行接收到用户输入的命令后,会调用JSRE的Evaluate()函数,我们来看看该函数的具体实现:

func (re *JSRE) Evaluate(code string, w io.Writer) error {
  var fail error
  re.Do(func(vm *otto.Otto) {
    val, err := vm.Run(code)
    if err != nil {
      prettyError(vm, err, w)
    } else {
      prettyPrint(vm, val, w)
    }
    fmt.Fprintln(w)
  })
  return fail
}

  可以发现,会调用Do()方法把该命令送入事件循环。同时还需要传入一个回调函数,当事件循环执行到该命令时,会调用该函数。在回调函数中,通过Otto的Run()函数执行该命令,然后把执行结果打印到命令行中。

  我们再来看一下Do()的具体实现:

func (re *JSRE) Do(fn func(*otto.Otto)) {
  done := make(chan bool)
  req := &evalReq{fn, done}
  re.evalQueue <- req
  <-done
}

  代码很简单,先往evalQueue通道中送入一个请求,然后等待被调度执行。

  接下来我们就来看看事件循环的实现,也就是JSRE中最为核心的runEVENtLoop()函数:

func (re *JSRE) runEventLoop() {
  vm := otto.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:
      ...
    }
  }
  ...
}

  首先创建Otto实例,然后把setTimeout()/setInterval()这些函数设置进去。上一节我们提到过,Otto默认没有提供这些函数,需要自己实现。接着就是一个for-select循环了,主要就是监听3个通道:

  timer:处理延时请求,时间到了以后通过Otto的Call()函数执行命令

  evalQueue:处理非延时请求,调用回调函数立即执行

  stopEventLoop:退出事件循环

04web3.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 otto.FunctionCall) (response otto.Value) {
  // 获取Javascript请求参数
  JSON, _ := call.Otto.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是一个数组,实际上是可以支持批量发送请求的,不过这个不是重点,在此略过。

05RPC 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通道中写入数据,同时也可以查询对端的管道是否关闭。

06RPC 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)
}07把所有知识串联到一起

  看到这里,相信大家应该对控制台的整个流程有了一个非常清晰的把握。本文之所以没有一上来就分析入口代码,然后一路向下,主要是担心大家会湮没在代码的细节中,无法在更高的维度上看清各个模块之间的关联。

  当然,出于完整性考虑,我们也在这里分析一下入口代码,方便大家把所有知识串联到一起。

  当我们在运行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执行。

  至此,以太坊控制台的整个流程就全部打通了,如果你再回头看一眼开头的那张框架图,相信一定会有不一样的感觉。如有疑问,欢迎给我留言。

你点的每个赞,我都认真当成了喜欢
版权信息
作者:飞久
来源:飞久

关于我们

联系我们

作者进驻

手机版

Copyright © 2013 比特巴 www.btb8.com
始建于2013年,提供比特币 区块链及数字货币新闻、技术教程、测评、项目周报、人物等资讯
本页面提供的是以太坊教程资讯,以太坊是一个平台和一种编程语言,意为“下一代加密货币与去中心化应用平台”,可以用来创建去中心化的程序、自治组织和智能合约.