--- title: 深入 Dojo 合约系统 date: 2023-09-10 10:47:30 tags: [[Dojo],[Cairo]] --- ## 概述 全链游戏突然间再次成为了叙述主流。万幸的是,我所熟悉的 StarkNet 生态系统内存在目前最好的全链游戏引擎之一的 [Dojo](https://www.dojoengine.org/en/)。 此教程可以认为是 Dojo 开发的 `Quick Start`,本文会介绍 dojo 开发环境的搭建与 ECS 系统的基本概念,但是由于笔者不擅长游戏逻辑设计且专精于 cairo 开发,所以本文会包含大量的源代码阅读,帮助读者深入了解 dojo 开发框架。 本文基于 `dojo v0.4.1` 编写。 ## 环境配置 我们首先使用以下命令安装 `dojoup`: ```bash curl -L https://install.dojoengine.org | bash ``` 然后使用以下命令安装 `Dojo` 组件: ```bash dojoup ``` 我们可以看到 `dojoup` 安装了以下四部分内容: ```bash dojoup: installed - katana 0.4.1 dojoup: installed - sozo 0.4.1 dojoup: installed - torii 0.4.1 dojoup: installed - dojo-language-server 0.4.1 dojoup: done! ``` `sozo` 是一个核心组件,主要用于项目初始化、代码测试、项目交互以及最终的项目部署和迁移更新等工作。与 Foundry 系统内的 `forge` 命令类似。 `katana` 是一个快速的 StarkNet 本地节点,允许用户快速启动 StarkNet 本地测试环境。 `torii` 则是一个本地的检索器,用于开始启动对于项目合约数据的链下检索,使用此命令后会启动一个 `graphql` 服务器来响应用户请求。 `dojo-languge-server` 是 dojo 的 LSP ,用于实时显示语法报错。 完成以上安装后,创建一个新的文件夹,并进入此文件夹使用以下命令初始化 dojo 项目: ```bash sozo init ``` 初始化完成后,我们可以获得以下目录结构: ```bash . ├── LICENSE ├── README.md ├── Scarb.lock ├── Scarb.toml ├── scripts │ └── default_auth.sh ├── src │ ├── actions.cairo │ ├── lib.cairo │ ├── models.cairo │ └── utils.cairo └── target ├── CACHEDIR.TAG └── dev ├── dojo::base::base.json ├── dojo::executor::executor.json ├── dojo::world::world.json ├── dojo_examples::actions::actions.json ├── dojo_examples::models::moves.json ├── dojo_examples::models::position.json └── manifest.json ``` 在介绍具体的合约代码前,我们首先体验一下这个项目的具体功能。此项目实现了以下功能: 1. 允许用户调用 `spawn` 创建一个可参与游戏的用户,用户的位置会被初始化到 `x = 1000` 和 `y = 1000` 的位置,同时会把用户的 `remaining` 设置为 100。当用户每移动一步,`remaining` 会自减 1 ,直至 `remaining = 0` 为止 2. 完成用户创建后,用户可以调用 `move` 方法实现移动, 我们可以使用以下方法在本地部署此项目: 首先,我们需要在项目文件夹内使用 `sozo build` 编译此项目。 打开一个新的终端输入以下命令: ```bash katana --disable-fee ``` 上述命令将启动本地的 StarkNet 测试节点。 完成测试节点启动后,我们使用以下命令在本地节点部署 dojo 项目: ```bash sozo migrate --name test ``` 我们可以看到如下输出: ``` Migration account: 0x517ececd29116499f4a1b64b094da79ba08dfd54a3edaa316134c41f8160973 World name: test [1] 🌎 Building World state.... > No remote World found [2] 🧰 Evaluating Worlds diff.... > Total diffs found: 5 [3] 📦 Preparing for migration.... > Total items to be migrated (5): New 5 Update 0 # Executor > Contract address: 0x30d87b1462e33becf675f22517d2ff6d6b3b00ace2217a629a7eab770e467bc # Base Contract > Class Hash: 0x6c458453d35753703ad25632deec20a29faf8531942ec109e6eb0650316a2bc # World > Contract address: 0x249f6226633c7a23abf30eec47dc1e27c5ea9aad5e702076d5f57c0bc12a271 # Models (2) dojo_examples::models::moves > Class hash: 0x64495ca6dc1dc328972697b30468cea364bcb7452bbb6e4aaad3e4b3f190147 dojo_examples::models::position > Class hash: 0x2b233bba9a232a5e891c85eca9f67beedca7a12f9768729ff017bcb62d25c9d > Registered at: 0x122d8a08c80ddf09d061df645242cfe7ce15401bae9226f873b2ec6d937644c # Contracts (1) dojo_examples::actions::actions > Contract address: 0x6cef16725f6d52fc1c8b30fb1f747ad8be450faa621685026e43d26fb9bfc25 🎉 Successfully migrated World at address 0x249f6226633c7a23abf30eec47dc1e27c5ea9aad5e702076d5f57c0bc12a271 ✨ Updating manifest.json... ✨ Done. ``` 此处显示了一系列合约,我们会在后文详细介绍每一个合约的具体功能及其代码。 完成以上部署后,为方便后期调用,我们需要在 `Scarb.toml` 内写入以下内容: ```toml world_address = "0x249f6226633c7a23abf30eec47dc1e27c5ea9aad5e702076d5f57c0bc12a271" ``` 我们可以进行交互操作,我们首先调用 `spawn` 方法创建一个可用于移动的用户: ```bash sozo execute dojo_examples::actions::actions spawn ``` 此处,我们填入了 `dojo_examples::actions::actions` 作为执行 `spawn` 操作的合约,此合约即上文部署的 `actions` 合约。在新版本的 dojo 中,我们可以提供直接填写 `dojo_examples::actions::actions` 来代替 `actions` 合约的实际地址。关于此处为什么需要填写合约地址,我们会在后文进一步讨论。 接下来,我们可以使用以下命令查询我们注册用户的位置: ```bash sozo model get Position 0x517ececd29116499f4a1b64b094da79ba08dfd54a3edaa316134c41f8160973 ``` 此命令的格式如下: ```bash sozo model get <NAME> [KEYS] ``` `NAME` 是我们需要检索的组件的名称,我们系统内存在两个组件,代码如下: ```rust #[derive(Model, Drop, Serde)] struct Moves { #[key] player: ContractAddress, remaining: u8, last_direction: Direction } #[derive(Copy, Drop, Serde, Introspect)] struct Vec2 { x: u32, y: u32 } #[derive(Model, Copy, Drop, Serde)] struct Position { #[key] player: ContractAddress, vec: Vec2, } ``` 我们需要检索 `Position` 组件,而 `[KEYS]` 则是需要检索内容的索引,此处为 `player` 即用户地址。 返回值如下: ```rust struct Position { #[key] player: ContractAddress = 0x517ececd29116499f4a1b64b094da79ba08dfd54a3edaa316134c41f8160973, vec: Vec2, } struct Vec2 { x: u32 = 10, y: u32 = 10, } ``` 在此处,我们就可以发现 `Model` 的核心功能就是存储用户数据,`#[key]` 则是用于检索数据的核心参数。 我们也可以尝试检索 `Moves` 组件,命令如下: ```bash sozo model get Moves 0x517ececd29116499f4a1b64b094da79ba08dfd54a3edaa316134c41f8160973 ``` 返回值如下: ```rust struct Moves { #[key] player: ContractAddress = 0x517ececd29116499f4a1b64b094da79ba08dfd54a3edaa316134c41f8160973, remaining: u8 = 99, last_direction: Direction = Left, } enum Direction { None Left Right Up Down } ``` 完成上述数据检索任务后,我们尝试移动用户的位置,使用以下命令: ```bash sozo execute dojo_examples::actions::actions move -c 1 ``` 此处 `execute` 表示执行某个函数,而 `calldata` 则需要填入具体参数,具体如何填写,我们需要参考 `move` 的具体代码: ```rust impl DirectionIntoFelt252 of Into<Direction, felt252> { fn into(self: Direction) -> felt252 { match self { Direction::Left(()) => 0, Direction::Right(()) => 1, Direction::Up(()) => 2, Direction::Down(()) => 3, } } } // Implementation of the move function for the ContractState struct. fn move(self: @ContractState, direction: Direction) { // Access the world dispatcher for reading. let world = self.world_dispatcher.read(); // Get the address of the current caller, possibly the player's address. let player = get_caller_address(); // Retrieve the player's current position and moves data from the world. let (mut position, mut moves) = get!(world, player, (Position, Moves)); // Deduct one from the player's remaining moves. moves.remaining -= 1; // Update the last direction the player moved in. moves.last_direction = direction; // Calculate the player's next position based on the provided direction. let next = next_position(position, direction); // Update the world state with the new moves data and position. set!(world, (moves, next)); // Emit an event to the world to notify about the player's move. emit!(world, Moved { player, direction }); } ``` 我们此处输入了 `1` ,根据 `DirectionIntoFelt252` 提供 `into` 方法,实际上代表向右移动,此时我们调用以下命令重新查询位置参数: ```bash sozo model get Position 0x517ececd29116499f4a1b64b094da79ba08dfd54a3edaa316134c41f8160973 ``` 返回值如下: ``` struct Vec2 { x: u32 = 9, y: u32 = 10, } ``` 注意到 `x` 的值已经增加了 `1` ,则与我们移动的预期结果是一致的。我们还可以调用以下内容查询用户的 `remaining` 是否发生变化: ```bash sozo model get Moves 0x517ececd29116499f4a1b64b094da79ba08dfd54a3edaa316134c41f8160973 ``` 返回值如下: ```bash struct Moves { #[key] player: ContractAddress = 0x517ececd29116499f4a1b64b094da79ba08dfd54a3edaa316134c41f8160973, remaining: u8 = 99, last_direction: Direction = Left, } enum Direction { None Left Right Up Down } ``` `remaining` 为 99,正好减少了 1 ,也符合我们预期。 最后,我们体验以下 dojo 自带的 `torii` 检索器,在使用检索器前,我们需要创建一个 `sqlite` 文件,请读者切换到 `target` 文件夹内,使用以下命令创建数据库: ```bash sqlite3 indexer.db ``` 在进入 `sqlite3` 的命令后,输入以下内容: ``` .database .q ``` 如下图: ![Indexer SqliteDB Init](https://img.gejiba.com/images/4aba57fe8e7f244e97d0c8a31fe6b6a7.png) 然后输入以下命令启动检索器: ```bash torii --database sqlite:indexer.db --world 0x249f6226633c7a23abf30eec47dc1e27c5ea9aad5e702076d5f57c0bc12a271 ``` 此处的 `--world` 后应为上文部署的 world 合约地址。 启动成功后,读者可以打开 `http://127.0.0.1:8080/graphql` 进入 `GraphQL` 控制台页面,使用以下查询即可获得用户当前位置: ```graphql query { positionModels { edges { node { player vec { x y } } } } } ``` 结果如下图: ![torii graphql](https://blogimage.4everland.store/toriigraphqlexample.png) 由于本文重点不在于讨论检索器相关内容,所以此处我们不再深入给出 `GraphQL` 的语法知识等,读者可以参考文档或者是我之前编写的 [基于Python与GraphQL的链上数据分析实战](https://blog.wssh.trade/posts/on-chain-data-basement/) 文章。 > 注意目前 `torii` 仍只可使用 `sqlitedb` 作为数据存储后端,但是考虑到 `torii` 使用了 `sqlx` ,支持其他数据库后端应该难度不大 ## 代码解析 在体验完整个项目的具体作用,我们开始进入代码解析阶段。我们需要对 ECS 系统有一定简单的了解,`ECS` 的全称为 `Entity Component System` ,其核心为: - `Component` 组件,正如上文所述,组件的作用是作为数据的载体,用来存储游戏内的各种数据,同时组件具有可组合性,我们可以为用户绑定多个组件 - `Entity` 实体,实际上就是只是一个索引,在上文中的 `player` 就是一个实体,我们可以为实体配置多个组件,目前 sozo 还没有命令可以直接获取实体的所有数据,但我们可以通过 `torii` 实现此功能 - `System` 系统,执行具体的游戏逻辑,系统内往往不存储数据,而是通过 `get!` 方法获取组件内的数据,修改完成后使用 `set!` 将其写回组件,我们使用 `ssozo execute $action move -c 1` 命令其实就是在调用系统内的 `move` 方法 在 dojo 开发系统内,还存在一个较为特殊的存在,其被称为 `World` 世界,简单来说,World 合约是 dojo 系统的核心,所有的 `Component` 都会注册到 World 合约中,可以认为 `World` 合约为 `dojo` 游戏的核心数据库。 在现有的代码中,`Component` 被定义在 `src/models.cairo` 中,该代码内最简单的 `Component` 定义如下: ```rust #[derive(Model, Copy, Drop, Serde)] struct Moves { #[key] player: ContractAddress, remaining: u8, last_direction: Direction } ``` 其中 `Model` 为 `Component` 自动生成了一系列模板代码,而 `Serde` 则用于 `Component` 的序列化。此处的 `#[key]` 定义了该 `Component` 的主键,也可以认为其代表 `Entity` 实体。一般来说,我们会将与数据直接相关的部分 trait 逻辑与组件定义写在一起,如: ```rust impl DirectionIntoFelt252 of Into<Direction, felt252> { fn into(self: Direction) -> felt252 { match self { Direction::None(()) => 0, Direction::Left(()) => 1, Direction::Right(()) => 2, Direction::Up(()) => 3, Direction::Down(()) => 4, } } } ``` 该 `impl` 实现了 `Direction` 组件的 `into` 函数,实现该函数后,我们可以通过调用 `into` 实现自动类型转换。常见的需要与组件一起定义的 trait 包含以下几个: - `PrintTrait<T>` 用于自定义类型 `T` 的打印 - `Into<T, E>` 用于 `into` 相关的自动类型转换 - `Add<T>` 等用于运算符重载的 trait,详细类型可参考 [traits.cairo](https://github.com/starkware-libs/cairo/blob/main/corelib/src/traits.cairo) 一个较为复杂的组件定义如下: ```rust #[derive(Copy, Drop, Serde, Print, Introspect)] struct Vec2 { x: u32, y: u32 } #[derive(Model, Copy, Drop, Print, Serde)] struct Position { #[key] player: ContractAddress, vec: Vec2, } ``` 此处的 `Vec2` 带有 `Introspect` ,该 trait 代表该组件只能被嵌入到其他组件内使用,我们可以看到在 `Position` 内使用了 `Vec2`。 而负责游戏逻辑的 `System` 系统被定义在 `src/actions.cairo` 文件内,`System` 的开发与普通的智能合约基本类似,但是我们会使用 `#[dojo::contract]` 定义合约,且我们需要使用 `use dojo_examples::models::{Position, Moves, Direction, Vec2};` 导入需要的组件。 我们可以看一个简单的示例: ```rust fn spawn(self: @ContractState) { // Access the world dispatcher for reading. let world = self.world_dispatcher.read(); // Get the address of the current caller, possibly the player's address. let player = get_caller_address(); // Retrieve the player's current position from the world. let position = get!(world, player, (Position)); // Retrieve the player's move data, e.g., how many moves they have left. let moves = get!(world, player, (Moves)); // Update the world state with the new data. // 1. Increase the player's remaining moves by 10. // 2. Move the player's position 10 units in both the x and y direction. set!( world, ( Moves { player, remaining: moves.remaining + 10, last_direction: Direction::None(()) }, Position { player, vec: Vec2 { x: position.vec.x + 10, y: position.vec.y + 10 } }, ) ); } ``` 在 `System` 中,我们使用 `get!` 获得数据,使用 `set!` 写入数据。上文给出的 `spawn` 是一个初始化函数。 一个复杂的 `System` 示例如下: ```rust fn move(self: @ContractState, direction: Direction) { // Access the world dispatcher for reading. let world = self.world_dispatcher.read(); // Get the address of the current caller, possibly the player's address. let player = get_caller_address(); // Retrieve the player's current position and moves data from the world. let (mut position, mut moves) = get!(world, player, (Position, Moves)); // Deduct one from the player's remaining moves. moves.remaining -= 1; // Update the last direction the player moved in. moves.last_direction = direction; // Calculate the player's next position based on the provided direction. let next = next_position(position, direction); // Update the world state with the new moves data and position. set!(world, (moves, next)); // Emit an event to the world to notify about the player's move. emit!(world, Moved { player, direction }); } ``` 在此处,我们可以看到此函数使用 `emit!` 释放了事件,简化了 cairo 语言原生的事件释放语法。在此处,我们也可以一个完整读取-修改-写入的流程。具体来说,使用 `get!` 获得了可变 `mut` 变量,然后修改结构体内的数据,然后直接 `set!` 到 `world` 合约。 最后,我们介绍 `System` 的测试,如下: ```rust #[test] #[available_gas(30000000)] fn test_move() { // caller let caller = starknet::contract_address_const::<0x0>(); // models let mut models = array![position::TEST_CLASS_HASH, moves::TEST_CLASS_HASH]; // deploy world with models let world = spawn_test_world(models); // deploy systems contract let contract_address = world .deploy_contract('salt', actions::TEST_CLASS_HASH.try_into().unwrap()); let actions_system = IActionsDispatcher { contract_address }; // call spawn() actions_system.spawn(); // call move with direction right actions_system.move(Direction::Right(())); // Check world state let moves = get!(world, caller, Moves); // casting right direction let right_dir_felt: felt252 = Direction::Right(()).into(); // check moves assert(moves.remaining == 9, 'moves is wrong'); // check last direction assert(moves.last_direction.into() == right_dir_felt, 'last direction is wrong'); // get new_position let new_position = get!(world, caller, Position); // check new position x assert(new_position.vec.x == 11, 'position x is wrong'); // check new position y assert(new_position.vec.y == 10, 'position y is wrong'); } ``` 在测试中,我们一般需要按照以下步骤进行: 第一步是初始化 World 合约,首先需要声明包含所有 `Component` 构成的 `models` 数组,然后使用 `spawn_test_world` 初始化并部署 `world` 合约,最后部署 `System` 合约。 ```rust // models let mut models = array![position::TEST_CLASS_HASH, moves::TEST_CLASS_HASH]; // deploy world with models let world = spawn_test_world(models); // deploy systems contract let contract_address = world .deploy_contract('salt', actions::TEST_CLASS_HASH.try_into().unwrap()); let actions_system = IActionsDispatcher { contract_address }; ``` 第二步就是进行调用和 `assert` 断言判断: ```rust // call spawn() actions_system.spawn(); // call move with direction right actions_system.move(Direction::Right(())); // Check world state let moves = get!(world, caller, Moves); // casting right direction let right_dir_felt: felt252 = Direction::Right(()).into(); // check moves assert(moves.remaining == 9, 'moves is wrong'); // check last direction assert(moves.last_direction.into() == right_dir_felt, 'last direction is wrong'); // get new_position let new_position = get!(world, caller, Position); // check new position x assert(new_position.vec.x == 11, 'position x is wrong'); // check new position y assert(new_position.vec.y == 10, 'position y is wrong'); ``` ## 深入部署 从此节开始,我们将深入 dojo 的源代码来探索每一步操作背后的原理。我们首先从一切的开始 world 及其他合约的部署开始聊起。 在上文的测试中,我们给出了合约初始化的 Cairo 代码: ```rust let mut models = array![position::TEST_CLASS_HASH, moves::TEST_CLASS_HASH]; // deploy world with models let world = spawn_test_world(models); // deploy systems contract let contract_address = world .deploy_contract('salt', actions::TEST_CLASS_HASH.try_into().unwrap()); let actions_system = IActionsDispatcher { contract_address }; ``` 此处的 `spawn_test_world` 函数定义如下: ```rust fn spawn_test_world(models: Array<felt252>) -> IWorldDispatcher { // deploy executor let constructor_calldata = array::ArrayTrait::new(); let (executor_address, _) = deploy_syscall( executor::TEST_CLASS_HASH.try_into().unwrap(), 0, constructor_calldata.span(), false ) .unwrap(); // deploy world let (world_address, _) = deploy_syscall( world::TEST_CLASS_HASH.try_into().unwrap(), 0, array![executor_address.into(), dojo::base::base::TEST_CLASS_HASH].span(), false ) .unwrap(); let world = IWorldDispatcher { contract_address: world_address }; // register models let mut index = 0; loop { if index == models.len() { break (); } world.register_model((*models[index]).try_into().unwrap()); index += 1; }; world } ``` 我们可以看到此处的部署是分为以下三步的: 1. 部署 `executor` 合约 2. 使用 `executor` 合约地址和 `dojo::base::base` 合约 class 作为参数部署 world 合约 3. 在 world 合约内使用 `model` 合约的 class hash 为参数调用 `register_model` 方法 我们首先给出 `executor` 合约的源代码: ```rust use starknet::ClassHash; #[starknet::interface] trait IExecutor<T> { fn call( self: @T, class_hash: ClassHash, entrypoint: felt252, calldata: Span<felt252> ) -> Span<felt252>; } #[starknet::contract] mod executor { use array::{ArrayTrait, SpanTrait}; use option::OptionTrait; use starknet::{ClassHash, SyscallResultTrait, SyscallResultTraitImpl}; use super::IExecutor; const EXECUTE_ENTRYPOINT: felt252 = 0x0240060cdb34fcc260f41eac7474ee1d7c80b7e3607daff9ac67c7ea2ebb1c44; #[storage] struct Storage {} #[external(v0)] impl Executor of IExecutor<ContractState> { /// Call the provided `entrypoint` method on the given `class_hash`. /// /// # Arguments /// /// * `class_hash` - Class Hash to call. /// * `entrypoint` - Entrypoint to call. /// * `calldata` - The calldata to pass. /// /// # Returns /// /// The return value of the call. fn call( self: @ContractState, class_hash: ClassHash, entrypoint: felt252, calldata: Span<felt252> ) -> Span<felt252> { starknet::syscalls::library_call_syscall(class_hash, entrypoint, calldata) .unwrap_syscall() } } } ``` 此处的 `EXECUTE_ENTRYPOINT` 是之前版本的遗留阐述,在本文讨论的 3.10 中已经没有用途。我们可以看到 `Executor` 合约只包含 `call` 函数用于进行 `library_call_syscall` 调用并返回调用结果。 在 World 合约中,我们在以下函数中使用了 `Executor` 合约: ```rust fn class_call( self: @ContractState, class_hash: ClassHash, entrypoint: felt252, calldata: Span<felt252> ) -> Span<felt252> { self.executor_dispatcher.read().call(class_hash, entrypoint, calldata) } ``` 而 `class_call` 在 world 合约内只使用了一次: ```rust let name = *class_call(@self, class_hash, NAME_ENTRYPOINT, calldata.span())[0]; ``` 读者可能好奇为什么 world 合约要引入 `executor` 进行 class 调用操作?这是为了安全的隔离考虑,某些用户可能恶意构造一些特殊的 class ,当合约调用时会修改调用者的存储。而当我们引入 `executor` 合约后,即使调用了恶意的 class ,也不会导致 world 合约内的数据被修改。 另一个合约 `dojo::base::base` 的源代码如下: ```rust use dojo::world::IWorldDispatcher; #[starknet::interface] trait IBase<T> { fn world(self: @T) -> IWorldDispatcher; } #[starknet::contract] mod base { use starknet::{ClassHash, get_caller_address}; use dojo::upgradable::{IUpgradeable, UpgradeableTrait}; use dojo::world::IWorldDispatcher; #[storage] struct Storage { world_dispatcher: IWorldDispatcher, } #[constructor] fn constructor(ref self: ContractState) { self.world_dispatcher.write(IWorldDispatcher { contract_address: get_caller_address() }); } #[external(v0)] fn world(self: @ContractState) -> IWorldDispatcher { self.world_dispatcher.read() } #[external(v0)] impl Upgradeable of IUpgradeable<ContractState> { /// Upgrade contract implementation to new_class_hash /// /// # Arguments /// /// * `new_class_hash` - The new implementation class hahs. fn upgrade(ref self: ContractState, new_class_hash: ClassHash) { UpgradeableTrait::upgrade(new_class_hash); } } } ``` 正如其名,此合约是一个最简单的基础合约,仅包含一个 `upgrade` 合约升级函数。该合约在 World 合约内存在以下用法: ```rust fn deploy_contract( ref self: ContractState, salt: felt252, class_hash: ClassHash ) -> ContractAddress { let (contract_address, _) = deploy_syscall( self.contract_base.read(), salt, array![].span(), false ) .unwrap_syscall(); let upgradable_dispatcher = IUpgradeableDispatcher { contract_address }; upgradable_dispatcher.upgrade(class_hash); self.owners.write((contract_address.into(), get_caller_address()), true); EventEmitter::emit( ref self, ContractDeployed { salt, class_hash, address: contract_address } ); contract_address } ``` 此函数用于部署 System 合约,如上文 `action` 合约即通过此函数部署。可能又有读者好奇为什么不直接使用 `deploy_syscall` 部署 System 合约,而是通过需要先部署 base 合约后升级到 System 合约呢? 这实际上是一个特殊的初始化方法。 在 `action` 合约内,我们会使用以下语句: ```rust fn spawn(self: @ContractState) { // Access the world dispatcher for reading. let world = self.world_dispatcher.read(); ``` 读者应该可以注意到 `action` 合约内根本没有 `world_dispatcher` 定义,此定义实际上是通过预先部署的 base 合约传递的。 部署的最后一步是注册 Component 组件,主要使用了 `register_model` 函数,此函数在 world 合约内定义如下: ```rust /// Registers a model in the world. If the model is already registered, /// the implementation will be updated. /// /// # Arguments /// /// * `class_hash` - The class hash of the model to be registered. fn register_model(ref self: ContractState, class_hash: ClassHash) { let caller = get_caller_address(); let calldata = ArrayTrait::new(); let name = *class_call(@self, class_hash, NAME_ENTRYPOINT, calldata.span())[0]; let mut prev_class_hash = starknet::class_hash::ClassHashZeroable::zero(); // If model is already registered, validate permission to update. let current_class_hash = self.models.read(name); if current_class_hash.is_non_zero() { assert(self.is_owner(caller, name), 'only owner can update'); prev_class_hash = current_class_hash; } else { self.owners.write((name, caller), true); }; self.models.write(name, class_hash); EventEmitter::emit(ref self, ModelRegistered { name, class_hash, prev_class_hash }); } ``` 此函数较为简单,其功能为如果系统内不存在当前 model 则直接将此 model 注册到 `models` 映射内,同时更新 model 的 `owner`;如果存在此 model,则判断调用者是否为 `owner`,如果是则进行更新 `models` 映射。 至此,我们再次阅读以下之前执行 `sozo migrate --name test` 的输出: ```bash # Executor > Contract address: 0x59f31686991d7cac25a7d4844225b9647c89e3e1e2d03460dbc61e3fbfafc59 # Base Contract > Class Hash: 0x5a2c567ed06c8059c8d1199684796a0a0ef614f9a2ab628700e804524816b5c # World > Contract address: 0x13dfc87155d415ae384a35ba4333dfe160645ad7c83dc8b5812bd7ade9d69d6 # Models (2) Moves > Class hash: 0x2e5174b54aef0b99d4685827ffa51488447e1f5607908293d5c715d6bd22433 Position > Class hash: 0x6a11b5b3003a3aa0ae7f8f443e48314cc0bc51eaea7c3ed1c19beb909f5dda3 > Registered at: 0x5b9ff0db391a01696e1fb61b2efe38a129eb61f0054ad70e5d9c11bfeeede1e # Contracts (1) actions > Contract address: 0x47c92218dfdaac465ad724f028f0f075b1c05c9ff9555d0e426c025e45c035 ``` 读者应该可以理解输出的顺序以及为什么某些输出为 `Class Hash` 而另一些为 `Contract address` > 此处的 `Registered at:` 后为 `Position` 注册的交易 hash ## 深入 Model 在上文中,我们展示了 dojo 中 Model 的定义方法,如下: ```rust #[derive(Model, Copy, Drop, Serde)] struct Moves { #[key] player: ContractAddress, remaining: u8, last_direction: Direction } ``` 但是在上文,我们可以看到这一个结构体实际上被拓展为了合约。此处,我们可以简单讨论一下 dojo 的编译流程。Dojo 的编译实际上建立在 Cairo 编译器的基础上,Dojo 编译器负责将其 Dojo 定义的语法元素(大部分都是宏),如 `Model` 和 `#[key]` 等转化为 Cairo 代码。 此处对于 Model 的转化代码可以在 [dojo-lang/src/model.rs](https://github.com/dojoengine/dojo/blob/main/crates/dojo-lang/src/model.rs) 内找到,完整代码如下: ```rust impl $type_name$Model of dojo::model::Model<$type_name$> { #[inline(always)] fn name(self: @$type_name$) -> felt252 { '$type_name$' } #[inline(always)] fn keys(self: @$type_name$) -> Span<felt252> { let mut serialized = ArrayTrait::new(); $serialized_keys$ array::ArrayTrait::span(@serialized) } #[inline(always)] fn values(self: @$type_name$) -> Span<felt252> { let mut serialized = ArrayTrait::new(); $serialized_values$ array::ArrayTrait::span(@serialized) } #[inline(always)] fn layout(self: @$type_name$) -> Span<u8> { let mut layout = ArrayTrait::new(); dojo::database::schema::SchemaIntrospection::<$type_name$>::layout(ref layout); array::ArrayTrait::span(@layout) } #[inline(always)] fn packed_size(self: @$type_name$) -> usize { let mut layout = self.layout(); dojo::packing::calculate_packed_size(ref layout) } } $schema_introspection$ #[starknet::interface] trait I$type_name$<T> { fn name(self: @T) -> felt252; } #[starknet::contract] mod $contract_name$ { use super::$type_name$; #[storage] struct Storage {} #[external(v0)] fn name(self: @ContractState) -> felt252 { '$type_name$' } #[external(v0)] fn unpacked_size(self: @ContractState) -> usize { dojo::database::schema::SchemaIntrospection::<$type_name$>::size() } #[external(v0)] fn packed_size(self: @ContractState) -> usize { let mut layout = ArrayTrait::new(); dojo::database::schema::SchemaIntrospection::<$type_name$>::layout(ref layout); let mut layout_span = layout.span(); dojo::packing::calculate_packed_size(ref layout_span) } #[external(v0)] fn layout(self: @ContractState) -> Span<u8> { let mut layout = ArrayTrait::new(); dojo::database::schema::SchemaIntrospection::<$type_name$>::layout(ref layout); array::ArrayTrait::span(@layout) } #[external(v0)] fn schema(self: @ContractState) -> dojo::database::schema::Ty { dojo::database::schema::SchemaIntrospection::<$type_name$>::ty() } } ``` 此处的所有使用美元符包裹的字段都会根据用户编写的 Dojo 代码进行重写。代码如下: ```rust &UnorderedHashMap::from([ ("contract_name".to_string(), RewriteNode::Text(name.to_case(Case::Snake))), ( "type_name".to_string(), RewriteNode::new_trimmed(struct_ast.name(db).as_syntax_node()), ), ("schema_introspection".to_string(), handle_introspect_struct(db, struct_ast)), ("serialized_keys".to_string(), RewriteNode::new_modified(serialized_keys)), ("serialized_values".to_string(), RewriteNode::new_modified(serialized_values)), ]), ``` 此部分代码较长,我们将其分为以下三部分分析: 1. `impl` 部分,此部分为结构体实现了一系列方法 2. `$schema_introspection$` 部分,提供结构体的数据库支持 3. `contract` 部分,此部分提供了大量合约功能 我们首先分析 `impl` 部分,此部分代码如下: ```rust impl $type_name$Model of dojo::model::Model<$type_name$> { #[inline(always)] fn name(self: @$type_name$) -> felt252 { '$type_name$' } #[inline(always)] fn keys(self: @$type_name$) -> Span<felt252> { let mut serialized = ArrayTrait::new(); $serialized_keys$ array::ArrayTrait::span(@serialized) } #[inline(always)] fn values(self: @$type_name$) -> Span<felt252> { let mut serialized = ArrayTrait::new(); $serialized_values$ array::ArrayTrait::span(@serialized) } #[inline(always)] fn layout(self: @$type_name$) -> Span<u8> { let mut layout = ArrayTrait::new(); dojo::database::schema::SchemaIntrospection::<$type_name$>::layout(ref layout); array::ArrayTrait::span(@layout) } #[inline(always)] fn packed_size(self: @$type_name$) -> usize { let mut layout = self.layout(); dojo::packing::calculate_packed_size(ref layout) } } ``` 此处我们一并给出上文的示例 Dojo 代码: ```rust #[derive(Model, Copy, Drop, Serde)] struct Moves { #[key] player: ContractAddress, remaining: u8, last_direction: Direction } ``` 此处的 `$type_name$` 即我们在 Dojo 系统内声明的 Model 名称,此处为 `Moves`。此部分为我们声明的 Model提供了多个方法,且使用了 `#[inline(always)]` 节约 gas。 > 对于 `#[inline(always)]` 节约 gas 的原理,可以参考 [深入探索 Cairo: Sierra IR 与 Cairo 底层](https://blog.wssh.trade/posts/deep-in-sierra/) 一文 `keys` 函数会返回 Model 的 key 部分,即我们上文使用 `#[key]` 标识的结构体成员,此处为 `player`,而 `values` 则会返回非键的结构体成员,此处为 `remaining` 和 `last_direction`。 根据编译器源代码,我们可以知道 `$serialized_keys$` 和 `$serialized_values$` 都使用了同一个函数实现: ```rust let serialize_member = |m: &Member, include_key: bool| { if m.key && !include_key { return None; } if m.ty == "felt252" { return Some(RewriteNode::Text(format!( "array::ArrayTrait::append(ref serialized, *self.{});", m.name ))); } Some(RewriteNode::Text(format!( "serde::Serde::serialize(self.{}, ref serialized);", m.name ))) }; let serialized_keys: Vec<_> = keys.iter().filter_map(|m| serialize_member(m, true)).collect::<_>(); let serialized_values: Vec<_> = members.iter().filter_map(|m| serialize_member(m, false)).collect::<_>(); ``` 此处的 `m` 指结构体每一个成员。读者可能无法猜测上述代码内出现 `ty` 的含义,我们在此处给出其 Rust 源代码: ```rust #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Member { /// Name of the member. pub name: String, /// Type of the member. #[serde(rename = "type")] pub ty: String, pub key: bool, } ``` 实际上,`ty` 只是此结构体成员的类型名称。简单来说,`serialize_member` 实现了以下功能: 1. 如果结构体成员类型为 `felt252` 则使用 `array::ArrayTrait::append(ref serialized, *self.{});` 将其加入 `serialized` 2. 如果结构体成员类型为其他类型,则使用 `serde::Serde::serialize(self.{}, ref serialized);` 将其加入 `serialized` 接下来,我们介绍复杂的 `layout` 函数,此函数实际上是对 `dojo::database::schema::SchemaIntrospection::<$type_name$>::layout` 的简单包装。而 `dojo::database::schema::SchemaIntrospection::<$type_name$>` 的实现其实是在 `$schema_introspection$` 内实现,而 `packed_size` 函数也与此相关。所以我们首先分析 `$schema_introspection$` ,此部分实现如下: ```rust fn handle_introspect_internal( _db: &dyn SyntaxGroup, name: String, mut layout: Vec<RewriteNode>, mut size_precompute: usize, type_ty: String, members: Vec<Member>, ) -> RewriteNode { let mut size = vec![]; let primitive_sizes = primitive_type_introspection(); members.iter().for_each(|m| { let primitive_intro = primitive_sizes.get(&m.ty); let mut attrs = vec![]; if let Some(p_ty) = primitive_intro { // It's a primitive type if m.key { attrs.push("'key'"); } else { size_precompute += p_ty.0; p_ty.1.iter().for_each(|l| { layout.push(RewriteNode::Text(format!("layout.append({});\n", l))) }); } } else { // It's a custom type if m.key { attrs.push("'key'"); } else { size.push(format!( "dojo::database::schema::SchemaIntrospection::<{}>::size()", m.ty, )); layout.push(RewriteNode::Text(format!( "dojo::database::schema::SchemaIntrospection::<{}>::layout(ref layout);\n", m.ty ))); } } }); if size_precompute > 0 { size.push(format!("{}", size_precompute)); } RewriteNode::interpolate_patched( " impl $name$SchemaIntrospection of dojo::database::schema::SchemaIntrospection<$name$> { #[inline(always)] fn size() -> usize { $size$ } #[inline(always)] fn layout(ref layout: Array<u8>) { $layout$ } #[inline(always)] fn ty() -> dojo::database::schema::Ty { $ty$ } } ", &UnorderedHashMap::from([ ("name".to_string(), RewriteNode::Text(name)), ("size".to_string(), RewriteNode::Text(size.join(" + "))), ("layout".to_string(), RewriteNode::new_modified(layout)), ("ty".to_string(), RewriteNode::Text(type_ty)), ]), ) } ```