承接上一篇文章,在介绍了 Substrate 的模型设计后,终于可以开始进行 Substrate 的 Runtime 部分的介绍。本篇首先介绍 Runtime 的概要模型,为后续文章打下基础。

上一篇文章已经介绍了对于运行“链上代码”的部分是一个沙盒,因此 Runtime 的模型从本质上而言就是一个与其他环境隔绝的沙盒。那么对于一个沙盒而言,由于其与外界隔绝,因此必定有相应的口子让 Runtime 与外界进行交互。因此 Runtime 对外开的口子主要分为 2 类:

  • IO:负责 Runtime Storage 的 Read/Write.
  • API:Runtime 与一切外界元素交互的入口,例如:
    • 创建区块
    • 执行交易
    • 验证交易合法性
    • Runtime 的 metadata (关于 metadata 今后专门撰写文章介绍)
    • 结构化读取 Runtime 存储
    • 执行合约
    • 等等 ...

Runtime 的模型概要

Runtime 的模型本质上如下所示:

Substrate 入门 - Runtime 概要 (八)

实际上图中所示的已经是最简化版本,这里只是表明这个意思,实际的实现还要更复杂一下。在图中需要留意的关键有以下几点:

1. Runtime 对外层的接口

在图中明显的表明,Runtime 对外层的接口实际上只有两种:

  • IO
  • API

其中 IO 是对于开发者不可见的,对于 Runtime 的 IO 接口后续会专门撰写进行介绍。本文重点介绍 API 接口。

这里的 API 我们命名为 Runtime API,其在 Runtime 的运转中具有极其重要的意义。不过由于最关键的区块构建,交易执行的 api 由 Substrate Core 负责了,因此对于普通开发者而言,懂个大概就足够了。

这里要专门介绍 Runtime 的意义在于,与区块链的模型相比,可以看出 Substrate 的 Runtime 抽象力争于将“Runtime 的概念”与“链”的概念进行解耦,让 Runtime 脱离链的执行环境(打包区块,执行交易),转而成为是 Runtime 向外界提供构建区块,执行交易的接口

因此在其他区块链模型中,一般遵循:

出块打包 / 同步区块 -> 执行 -> 创建环境 -> 调用交易中对应的某个函数接口

而在 Substrate 的 Runtime 模型中,遵循

出块打包 / 同步区块 -> 执行 -> 创建环境 ->调用 Runtime 的执行区块 api-> 进入 Runtime 层(在执行区块的过程中,才会调用交易中对应的函数接口)

因此在这种模型下,一个完整的 Runtime可以承载在不同的外界环境中运行

  1. 例如使用 c++重新实现 Runtime 的执行环境体
  2. 在浏览器中运行 WASM
  3. 波卡的分片跨链的平行链
  4. 等等 ...

因此我们在 Substrate 的 runtime 构建的过程中,可以看到其为 Runtime 定义了一些 Core 的 api,用于执行区块链的核心逻辑:

定义 api 代码:primitives/api/hide/lib.rs:L464

    decl_runtime_apis! {  
       /// The `Core` runtime api that every Substrate runtime needs to implement.  
       #[core_trait]  
       #[api_version(2)]  
       pub trait Core {  
          /// Returns the version of the runtime.  
          fn version() -> RuntimeVersion;  
          /// Execute the given block.  
          #[skip_initialize_block]  
          fn execute_block(block: Block);  
          /// Initialize a block with the given header.  
          #[renamed("initialise_block", 2)]  
          #[skip_initialize_block]  
          #[initialize_block]  
          fn initialize_block(header: &as BlockT>::Header);  
       }  

       /// The `Metadata` api trait that returns metadata for the runtime.  
       pub trait Metadata {  
          /// Returns the metadata of a runtime.  
          fn metadata() -> OpaqueMetadata;  
       }  
    }  

实现代码:bin/node/runtime/hide/lib.rs:L609

    impl_runtime_apis! {  
       impl sp_api::Core for Runtime {  
          fn version() -> RuntimeVersion {  
             VERSION  
          }  

          fn execute_block(block: Block) {  
             Executive::execute_block(block)  
          }  

          fn initialize_block(header: &as BlockT>::Header) {  
             Executive::initialize_block(header)  
          }  
       }  
        // metadata...  

       impl sp_block_builder::BlockBuilder for Runtime {  
          fn apply_extrinsic(extrinsic: as BlockT>::Extrinsic) -> ApplyExtrinsicResult {  
             Executive::apply_extrinsic(extrinsic)  
          }  
            // ...  
       }  
        //...  
    }  

在 Substrate 中,对于 API 的实现极其复杂,采用了很多宏的实现。由于本系列只是入门,所以这里我们不探究宏背后的实现,直接给出案例说明结论:

对于一个 API,首先需要对其进行声明,通过宏 decl_runtime_apis!,然后对应于声明过的宏,需要在 Runtime 内部(例如在 node/runtime/hide 下)对其进行实现,而实现的方式也只能通过宏 impl_runtime_apis!

毕竟 Substrate 是以“区块链”的模型存在的,因此我们可以看到在 primitives/api/hide/lib.rs 中声明了 Core 的 api (注意这里的包是在 primitives 下,参见前几文章对模块分类的介绍)。在这个 api 的声明中,拥有 3 个关键的接口:

  • version Runtime 的版本,参见之前文章对 Runtime 版本的介绍
  • execute_block 执行一个区块,接受的参数是 Block
  • initialize_block 初始化一个区块,注意这个函数接受的参数是 Header

可以看到这个接口覆盖了一个链的基本核心,因此每一条链至少都必须实现这里定义的 CoreAPI 接口。

另一方面我们观察他的实现,例如:

    fn execute_block(block: Block) {  
        Executive::execute_block(block)  
    }  

其中调用了 Executive 的函数。请注意这里的 Executive 已经是 Runtime 的内部模块了,其对应的 frame 中的 executiveframe/executive/,参见之前的对于模块分类的文章,我们可知实际上这里的 Executive::execute_block(block) 是可以被任意替换的,这意味着开发者完全可以实现自己的区块执行逻辑,且若更改了这个执行逻辑,在任意能承载 Runtime 运行的平台上都可以统一运行,而不需要把不同平台的区块执行逻辑都做相同更改(结合上文描述 Runtime 运载不同平台的介绍)。

另一方面我们来看看另一个 api 接口 BlockBuilder

该接口专门负责的一个区块的构建过程,(与刚才 Core 中的执行区块分开,这里的区块的提议过程 proposal)。

这里我们只看 apply_extrinsic 这个接口,这个接口即是打包区块过程中的执行交易接口。显然要执行交易,其交易一定是从外部传入进来的,那么我们可以跟随这个接口,介绍一下 Runtime 是如何与外部通过 api 进行交互的。

2. 外部与 Runtime 交互的方式

在一开始的图里,笔者显然指出一个 api 的调用,必定需要包裹在 Client 里。在后文介绍 API 和 client 的关系。这里我们跳过这层关系,直接找一个区块的打包过程。

显然一个区块能被创建出来,其必定是在共识流程中,在 Pos 中,一般都会推选出一个 proposer 来构建出这个区块,这里直接指出代码位于 /client/basic-authorship/hide/lib.rs:L169(注意这里位于 client 目录下):

    let mut block_builder = self.client.new_block_at(  
                &self.parent_id,  
                inherent_digests,  
                record_proof,  
            )?;  

            // We don't check the API versions any further here since the dispatch compatibility  
            // check should be enough.  
            for extrinsic in self.client.runtime_api()  
                .inherent_extrinsics_with_context(  
                    &self.parent_id,  
                    ExecutionContext::BlockConstruction,  
                    inherent_data  
                )?  
            {  
                block_builder.push(extrinsic)?;  // 这里出现了 builder.push()  
            }  
            //...  
            debug!("Attempting to push transactions from the pool.");  
            for pending_tx in pending_iterator {  
            //...  
                let pending_tx_data = pending_tx.data().clone();  
                let pending_tx_hash = pending_tx.hash().clone();  
                trace!("[{:?}] Pushing to the block.", pending_tx_hash);  
                // 这里即是打包区块的过程,将交易 push 进入 builder 过程中,这里的 Push 和刚才的 Push 一致  
                match sc_block_builder::BlockBuilder::push(&mut block_builder, pending_tx_data) {  
                    Ok(()) => {  
                        debug!("[{:?}] Pushed to the block.", pending_tx_hash);  
                    }  
                    // ...  
                }  
             // ...  
    }  

而我们来看一下 push 的实现 client/block-builder/hide/lib.rs:L128:

    pub fn push(&mut self, xt: as BlockT>::Extrinsic) -> Result<(), ApiErrorFor> {  
            let block_id = &self.block_id;  
            let extrinsics = &mut self.extrinsics;  

            if  // ...  
            {  
                // ...  
            } else {  
                // 请注意这里的 api.map_api_result  
                self.api.map_api_result(|api| {  
                    // 请注意这里的 api.apply_extrinsic_with_context  
                    match api.apply_extrinsic_with_context(  
                        block_id,  
                        ExecutionContext::BlockConstruction,  
                        xt.clone(),  
                    )? {  
                        // ...  
                    }  
                })  
            }  
        }  

由于宏展开的过程十分复杂,这里直接告诉读者,这里的 apply_extrinsic_with_context 实际上最后即调用到了 runtime 中对于 api 的实现体:

        impl sp_block_builder::BlockBuilder for Runtime {  
            fn apply_extrinsic(extrinsic: as BlockT>::Extrinsic) -> ApplyExtrinsicResult {  
                Executive::apply_extrinsic(extrinsic)  
            }  
    }  

中,也就是说 apply_extrinsic_with_context 在一系列的宏张开的调用过程中,最后调用到了 runtime 层,调用了 apply_extrinsic,并进而调用了真正的实现体 Executive::apply_extrinsic(extrinsic)

读者只需要记住,定义的 Runtime 的 api,通过宏展开后,会在生成:

  • 原名函数
  • 原名函数_with_context (这里的 context 主要是为区分不同的执行上下文,需要提供一个不同的 context 环境,否则在原函数名称的实现中,默认会传入 Context::OffchainCall(None))

这个生成的函数实际上是赋予的 Runtime 的 Api 对象 RuntimeApiImpl,这里不展开这个是怎么来的,只需要明白在 impl_runtime_apis 展开后,对于原函数的实现会包装一些如一开始的图中的调用实现,然后会生成一个 api 对象,这个对象最后会赋给 client 的 api 属性

     impl Client {    
        pub fn new_block(  
            &self,  
            inherent_digests: DigestFor,  
        ) -> sp_blockchain::ResultSelf, B>> where     
    ///  
        {  
            let info = self.chain_info();  
            sc_block_builder::BlockBuilder::new(  
                self,  // 注意这里的 BlockBuilder  api_ref 参数传入的是 self,也就是说是 client 自身  
                //..  
            )  
        }  
    }  
    impl<'a, Block, A, B> BlockBuilder<'a, Block, A, B> {  
        pub fn new(  
            api: &'a A,  
            parent_hash: Block::Hash,  
            parent_number: NumberFor,  
            record_proof: RecordProof,  
            inherent_digests: DigestFor,  
            backend: &'a B,  
        ) -> Result<Self, ApiErrorFor> {  
            // 留意这里的 header  
            let header = <as BlockT>::Header as HeaderT>::new(  
                parent_number + One::one(),  // 请留意这里的 header 是在 parent +1,即意味着下一个区块!  
                //...  
            );  
            let mut api = api.runtime_api();  
            //...  
            let block_id = BlockId::Hash(parent_hash); // 注意这里的 block_id 来自的是 parent!!!  
            // 这里的 initialize_block_with_context 即是调用了 runtime  api 实现里 `Core` 下的 initialize_block 函数  
            api.initialize_block_with_context(  
                &block;_id, ExecutionContext::BlockConstruction, &header;,  
            )?;  

            Ok(Self {  
                parent_hash,  
                extrinsics: Vec::new(),  
                api,  // 这里的 api 即是来自 client  
                block_id, // 这里传入的是 parent (也就是当前最新区块)的 blockid  
                backend,  
            })  
        }  
    }  

由以上这段代码可以看出,client 自身可以通过 runtime_api 获取到 api 实例(这里就不介绍怎么来的了),通过 api 可以调用 initialize_block_with_context,然后 block_builder 会持有这个 api 引用,因而在 push 里面可以通过 api 调用 apply_extrinsic_with_context

因此整个过程就梳理清楚了,Substrate 抽象了 Runtime,并且对于 Runtime 对外界的接口采用了定义 api,实现 api 的方式。这种 api 的形式会通过宏展开的形式,将定义的 api 的调用方式赋予给 Runtime 外层的 client 对象。而在笔者比较早期的文章介绍过,substrate 的架构实现实际上和 c++ 版本的 Ethereum 近似,所以这里的 client 和 c++的 Ethereum 一样,实际上是一个节点运行中的单例,持有了所有的运行时对象并具备访问数据库能力的一个集合对象。通过 client 可以调用到 Runtime 的 api,进而实例化 Runtime 并进行相应函数的调用。

那么这里的问题就随之就来了,通过外部调用 runtime 的 api,需要通过如此复杂的宏展开的方式么?答案是:其实不一定,但是目前似乎是这么做最好。

理由就是我们首先需要观察到,在 runtime 内的 api 定义的函数,与 api 调用的函数有什么区别?

3. 外部加载状态调用的 Runtime

那么在外界 api 可以调用的 apply_extrinsic 并附带函数签名是:

  • apply_extrinsic_with_context(block_id: BlockId, context: sp_api::ExecutionContext, e: xt: ::Extrinsic)
  • apply_extrinsic(block_id: BlockId, context: sp_api::ExecutionContext, e: xt: ::Extrinsic)

这里直接告诉读者带 _with_context 的版本与不带的其他部分实现是一样的,区别只是在于提供的 context,而不带的版本默认为 Context::OffchainCall(None)

而在 Runtime 内定义的函数签名为

    fn apply_extrinsic(extrinsic: as BlockT>::Extrinsic);  

如果比较不带 _with_context 的版本,我们可以显然的注意到在外部调用的 api 的版本中,与在 Runtime 中定义的相比,多了一个 blockid

实际上这里的 blockid 即是一个状态区块链运行的核心 -- 基于某个状态去执行 Runtime。

在 2 的部分中,代码的注释里,笔者强调了注意 blockid 传入的值是什么。由于构建区块显然是要基于最新(或该节点认为应该基于的区块)状态进行构建,因此传入了 parent

在本文开头的图里,有一个虚线框框住了 state,executor,backend 等,并表明这个框的内部都是由宏展开实现的。api 通过 call_api_at(其也是宏展开内的东西),基于一个给予的 state (即通过调用 api 时传入的 blockid),创建环境并调用执行器去执行。在这个执行环境下,Runtime 的 IO 读取,即 Runtime Storage 也是在该环境下基于该 State 进行读写。

显然若指定了不同的 State,那么执行 api 时基于的环境就将会不同,因此例如想要实现读取过去的状态等功能时,即是通过状态不同的状态实现

而关于不同的 State 的细节,请参考笔者之前关于《基于状态的链》的相关文章

因此若不管所有细节,读者需要明白的就是:

对于 Runtime 对外通过 api 定义并暴露的接口,在 Runtime 外通过 api 调用时,需要指定执行该 api 需要基础的状态,即表明“基于某个状态去执行 Runtime 的 api 调用”

而这个状态即是通过宏展开的 api 的第一个参数 blockid 去指定。

总结

Substrate 的 Runtime 抽象在笔者看来是一个比较出色的抽象,其将 Runtime 的概念与区块链本身进行的剥离,虽然通过比较奇怪且难以理解的宏的实现方式,但是将 Runtime 的 api 与外界调用 api 的过程进行的挂接,使得加载一个 Runtime 需要通过一个状态去执行。在这里调用过程中封装了许多复杂的过程,本文不展开讲解,因此读者只需要记住 2 点:

  1. 外界沟通 Runtime 的方式的唯一入口是通过 Runtime 的 api,api 可以由开发者自由定制
  2. 调用 api 的时候需要装载执行这个 api 时基于的环境,加载了不同的状态,那么意味着在这个状态下去执行 api

本文经作者金晓授权,转载自知乎专栏 https://zhuanlan.zhihu.com/c_74315572

更多该系列内容:

Substrate 入门 - Substrate 的模型设计 (七)

Substrate 入门 - 交易体 (六)

Substrate 入门 - 区块头 (五)

Substrate 入门 - 项目结构 (四)

Substrate 入门 - 具备状态的链(三)

Substrate 入门 - 运行与调试 (二)

Substrate 入门 - 环境配置与编译(一)

Substrate 设计总览(三)

Substrate 设计总览(二)

Substrate 设计总览 (一)

扫码关注公众号,回复 “1” 加入波卡群

Substrate 入门 - Runtime 概要 (八)

来源链接:mp.weixin.qq.com