Go Ethereum源码学习笔记
- 前言
- [Chapter_001] 万物的起点: Geth Start
- 什么是 geth?
- go-ethereum Codebase 结构
- Geth Start
- 前奏: Geth Console
- geth 节点是如何启动的
- Node
- Node的关闭
- Ethereum Backend
- 附录
前言
首先读者需要具备Go语言基础,至少要通关菜鸟教程,知道Go语言的基本语法,这些基础教程网络上非常多,请大家自行学习。
具备语言基础了,还需要在开始这一章之前做一些准备工作:
- 安装Go SDK,即Go语言的开发环境;
- 安装GoLand,即Go语言的IDE,当然也可以选择VSCode等其他IDE;
- 克隆 Go Ethereum源码;
- 克隆Understanding-Ethereum-Go-version源码(可选);
- 以太坊基础知识,Ethereum 协议(黄皮书 )
做好这些准备工作,就可以打开 Go Ethereum源码了,如下图所示:
以太坊是以区块链作为基础的应用,所以我们必须具备区块链相关的基础知识,否则很难读懂源码究竟在做什么。
侵删声明:如果本文有侵犯到Understanding-Ethereum-Go-version原作者的地方,请告知删除相关内容,笔者已经向Understanding-Ethereum-Go-version作者发送了添加微信好友的请求,希望可以加到对方好友!
本文的宗旨还是学习和分享,并无商业目的,希望可以将自己的心得记录下来,对于引用的出处都会提前声明。
下面开始对Understanding-Ethereum-Go-version的增删改查工作,当然,主要是跟着作者思路去学习!
[Chapter_001] 万物的起点: Geth Start
本章概要:
go-ethereum
代码库的主要目录结构。geth
客户端/节点是如何启动的。- 如何修改/添加
geth
对外的APIs。
什么是 geth?
geth
是以太坊基金会基于 Go 语言开发以太坊的官方客户端,它实现了 Ethereum 协议(黄皮书 )中所有需要的实现的功能模块。我们可以通过启动 geth
来运行一个 Ethereum 的节点。在以太坊 Merge 之后,geth
作为节点的执行层继续在以太坊生态中发挥重要的作用。 go-ethereum
是包含了 geth
客户端代码和以及编译 geth
所需要的其他代码在内的一个完整的代码库。在本系列中我们会通过深入 go-ethereum 代码库,从High-level 的 API 接口出发,沿着 Ethereum 主 Workflow,逐一的理解 Ethereum 具体实现的细节。
为了方便区分,在接下来的文章中,我们用 geth
来表示 Geth 客户端程序,用 go-ethereum (Geth
)来表示 go-ethereum 的代码库。
总结的来说:
- 基于
go-ethereum
代码库中的代码,我们可以编译出geth
客户端程序。 - 通过运行
geth
客户端程序我们可以启动一个 Ethereum 的节点。
go-ethereum Codebase 结构
为了更好的从整体工作流的角度来理解 Ethereum,根据主要的业务功能,我们可以把 go-ethereum
划分成如下几个模块。
- Geth Client 模块(客户端)
- Core 数据结构模块
- State Management 模块(状态管理)
- StateDB 模块(状态数据库)
- Trie 数据结构模块
- State Optimization (Pruning)减枝算法优化
- Mining 模块(挖矿)
- EVM 模块(以太坊虚拟机Ethereum Virtual Machine)
- P2P 网络模块
- 节点数据同步
- 交易数据
- 区块数据
- 区块链数据
- 节点数据同步
- Storage 模块
- 抽象数据库层
- LevelDB 调用
- …
目前,go-ethereum 代码库中的主要目录结构如下所示:
cmd/ 以太坊基金会官方开发的一些 Command-line 程序。该目录下的每个子目录都是一个单独运行的 CLI 程序。
|── clef/ 以太坊官方推出的账户管理程序.
|── geth/ 以太坊官方的节点客户端。
core/ 以太坊核心模块,包括核心数据结构,statedb,EVM 等核心数据结构以及算法实现
|── rawdb/ db 相关函数的高层封装(在 ethdb 和更底层的 leveldb 之上的封装)
├──accessors_state.go 从 Disk Level 读取/写入与 State 相关的数据结构。
|── state/
├── statedb.go StateDB 是管理以太坊 World State 最核心的代码,用于管理链上所有的 State 相关操作。
├── state_object.go state_object 是以太坊账户(包括 EOA & Contract)在 StateDB 具体的实现。
|── txpool Transaction Pool 相关的代码。
|── txpool.go Transaction Pool 的具体实现。
|── types/ 以太坊中最核心的数据结构
|── block.go 以太坊 Block 的的数据结构定义与相关函数实现
|── bloom9.go 以太坊使用的一个 Bloom Filter 的实现
|── transaction.go 以太坊 Transaction 的数据结构定义与相关函数实现。
|── transaction_signing.go 用于对 Transaction 进行签名的函数的实现。
|── receipt.go 以太坊交易收据的实现,用于记录以太坊 Transaction 执行的结果
|── vm/ 以太坊的核心中核心 EVM 相关的一些的数据结构的定义。
|── evm.go EVM 数据结构和方法的定义
|── instructions.go EVM 指令的具体的定义,核心中的核心中的核心文件。
|── logger.go 用于追踪 EVM 执行交易过程的日志接口的定义。具体的实现在eth/tracers/logger/logger.go 文件中。
|── opcode.go EVM 指令和数值的对应关系。
|── genesis.go 创世区块相关的函数。每个 geth 客户端/以太坊节点初始化的都需要调用这个模块。
|── state_processor.go EVM 执行交易的核心代码模块。
console/
|── bridge.go
|── console.go Geth Web3 控制台的入口
eth/ Ethereum 节点/后端/客户端具体功能定义和实现。例如节点的启动关闭,P2P 网络中交易和区块的同步。
ethdb/ Ethereum 本地存储的相关实现, 包括 leveldb 的调用
|── leveldb/ Go-Ethereum使用的与 Bitcoin Core version一样的Leveldb作为本机存储用的数据库
internal/ 一些内部使用的工具库的集合,比如在测试用例中模拟 cmd 的工具。在构建 Ethereum 生态相关的工具时值得注意这个文件夹。
miner/
|── miner.go 矿工模块的实现。
|── worker.go Block generation 的实现,包括打包 transaction,计算合法的 Block
p2p/ Ethereum 的P2P模块
|── params Ethereum 的一些参数的配置,例如: bootnode 的 enode 地址
|── bootnodes.go bootnode 的 enode 地址 like: aws 的一些节点,azure 的一些节点,Ethereum Foundation 的节点和 Rinkeby 测试网的节点
rlp/ RLP的 Encode与 Decode的相关,RLP(Recursive Length Prefix)是以太坊中序列化数据的编码方式。
rpc/ Ethereum RPC客户端的实现,远程过程调用。
les/ Ethereum light client 轻节点的实现
trie/ Ethereum 中至关重要的数据结构 Merkle Patrica Trie(MPT) 的实现
|── committer.go Trie 向 Memory Database 提交数据的工具函数。
|── database.go Memory Database,是 Trie 数据和 Disk Database 提交的中间层。同时还实现了 Trie 剪枝的功能。**非常重要**
|── node.go MPT中的节点的定义以及相关的函数。
|── secure_trie.go 基于 Trie 的封装的结构。与 trie 中的函数功能相同,不过secure_trie中的 key 是经过hashKey()函数hash过的,无法通过路径获得原始的 key值
|── stack_trie.go Block 中使用的 Transaction/Receipt Trie 的实现
|── trie.go MPT 具体功能的函数实现。
Geth Start
前奏: Geth Console
当我们想要部署一个 Ethereum 节点的时候,最直接的方式就是下载官方提供的发行版的 geth 客户端程序。geth
是一个基于 CLI (命令行)的应用,启动geth
和调用 geth
的功能性 API 需要使用对应的指令来操作。geth
提供了一个相对友好的 console 来方便用户调用各种指令。当我第一次阅读 Ethereum 的文档的时候,我曾经有过这样的疑问,为什么geth
是由 Go 语言编写的,但是在官方文档中的 Web3 的API却是基于 Javascript 的调用?
这是因为 geth
内置了一个 Javascript 的解释器: Goja (interpreter),来作为用户与 geth
交互的 CLI Console。我们可以在console/console.go
中找到它的定义。
< !-- /Goja is an implementation of ECMAScript 5.1 in Pure GO/ -->
//控制台是一个JavaScript解释的运行时环境。它是一个完全成熟的JavaScript控制台,通过外部或进程内RPC客户端连接到正在运行的节点。
type Console struct {
client *rpc.Client // 通过RPC客户端执行以太坊请求
jsre *jsre.JSRE // 运行解释器的JavaScript运行时环境
prompt string // 输入提示前缀字符串
prompter prompt.UserPrompter // 输入提示器,通过它来允许交互式用户反馈
histPath string // 控制台回滚历史记录的绝对路径
history []string // 由控制台维护的滚动历史记录字符串数组
printer io.Writer // 输出写入器,通过它来序列化任何显示字符串
interactiveStopped chan struct{}
stopInteractiveCh chan struct{}
signalReceived chan struct{}
stopped chan struct{}
wg sync.WaitGroup
stopOnce sync.Once
}
笔者对引用的源代码做了更新,并且对注解做了中文翻译,大家阅读时可以参照源代码。
geth 节点是如何启动的
了解 Ethereum,我们首先要了解 Ethereum 客户端 Geth 是怎么运行的。 geth 程序的启动点位于 cmd/geth/main.go/main()
函数处,如下所示。
func main() {
//笔者注:运行app,如果有错误就打印出来,然后退出
if err := app.Run(os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
笔者这里是补充一下main.go中对app的定义
var app = flags.NewApp("the go-ethereum command line interface")
func init() {
// Initialize the CLI app and start Geth
app.Action = geth
app.HideVersion = true // we have a command to print the version
app.Copyright = "Copyright 2013-2022 The go-ethereum Authors"
app.Commands = []*cli.Command{
// See chaincmd.go:
initCommand,
importCommand,
exportCommand,
importPreimagesCommand,
exportPreimagesCommand,
removedbCommand,
dumpCommand,
dumpGenesisCommand,
// See accountcmd.go:
accountCommand,
walletCommand,
// See consolecmd.go:
consoleCommand,
attachCommand,
javascriptCommand,
// See misccmd.go:
makecacheCommand,
makedagCommand,
versionCommand,
versionCheckCommand,
licenseCommand,
// See config.go
dumpConfigCommand,
// see dbcmd.go
dbCommand,
// See cmd/utils/flags_legacy.go
utils.ShowDeprecated,
// See snapshot.go
snapshotCommand,
// See verkle.go
verkleCommand,
}
sort.Sort(cli.CommandsByName(app.Commands))
app.Flags = flags.Merge(
nodeFlags,
rpcFlags,
consoleFlags,
debug.Flags,
metricsFlags,
)
app.Before = func(ctx *cli.Context) error {
flags.MigrateGlobalFlags(ctx)
return debug.Setup(ctx)
}
app.After = func(ctx *cli.Context) error {
debug.Exit()
prompt.Stdin.Close() // Resets terminal mode.
return nil
}
}
大家从app的定义中知道了app就是go-ethereum命令行接口,那么才会进一步有下面的逻辑,所以大家在阅读的时候一定要参照源代码,否则很难跟上作者的节奏。
我们可以看到 main()
函数非常的简短,其主要功能就是启动一个解析 command line命令的工具: gopkg.in/urfave/cli.v1
。继续深入,我们会发现在 cli app 初始化的时候会调用 app.Action = geth
,来调用 geth()
函数。而 geth()
函数就是用于启动 Ethereum 节点的顶层函数,其代码如下所示:
// 如果没有运行特殊的子命令,Geth是进入系统的主要入口点。
// 它根据命令行参数创建一个默认节点,并以阻塞模式运行它,直到它关闭才解除阻塞。
func geth(ctx *cli.Context) error {
if args := ctx.Args().Slice(); len(args) > 0 {
return fmt.Errorf("invalid command: %q", args[0])
}
prepare(ctx)
stack, backend := makeFullNode(ctx)
defer stack.Close()
startNode(ctx, stack, backend, false)
stack.Wait()
return nil
}
在 geth()
函数中,有三个比较重要的函数调用,分别是:prepare()
,makeFullNode()
,以及 startNode()
。
prepare()
函数的实现就在当前的 main.go
文件中。它主要用于设置一些节点初始化需要的配置。比如,我们在节点启动时看到的这句话: Starting Geth on Ethereum mainnet… 就是在 prepare()
函数中被打印出来的。
// prepare函数操作内存缓存空间分配并设置矩阵系统。
// 这个函数应该在启动devp2p栈之前被调用。(devp2p,dev就是开发的意思,p2p就是点到点)
func prepare(ctx *cli.Context) {
// 如果我们正在运行一个已知的预设,为了方便起见记录它。
switch {
case ctx.IsSet(utils.RopstenFlag.Name):
log.Info("Starting Geth on Ropsten testnet...")
case ctx.IsSet(utils.RinkebyFlag.Name):
log.Info("Starting Geth on Rinkeby testnet...")
case ctx.IsSet(utils.GoerliFlag.Name):
log.Info("Starting Geth on Görli testnet...")
case ctx.IsSet(utils.SepoliaFlag.Name):
log.Info("Starting Geth on Sepolia testnet...")
case ctx.IsSet(utils.KilnFlag.Name):
log.Info("Starting Geth on Kiln testnet...")
case ctx.IsSet(utils.DeveloperFlag.Name):
log.Info("Starting Geth in ephemeral dev mode...")
log.Warn(`You are running Geth in --dev mode. Please note the following:
1. 此模式仅用于快速的迭代开发,没有安全性或持久性的考虑。
2. 除非另有说明,否则数据库将在内存中创建。因此,关闭计算机或断电将擦除开发环境中的整个区块数据和链状态。
3. 一个随机的、预先分配的开发者账户将可用并解锁为eth.Coinbase,可用于测试。随机的dev帐户是临时的,存储在一个ramdisk硬盘上,如果你的机器重新启动,这个帐户就会丢失。
4. 默认开启挖掘。但是,只有在mempool(内存池)中有待处理的事务时,客户端才会密封块。该矿工接受的最低汽油价格是1。
5. 禁用网络;没有listen-address(监听地址),最大对等体数设置为0,发现功能未开启。
`)
case !ctx.IsSet(utils.NetworkIdFlag.Name):
log.Info("Starting Geth on Ethereum mainnet...")
}
// 如果我们是主网上没有指定缓存的完整节点,则取消默认缓存配额
if ctx.String(utils.SyncModeFlag.Name) != "light" && !ctx.IsSet(utils.CacheFlag.Name) && !ctx.IsSet(utils.NetworkIdFlag.Name) {
// 确保我们也不在任何受支持的预配置testnet测试网络上
if !ctx.IsSet(utils.RopstenFlag.Name) &&
!ctx.IsSet(utils.SepoliaFlag.Name) &&
!ctx.IsSet(utils.RinkebyFlag.Name) &&
!ctx.IsSet(utils.GoerliFlag.Name) &&
!ctx.IsSet(utils.KilnFlag.Name) &&
!ctx.IsSet(utils.DeveloperFlag.Name) {
// 不,我们真的在主网上。提升缓存!
log.Info("Bumping default cache on mainnet", "provided", ctx.Int(utils.CacheFlag.Name), "updated", 4096)
ctx.Set(utils.CacheFlag.Name, strconv.Itoa(4096))
}
}
// 如果我们在任何网络上运行轻量客户端,请将缓存降低到某个有意义的值
if ctx.String(utils.SyncModeFlag.Name) == "light" && !ctx.IsSet(utils.CacheFlag.Name) {
log.Info("Dropping default light client cache", "provided", ctx.Int(utils.CacheFlag.Name), "updated", 128)
ctx.Set(utils.CacheFlag.Name, strconv.Itoa(128))
}
// 如果有启用则启动矩阵导出
utils.SetupMetrics(ctx)
// 启动系统运行时矩阵收集
go metrics.CollectProcessMetrics(3 * time.Second)
}
prepare函数源码在原文中没有给出,因为作者预设大家会看源代码,所以比较精简,笔者在这里给出源代码是为了方便大家流畅阅读,后续的源码中如果有“笔者附加源码”的字样,代表原文没有引用的源码,但是笔者为了方便大家阅读而附加上去的,类似这样的提示后文就不再说明了。
makeFullNode()
函数的实现位于 cmd/geth/config.go
文件中。它会将 Geth 启动时的命令的上下文加载到配置中,并生成 stack
和backend
这两个实例。其中 stack
是一个 Node 类型的实例,它是通过 makeFullNode()
函数调用 makeConfigNode()
函数来初始化的。Node
是 geth 生命周期中最顶级的实例,它负责管理节点中的 P2P Server, Http Server, Database 等业务非直接相关的高级抽象。关于 Node 类型的定义位于node/node.go
文件中。
// 笔者附加源码
// makeFullNode加载geth配置并创建以太坊后端。
func makeFullNode(ctx *cli.Context) (*node.Node, ethapi.Backend) {
stack, cfg := makeConfigNode(ctx)//这里的stack就是返回的Node节点,cfg是config配置
//读取配置
if ctx.IsSet(utils.OverrideTerminalTotalDifficulty.Name) {
cfg.Eth.OverrideTerminalTotalDifficulty = flags.GlobalBig(ctx, utils.OverrideTerminalTotalDifficulty.Name)
}
if ctx.IsSet(utils.OverrideTerminalTotalDifficultyPassed.Name) {
override := ctx.Bool(utils.OverrideTerminalTotalDifficultyPassed.Name)
cfg.Eth.OverrideTerminalTotalDifficultyPassed = &override
}
//根据节点和配置注册以太坊服务
backend, eth := utils.RegisterEthService(stack, &cfg.Eth)
// 警告用户迁移,如果他们有一个遗留的冷冻格式。
if eth != nil && !ctx.IsSet(utils.IgnoreLegacyReceiptsFlag.Name) {
firstIdx := uint64(0)
// 侵入以加快对主网的检查,因为我们知道第一个非空块,创世区块46147,这个编号说明以太坊为自己至少提前挖掘了46147个区块。
ghash := rawdb.ReadCanonicalHash(eth.ChainDb(), 0)
if cfg.Eth.NetworkId == 1 && ghash == params.MainnetGenesisHash {
firstIdx = 46147
}
isLegacy, firstLegacy, err := dbHasLegacyReceipts(eth.ChainDb(), firstIdx)
if err != nil {
log.Error("Failed to check db for legacy receipts", "err", err)
} else if isLegacy {
stack.Close()
log.Error("Database has receipts with a legacy format", "firstLegacy", firstLegacy)
utils.Fatalf("Aborting. Please run `geth db freezer-migrate`.")
}
}
// 配置日志过滤器RPC API。
filterSystem := utils.RegisterFilterAPI(stack, backend, &cfg.Eth)
// 如果需要,配置GraphQL。
if ctx.IsSet(utils.GraphQLEnabledFlag.Name) {
utils.RegisterGraphQLService(stack, backend, filterSystem, &cfg.Node)
}
// 如果需要,添加以太坊统计守护进程。
if cfg.Ethstats.URL != "" {
utils.RegisterEthStatsService(stack, backend, cfg.Ethstats.URL)
}
return stack, backend
}
这里的 backend
是一个 ethapi.Backend
类型的接口,提供了获取以太坊执行层运行时,所需要的基本函数功能。它的定义位于 internal/ethapi/backend.go
中。 由于这个接口中函数较多,我们选取了其中的部分关键函数方便大家理解这个接口所提供的基本功能,如下所示。
// 笔者更新源码且附注中文注解
// 后端接口提供公共API服务(由完全客户端和轻量级客户端提供)以访问必要的功能。
type Backend interface {
// 以太坊通用API
SyncProgress() ethereum.SyncProgress//同步进度
SuggestGasTipCap(ctx context.Context) (*big.Int, error)
FeeHistory(ctx context.Context, blockCount int, lastBlock rpc.BlockNumber, rewardPercentiles []float64) (*big.Int, [][]*big.Int, []*big.Int, []float64, error)
ChainDb() ethdb.Database
AccountManager() *accounts.Manager
ExtRPCEnabled() bool
RPCGasCap() uint64 // global gas cap for eth_call over rpc: DoS protection
RPCEVMTimeout() time.Duration // global timeout for eth_call over rpc: DoS protection
RPCTxFeeCap() float64 // global tx fee cap for all transaction related APIs
UnprotectedAllowed() bool // allows only for EIP155 transactions.
// 区块链API
SetHead(number uint64)
HeaderByNumber(ctx context.Context, number rpc.BlockNumber) (*types.Header, error)
HeaderByHash(ctx context.Context, hash common.Hash) (*types.Header, error)
HeaderByNumberOrHash(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (*types.Header, error)
CurrentHeader() *types.Header
CurrentBlock() *types.Block
BlockByNumber(ctx context.Context, number rpc.BlockNumber) (*types.Block, error)
BlockByHash(ctx context.Context, hash common.Hash) (*types.Block, error)
BlockByNumberOrHash(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (*types.Block, error)
StateAndHeaderByNumber(ctx context.Context, number rpc.BlockNumber) (*state.StateDB, *types.Header, error)
StateAndHeaderByNumberOrHash(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (*state.StateDB, *types.Header, error)
PendingBlockAndReceipts() (*types.Block, types.Receipts)
GetReceipts(ctx context.Context, hash common.Hash) (types.Receipts, error)
GetTd(ctx context.Context, hash common.Hash) *big.Int
GetEVM(ctx context.Context, msg core.Message, state *state.StateDB, header *types.Header, vmConfig *vm.Config) (*vm.EVM, func() error, error)
SubscribeChainEvent(ch chan<- core.ChainEvent) event.Subscription
SubscribeChainHeadEvent(ch chan<- core.ChainHeadEvent) event.Subscription
SubscribeChainSideEvent(ch chan<- core.ChainSideEvent) event.Subscription
// 交易池API
SendTx(ctx context.Context, signedTx *types.Transaction) error
GetTransaction(ctx context.Context, txHash common.Hash) (*types.Transaction, common.Hash, uint64, uint64, error)
GetPoolTransactions() (types.Transactions, error)
GetPoolTransaction(txHash common.Hash) *types.Transaction
GetPoolNonce(ctx context.Context, addr common.Address) (uint64, error)
Stats() (pending int, queued int)
TxPoolContent() (map[common.Address]types.Transactions, map[common.Address]types.Transactions)
TxPoolContentFrom(addr common.Address) (types.Transactions, types.Transactions)
SubscribeNewTxsEvent(chan<- core.NewTxsEvent) event.Subscription
ChainConfig() *params.ChainConfig
Engine() consensus.Engine
// 这里是从filters.Backend复制的
// eth/filters 需要从这个后端类型初始化,所以它所需的方法也必须包含在这里。
GetLogs(ctx context.Context, blockHash common.Hash, number uint64) ([][]*types.Log, error)
SubscribeRemovedLogsEvent(ch chan<- core.RemovedLogsEvent) event.Subscription
SubscribeLogsEvent(ch chan<- []*types.Log) event.Subscription
SubscribePendingLogsEvent(ch chan<- []*types.Log) event.Subscription
BloomStatus() (uint64, uint64)
ServiceFilter(ctx context.Context, session *bloombits.MatcherSession)
}
我们可以发现 ethapi.Backend
接口主要对外提供了:
- General Ethereum APIs, 这些 General APIs 对外提供了查询区块链节点管理对象的接口,例如
ChainDb()
返回当前节点的 DB 实例,AccountManager()
返回账户管理对象; - Blockchain 相关的 APIs, 例如链上数据的查询(Block & Transaction),
CurrentHeader(), BlockByNumber(), GetTransaction()
; - Transaction Pool (交易缓存池)相关的APIs, 例如发送交易到本节点的 Transaction Pool, 以及查询交易池中的 Transactions,
GetPoolTransaction
获取交易池。
目前 Geth 代码库中,有两个 ethapi.Backend
接口的实现,分别是:
- 位于
eth\api_backend
中的EthAPIBackend
(全节点) - 位于
les\api_backend
的LesApiBackend
(轻节点)
顾名思义,EthAPIBackend
提供了针对全节点的 Backend API 服务, 而 LesApiBackend
提供了轻节点的 Backend API 服务。总结的来说,如果读者想定制一些新的 RPC API(远程过程调用接口),可以在 ethapi.Backend
接口中定义函数,并给 EthAPIBackend
添加具体的实现。
读者可能会发现,ethapi.Backend
接口所提供的函数功能,主要读写本地的维护的数据结构(i.e. Transaction Pool, Blockchain)的为主。那么作为一个有网络连接的 Backend, 以太坊的 Backend 或者说 Node 是怎么管理以太坊执行层节点的网络连接,共识等功能模块的呢?
我们深入 makeFullNode()
函数可以发现,生成ethapi.Backend
接口的语句 backend, eth := utils.RegisterEthService(stack, &cfg.Eth)
, 还返回了另一个 Ethereum
类型的实例 eth
。 这个 Ethereum
类型才是以太坊节点数结构中核心中的核心,它实现了以太坊全节点所需要的所有的 Service。它负责提供更为具体的以太坊的功能性 Service, 负责与以太坊业务直接相关的抽象,比如维护 Blockchain 的更新,共识算法,从 P2P 网络中同步区块,同步P2P节点远端的交易并放到交易池中,等业务功能。我们会在后续详细讲解 Ethereum
类型具体提供的服务。
// 笔者附加源码
// RegisterEthService将以太坊客户端添加到栈中。
// 第二个返回值是完整的node实例,如果节点作为轻量客户端运行,这个值可能是nil。
func RegisterEthService(stack *node.Node, cfg *ethconfig.Config) (ethapi.Backend, *eth.Ethereum) {
//轻节点同步
if cfg.SyncMode == downloader.LightSync {
backend, err := les.New(stack, cfg)
if err != nil {
Fatalf("Failed to register the Ethereum service: %v", err)
}
stack.RegisterAPIs(tracers.APIs(backend.ApiBackend))
if err := lescatalyst.Register(stack, backend); err != nil {
Fatalf("Failed to register the Engine API service: %v", err)
}
return backend.ApiBackend, nil
}
backend, err := eth.New(stack, cfg)
if err != nil {
Fatalf("Failed to register the Ethereum service: %v", err)
}
if cfg.LightServ > 0 {
_, err := les.NewLesServer(stack, backend, cfg)
if err != nil {
Fatalf("Failed to create the LES server: %v", err)
}
}
if err := ethcatalyst.Register(stack, backend); err != nil {
Fatalf("Failed to register the Engine API service: %v", err)
}
stack.RegisterAPIs(tracers.APIs(backend.APIBackend))
// 在同步目标配置完成的情况下,注册辅助的全同步测试服务。
if cfg.SyncTarget != nil && cfg.SyncMode == downloader.FullSync {
ethcatalyst.RegisterFullSyncTester(stack, backend, cfg.SyncTarget)
log.Info("Registered full-sync tester", "number", cfg.SyncTarget.NumberU64(), "hash", cfg.SyncTarget.Hash())
}
return backend.APIBackend, backend
}
Ethereum
实例根据上下文的配置信息在调用 utils.RegisterEthService()
函数生成。在utils.RegisterEthService()
函数中,首先会根据当前的config来判断需要生成的Ethereum backend 的类型,是 light node backend 还是 full node backend。我们可以在 eth/backend/new()
函数和 les/client.go/new()
中找到这两种 Ethereum backend 的实例是如何初始化的。Ethereum backend 的实例定义了一些更底层的配置,比如chainid,链使用的共识算法的类型等。这两种后端服务的一个典型的区别是 light node backend 不能启动 Mining 服务。在 utils.RegisterEthService()
函数的最后,调用了 Nodes.RegisterAPIs()
函数,将刚刚生成的 backend 实例注册到 stack
实例中。
总结的说,api_backend
主要是用于对外提供查询,或者与后端功能性生命周期无关的函数,Ethereum
这类的节点层的后端,主要用于管理/控制节点后端的生命周期。
最后一个关键函数,startNode()
的作用是正式的启动一个以太坊执行层的节点。它通过调用 utils.StartNode()
函数来触发 Node.Start()
函数来启动Stack
实例(Node)。在 Node.Start()
函数中,会遍历 Node.lifecycles
中注册的后端实例,并启动它们。此外,在 startNode()
函数中,还是调用了unlockAccounts()
函数,并将解锁的钱包注册到 stack
中,以及通过 stack.Attach()
函数创建了与 local Geth 交互的 RPClient 模块。
// 笔者附加源码
// startNode启动系统节点和所有注册的协议,之后它解锁任何请求的帐户,并启动RPC/IPC接口和矿工。
func startNode(ctx *cli.Context, stack *node.Node, backend ethapi.Backend, isConsole bool) {
debug.Memsize.Add("node", stack)
// 启动节点本身
utils.StartNode(ctx, stack, isConsole)
// 解锁任何特定要求的账户
unlockAccounts(ctx, stack)
// 注册钱包事件处理程序来打开和自动导出钱包
events := make(chan accounts.WalletEvent, 16)
stack.AccountManager().Subscribe(events)
// 创建一个客户端与本地geth节点交互。
rpcClient, err := stack.Attach()
if err != nil {
utils.Fatalf("Failed to attach to self: %v", err)
}
ethClient := ethclient.NewClient(rpcClient)
go func() {
// 打开任何已经连接的钱包
for _, wallet := range stack.AccountManager().Wallets() {
if err := wallet.Open(""); err != nil {
log.Warn("Failed to open wallet", "url", wallet.URL(), "err", err)
}
}
// 监听钱包事件直到终止
for event := range events {
switch event.Kind {
case accounts.WalletArrived:
if err := event.Wallet.Open(""); err != nil {
log.Warn("New wallet appeared, failed to open", "url", event.Wallet.URL(), "err", err)
}
case accounts.WalletOpened:
status, _ := event.Wallet.Status()
log.Info("New wallet appeared", "url", event.Wallet.URL(), "status", status)
var derivationPaths []accounts.DerivationPath
if event.Wallet.URL().Scheme == "ledger" {
derivationPaths = append(derivationPaths, accounts.LegacyLedgerBaseDerivationPath)
}
derivationPaths = append(derivationPaths, accounts.DefaultBaseDerivationPath)
event.Wallet.SelfDerive(derivationPaths, ethClient)
case accounts.WalletDropped:
log.Info("Old wallet dropped", "url", event.Wallet.URL())
event.Wallet.Close()
}
}
}()
// 生成一个独立的goroutine用于状态同步监控,如果用户需要,同步完成后关闭节点。
if ctx.Bool(utils.ExitWhenSyncedFlag.Name) {
go func() {
sub := stack.EventMux().Subscribe(downloader.DoneEvent{})
defer sub.Unsubscribe()
for {
event := <-sub.Chan()
if event == nil {
continue
}
done, ok := event.Data.(downloader.DoneEvent)
if !ok {
continue
}
if timestamp := time.Unix(int64(done.Latest.Time), 0); time.Since(timestamp) < 10*time.Minute {
log.Info("Synchronisation completed", "latestnum", done.Latest.Number, "latesthash", done.Latest.Hash(),
"age", common.PrettyAge(timestamp))
stack.Close()
}
}
}()
}
// 启动辅助服务(如果启用)
if ctx.Bool(utils.MiningEnabledFlag.Name) || ctx.Bool(utils.DeveloperFlag.Name) {
// 只有在完整的以太坊节点运行时,挖矿才有意义
if ctx.String(utils.SyncModeFlag.Name) == "light" {
utils.Fatalf("Light clients do not support mining")
}
ethBackend, ok := backend.(*eth.EthAPIBackend)
if !ok {
utils.Fatalf("Ethereum service not running")
}
// 通过CLI将汽油价格设置为限制,然后开始挖矿
gasprice := flags.GlobalBig(ctx, utils.MinerGasPriceFlag.Name)
ethBackend.TxPool().SetGasPrice(gasprice)
// 开始挖矿
threads := ctx.Int(utils.MinerThreadsFlag.Name)
if err := ethBackend.StartMining(threads); err != nil {
utils.Fatalf("Failed to start mining: %v", err)
}
}
}
在 geth()
函数的最后,函数通过执行 stack.Wait()
,使得主线程进入了阻塞状态,其他的功能模块的服务被分散到其他的子协程中进行维护。
Node
正如我们前面提到的,Node 类型在 geth 的生命周期性中属于顶级实例,它负责作为与外部通信的高级抽象模块的管理员,比如管理 rpc server,http server,Web Socket,以及P2P Server外部接口。同时,Node中维护了节点运行所需要的后端的实例和服务 (lifecycles []Lifecycle
),例如我们上面提到的负责具体 Service 的Ethereum
类型。
// Node节点是一个可以注册服务的容器。
type Node struct {
eventmux *event.TypeMux
config *Config
accman *accounts.Manager
log log.Logger
keyDir string // 密钥存储目录
keyDirTemp bool // 如果为true,将通过Stop函数删除密钥目录,因为只是临时目录
dirLock fileutil.Releaser // 阻止并发使用实例目录
stop chan struct{} // 等待终止通知的通道
server *p2p.Server // 目前运行的P2P网络层
startStopLock sync.Mutex // Start/Stop函数由一个额外的互斥锁保护
state int // 跟踪节点的生命周期状态
lock sync.Mutex
lifecycles []Lifecycle // 所有具有生命周期的注册后端、服务和辅助服务
rpcAPIs []rpc.API // 节点当前提供的api列表
http *httpServer //
ws *httpServer //
httpAuth *httpServer //
wsAuth *httpServer //
ipc *ipcServer // 存储ipc http服务器信息
inprocHandler *rpc.Server // 进程内RPC请求处理程序来处理API请求
databases map[*closeTrackingDB]struct{} // 所有开放数据库
}
Node的关闭
在前面我们提到,整个程序的主线程因为调用了 stack.Wait()
而进入了阻塞状态。我们可以看到 Node 结构中声明了一个叫做 stop
的 channel。由于这个 Channel 一直没有被赋值,所以整个 geth 的主进程才进入了阻塞状态,持续并发的执行其他的业务协程。
// Wait函数阻塞,直到节点关闭。
func (n *Node) Wait() {
<-n.stop
}
当 n.stop
这个 Channel 被赋予值的时候,geth
主函数就会停止当前的阻塞状态,并开始执行相应的一系列的资源释放的操作。这个地方的写法还是非常有意思的,值得我们参考。
值得注意的是,在目前的 go-ethereum 的 codebase 中,并没有直接通过给 stop
这个 channel 赋值方式来结束主进程的阻塞状态,而是使用一种更简洁粗暴的方式: 调用 close()
函数直接关闭 Channel。我们可以在 node.doClose()
找到相关的实现。close()
是go语言的原生函数,用于关闭 Channel 时使用。
// doClose释放New()获取的资源,并且收集错误。
func (n *Node) doClose(errs []error) error {
// 关闭数据库。这个操作需要锁,因为它需要与OpenDatabase*同步。
n.lock.Lock()
n.state = closedState
errs = append(errs, n.closeDatabases()...)
n.lock.Unlock()
//关闭账户管理器
if err := n.accman.Close(); err != nil {
errs = append(errs, err)
}
//如果是临时目录,则全部删除
if n.keyDirTemp {
if err := os.RemoveAll(n.keyDir); err != nil {
errs = append(errs, err)
}
}
// 释放实例目录锁。
n.closeDataDir()
// 解锁n.Wait.这样就可以解除Wait造成的阻塞
close(n.stop)
// 报告可能发生的任何错误。
switch len(errs) {
case 0:
return nil
case 1:
return errs[0]
default:
return fmt.Errorf("%v", errs)
}
}
Ethereum Backend
我们可以在 eth/backend.go
中找到 Ethereum
这个结构体的定义。这个结构体包含的成员变量以及接收的方法实现了一个 Ethereum full node 所需要的全部功能和数据结构。我们可以在下面的代码定义中看到,Ethereum结构体中包含 TxPool
,Blockchain
,consensus.Engine
,miner
等最核心的几个数据结构作为成员变量,我们会在后面的章节中详细的讲述这些核心数据结构的主要功能,以及它们的实现的方法。
// Ethereum 实现了以太坊全节点服务.
type Ethereum struct {
config *ethconfig.Config
// 处理器
txPool *txpool.TxPool
blockchain *core.BlockChain
handler *handler // handler 是P2P 网络数据同步的核心实例,我们会在后续的网络同步模块仔细的讲解它的功能
ethDialCandidates enode.Iterator
snapDialCandidates enode.Iterator
merger *consensus.Merger
// 数据库接口
chainDb ethdb.Database // 区块链数据库
eventMux *event.TypeMux
engine consensus.Engine
accountManager *accounts.Manager
bloomRequests chan chan *bloombits.Retrieval // 接收bloom data数据检索请求的通道
bloomIndexer *core.ChainIndexer // Bloom索引器在块导入期间运行
closeBloomHandler chan struct{}
APIBackend *EthAPIBackend
miner *miner.Miner
gasPrice *big.Int
etherbase common.Address
networkID uint64
netRPCService *ethapi.NetAPI
p2pServer *p2p.Server
lock sync.RWMutex // 读写互斥锁,保护可变字段(例如汽油价格和etherbase)
shutdownTracker *shutdowncheck.ShutdownTracker // 跟踪节点是否以及何时非正常关闭
}
节点启动和停止 Mining 的就是通过调用 Ethereum.StartMining()
和 Ethereum.StopMining()
实现的。设置 Mining 的收益账户是通过调用 Ethereum.SetEtherbase()
实现的。
// 笔者更新源码和注解
// StartMining使用给定的CPU线程数启动矿工。如果挖掘已经在运行,该方法会调整允许使用的线程数,并更新交易池所需的最低价格。
func (s *Ethereum) StartMining(threads int) error {
// 更新共识引擎中的线程数
type threaded interface {
SetThreads(threads int)
}
if th, ok := s.engine.(threaded); ok {
log.Info("Updated mining threads", "threads", threads)
if threads == 0 {
threads = -1 // 从内部禁用矿工
}
th.SetThreads(threads)
}
// 如果矿工没有运行,初始化它
if !s.IsMining() {
// 将初始价格点传播到交易池
s.lock.RLock()
price := s.gasPrice
s.lock.RUnlock()
s.txPool.SetGasPrice(price)
// 配置本地挖掘地址
eb, err := s.Etherbase()
if err != nil {
log.Error("Cannot start mining without etherbase", "err", err)
return fmt.Errorf("etherbase missing: %v", err)
}
var cli *clique.Clique
if c, ok := s.engine.(*clique.Clique); ok {
cli = c
} else if cl, ok := s.engine.(*beacon.Beacon); ok {
if c, ok := cl.InnerEngine().(*clique.Clique); ok {
cli = c
}
}
if cli != nil {
wallet, err := s.accountManager.Find(accounts.Account{Address: eb})
if wallet == nil || err != nil {
log.Error("Etherbase account unavailable locally", "err", err)
return fmt.Errorf("signer missing: %v", err)
}
cli.Authorize(eb, wallet.SignData)
}
// 如果开始挖掘,我们可以禁用为加快同步时间而引入的事务拒绝机制。
atomic.StoreUint32(&s.handler.acceptTxs, 1)
go s.miner.Start(eb)
}
return nil
}
这里我们额外关注一下 handler
这个成员变量。handler
的定义在 eth/handler.go
中。
我们从从宏观角度来看,一个节点的主工作流需要:
1.从网络中获取/同步 Transaction 和 Block 的数据
2. 将网络中获取到 Block 添加到 Blockchain 中。
而 handler
就负责提供其中同步区块和交易数据的功能,例如,downloader.Downloader
负责从网络中同步 Block ,fetcher.TxFetcher
负责从网络中同步交易。关于这些方法的具体实现,我们会在后续章节:数据同步中详细介绍。
type handler struct {
networkID uint64
forkFilter forkid.Filter // Fork ID过滤器,在节点的生命周期中保持不变
snapSync uint32 // 标志是否启用snap sync(如果我们已经有数据块,则禁用)
acceptTxs uint32 // 标志我们是否被认为是同步的(启用事务处理)
checkpointNumber uint64 // 同步进度验证器要交叉引用的块号
checkpointHash common.Hash // 同步进度验证器用于交叉引用的块哈希值
database ethdb.Database
txpool txPool
chain *core.BlockChain
maxPeers int
downloader *downloader.Downloader
blockFetcher *fetcher.BlockFetcher
txFetcher *fetcher.TxFetcher
peers *peerSet
merger *consensus.Merger
eventMux *event.TypeMux
txsCh chan core.NewTxsEvent
txsSub event.Subscription
minedBlockSub *event.TypeMuxSubscription
requiredBlocks map[uint64]common.Hash
// 用于获取器,同步器,txsyncLoop的通道
quitSync chan struct{}
chainSync *chainSyncer
wg sync.WaitGroup
peerWG sync.WaitGroup
}
到此,我们就介绍了 geth 及其所需要的基本模块是如何启动的和关闭的。我们在接下来将视角转入到各个模块中,从更细粒度的角度深入探索 Ethereum 的具体实现。
附录
这里补充一个Go语言的语法知识: 类型断言。在Ethereum.StartMining()
函数中,出现了if c, ok := s.engine.(*clique.Clique); ok
的写法。该写法是Golang中的语法糖,称为类型断言。具体的语法是value, ok := element.(T)
,它的含义是如果element
是T
类型的话,那么ok等于True
, value
等于element
的值。在if c, ok := s.engine.(*clique.Clique); ok
语句中,就是在判断s.engine
的是否为*clique.Clique
类型。
var cli *clique.Clique
if c, ok := s.engine.(*clique.Clique); ok {
cli = c
} else if cl, ok := s.engine.(*beacon.Beacon); ok {
if c, ok := cl.InnerEngine().(*clique.Clique); ok {
cli = c
}
}