文章详细介绍了Solana区块链中数据存储的机制,特别是如何通过账户和程序来管理和初始化存储数据。文章通过对比以太坊的存储方式,深入探讨了Solana的存储模型和使用Rust语言进行账户初始化的具体步骤。
迄今为止,我们的所有教程都没有使用“存储变量”或存储任何永久性内容。
在 Solidity 和 Ethereum 中,另一种更为特殊的设计模式是 SSTORE2 或 SSTORE3,其中数据存储在另一个智能合约的字节码中。
在 Solana 中,这并不是一种特殊的设计模式,而是一种常规做法!
回想一下,除非程序被标记为不可变,否则我们可以随意更新 Solana 程序的字节码(如果我们是原始部署者)。
Solana 使用相同的机制来存储数据。
以太坊中的存储槽实际上是一个巨大的键值存储:
{
key: [smart_contract_address, storage slot]
value: 32_byte_slot // (例如: 0x00)
}
Solana 的模型类似:它是一个巨大的键值存储,其中“键”是一个 base 58 编码的地址,而值是一个可以大到 10MB 的数据块(或可选择不存储任何内容)。它可以这样可视化:
{
// key 是一个 base58 编码的 32 字节序列
key: ETnqC8mvPRyUVXyXoph22EQ1GS5sTs1zndkn5eGMYWfs
value: {
data: 020000006ad1897139ac2bdb67a3c66a...
// 其他字段省略
}
}
在 Ethereum 中,一个智能合约的字节码和存储变量是分别存储的,即它们是不同索引的,必须使用不同的 API 加载。
以下图表展示了以太坊如何维护状态。每个帐户都是 Merkle 树中的一个叶子。请注意,“存储变量”被存储在智能合约的帐户(帐户 1)“内部”。 在 Solana 中,一切都是帐户,可以潜在地保存数据。有时我们将一个帐户称为“程序帐户”,另一个帐户称为“存储帐户”,但唯一的区别在于可执行标志是否设置为真,以及我们打算如何使用帐户的数据字段。
下面,我们可以看到 Solana 存储是一个从 Solana 地址到帐户的巨大键值存储:
想象一下,如果以太坊没有存储变量,智能合约默认是可变的。要存储数据,你必须创建其他“智能合约”,并将数据保存在它们的字节码中,然后在必要时修改它。这是 Solana 的一种心理模型。
另一种心理模型是 Unix 中的一切都是文件,有些文件是可执行的。可以将 Solana 帐户视为文件。它们保存内容,但它们也具有元数据,指示谁拥有该文件,它是否可执行,等等。
在 Ethereum 中,存储变量直接与智能合约耦合。除非智能合约通过公共变量、delegatecall 或某些设置方法授予读写访问权限,否则存储变量默认只能由单个合约写入或读取(尽管任何人都可以从链下读取存储变量)。在 Solana 中,所有“存储变量”可以被任何程序读取,但只有其所有者程序可以写入它。
存储与程序“绑定”的方式是通过所有者字段。
在下图中,我们看到帐户 B 是由程序帐户 A 所拥有。我们知道 A 是程序帐户,因为“可执行”设置为 true
。这表明 B 的数据字段将存储 A 的数据:
在 Ethereum 中,我们可以直接写入一个我们之前未使用的存储变量。然而,Solana 程序需要一个显式的初始化事务。也就是说,我们必须先创建帐户,然后才能向其中写入数据。
可以在一个事务中初始化并写入一个 Solana 帐户——然而这会引入安全问题,这将使讨论变得复杂。如果我们现在处理它,暂时只需说 Solana 帐户必须在使用之前进行初始化。
让我们将以下 Solidity 代码翻译为 Solana:
contract BasicStorage {
Struct MyStorage {
uint64 x;
}
MyStorage public myStorage;
function set(uint64 _x) external {
myStorage.x = _x;
}
}
将单个变量包裹在一个结构中可能看起来很奇怪。
但在 Solana 程序中,特别是在 Anchor 中,所有存储,或者说帐户数据,都被视为结构。原因在于帐户数据的灵活性。由于帐户是可以非常大的数据块(最大可达 10MB),我们需要某种“结构”来解读数据,否则它只是一个毫无意义的字节序列。
在后台,Anchor 在我们尝试读取或写入数据时会对帐户数据进行反序列化和序列化为结构。
如上所述,我们需要在使用 Solana 帐户之前对其进行初始化,因此在实现 set()
函数之前,我们需要编写 initialize()
函数。
让我们创建一个名为 basic_storage
的新 Anchor 项目。
以下是我们编写的最小代码,以初始化一个仅保存一个数字 x
的 MyStorage
结构。 (请参见底部代码中的结构 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,
}
请注意,initialize()
函数中没有代码——实际上它所做的只是返回 Ok(())
:
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
Ok(())
}
初始化帐户的函数并不一定必须为空,我们可以有自定义逻辑。但在我们的示例中,它是空的。用于“初始化”的函数不必命名为 initialize,但这个名字是有帮助的。
Initialize
结构包含初始化帐户所需资源的引用:
my_storage
: 我们正在初始化的 MyStorage
类型的结构。signer
: 负责为结构的存储支付“Gas费”的钱包。 (关于存储的Gas费用将在后面讨论)。system_program
: 我们将在本教程稍后讨论。 'info
关键字是一个 Rust 生命周期。这是一个重要的主题,目前我们最好将其视为样板代码。
我们将专注于 my_storage
上方的宏,因为这就是初始化工作的地方。
my_storage
字段位于 my_storage
字段上方的属性宏(紫色箭头)是 Anchor 知道此事务旨在初始化此帐户的方式(请记住,属性宏以 #
开头,并通过额外功能增强结构):
这里重要的关键字是 init
。
当我们 init
一个帐户时,必须提供额外的信息:
payer
(蓝框):谁在为分配存储支付 SOL。签名者被指定为 mut
,因为他们的帐户余额将会变化,即会从他们的帐户中扣除一些 SOL。因此,我们将他们的帐户注释为“可变”。space
(橙框):这指示帐户将占用多少空间。我们可以使用 std::mem::size_of
工具,并将我们正在尝试存储的结构 MyStorage
(绿色框)作为参数。+ 8
(粉色框)的含义将在下一点中讨论。seeds
和 bump
(红框):一个程序可以拥有多个帐户,它通过“种子”在帐户之间进行“区分”,该种子用于计算“鉴别符”。“鉴别符”占 8 个字节,这就是为什么除了我们结构所占的空间外还需要分配额外的 8 个字节。bump 目前可以视为样板代码。这可能听起来有点复杂,但不用担心。初始化帐户在很大程度上可以视为样板代码。
system program
是一个内置于 Solana 运行时的程序(有点像 Ethereum 预编译),它从一个帐户向另一个帐户转移 SOL。我们将在后面的教程中重新访问这个概念。现在,我们需要将 SOL 从支付 MyStruct
存储的签名者那里转移,因此 system program
总是初始化事务的一部分。
回想一下 Solana 帐户内的数据字段: 在幕后,这是一串字节序列。上面示例中的结构:
#[account]
pub struct MyStorage {
x: u64,
}
在写入时被序列化为字节序列并存储在 data
字段中。在写入期间,data
字段根据该结构被反序列化。
在我们的示例中,我们仅使用了结构中的一个变量,尽管如果我们想的话,可以添加更多变量或其他类型的变量。
Solana 运行时并不强制我们使用结构来存储数据。从 Solana 的角度来看,帐户只是保存数据块。但是,Rust 有很多方便的库将结构转换为数据块和反之亦然,因此结构是惯例。Anchor 在幕后利用这些库。
你不需要使用结构来使用 Solana 帐户。可以直接写入字节序列,但这不是存储数据的便捷方式。
#[account]
宏透明地实现了所有魔法。
以下 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 程序中一样。
在以太坊中,使用 create2 创建的合约地址依赖于:
预测 Solana 中初始化帐户的地址则非常相似,只是忽略了“字节码”。具体而言,它依赖于:
basic_storage
(这类似于部署合约的地址)seeds
(这类似于 create2 的“盐”)在本教程中的所有示例中,seeds
是一个空数组,但我们将在后面的教程中探讨非空数组。
Anchor 将 Rust 的蛇形命名法默默转换为 Typescript 的驼峰命名法。当我们在 Typescript 中向 initialize 函数提供 .accounts({myStorage: myStorage})
时,它是在“填写” Rust 中 Initialize
结构的 my_storage
键(下方绿色圆圈)。system_program
和 Signer
会由 Anchor 静默填充:
如果我们可以重新初始化一个帐户,那将非常麻烦,因为用户可能会清除系统中的数据!幸运的是,Anchor 在后台对此进行了防护。
如果你在第二次运行测试(不重置本地验证器)时会得到下图中的错误。
或者,如果你不使用本地验证器,可以运行以下测试:
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);
// ********************************************
// **** 请注意,我们调用了初始化两次 ****
// ********************************************
await program.methods.initialize().accounts({myStorage: myStorage}).rpc();
await program.methods.initialize().accounts({myStorage: myStorage}).rpc();
});
});
当我们运行测试时,测试失败,因为第二次调用 initialize
抛出了错误。预期输出如下所示:
因为 solana-test-validator
会仍然记住第一次单元测试中的帐户,所以你需要使用 solana-test-validator --reset
在测试之间重置验证器。否则,你会遇到上述错误。
对大多数 EVM 开发者来说,初始化帐户的必要性可能感觉不自然。
别担心,你会不断看到这一代码序列,它会随着时间的推移而成为你的第二天性。
在本教程中,我们只考虑了初始化存储,在接下来的教程中,我们将学习读取、写入和删除存储。将有很多机会让你直观地了解我们今天查看的所有代码的作用。
练习: 修改 MyStorage
以保存 x
和 y
,就像它是一个笛卡尔坐标。这意味着在 MyStorage
结构中添加 y
并将它们的类型从 u64
改为 i64
。你无需修改代码的其他部分,因为 size_of
会为你重新计算大小。确保重置验证器,以便原始存储帐户被擦除,从而不阻止你再次初始化该帐户。
请参阅我们的 Solana 课程 以获取更多信息。
最初发表于 2024 年 2 月 24 日
- 原文链接: rareskills.io/post/solan...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!