ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Substrate区块链开发框架解析:用FRAME模块化组装应用链

Substrate区块链开发框架解析:用FRAME模块化组装应用链 先说明一句这个世界的软件开发很多时候其实是在重复造轮子。一旦你决定要脱离智能合约平台自己去搞一条链那就意味着从底层字节插曲、共识、P2P网络再到账本模型、状态存储、浏览器钱包适配全都要自己重新来一遍。早期还在做区块链底层的时候我自己试过从零写链那种痛苦根本不是写业务代码能比的——不是难在某个单个组件而是难在所有组件咬合在一起还不崩。后来接触到Substrate它给我的感觉是这玩意儿是真的把“区块链开发”从“操作系统开发”降维成了“业务系统开发”。这篇文章就围绕Substrate这个框架本身聊透它解决了什么问题、内部核心机制是什么、怎么用它组装一条链以及我在实际使用过程中踩过的那些坑。1. 为什么还要做一个区块链开发框架这个话题得从一条链的本质说起。很多人第一次接触区块链第一反应是“以太坊上的智能合约就能做应用了为什么还要自己做链”这个疑问非常合理但实际做下去会发现一个明显的边界。1.1 从智能合约平台到应用链智能合约的模型说得直接一点就是所有人都挤在同一台“公共计算机”上跑程序。以太坊这台公共机要执行每一个合约的每一个字节码全局共享同样的计算资源、存储资源和状态。好处是部署方便任何人写一段合约丢上去就能跑坏处也很明显——你会被别人的业务拖累网络拥堵时你的应用交易排队转账手续费飙升存储配额被全局状态占满连带着出块速度都会受到影响。业务一旦成长起来对性能、费用、结算确定性有硬性要求的时候社区就会转向“应用链”的思路为自己这条链单独做一条 L1独享全部区块空间和执行资源。应用链的好处很直接手续费你自己定出块参数你自己调区块大小和存储逻辑自己说了算。听起来很理想但紧接着问题就来了——从零做链的工作量大到团队根本扛不住。如果从零起步你需要做什么核心节点要用 P2P 协议组网底层网络协议、握手、async 重连、消息广播共识算法得选并且实现工作量证明 / 权益证明 / PBFT / Tenderdint 类自定义状态树要设计Merkle Trie、Patricia Trie快照历史状态修剪交易池、运行时虚拟机和账户模型全都需要自行定义和实现链上治理和升级机制也得自研。这还是没考虑浏览器钱包、浏览器插件、区块浏览器、多签工具这些生态配套的情况。Substrate 的定位就是把这些都被统一抽象好的框架依赖标准 Rust 生态用一套模块化架构把链的骨架都给出来。它不一定适合所有人但它确实解决了做链时最痛苦的工程重复建设问题。1.2 Substrate 恰好补上的空缺Substrate 是一个基于 Rust 的区块链开发框架由 Parity Technologies 主导开发最出名的应用是波卡Polkadot项目本人也是基于 Substrate 构建的。但得说清楚Substrate 不要求你一定要成为波卡的平行链你完全可以用它独立启动一条自己的链自主运行、自主发展和波卡生态互不绑定。它所要解决的恰恰是从零写链的工程性和升级性问题。核心思路是把区块链节点拆成“外层 Client”和“内层 Runtime”两个部分把所有链上状态转换规则放进 Runtime而 Client 只负责执行 Block Import、共识、网络同步、RPC、数据库访问这些“通用基础设施”。这意味着只要 Runtime 不变更Client 版本升级非常自由反过来如果你想改链逻辑比如交易费算法不需要全节点停机和硬分叉直接通过链上治理机制把新 Runtime 的 Wasm 字节码通过一笔特殊交易提交上链再指定一个高度去激活节点自动完成升级。这在几乎所有传统区块链项目里都做不到但 Substrate 天然支持这也就是常说的 Forkless Upgrade无分叉升级。这套设计思想直接改变了区块链开发的玩法不是先定死共识和存储再写业务逻辑而是业务逻辑本身运行在 Native/Wasm 双状态里链上规则可以随时演化和修正。团队可以把主要精力放在业务模块上而不是每次调整规则都要动员所有节点去升级客户端。2. 核心架构拆解一条链是怎么被组起来的Substrate 整体架构没有你想象得那么复杂但分层非常讲究。对开发过区块链的人而言这个分层一眼就能明白其用意它把“链运行的本质”和“业务规则”彻底分开。2.1 外层 Client 与内层 Runtime如果把 Substrate 比作一辆自动挡汽车Client 就是底盘、传动系统和方向盘Runtime 则是这辆车的驾驶逻辑和行驶路线。Client 负责所有节点通信、区块同步以及本地数据库读取Runtime 则执行所有对状态转换有意义的东西——包括账户余额怎么变化、投票怎么统计、名怎么注册等。合起来才是一个整体节点。Client 的关键组件列表如下Substrate Client管理链数据库、区块导入、任务队列和运行时调用入口。网络层默认使用 libp2p负责节点发现、区块广播和事务传播。共识层可选 Aura、BABE、GRANDPA、SASSAFRAS甚至你还可以自己实现共识引擎。RPC提供 JSON-RPC 接口供钱包、浏览器、Polkadot-JS Apps 等外部工具交互。Runtime API封装了链上状态访问的接口包括查询余额、查询存储、调用函数。外部节点Offchain Workers可选地访问外部 HTTP API做链下计算或将链下数据签名提交上链。Runtime 则主要由 SR25519 签名校验、状态根计算、事件索引、事务权重记账机制、Pallet 模块、元数据和 Wasm 执行环境组成。这两者之间通过 Runtime API 桥接Client 并不知道链上业务的具体细节它只负责“把这个区块导入并执行 runtime 对应的函数”。正因为这样改业务逻辑的时候Client 根本不用动。2.2 存储抽象与状态根区块链最重要的事就是要保证每个节点的状态一致。“状态”不是指代币余额这一个维度而是一个巨大的 Merkle 树——Substrate 里叫做 State Trie。每个区块的执行结果会把整个 Trie 的新根哈希记录下来这个根叫做 State Root。任何节点执行完同一个区块如果状态根一致说明大家看到的状态没有分歧。Substrate 的存储模型非常直白它是一个 KV 数据库默认 RocksDB Parity DB 风格上层封装了带前缀的键值空间。每个 Pallet 的存储项都有独立前缀因此不会冲突。查询存储的简单性就体现在这里——一个双重映射键两个 key 查询数据就是一个简单的两次映射不需要拼接复杂的数据库字段名称。我自己写代码时最喜欢的是它的#[pallet::storage]宏。比如声明一个StorageMap它自动帮你生成标准的 key-value 存储并提供get、insert、mutate这类操作接口完全不暴露下层 DB 细节连编码都是自动的SCALE Codec。这套存储抽象极大地降低了写链上逻辑的心理负担。还有一点值得单独提一下因为状态根的存在轻客户端和浏览器也能验证链上数据不需要跑完整节点基于状态证明和 MMR 历史数据做验证这是 Substrate 在互操作性上和其他生态一个很大的区别。2.3 共识与网络层共识是区块链最容易被误解的部分很多人以为 Substrate 只有一种共识。其实它有成熟的共识插件体系。最常用的是 AuraAuthority Round基于权威节点轮流出块简单高效通常用于私有链或 testnet 场景。波卡则用 BABE 出块 GRANDPA 最终性确认BABE 负责产生候选区块GRANDPA 对区块进行最终确认二者各司其职兼顾出块速率和最终性安全。我自己在开发测试链时一般选用 Aura因为足够简单不需要复杂的随机数逻辑配置相对轻松。一旦进入生产环境或需要接入中继链就得切换 BABE GRANDPA这是需要提前想清楚的选型。网络层基于 libp2p属于社区标准实现。原本 P2P 组网里最磨人的 NAT 穿透、节点发现、断线重连、协议分帧Substrate 都封装好了。开发者真正需要关心的只是如何给自己的链设计消息类型比如交易广播、区块请求至于用什么算法找到对等节点不需要太操心。2.4 无分叉升级的技术基础刚才说了 Forkless Upgrade这个能力不是凭空来的。核心机制是 Runtime 以两种形式存在Native 版本Rust 编译出的原生二进制和 Wasm 版本编译为可移植的字节码本地节点可校验。运行一个区块时区块头的spec_version和runtime_version会被比对。如果发现新的 Wasm Runtime 已提交当前高度达到激活条件后节点自动将新的 Wasm 作为执行环境并从新的高度开始执行规则。这看起来简单但底层藏了很多细节。比如升级之后所有存储项必须保持兼容旧的存储项不能莫名消失新的存储逻辑必须按迁移方案处理。这个机制要求你开发时始终把运行时的“可迁移性”放在心上否则一个 Pallet 改名、存储项结构变更就会导致节点在高度同步时出现 Store 迁移异常。这也是很多人第一次用 Substrate 时容易踩的坑后面我会具体提。3. FRAME 模块系统像搭乐高一样开发区块链如果只到这一步Substrate 还只是一个功能齐整的骨架真正让它变得足够灵活顺手是 FRAMEFramework for Runtime Aggregation of Modular Entities。FRAME 是一套标准化模块协议它把区块链功能拆成可独立复用、可配置、可插拔的 Pallet。3.1 核心概念Pallet 与特质Pallet 可以理解为“链上的一个功能包”。一个 Pallet 就是一个 Rust 模块它包含一组存储项、一组事件、一组错误、一组可调用函数和一组钩子函数。比如pallet_balances负责账户余额pallet_session负责验证人会话密钥管理pallet_democracy负责投票治理pallet_treasury负责国库资金流。关键是 Pallet 之间可以通过 trait 约束互相依赖。你不需要把所有功能都写进一个文件里只需要在你的 Pallet 中声明T: Config并在Config里通过type Currency: CurrencySelf::AccountId来使用其他 Pallet 的能力。这就好比装配主板你只需要插上 CPU、内存和硬盘不需要自己造芯片。3.2 手写一个自定义 Pallet直接上一段代码我写过的一个最简 Pallet用来记录“谁提交了数据以及数据是否已验证”。核心逻辑并不复杂但能看出来 Substrate 的精髓。#![cfg_attr(not(feature std), no_std)] pub use pallet::*; #[frame_support::pallet] pub mod pallet { use frame_support::pallet_prelude::*; use frame_system::pallet_prelude::*; #[pallet::pallet] pub struct PalletT(_); #[pallet::config] pub trait Config: frame_system::Config { type RuntimeEvent: FromEventSelf IsTypeSelf as frame_system::Config::RuntimeEvent; type MaxDataLength: Getu32; } #[pallet::storage] pub type DataStoreT: Config StorageMap _, Blake2_128Concat, T::AccountId, Vecu8, ValueQuery, ; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum EventT: Config { DataStored(T::AccountId, Vecu8), } #[pallet::error] pub enum ErrorT { DataTooLong, } #[pallet::call] implT: Config PalletT { #[pallet::weight(10_000)] pub fn store_data( origin: OriginForT, data: Vecu8, ) - DispatchResult { let who ensure_signed(origin)?; let max_len T::MaxDataLength::get(); ensure!(data.len() max_len as usize, Error::T::DataTooLong); DataStore::T::insert(who, data.clone()); Self::deposit_event(Event::DataStored(who, data)); Ok(()) } } }上面这段代码做了这几件事用#[pallet::pallet]声明这是一个 Substrate Pallet用Config定义这个模块需要的通用类型这里要求指定一个RuntimeEvent和一个MaxDataLengthDataStore是一个 StorageMap按账号存数据store_data是一个可调用函数可理解为链上的公开入口只允许签名者调用并对数据长度做检查。单独看代码不难。难的是理解这个模块如何被塞进 Runtime 里以及为什么这样设计就可以做到模块化。3.3 组合与配置construct_runtime!创建一条链你要做的不是把各个 Pallet 的代码拷贝到同一个文件夹里而是在runtime/src/lib.rs中用construct_runtime!宏把这些模块装配起来。宏本身是 FRAME 提供的一种语法糖它会帮你自动生成把 Pallet 挂到 Runtime 上的关联代码包括整体 Call 枚举、整体 Event 枚举、存储合并逻辑以及执行顺序。construct_runtime!( pub enum Runtime { System: frame_system, Balances: pallet_balances, DataStore: pallet_datastore, } );这个宏看起来像是在做“接线”实际运行时它会根据你声明的 Pallet 生成一个统一入口。所有 Pallet 的 Call 都会被打包成RuntimeCall枚举所有事件都会被汇总为RuntimeEvent枚举。外部提交一笔交易时节点会把这段 Call 发给相对应的 Pallet 处理。务必留意一个大坑construct_runtime!中 Pallet 的顺序和GenesisConfig的配置必须一一对应否则链在初始化创世区块时可能直接失败或者生成的存储前缀和链码、合法配置不一致导致数据读取不到。3.4 权重模型与手续费手续费是所有链都逃不过的问题。传统区块链实现手续费往往比较简单看交易大小或者写死一个固定值但是 Substrate 里用“权重”Weight这个抽象来统一调度资源。权重模型本质上是把交易消耗的计算时间和数据库读写时间折算成统一数值。#[pallet::weight]接受一个常量或函数该函数根据入参决定消耗的权重数。执行交易时系统会先收取手续费然后实际消耗的权重不能超过预先声明的上限。如果函数执行过程异常超出权重上限节点就会拒绝执行。我们团队在实际生产中会手动为每个复杂函数编写 Benchmark用frame_benchmarking生成不同规模输入的最终权重再通过测试辅助工具生成权重文件。这可以避免手动写权重导致“过高收费”或“低到恶意刷”的情况。手工估权非常不可靠封装好的 Benchmark 自动化流程能节省大量体力。4. 跨链与开放生态XCM 与波卡Substrate 另外一个容易让人迷惑的点是它和“跨链”捆绑得那么紧密。其实跨链并不是 Substrate 的强制功能但生态设计里天然预留了消息传递和跨链互操作的接口。波卡之所以能实现异构链互相通信底层就是因为各条平行链都基于 Substrate内置了一套跨链格式。4.1 XCM 是什么解决了什么问题XCMCross-Consensus Message Format是 Substrate 生态中新提出的跨共识消息格式。它不强制要求所有链内部运行逻辑一致只要链实现了该格式的消息接收与执行协议就能互相之间发送“意图”。举个例子A 链想把自己的 Token 转移到 B 链它并不是在 A 链上直接改 B 链的数据库也不可能做到而是生成一段 XCM 指令比如“把我在 A 链上的账户置于锁定状态并在 B 链上铸造对应代币”然后把这段指令投递给 B 链。B 链收到后验证发送方的共识安全通过轻客户端或转接桥再执行这个指令。XCM 核心概念包括多位置资产MultiLocation、资产转移执行Transfer Asset、通用执行器General Executor、版本协商机制。这些概念更新迭代很快但我建议初学者不要一开始就死抠 XCM 的每个细节因为大部分场景用不到那么底层的能力。理解一个基本模型就够了跨链本质上是“消息带着预定义的操作语义跑到另外一条链上去执行”。4.2 跨链场景实例只讲概念有点虚实际跨链场景很现实。比如某条链发起了 DeFi 借贷产品需要从另一条链借入资产作为抵押。它可以在本地锁定一部分原生资产发出 XCM 指令到另一条链去铸造对应资产。要确保这个过程安全另一条链必须验证锁定证据。也可以用更轻量的方式通过“通用资产转发器”模块。波卡生态里有很多项目做“转接桥资产”本质上就是由桥模块捕获另一条链的事件的状态证明并在本地映射出对应资产接着利用 XCM 做结算。我之前写跨链转账时最容易出现的问题是版本不匹配。A 链运行的 XCM 版本和 B 链的不一致消息发出后很可能被拒绝。必须使用 XCM Versioning 机制让发送方读取接收方当前支持的版本再动态调整消息格式。如果这部分踩坑排查起来非常耗时因为错误经常不在本地日志而是对方完全不回应。4.3 生态格局与常见选型当前生态里最常用的基础链底座包括 Substrate 原生的 FRAME这一层其实不需要太多选型因为它已经被波卡、Kusama 等大量核心项目验证过了。需要认真选型的是业务层 Pallet比如治理、桥、资产模块。我的建议是如果只是为了验证业务模型直接用substrate-node-template作为基础模板在此基础上删减不需要的模块如果需要和波卡生态打通考虑选择polkadot-sdk的 parachain 模板如果你的业务有很强的定制性需求可以不用波卡中继链而是单独拉起一条独立链后续再考虑接入桥或互操作协议。4.4 常见问题速查表在处理 Substrate 相关问题时很多坑都有共性。我整理了一个速查表方便排查时参考问题现象可能原因建议排查步骤节点同步时出现Storage root mismatch运行时存储结构不一致或迁移不完整检查运行时版本确认 Pallet 存储项前缀未改动执行存储迁移交易提交后被InvalidTransaction::Payment拒绝余额不足或手续费配置过高检查账户余额、权重系数调低权重或增发测试币出块正常但交易无法打包交易池被填满或交易池 ttl 过低检查交易池配置和TransactionPriority设置运行 Runtime 升级后事件丢失事件索引和存储迁移逻辑未处理在 Runtime 中增加迁移逻辑或者事件模块不可变动验证人集合不稳定导致 Finality 卡住共识配置有问题或 GRANDPA 权限变化检查验证人数量和权重分配以及pallet_session周期设置RPC 无法查询链上存储Metadata 版本过旧或自定义类型未声明更新 Metadata 和 JS 类型映射用polkadot-js/apps测试这个表格并不能覆盖全部情况但能帮你快速缩小范围避免从零排查。5. 实操过程与核心环境搭建说了这么多理论没有实操就像纸上谈兵。最后这部分我会走一遍用 Substrate 从零拉起一条开发链的全过程以及项目结构到底长什么样方便你写第一行代码时不至于毫无头绪。5.1 开发环境准备Substrate 开发主要依赖 Rust 工具链。安装环境的过程不复杂但有个大坑就是 Rust 版本兼容性Substrate 对 nightly 版本的依赖很高。我建议使用官方脚本直接安装curl https://getsubstrate.io -sSf | bash -s -- --fast这个脚本会安装rustup、substrate依赖、wasm-target和常用工具链。我更推荐的做法是手动管理rustup update nightly rustup target add wasm32-unknown-unknown --toolchain nightly cargo install --git https://github.com/paritytech/substrate --tag version subkey这里有个重点编译 Substrate 节点必须用到wasm32-unknown-unknowntarget因为 Runtime 需要编译成 Wasm 字节码方便链上验证和无分叉升级。如果没有这个 targetcargo build 会在最后的 Wasm 编译步骤直接报错。5.2 项目结构解析以官方substrate-node-template为例创建新项目后你能看到这几个重要目录runtime/src/lib.rs运行时主文件所有 Pallet 都在这组装也是业务逻辑的核心仓库。runtime/src/weights.rs存放每个模块的权重常量。pallets/template/一个示例 Pallet自带基本存储和调用逻辑。node/src/chain_spec.rs链的创世配置设置初始验证人、初始余额、启用哪些模块。node/src/rpc.rs自定义 RPC 挂载入口。pallets/你实际业务逻辑所在的位置可以自行增加复制模块。改造一条链的第一步通常不是写新逻辑而是先在runtime/src/lib.rs里把已有的模块调通再逐步添加或冻结不需要的模块。这个过程有点像拼装乐高先把官方积木验证一遍再拆掉不需要的再上自己的积木。5.3 编译与启动验证直接跑cargo build --release会耗费非常多时间第一次编译大概需要 15-30 分钟这取决于机器性能。编译结束得到target/release/node-template接着可以用开发模式启动./target/release/node-template --dev--dev模式意味着链是单节点开发测试链出块快不设置任何真实网络向外广播所有交易都会被打包进本地区块。启动后如果你看到类似 Idle的日志说明区块已经停止出块如果看到✨ Imported #12345不断刷屏说明链正常运行中。随时连接 Polkadot-JS Apps 或直接用subkey生成账户转账测试。用--dev模式启动的链Clerk 会把本地节点当成开发环境会开一个 WebSocket 的 RPC 端口你可以通过 RPC 调用查询链上状态。5.4 测试与调试细节测试是链开发里最容易被忽略却又最显功夫的部分。主推的做法有这么几层第一层用cargo test跑 Pallet 单元测试主要测业务逻辑正确性不涉及区块生产。第二层用frame_support::construct_runtime的方式建立 mock runtime模拟 Multisig、多账户、不同调用者场景验证模块与模块之间的交互。第三层用zombienet或者parachain-launch启动多个节点的本地测试网验证共识、交易池、网络同步等真实环境行为。在写 Pallet 时有一个省心技巧尽量把业务逻辑拆分成多个内部函数减少在 Dispatchable 函数里的逻辑嵌套。这样写单元测试会容易很多不然测试各种边界条件时你会发现无法直接调用内部的原始函数只能通过 extrinsic 往返费时费力。5.5 上线前的必要检查清单如果项目要从小范围验证走向生产至少在第一次上线前把下面这些事情过一遍检查存储迁移路径确认 Runtime 升级路径有明确的版本号记录检查权重基准测试数据不要使用拍脑袋的权重常量检查验证人选举与奖励分配逻辑是否经过充分场景测试检查代币精度一个 Pallet 的精度设置不统一会导致转账精度丢失检查密钥管理验证人节点/持币账户的密钥隔离和多重签名机制准备一个专门的监控节点记录区块高度、延迟、交易池积压、磁盘增长。6. 常见问题与排查技巧实录Substrate 的坑很多我再集中说几个特别典型的这类你可能迟早会遇到。6.1 Runtime 版本号未更新导致的升级失败我自己最早从spec_version100 升级到 101 的时候只改了逻辑没有更新版本号导致网络中部分节点认为新区块里的 Runtime 和旧版一致拒绝执行预期中的新逻辑整个网络出现短暂分叉。排查方法很简单——检查每个 Runtime 版本是否在所有节点里一致。升级时只要改了 Migration、新增存储或修改交易逻辑就必须同步提升spec_version。6.2 Pallet 存储 key 前缀引发的数据丢失新建 Pallet 时如果不小心改了 Pallet 名称它的存储前缀会跟着变。虽然编译会通过但链上旧数据都变成了无法读取的分离碎片。这个坑在测试阶段不致命但一旦上了生产就神仙难救要么做存储迁移要么接受旧数据丢失。所以从第一天起就不要随意改 Pallet 名称并且所有存储项都要设计好版本控制。6.3 手续费模拟和实际耗用的差异由于 Substrate 默认手续费计算依赖权重而权重包含和执行时间。如果重量依赖 For 循环处理大集合模拟环境很快但真实高负载节点可能明显更慢导致某笔交易在运行时超过了实际限定的最大权重。解决这个问题的办法只能是严格实施 Benchmark为最坏情况准备好充分上限同时使用CheckWeight对操作的时间和存储消耗预检查。6.4 出块正常但区块始终无法最终确认这种情况多见于多验证人网络里验证人集合不完整或 session 管理过期。排查思路先确认验证人数量大于等于 GRANDPA 的required再确认 session 轮换是否有模块 block 收集密钥最后检查网络层是否有大量 block announce 丢包。最快速的定位方法是在每个验证人节点上检查grandpa_currentSet和authorities是否一致。6.5 Offchain Worker 数据无法上链Substrate 支持 Offchain Worker 做链下计算很多人会在这里踩坑。它的数据默认不会自动进入链状态需要通过ocw签名交易、通过UnsignedTransaction提交或者使用offchain_index写入带外存储。如果忘记设置交易的unsigned校验逻辑或者没有给 Offchain Worker 正确配置 HTTP 超时数据最终就会丢失。排查时务必逐段开日志确认 worker 是否执行、签名是否合法、交易是否被ValidateUnsigned接受。7. 写代码之外的一点心得最后抛开工具和代码讲点个人感受。Substrate 的学习曲线非常陡入门不是难在 Rust 语言而是它的抽象层次太多新手经常搞不清“什么逻辑放在 Pallet 里什么逻辑放在 Node 里什么逻辑放在 Runtime 之外”。这条线理清了后面开发会顺畅很多。我个人经验里最重要的一条是永远不要只凭直觉给权重定一个估算值为每个 Pallet 跑 Benchmark穷举输入范围并生成真实数据这一步虽然费时间但能帮你省掉之后所有线上性能问题。另一条经验是尽量在项目一开始就决定好“要不要接入波卡生态”。如果答案是“要”那么从一开始就按 Parachain 模式来布局否则后来迁移成本极高如果答案是“不要”那就安心把独立链的共识、验证人、手续费、治理这些模块配好不需要给自己增加不必要的复杂度。这个方向我自己已经踩过了很多坑整理出来给后来者。真想把一条链做扎实光靠看文档不行拉下一个模板、改一个 Pallet、跑通一条跨链转账感受完全不一样。试试看你应该也会有这个感觉。
返回列表