到目前为止,我们所有的教程都没有使用过“存储变量”(storage variables)或存储任何永久性的内容。
在 Solidity 和 Ethereum 中,一种更冷门的数据存储设计模式是 SSTORE2 或 SSTORE3,即数据被存储在另一个智能合约的字节码中。
在 Solana 中,这并不是一种冷门的设计模式,而是常态!
回想一下,只要 Solana 程序没有被标记为不可变(immutable),我们(如果是原始部署者)就可以随意更新它的字节码。
Solana 使用相同的机制进行数据存储。
Ethereum 中的存储槽(Storage slots)实际上是一个庞大的键值存储:
{
key: [smart_contract_address, storage slot]
value: 32_byte_slot // (for example: 0x00)
}
Solana 的模型与此类似:它也是一个庞大的键值存储,其中“键”(key)是经过 base58 编码的地址,而“值”(value)是一个最大可达 10MB 的数据块(或者也可以不包含任何内容)。可以将其可视化如下:
{
// key is a base58 encoded 32 byte sequence
key: ETnqC8mvPRyUVXyXoph22EQ1GS5sTs1zndkn5eGMYWfs
value: {
data: 020000006ad1897139ac2bdb67a3c66a...
// other fields are omitted
}
}
在 Ethereum 中,智能合约的字节码和智能合约的存储变量是分开存储的,即它们的索引方式不同,并且必须使用不同的 API 来加载。
下图展示了 Ethereum 是如何维护状态的。每个账户都是 Merkle 树中的一个叶子节点。请注意,“存储变量”被存储在智能合约(Account 1)的账户“内部”。

在 Solana 中,一切都是账户,并且都有可能保存数据。有时我们将一个账户称为“程序账户”(program account),将另一个账户称为“存储账户”(storage account),但唯一的区别在于其可执行标志(executable flag)是否被设置为 true,以及我们打算如何使用该账户的 data 字段。
在下面我们可以看到,Solana 存储是一个巨大的键值对存储,将 Solana 地址映射到一个账户:

想象一下,如果 Ethereum 没有存储变量,且智能合约默认是可变的。为了存储数据,你必须创建其他的“智能合约”并将数据保存在它们的字节码中,然后在必要时进行修改。这就是理解 Solana 的一种心智模型。
另一种心智模型是 Unix 中的“一切皆文件”,只不过有些文件是可执行的。Solana 账户可以被看作是文件。它们包含内容,但也拥有指示谁拥有该文件、文件是否可执行等信息的元数据。
在 Ethereum 中,存储变量与智能合约直接耦合。除非智能合约通过 public 变量、delegatecall 或某些 setter 方法授予读写权限,否则默认情况下,存储变量只能由单个合约写入或读取(尽管任何人都可以在链下读取存储变量)。在 Solana 中,所有的“存储变量”都可以被任何程序读取,但只有它的所有者程序(owner program)才能对其进行写入。
存储与程序“绑定”的方式是通过 owner 字段。
在下图中,我们可以看到账户 B 由程序账户 A 拥有。我们知道 A 是一个程序账户,因为它的 executable 被设置为了 true。这表明 B 的 data 字段将用于为 A 存储数据:

Solana 程序在使用前需要被初始化
在 Ethereum 中,我们可以直接对以前未使用过的存储变量进行写入。然而,Solana 程序需要一个明确的初始化交易。也就是说,在向账户写入数据之前,我们必须先创建该账户。
在单笔交易中同时初始化并向 Solana 账户写入数据是可行的——但这会引入安全问题,如果我们现在处理这些问题,将会使讨论变得复杂。目前,只需记住 Solana 账户必须先初始化才能使用即可。
一个基本的存储示例
让我们将下面的 Solidity 代码转换为 Solana 代码:
contract BasicStorage {
Struct MyStorage {
uint64 x;
}
MyStorage public myStorage;
function set(uint64 _x) external {
myStorage.x = _x;
}
}
把单一变量包装在一个结构体(struct)中似乎有些奇怪。
但在 Solana 程序中,尤其是 Anchor 中,所有存储,或者更确切地说是账户数据,都被视作结构体。原因在于账户数据的灵活性。由于账户是可能非常大(最大 10MB)的数据块,我们需要一些“结构”来解释这些数据,否则它仅仅是一串毫无意义的字节序列。
在幕后,当我们尝试读取或写入数据时,Anchor 会将账户数据反序列化和序列化为结构体。
如上所述,我们需要在使用 Solana 账户之前对其进行初始化,因此在实现 set() 函数之前,我们需要编写 initialize() 函数。
账户初始化样板代码
让我们创建一个名为 basic_storage 的新 Anchor 项目。
下面我们编写了初始化 MyStorage 结构体的最小代码,该结构体仅包含一个数字 x。(请参见代码底部的 MyStorage 结构体):
use anchor_lang::prelude::*;
use std::mem::size_of;
declare_id!("GLKUcCtHx6nkuDLTz5TNFrR4tt4wDNuk24Aid2GrDLC6");
#[program]
pub mod basic_storage {
use super::*;
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(init,
payer = signer,
space=size_of::<MyStorage>() + 8,
seeds = [],
bump)]
pub my_storage: Account<'info, MyStorage>,
#[account(mut)]
pub signer: Signer<'info>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct MyStorage {
x: u64,
}
1) initialize 函数
请注意,initialize() 函数中没有任何代码——实际上它所做的只是返回 Ok(()):
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
初始化账户的函数并不强制要求为空,我们可以添加自定义逻辑。但在我们的示例中,它是空的。initialize 账户的函数也不强制要求被命名为 initialize,但这是一个很有帮助的名称。
2) Initialize 结构体
Initialize 结构体包含了初始化账户所需资源的引用:
my_storage:我们正在初始化的MyStorage类型的结构体。signer:为存储该结构体支付“Gas”费用的钱包。(存储的 Gas 成本将在稍后讨论)。system_program:我们将会在本教程的后面部分进行讨论。

'info 关键字是一个 Rust 生命周期(Rust lifetime)。这是一个庞大的主题,目前最好将其视为样板代码。
我们将重点关注 my_storage 上方的宏(macro),因为这里才是执行初始化动作的地方。
3) Initialize 结构体中的 my_storage 字段
my_storage 字段上方的属性宏(紫色箭头)是 Anchor 知道这笔交易旨在初始化该账户的方式(记住,类似属性的宏(attribute-like macro) 以 # 开头,并为结构体扩充额外的功能):

这里重要的关键字是 init。
当我们 init 一个账户时,我们必须提供额外的信息:
payer(蓝框):谁在为分配存储空间支付 SOL。signer被指定为mut,因为他们的账户余额将会发生变化,即一些 SOL 将从他们的账户中扣除。因此,我们将他们的账户标注为“可变的”(mutable)。space(橙框):这表明账户将占用多少空间。与其我们自己计算,不如使用std::mem::size_of工具,并将我们要尝试存储的结构体MyStorage(绿框)作为参数传入。对于+ 8(粉框),我们将在下一点进行讨论。seeds和bump(红框):一个程序可以拥有多个账户,它通过“种子”(seed)在账户之间进行“区分”(discriminate),该“种子”用于计算“鉴别器”(discriminator)。“鉴别器”占用 8 个字节,这就是为什么除了结构体占用的空间外,我们还需要额外分配 8 个字节。目前,bump 可以被视为样板代码。
一下子接触这些内容可能看起来有点多,但不要担心。目前在很大程度上可以将初始化账户视为样板代码。
4) 什么是 system program?
system program 是内置于 Solana 运行时(runtime)中的一个程序(有点像 Ethereum 预编译(Ethereum precompile)),负责将 SOL 从一个账户转移到另一个账户。在后面关于转移 SOL 的教程中,我们将重新讨论这个问题。现在,我们需要从支付 MyStruct 存储费用的 signer 账户中转出 SOL,因此 system program 始终是初始化交易的一部分。
5) MyStorage 结构体
回想一下 Solana 账户内部的 data 字段:

在底层,这是一个字节序列。上面示例中的结构体:
#[account]
pub struct MyStorage {
x: u64,
}
在被写入时,它会被序列化成一个字节序列并存储在 data 字段中。在执行写入操作期间,data 字段会根据该结构体被反序列化。
在我们的示例中,我们只在结构体中使用了一个变量,不过如果我们愿意,也可以添加更多变量或其他类型的变量。
Solana 运行时并不强制我们使用结构体来存储数据。从 Solana 的角度来看,账户仅仅是保存了一个数据块。然而,Rust 有很多方便的库可以将结构体转换为数据块,反之亦然,因此使用结构体是一种惯例。Anchor 在幕后利用了这些库。
使用 Solana 账户并非一定要使用结构体。直接写入字节序列也是可能的,但这不是一种方便的存储数据的方式。
#[account] 宏透明地实现了所有的魔法。
6) 单元测试初始化
下面的 Typescript 代码将运行上述 Rust 代码。
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { BasicStorage } from "../target/types/basic_storage";
describe("basic_storage", () => {
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.BasicStorage as Program<BasicStorage>;
it("Is initialized!", async () => {
const seeds = []
const [myStorage, _bump] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);
console.log("the storage account address is", myStorage.toBase58());
await program.methods.initialize().accounts({ myStorage: myStorage }).rpc();
});
});
下面是单元测试的输出结果:

我们将在后续教程中学习更多相关知识,但 Solana 要求我们提前指定一笔交易将要交互的账户。由于我们将与存储 MyStruct 的账户进行交互,我们需要提前计算其“地址”,并将其传递给 initialize() 函数。这通过以下 Typescript 代码完成:
seeds = []
const [myStorage, _bump] =
anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);
请注意,seeds 是一个空数组,这与 Anchor 程序中的一样。
在 Solana 中预测账户地址就像在 Ethereum 中的 create2
在 Ethereum 中,使用 create2 创建的合约地址取决于:
- 部署合约的地址
- 盐值(salt)
- 创建合约的字节码
在 Solana 中预测已初始化账户的地址与此非常相似,只是它忽略了“字节码”。具体来说,它取决于:
- 拥有该存储账户的程序
basic_storage(类似于部署合约的地址) seeds(类似于 create2 的“盐值”)
在本教程的所有示例中,seeds 都是一个空数组,但我们将在稍后的教程中探索非空数组的情况。
别忘了将 my_storage 转换为 myStorage
Anchor 会在后台默默地将 Rust 的蛇形命名法(snake case)转换为 Typescript 的驼峰命名法(camel case)。当我们在 Typescript 中为 initialize 函数提供 .accounts({myStorage: myStorage}) 时,它实际上是在“填充” Rust 中 Initialize 结构体里的 my_storage 键(下方的绿圈)。而 system_program 和 Signer 则由 Anchor 默默填充:

账户不能被初始化两次
如果我们可以重新初始化一个账户,那将会是一个大问题,因为用户可以通过这种方式抹除系统中的数据!幸运的是,Anchor 在后台进行了防御。
如果你第二次运行该测试(且没有重置本地验证节点 validator),你将会看到下方截图中的错误。
另外,如果你没有使用本地验证节点,你可以运行以下测试:
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { BasicStorage } from "../target/types/basic_storage";
describe("basic_storage", () => {
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.BasicStorage as Program<BasicStorage>;
it("Is initialized!", async () => {
const seeds = []
const [myStorage, _bump] = anchor.web3.PublicKey.findProgramAddressSync(seeds, program.programId);
// ********************************************
// **** NOTE THAT WE CALL INITIALIZE TWICE ****
// ********************************************
await program.methods.initialize().accounts({myStorage: myStorage}).rpc();
await program.methods.initialize().accounts({myStorage: myStorage}).rpc();
});
});
当我们运行测试时,测试会失败,因为第二次调用 initialize 抛出了一个错误。预期输出如下:

如果多次运行测试,别忘了重置验证节点(validator)
由于 solana-test-validator 仍然会记得第一次单元测试中的账户,你需要使用 solana-test-validator --reset 在每次测试之间重置验证节点。否则,你就会遇到上面的错误。
账户初始化总结
对于大多数 EVM 开发者来说,需要初始化账户这一要求可能会让人感觉不太自然。
别担心,你会一遍又一遍地看到这个代码序列,一段时间后它就会变成你的第二本能。
在本教程中,我们只探讨了如何初始化存储,在接下来的教程中,我们将学习如何读取、写入和删除存储。之后会有很多机会让你直观地掌握我们今天所看到的所有代码的作用。
练习: 修改 MyStorage 以包含 x 和 y,就像它是一个笛卡尔坐标一样。这意味着在 MyStorage 结构体中添加 y,并将它们的类型从 u64 更改为 i64。你不需要修改代码的其他部分,因为 size_of 会为你重新计算大小。一定要重置验证节点,这样原有的存储账户就会被擦除,你再次初始化账户时才不会被阻止。
通过 RareSkills 了解更多
请查看我们的 Solana 课程 以了解更多。
原文发布于 2024 年 2 月 24 日