一、 创建节点
在 App 一章我们说到,以太坊的程序从 main 函数进入,并执行全局 app 对象的 Run 方法,最终调用 app.Action 也就是 geth 主函数。这一章我们就进入正题,一起来看看以太坊的基本框架是怎样的。
1.1 app.Action(geth)
找到 geth
函数定义的地方
// geth is the main entry point into the system if no special subcommand is ran.
// It creates a default node based on the command line arguments and runs it in
// blocking mode, waiting for it to be shut down.
func geth(ctx *cli.Context) error {
if args := ctx.Args(); len(args) > 0 {
return fmt.Errorf("invalid command: %q", args[0])
}
node := makeFullNode(ctx)
startNode(ctx, node)
node.Wait()
return nil
}
以太坊代码项目是开源公链,一个比较好的地方就是注释很详细。我们看下 geth
的官方解释:
如果没有运行特殊的子命令,
geth
是进入系统的主要入口点。它根据命令行参数创建一个默认节点,并以阻塞模式运行它,等待它关闭。
也就是说,geth
在官方的定义是,他是一个节点程序,而且是单进程的,这一点在实际应用中其实不是很友好,但暂时先不管,以后有时间我们说到公链和许可链的区别时再讨论。
先看看 geth
的逻辑:函数先从 cli.Context
结构中获取参数列表,如果参数个数大于0,报错返回,否则创建一个全节点对象,启动节点,以阻塞的方式等待节点退出。函数退出后,返回 nil。
1.2 node.Wait
看下节点(程序)等待退出的条件。
// Wait blocks the thread until the node is stopped. If the node is not running
// at the time of invocation, the method immediately returns.
func (n *Node) Wait() {
n.lock.RLock()
if n.server == nil {
n.lock.RUnlock()
return
}
stop := n.stop
n.lock.RUnlock()
<-stop
}
Wait
方法先检查节点的服务是否启动了,如果没有启动,立马返回。否则获取 stop
通道的拷贝,释放读锁,并阻塞等待通道中的消息,如果收到消息说明程序退出了,此时返回。
也就是说,阻塞等待节点退出的实现方式是是,判断 stop 通道中是否有值过来,我们可以将之视为一个信号,它是一个空结构体的通道,后面会有很多地方这样用到。
1.3 创建一个全节点
以太坊有全节点和轻节点之分,通常我们研究全节点就行了。这两种节点都是通过 makeFullNode 函数实现的,一起来看下。
func makeFullNode(ctx *cli.Context) *node.Node {
stack, cfg := makeConfigNode(ctx)
utils.RegisterEthService(stack, &cfg.Eth)
if ctx.GlobalBool(utils.DashboardEnabledFlag.Name) {
utils.RegisterDashboardService(stack, &cfg.Dashboard, gitCommit)
}
// Whisper must be explicitly enabled by specifying at least 1 whisper flag or in dev mode
shhEnabled := enableWhisper(ctx)
shhAutoEnabled := !ctx.GlobalIsSet(utils.WhisperEnabledFlag.Name) && ctx.GlobalIsSet(utils.DeveloperFlag.Name)
if shhEnabled || shhAutoEnabled {
if ctx.GlobalIsSet(utils.WhisperMaxMessageSizeFlag.Name) {
cfg.Shh.MaxMessageSize = uint32(ctx.Int(utils.WhisperMaxMessageSizeFlag.Name))
}
if ctx.GlobalIsSet(utils.WhisperMinPOWFlag.Name) {
cfg.Shh.MinimumAcceptedPOW = ctx.Float64(utils.WhisperMinPOWFlag.Name)
}
utils.RegisterShhService(stack, &cfg.Shh)
}
// Add the Ethereum Stats daemon if requested.
if cfg.Ethstats.URL != "" {
utils.RegisterEthStatsService(stack, cfg.Ethstats.URL)
}
return stack
}
我们看下函数返回值,*node.Node
是个 Node
类型的指针,返回的对象是 stack
。
函数逻辑如下:
- 调用
makeConfigNode()
方法创建一个 Node 对象stack
和相应的cfg
配置对象 - 调用
utils.RegisterEthService()
方法注册以太坊服务 - 判断是否设置了
Dashboard
标志,如果设置了,调用utils.RegisterDashboardService()
方法注册Dashboard
服务 - 判断
shhEnabled
或shhAutoEnabled
标志是否为真,如果是,则调用utils.RegisterShhService()
注册shh
服务 - 判断
cfg
对象的Ethstats
对象是否需要,如果是,调用utils.RegisterEthStatsService()
方法注册EthStats
对象 - 返回
stack
对象。
注意,如果所有的标志都设置了,将注册
Eth
服务、Dashboard
服务、Shh
服务、EthStats
服务。
1.3.1 makeConfigNode
以太坊的 cfg
是个相较很重要的概念,它设置了节点所有服务的初始配置,并影响一些常用对象的创建。我们来看下细节。
func makeConfigNode(ctx *cli.Context) (*node.Node, gethConfig) {
// Load defaults.
cfg := gethConfig{
Eth: eth.DefaultConfig,
Shh: whisper.DefaultConfig,
Node: defaultNodeConfig(),
Dashboard: dashboard.DefaultConfig,
}
// Load config file.
if file := ctx.GlobalString(configFileFlag.Name); file != "" {
if err := loadConfig(file, &cfg); err != nil {
utils.Fatalf("%v", err)
}
}
// Apply flags.
utils.SetNodeConfig(ctx, &cfg.Node)
stack, err := node.New(&cfg.Node)
if err != nil {
utils.Fatalf("Failed to create the protocol stack: %v", err)
}
utils.SetEthConfig(ctx, stack, &cfg.Eth)
if ctx.GlobalIsSet(utils.EthStatsURLFlag.Name) {
cfg.Ethstats.URL = ctx.GlobalString(utils.EthStatsURLFlag.Name)
}
utils.SetShhConfig(ctx, stack, &cfg.Shh)
utils.SetDashboardConfig(ctx, &cfg.Dashboard)
return stack, cfg
}
函数首先使用默认的配置创建一个 cfg
对象,我们看下
type gethConfig struct {
Eth eth.Config
Shh whisper.Config
Node node.Config
Ethstats ethstatsConfig
Dashboard dashboard.Config
}
是不是感觉有点熟悉,不错,gethConfig
这几个配置成员项对应着上面创建全节点时注册的几个服务,Node
是比较特殊的,后面讲。
获取完了默认配置后,判断是否设置了 configFile
标志,如果设置了,在配置文件名不为空的情况下,读取配置文件的内容,如果读取失败,直接报错退出。
接下来根据命令行参数和配置文件中配置项,调用 utils.SetNodeConfig()
方法设置 cfg.Node
成员,使用该配置创建一个 stack
对象。调用 utils.SetEthConfig()
方法设置 cfg.Eth
成员。如果设置了 EthStatsURL
标志,给 cfg.Ethstats.URL
赋值。最后分别调用 utils.SetShhConfig()
和 utils.SetDashboardConfig()
方法设置 cfg.Shh
和 cfg.Dashboard
成员。
综上,makeConfigNode 函数先获取几个主要服务的默认配置,再读取配置文件里面的个性配置,接着使用这些配置为各个服务对象设置服务相关的参数,中间还使用 Node 服务的配置生成一个 Node 对象
stack
。
1.3.2 注册服务
上面我们说到,通过 makeConfigNode 函数创建了一个全局的节点 stack
对象以及存储了配置内容的 cfg
对象。这样是否就可以运行一个 p2p 节点开始挖矿了呢?当然不可能!
我们知道,以太坊服务节点仅仅只用一个进程就完成了挖矿,区块打包,广播区块的功能,它肯定不是简简单单的让 node
start 一下就可以的了。那么?真相是什么?就是我们这里要说的“注册服务”了。
不得不说,以太坊区块链程序跟我们以往的后台服务器程序还是蛮像的,那就是:先读取配置文件,然后加载全局配置,将要做的事情抽象成服务注册到一个服务管理器中,使用管理器一键启动。这也是 Ethereum
程序基础架构,我们来看下:
- 注册
Eth
服务
// RegisterEthService adds an Ethereum client to the stack.
func RegisterEthService(stack *node.Node, cfg *eth.Config) {
var err error
if cfg.SyncMode == downloader.LightSync {
err = stack.Register(func(ctx *node.ServiceContext) (node.Service, error) {
return les.New(ctx, cfg)
})
} else {
err = stack.Register(func(ctx *node.ServiceContext) (node.Service, error) {
fullNode, err := eth.New(ctx, cfg)
if fullNode != nil && cfg.LightServ > 0 {
ls, _ := les.NewLesServer(fullNode, cfg)
fullNode.AddLesServer(ls)
}
return fullNode, err
})
}
if err != nil {
Fatalf("Failed to register the Ethereum service: %v", err)
}
}
Eth
服务是以太坊中的核心服务,使用 RegisterEthService()
方法注册,函数先判断当前的同步模式是否是轻节点模式,如果是,调用 stack.Register()
方法将函数注册进去,否则,还是调用 stack.Register()
将另一个函数注册进去。
- 注册
DashBoard
服务
// RegisterDashboardService adds a dashboard to the stack.
func RegisterDashboardService(stack *node.Node, cfg *dashboard.Config, commit string) {
stack.Register(func(ctx *node.ServiceContext) (node.Service, error) {
return dashboard.New(cfg, commit)
})
}
DashBoard 服务是辅助服务,用来测试程序性能用的。
- 注册 Shh 服务,即 Whisper 服务
// RegisterShhService configures Whisper and adds it to the given node.
func RegisterShhService(stack *node.Node, cfg *whisper.Config) {
if err := stack.Register(func(n *node.ServiceContext) (node.Service, error) {
return whisper.New(cfg), nil
}); err != nil {
Fatalf("Failed to register the Whisper service: %v", err)
}
}
Whisper 服务用来在 Dapp 之间进行少量数据的通信服务。
- 注册 EthStats 服务
// RegisterEthStatsService configures the Ethereum Stats daemon and adds it to
// th egiven node.
func RegisterEthStatsService(stack *node.Node, url string) {
if err := stack.Register(func(ctx *node.ServiceContext) (node.Service, error) {
// Retrieve both eth and les services
var ethServ *eth.Ethereum
ctx.Service(ðServ)
var lesServ *les.LightEthereum
ctx.Service(&lesServ)
return ethstats.New(url, ethServ, lesServ)
}); err != nil {
Fatalf("Failed to register the Ethereum Stats service: %v", err)
}
}
EthStats 是以太坊的监听服务,后面讲解代码时详细介绍。
我们看到,所有服务的注册方式都是通过 stack.Register
方法来注册的,注册的内容是一个函数。纳尼?注册一个函数进去?我们来看看这里面到底有什么阴谋。
// Register injects a new service into the node's stack. The service created by
// the passed constructor must be unique in its type with regard to sibling ones.
func (n *Node) Register(constructor ServiceConstructor) error {
n.lock.Lock()
defer n.lock.Unlock()
if n.server != nil {
return ErrNodeRunning
}
n.serviceFuncs = append(n.serviceFuncs, constructor)
return nil
}
原来,这里的所谓注册服务,其实就是将一个函数构造器(ServiceConstructor) 添加到节点的 serviceFuncs
数组(切片)里,而所谓的函数构造器即 type ServiceConstructor func(ctx *ServiceContext) (Service, error)
,也就是个函数类型。这跟 C/C++ 语言中传递函数指针是一个道理。
需要注意的是,Register()
函数在注册服务时,需要在类型方面必须是唯一的,且对应服务真正的执行是通过反射在运行时动态完成的。
1.3.3 轻节点构造器 VS 全节点构造器
func(ctx *node.ServiceContext) (node.Service, error) {
return les.New(ctx, cfg)
}
以上是轻节点构造器
func(ctx *node.ServiceContext) (node.Service, error) {
fullNode, err := eth.New(ctx, cfg)
if fullNode != nil && cfg.LightServ > 0 {
ls, _ := les.NewLesServer(fullNode, cfg)
fullNode.AddLesServer(ls)
}
return fullNode, err
}
全节点构造器比轻节点的要复杂一点,它先创建一个全节点的 Ethereum
对象,如果创建的对象不为空,并且,配置项 cfg.LightServ
大于0,通过当前的节点和配置创建一个轻节点,将这个轻节点服务加入到 fullNode
中,返回结果。
通过这里我们看到,如果是轻节点的服务的话,那么很简单,直接返回一个新对象就行;而如果是全节点的话,那就看启动节点时有没有附带要创建轻节点的需求,有就加进去,没有就算了。看起来全节点像是个大哥的样子。
二、启动节点
2.1 startNode 启动节点
我们创建好了节点对象和相关的配置对象,并把服务都注册到了节点对象之后,我们要启动节点,让 Node 对象来管理这些服务。
// startNode boots up the system node and all registered protocols, after which
// it unlocks any requested accounts, and starts the RPC/IPC interfaces and the
// miner.
func startNode(ctx *cli.Context, stack *node.Node) {
// Start up the node itself
utils.StartNode(stack)
// Unlock any account specifically requested
ks := stack.AccountManager().Backends(keystore.KeyStoreType)[0].(*keystore.KeyStore)
passwords := utils.MakePasswordList(ctx)
unlocks := strings.Split(ctx.GlobalString(utils.UnlockedAccountFlag.Name), ",")
for i, account := range unlocks {
if trimmed := strings.TrimSpace(account); trimmed != "" {
unlockAccount(ctx, ks, trimmed, i, passwords)
}
}
// Register wallet event handlers to open and auto-derive wallets
events := make(chan accounts.WalletEvent, 16)
stack.AccountManager().Subscribe(events)
go func() {
// Create an chain state reader for self-derivation
rpcClient, err := stack.Attach()
if err != nil {
utils.Fatalf("Failed to attach to self: %v", err)
}
stateReader := ethclient.NewClient(rpcClient)
// Open any wallets already attached
for _, wallet := range stack.AccountManager().Wallets() {
if err := wallet.Open(""); err != nil {
log.Warn("Failed to open wallet", "url", wallet.URL(), "err", err)
}
}
// Listen for wallet event till termination
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)
if event.Wallet.URL().Scheme == "ledger" {
event.Wallet.SelfDerive(accounts.DefaultLedgerBaseDerivationPath, stateReader)
} else {
event.Wallet.SelfDerive(accounts.DefaultBaseDerivationPath, stateReader)
}
case accounts.WalletDropped:
log.Info("Old wallet dropped", "url", event.Wallet.URL())
event.Wallet.Close()
}
}
}()
// Start auxiliary services if enabled
if ctx.GlobalBool(utils.MiningEnabledFlag.Name) || ctx.GlobalBool(utils.DeveloperFlag.Name) {
// Mining only makes sense if a full Ethereum node is running
if ctx.GlobalBool(utils.LightModeFlag.Name) || ctx.GlobalString(utils.SyncModeFlag.Name) == "light" {
utils.Fatalf("Light clients do not support mining")
}
var ethereum *eth.Ethereum
if err := stack.Service(ðereum); err != nil {
utils.Fatalf("Ethereum service not running: %v", err)
}
// Use a reduced number of threads if requested
if threads := ctx.GlobalInt(utils.MinerThreadsFlag.Name); threads > 0 {
type threaded interface {
SetThreads(threads int)
}
if th, ok := ethereum.Engine().(threaded); ok {
th.SetThreads(threads)
}
}
// Set the gas price to the limits from the CLI and start mining
ethereum.TxPool().SetGasPrice(utils.GlobalBig(ctx, utils.GasPriceFlag.Name))
if err := ethereum.StartMining(true); err != nil {
utils.Fatalf("Failed to start mining: %v", err)
}
}
}
这部分就是启动节点 Node 的逻辑,我们看下函数实现:
- 调用
utils.StartNode
启动 Node 自身 - 解锁在命令行中指定请求的账户
- 使用账户管理器订阅钱包事件
- 使用一个协程创建 rpcClient 并处理钱包事件
- 判断命令行是否启用了挖矿功能,后者是否启用了开发者模式,如果启用了任何一个标志,将启动挖矿功能。其中,轻节点同步模式和轻节点服务模式不支持挖矿功能。
在查看节点怎么真正启动之前,我们稍微看下 Node
是怎么定义的:
// Node is a container on which services can be registered.
type Node struct {
eventmux *event.TypeMux
config *Config // 节点配置
accman *accounts.Manager // 账户管理器
ephemeralKeystore string // 密码
instanceDirLock flock.Releaser // keystore 的目录锁
serverConfig p2p.Config // p2p 服务配置
server *p2p.Server // p2p 服务
serviceFuncs []ServiceConstructor // 函数构造器列表
services map[reflect.Type]Service // 服务映射表
rpcAPIs []rpc.API // rpc 服务的API
inprocHandler *rpc.Server // rpc 服务处理器
ipcEndpoint string // RPC-IPC 服务端点信息
ipcListener net.Listener // RPC-IPC 服务监听器
ipcHandler *rpc.Server // RPC-IPC 服务
httpEndpoint string // RPC-HTTP 服务端点信息
httpWhitelist []string // RPC-HTTP 服务白名单
httpListener net.Listener // RPC-HTTP 服务监听器
httpHandler *rpc.Server // RPC-HTTP 服务
wsEndpoint string // RPC-WS 服务端点信息
wsListener net.Listener // RPC-WS 服务监听器
wsHandler *rpc.Server // RPC-WS 服务
stop chan struct{} // 节点停止通道
lock sync.RWMutex // 通道读写锁
log log.Logger // 日志
}
到这里已经进入了以太坊架构的核心部分。以太坊说到底还是个由多个 p2p 节点连接起来的分布式网络,Node
在底层服务中承担着重要作用。从 Node 的定义中我们可以看出来,以太坊中的节点,即 Node
承担着以下几重角色:
- 一个存放数据的服务器
- 一个负责跟其他 peer 进行 p2p 通信的网络节点
- 一个管理多个服务的”容器“
- 支持多种 rpc 通信功能的服务
也就是说,在以太坊设计框架中,一个节点,它既是一台拥有账户管理功能的机器,还能支持 RPC 访问,同时,它还是 p2p 网络中的一个 peer。除此之外,节点还是一个可以挖矿的计算机,当然,这是通过 Node 的 ethereum 服务实现的。
可以说,Ethereum 使用一些相对较杂凑的功能模块完成了一个比较庞大的功能,这真的好吗?其实这是公链中没办法的事,公链的特点是适用人群五花八门,而其中更多的人群是单一开发人员,因而为了操作上的方便,尽量的让功能更紧凑,操作起来也就更方便。
对于上面这一点,在许可链里面则没有这样的情况。或者在设计上直接避过这一环。对于更多的许可链内容不在这里展开,如果大家有兴趣可以评论区留言。
2.2 StartNode 启动节点自身
func StartNode(stack *node.Node) {
if err := stack.Start(); err != nil {
Fatalf("Error starting protocol stack: %v", err)
}
go func() {
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, os.Interrupt)
defer signal.Stop(sigc)
<-sigc
log.Info("Got interrupt, shutting down...")
go stack.Stop()
for i := 10; i > 0; i-- {
<-sigc
if i > 1 {
log.Warn("Already shutting down, interrupt more to panic.", "times", i-1)
}
}
debug.Exit() // ensure trace and CPU profile data is flushed.
debug.LoudPanic("boom")
}()
}
函数先调用 node.Start()
函数,然后使用一个协程捕获 Interrupt 信号,如果捕获到了,就执行 node.Stop()
函数停止节点。
2.3 node.Start()
// Start create a live P2P node and starts running it.
func (n *Node) Start() error {
n.lock.Lock()
defer n.lock.Unlock()
// Short circuit if the node's already running
if n.server != nil {
return ErrNodeRunning
}
if err := n.openDataDir(); err != nil {
return err
}
// Initialize the p2p server. This creates the node key and
// discovery databases.
n.serverConfig = n.config.P2P
n.serverConfig.PrivateKey = n.config.NodeKey()
n.serverConfig.Name = n.config.NodeName()
n.serverConfig.Logger = n.log
if n.serverConfig.StaticNodes == nil {
n.serverConfig.StaticNodes = n.config.StaticNodes()
}
if n.serverConfig.TrustedNodes == nil {
n.serverConfig.TrustedNodes = n.config.TrustedNodes()
}
if n.serverConfig.NodeDatabase == "" {
n.serverConfig.NodeDatabase = n.config.NodeDB()
}
running := &p2p.Server{Config: n.serverConfig}
n.log.Info("Starting peer-to-peer node", "instance", n.serverConfig.Name)
// Otherwise copy and specialize the P2P configuration
services := make(map[reflect.Type]Service)
for _, constructor := range n.serviceFuncs {
// Create a new context for the particular service
ctx := &ServiceContext{
config: n.config,
services: make(map[reflect.Type]Service),
EventMux: n.eventmux,
AccountManager: n.accman,
}
for kind, s := range services { // copy needed for threaded access
ctx.services[kind] = s
}
// Construct and save the service
service, err := constructor(ctx)
if err != nil {
return err
}
kind := reflect.TypeOf(service)
if _, exists := services[kind]; exists {
return &DuplicateServiceError{Kind: kind}
}
services[kind] = service
}
// Gather the protocols and start the freshly assembled P2P server
for _, service := range services {
running.Protocols = append(running.Protocols, service.Protocols()...)
}
if err := running.Start(); err != nil {
return convertFileLockError(err)
}
// Start each of the services
started := []reflect.Type{}
for kind, service := range services {
// Start the next service, stopping all previous upon failure
if err := service.Start(running); err != nil {
for _, kind := range started {
services[kind].Stop()
}
running.Stop()
return err
}
// Mark the service started for potential cleanup
started = append(started, kind)
}
// Lastly start the configured RPC interfaces
if err := n.startRPC(services); err != nil {
for _, service := range services {
service.Stop()
}
running.Stop()
return err
}
// Finish initializing the startup
n.services = services
n.server = running
n.stop = make(chan struct{})
return nil
}
函数主要做了以下几件事:
- 加锁,打开 data 目录
- 给节点的 p2p 服务配置 serverConfig 赋值
- 使用 serverConfig 新建 p2p server 对象
- 遍历 serviceFuncs 构造出服务实例
- 遍历服务,将服务的协议添加到 p2p 实例
running
的协议集中 - 启动 p2p 服务
- 启动各服务,如果有一个服务启动失败,停止所有已启动服务并退出
- 启动 RPC 服务
- 给 Node 成员赋值,函数返回 nil
2.4 node.Stop()
// Stop terminates a running node along with all it's services. In the node was
// not started, an error is returned.
func (n *Node) Stop() error {
n.lock.Lock()
defer n.lock.Unlock()
// Short circuit if the node's not running
if n.server == nil {
return ErrNodeStopped
}
// Terminate the API, services and the p2p server.
n.stopWS()
n.stopHTTP()
n.stopIPC()
n.rpcAPIs = nil
failure := &StopError{
Services: make(map[reflect.Type]error),
}
for kind, service := range n.services {
if err := service.Stop(); err != nil {
failure.Services[kind] = err
}
}
n.server.Stop()
n.services = nil
n.server = nil
// Release instance directory lock.
if n.instanceDirLock != nil {
if err := n.instanceDirLock.Release(); err != nil {
n.log.Error("Can't release datadir lock", "err", err)
}
n.instanceDirLock = nil
}
// unblock n.Wait
close(n.stop)
// Remove the keystore if it was created ephemerally.
var keystoreErr error
if n.ephemeralKeystore != "" {
keystoreErr = os.RemoveAll(n.ephemeralKeystore)
}
if len(failure.Services) > 0 {
return failure
}
if keystoreErr != nil {
return keystoreErr
}
return nil
}
stop 函数用来退出一个正在运行的节点,并停止所有的服务,它的过程如下:
- 加锁,判断节点是否在运行,如果节点已停止,报错退出
- 停止 RPC 服务
- 停止所有的服务
- 解锁目录锁
- 向 node.stop 通道发送节点停止信号
- 移除掉所暂时生成的 keystore
节点整个停止动作基本是启动的逆过程。
三、总结
这里回顾一下,我们对以太坊程序的简单认识:
1)以太坊的服务端程序是一个 App 应用程序,它是单进程的,通过 gopkg.in/urfave/cli.v1
包中的 app 应用来实现
2)我们学习了 以太坊应用程序 app
是怎么启动的,他真正运行的是哪个函数 —— geth
3)接着我们简单走读了一下 geth 函数的执行过程,并分析了 Node 对象是个什么玩意儿
4)Node 对象是以太坊底层最重要的几个概念之一,它是几个功能模块的组合:账号管理器,服务管理者,p2p 服务,rpc 服务端。
5)Node 的启动就是围绕这几个功能模块来展开的。