Skip to main content

🤠 状态管理 - 让你的数据在区块链上安家!

🎯 本章目标: 掌握Solana链上数据存储的核心技能,让你的程序拥有"记忆"!


🌟 开篇:从无状态到有状态的华丽转身!

还记得第一节我们玩过的电影评论程序吗?当时只能看,不能写。今天,我们要赋予它真正的灵魂 —— 让它能够记住每一条精彩的影评!🎬

🎮 今日冒险地图

📍 你在这里(只会打印Hello)

🧠 理解状态存储 → 💰 计算租金 → 🔑 创建PDA
↓ ↓ ↓
数据的家 付房租 安全保险箱

🎬 构建电影评论程序 → 🚀 部署上链

🏆 你的程序能记住一切了!

💡 重要概念: 在Solana中,"状态"就是存储在链上的程序数据 —— 你的程序的长期记忆!


📝 将程序状态作为Rust数据类型 - 数据的变形记!

🎯 为什么Solana程序是无状态的?

想象一下两种餐厅模式:

🍔 传统区块链(有状态)
餐厅 = 程序
厨房 = 内部存储
问题:厨房太小,食材有限!

🚀 Solana(无状态)
餐厅 = 程序(只有厨师和食谱)
冰箱 = 外部账户(无限扩展)
优势:可以有无数个冰箱!

🔄 数据的变形过程

// 🎭 数据的三种形态

// 1️⃣ 在链上存储时 - 原始字节
账户数据: [0x48, 0x65, 0x6c, 0x6c, 0x6f...]

// 2️⃣ 在程序中处理时 - Rust类型
struct NoteState {
title: String, // "我的笔记"
body: String, // "内容..."
id: u64 // 12345
}

// 3️⃣ 传输时 - 序列化的字节
传输数据: serialize(NoteState)[bytes...]

💻 代码实现

// 🎨 使用Borsh让数据类型具备"变形"能力
use borsh::{BorshDeserialize, BorshSerialize};

// 📝 定义笔记的数据结构
#[derive(BorshSerialize, BorshDeserialize, Debug)]
struct NoteState {
title: String, // 📑 标题
body: String, // 📄 内容
id: u64, // 🔢 唯一ID
}

// 🎯 这个结构体现在拥有了三个超能力:
// BorshSerialize: 能变成字节(序列化)✨
// BorshDeserialize: 能从字节还原(反序列化)🔄
// Debug: 能被打印出来调试 🐛

💡 类比理解:

  • 序列化 = 把乐高模型拆成零件装盒
  • 反序列化 = 把零件重新组装成模型
  • Borsh = 拆装说明书

🏠 空间与租金 - 区块链上的房地产经济学!

💰 为什么要交租金?

🏢 现实世界              🌐 Solana世界
房东 = 验证者 租客 = 你的程序
房子 = 存储空间 租金 = Lamports
水电 = 计算资源 合同 = 账户

为什么收租?
1. 🚫 防止垃圾数据(没人想要废弃房屋)
2. 💾 补偿验证者的存储成本
3. 🎯 激励高效使用空间

📊 租金价目表

不同数据类型的"房租"(以字节计):

// 🏠 数据类型的空间占用对照表

基础类型(固定大小):
┌─────────────┬────────┬──────────────┐
│ 类型 │ 字节 │ 示例 │
├─────────────┼────────┼──────────────┤
bool1true/false
u8/i810-255
u16/i1620-65535
u32/i324 │ 更大的数字 │
u64/i648 │ 超大数字 │
u128/i12816 │ 巨大数字 │
Pubkey32 │ 公钥地址 │
└─────────────┴────────┴──────────────┘

动态类型(可变大小):
┌─────────────┬─────────────────┬──────────────┐
│ 类型 │ 字节 │ 说明 │
├─────────────┼─────────────────┼──────────────┤
String4 + 长度 │ 4字节存长度 │
Vec<T>4 + (项数 × T) │ 动态数组 │
└─────────────┴─────────────────┴──────────────┘

🏦 租金支付方案

// 💰 两种支付方式

// 方案1:按期付款(已废弃)❌
// 像月租,但忘记交租数据就没了!

// 方案2:一次性付清(推荐)✅
// 存入2年租金 = 永久免租!
// 原理:硬件成本每2年降50%,所以2年后你的租金利息就够了

// 🎁 额外福利:不用时可以关闭账户,取回所有租金!

💡 租金计算示例

// 🧮 计算我需要付多少租金

// 假设我要存储一个笔记:
struct Note {
title: String, // "我的日记" = 4 + 12 = 16字节
body: String, // "今天很开心" = 4 + 15 = 19字节
id: u64, // 12345 = 8字节
is_public: bool // true = 1字节
}

// 总空间 = 16 + 19 + 8 + 1 = 44字节

// 计算租金(大约):
// 44字节 ≈ 0.002 SOL(2年免租)
// 相当于 $0.1(按当前价格)—— 超便宜!💸

🎯 省钱技巧:

  • 使用更小的数据类型(u8 vs u64)
  • 限制字符串长度
  • 及时关闭不用的账户回收租金

📊 租金计算实战 - 让数学变简单!

🧮 计算公式详解

// 🎯 超级笔记程序的租金计算

// 步骤1️⃣:计算数据大小
let account_len: usize =
(4 + title.len()) // 📝 标题:长度标记(4) + 实际内容
+ (4 + body.len()) // 📄 正文:长度标记(4) + 实际内容
+ 8; // 🔢 ID:固定8字节

// 💡 为什么字符串要加4?
// 因为需要4个字节来记录字符串有多长!
// 就像包裹上的标签告诉你里面有多少东西

// 步骤2️⃣:获取租金计算器
let rent = Rent::get()?; // 🏦 获取当前租金费率

// 步骤3️⃣:计算最低余额(2年免租)
let rent_lamports = rent.minimum_balance(account_len);

// 📊 实际例子:
// 标题:"星际穿越影评" (21字节)
// 正文:"这是一部关于..." (100字节)
// 总计:4+21 + 4+100 + 8 = 137字节
// 租金:约0.0015 SOL ≈ $0.075 💰

🎯 智能优化技巧

// ❌ 浪费空间的做法
struct BadNote {
title: String, // 不限长度,可能很长
created_at: String, // "2024-01-01" 用字符串存日期
}

// ✅ 优化后的做法
struct GoodNote {
title: [u8; 50], // 固定50字节,省去4字节长度标记
created_at: i64, // 用时间戳存储,只要8字节
}

// 💰 节省了:4字节 + (日期字符串长度-8字节) ≈ 10+字节!

📜 程序派生地址(PDA) - 区块链上的智能保险箱!

🔐 什么是PDA?

想象一个特殊的保险箱:

🏦 传统保险箱          🔮 PDA保险箱
需要钥匙 🔑 不需要钥匙!
任何人都能开(有钥匙) 只有特定程序能开
可能丢钥匙 永远不会丢"钥匙"
一个箱子一把钥匙 程序ID + 种子 = 唯一地址

🎯 PDA的魔法原理

// 🪄 PDA生成魔法公式

// 原料(种子):
let seeds = [
用户公钥, // 👤 谁的数据
"笔记", // 📝 数据类型
笔记ID // 🔢 具体哪条
];

// 魔法咒语:
let (pda_address, bump_seed) = Pubkey::find_program_address(
&seeds, // 🌱 种子组合
&program_id // 🏭 哪个程序
);

// 结果:
// pda_address = 独一无二的地址!
// bump_seed = 魔法数字(确保地址不在椭圆曲线上)

💡 PDA的超能力

// 🦸 PDA的三大超能力

// 1️⃣ 确定性:相同输入 = 相同地址
find_program_address([user, "note", 1], program_id)
// 永远得到同一个地址!

// 2️⃣ 无私钥:没有对应的私钥
// 意味着:
// ✅ 永远不会丢私钥
// ✅ 只有程序能控制
// ✅ 超级安全

// 3️⃣ 程序签名:程序可以为PDA签名
invoke_signed(
&instruction,
&accounts,
&[&[seeds, &[bump]]] // 🔏 程序的签名权
);

🛫 跨程序调用(CPI) - 程序间的协作!

🤝 什么是CPI?

🎭 现实类比:
你的程序 = 导演 🎬
系统程序 = 摄影师 📹
CPI = "摄影师,请拍这个镜头!"

结果:导演不用自己扛摄像机!

📞 两种调用方式

// 🔵 方式1: invoke - 普通调用(不需要签名)
pub fn invoke(
instruction: &Instruction, // 📋 要执行的指令
account_infos: &[AccountInfo], // 📦 涉及的账户
) -> ProgramResult

// 使用场景:转账给别人(你有私钥)
invoke(
&transfer_instruction,
&[from_account, to_account, system_program],
)?;

// 🔴 方式2: invoke_signed - 需要程序签名(PDA专用)
pub fn invoke_signed(
instruction: &Instruction, // 📋 要执行的指令
account_infos: &[AccountInfo], // 📦 涉及的账户
signers_seeds: &[&[u8]], // 🔑 PDA种子(程序的签名)
) -> ProgramResult

// 使用场景:从PDA账户转账(程序代为签名)
invoke_signed(
&transfer_instruction,
&[pda_account, to_account, system_program],
&[&[user.key.as_ref(), b"vault", &[bump]]], // 🔐 PDA种子
)?;

🎨 完整的CPI示例

// 🏗️ 创建PDA账户的完整流程

// 1️⃣ 准备种子
let seeds = &[
initializer.key.as_ref(), // 👤 用户公钥
b"movie-review", // 🎬 类型标识
title.as_bytes(), // 📝 电影标题
];

// 2️⃣ 生成PDA地址
let (pda, bump) = Pubkey::find_program_address(seeds, program_id);

// 3️⃣ 计算空间和租金
let space = 1 + 4 + title.len() + 4 + review.len();
let rent = Rent::get()?.minimum_balance(space);

// 4️⃣ 创建账户(CPI调用)
invoke_signed(
// 指令:创建账户
&system_instruction::create_account(
initializer.key, // 💰 谁付钱
&pda, // 📍 新账户地址
rent, // 💵 租金
space as u64, // 📏 空间大小
program_id, // 🏭 所有者程序
),
// 账户列表
&[initializer.clone(), pda_account.clone(), system_program.clone()],
// PDA种子(加上bump)
&[&[
initializer.key.as_ref(),
b"movie-review",
title.as_bytes(),
&[bump] // 🎯 重要:加上bump!
]],
)?;

msg!("🎉 PDA创建成功: {}", pda);

✂️ 账户数据的序列化和反序列化 - 数据的打包与拆包!

📦 反序列化:从字节到结构体

// 🎁 拆包过程:把字节数组变成可用的Rust类型

// 1️⃣ 借用账户数据(不获取所有权)
let borrowed_data = pda_account.data.borrow();

// 2️⃣ 反序列化成Rust类型
let mut account_data = try_from_slice_unchecked::<MovieAccountState>(
&borrowed_data
).unwrap();

// 3️⃣ 现在可以像操作普通对象一样操作了!
account_data.title = "星际穿越".to_string();
account_data.rating = 5;
account_data.description = "震撼人心的科幻巨作!".to_string();

// 💡 理解借用:
// borrow() = "让我看看数据"(只读)
// borrow_mut() = "让我改改数据"(可写)

📤 序列化:从结构体到字节

// 🎁 打包过程:把Rust类型变回字节数组存储

// 这行代码看起来很复杂,让我们分解它:
account_data.serialize(&mut &mut pda_account.data.borrow_mut()[..])?;

// 📖 逐步解释:
// 1. pda_account.data.borrow_mut() - 获取可变借用
// 2. [..] - 转换为切片(slice)
// 3. &mut &mut - 创建可变引用的可变引用(Rust特色)
// 4. serialize() - 执行序列化
// 5. ? - 处理可能的错误

// 💡 简单理解:把修改后的数据保存回账户

🔄 完整的读改写流程

// 🎯 完整示例:更新电影评论

// 读取 📖
let mut review = try_from_slice_unchecked::<MovieAccountState>(
&pda_account.data.borrow()
).unwrap();

msg!("📖 原评分: {}", review.rating);

// 修改 ✏️
review.rating = new_rating;
review.description = new_description;
review.last_updated = Clock::get()?.unix_timestamp;

msg!("✏️ 新评分: {}", review.rating);

// 写回 💾
review.serialize(&mut &mut pda_account.data.borrow_mut()[..])?;

msg!("💾 评论已更新!");

📼 总结 - 完整流程回顾!

🎬 数据存储的完整剧本

🎬 场景:用户要存储一条电影评论

第1幕:接收指令 📨
├── 用户发送:电影名 + 评分 + 评论
└── 程序接收:解析指令数据

第2幕:准备存储 🏗️
├── 计算需要空间:标题长度 + 评论长度 + 评分
├── 计算租金:Rent::get()?.minimum_balance(space)
└── 生成PDA地址:find_program_address()

第3幕:创建账户 🏠
├── 调用系统程序:invoke_signed()
├── 创建新账户:指定大小和所有者
└── 支付租金:从用户账户扣除

第4幕:存储数据 💾
├── 反序列化:空账户 → Rust结构体
├── 填充数据:标题、评分、评论
├── 序列化:Rust结构体 → 字节数组
└── 保存:写入账户data字段

第5幕:完成! 🎉
└── 数据永久保存在区块链上!

🎥 实战:构建电影评论程序!

🏗️ 项目结构

📦 电影评论程序
┣ 📄 lib.rs # 主入口
┣ 📄 instruction.rs # 指令处理
┣ 📄 state.rs # 数据结构
┗ 📄 processor.rs # 业务逻辑

📝 Step 1: 定义数据结构

创建 state.rs

// 🎬 电影评论的数据结构
use borsh::{BorshSerialize, BorshDeserialize};

#[derive(BorshSerialize, BorshDeserialize)]
pub struct MovieAccountState {
pub is_initialized: bool, // 🔐 是否已初始化(防止重复创建)
pub rating: u8, // ⭐ 评分 (1-5星)
pub title: String, // 🎬 电影标题
pub description: String, // 📝 评论内容
}

// 💡 小技巧:is_initialized防止账户被覆盖!

📚 Step 2: 更新导入

更新 lib.rs

// 🎯 导入所有需要的工具
use solana_program::{
entrypoint, // 🚪 程序入口
entrypoint::ProgramResult, // ✅ 结果类型
pubkey::Pubkey, // 🔑 公钥
msg, // 📢 日志
account_info::{next_account_info, AccountInfo}, // 📦 账户处理
system_instruction, // 💻 系统指令
sysvar::{rent::Rent, Sysvar}, // 🏠 租金
program::invoke_signed, // 📞 CPI调用
borsh::try_from_slice_unchecked, // 🔄 反序列化
};
use std::convert::TryInto;
use borsh::BorshSerialize;

// 📦 导入我们的模块
pub mod instruction;
pub mod state;
use instruction::MovieInstruction;
use state::MovieAccountState;

🎮 Step 3: 实现核心逻辑

// 🎬 添加电影评论的完整实现
pub fn add_movie_review(
program_id: &Pubkey,
accounts: &[AccountInfo],
title: String,
rating: u8,
description: String
) -> ProgramResult {
msg!("🎬 开始添加电影评论...");
msg!(" 📽️ 电影: {}", title);
msg!(" ⭐ 评分: {}/5", rating);

// 1️⃣ 获取账户
let account_info_iter = &mut accounts.iter();
let initializer = next_account_info(account_info_iter)?; // 用户
let pda_account = next_account_info(account_info_iter)?; // PDA
let system_program = next_account_info(account_info_iter)?; // 系统

// 2️⃣ 验证用户签名
if !initializer.is_signer {
msg!("❌ 错误:用户未签名!");
return Err(ProgramError::MissingRequiredSignature);
}

// 3️⃣ 生成PDA
let (pda, bump_seed) = Pubkey::find_program_address(
&[
initializer.key.as_ref(),
title.as_bytes().as_ref(),
],
program_id
);

msg!("📍 PDA地址: {}", pda);

// 4️⃣ 验证PDA匹配
if pda != *pda_account.key {
msg!("❌ PDA不匹配!");
return Err(ProgramError::InvalidAccountData);
}

// 5️⃣ 计算空间和租金
let account_len: usize = 1 + 1 + (4 + title.len()) + (4 + description.len());
let rent = Rent::get()?;
let rent_lamports = rent.minimum_balance(account_len);

msg!("💰 需要租金: {} lamports", rent_lamports);

// 6️⃣ 创建账户
if pda_account.data_len() == 0 {
msg!("🏗️ 创建新PDA账户...");

invoke_signed(
&system_instruction::create_account(
initializer.key,
pda_account.key,
rent_lamports,
account_len.try_into().unwrap(),
program_id,
),
&[initializer.clone(), pda_account.clone(), system_program.clone()],
&[&[initializer.key.as_ref(), title.as_bytes().as_ref(), &[bump_seed]]],
)?;

msg!("✅ 账户创建成功!");
}

// 7️⃣ 更新账户数据
msg!("📝 写入评论数据...");

let mut account_data = try_from_slice_unchecked::<MovieAccountState>(
&pda_account.data.borrow()
).unwrap();

// 检查是否已初始化
if account_data.is_initialized {
msg!("⚠️ 账户已存在,更新评论");
}

// 更新数据
account_data.title = title;
account_data.rating = rating;
account_data.description = description;
account_data.is_initialized = true;

// 保存数据
account_data.serialize(&mut &mut pda_account.data.borrow_mut()[..])?;

msg!("🎉 电影评论保存成功!");
Ok(())
}

🧪 Step 4: 测试你的程序

// 🎯 客户端测试代码
import {
Connection,
PublicKey,
Transaction,
TransactionInstruction,
sendAndConfirmTransaction,
Keypair
} from '@solana/web3.js';

// 🎬 创建评论
async function createMovieReview(
title: string,
rating: number,
description: string
) {
// 📦 准备指令数据
const instruction = new TransactionInstruction({
keys: [
{ pubkey: wallet.publicKey, isSigner: true, isWritable: true },
{ pubkey: pda, isSigner: false, isWritable: true },
{ pubkey: SystemProgram.programId, isSigner: false, isWritable: false }
],
programId: PROGRAM_ID,
data: Buffer.from([
0, // 指令类型
...Buffer.from(title),
rating,
...Buffer.from(description)
])
});

// 🚀 发送交易
const tx = new Transaction().add(instruction);
const signature = await sendAndConfirmTransaction(connection, tx, [wallet]);

console.log('🎉 评论已提交!');
console.log('🔗 交易签名:', signature);
}

💡 专业技巧大放送!

🚀 性能优化

// ✅ 好的实践
// 1. 预先验证,失败要早
if !initializer.is_signer {
return Err(ProgramError::MissingRequiredSignature);
}

// 2. 批量处理减少CPI
let instructions = vec![create_account, initialize_data];
for ix in instructions {
invoke_signed(&ix, accounts, signers)?;
}

// 3. 使用固定大小数组节省空间
pub struct OptimizedState {
pub title: [u8; 50], // 固定50字节,不用4字节长度前缀
pub rating: u8,
}

🐛 调试技巧

// 🔍 添加详细日志
msg!("Debug: account_len={}, rent={}", account_len, rent_lamports);

// 🎯 使用断言验证
assert!(rating <= 5, "评分必须在1-5之间");

// 💡 分步执行,便于定位问题
msg!("Step 1: Validated inputs ✓");
msg!("Step 2: Generated PDA ✓");
msg!("Step 3: Created account ✓");

🚢 终极挑战!

🎯 任务:学生介绍程序

创建一个程序,让学生可以:

  1. 📝 提交姓名和自我介绍
  2. 💾 数据永久保存在链上
  3. 🔄 可以更新介绍内容
// 💡 提示:数据结构
pub struct StudentIntro {
pub is_initialized: bool,
pub name: String,
pub message: String,
}

// 🎯 挑战要求:
// 1. 每个学生一个独立PDA
// 2. 防止重复初始化
// 3. 支持更新功能

🏆 成功标准

  • ✅ 能创建学生介绍
  • ✅ 数据正确存储
  • ✅ 能在Explorer查看
  • ✅ 支持更新操作

🎊 恭喜完成!

📊 你的成就

🏆 掌握技能清单:
✅ Solana状态管理
✅ PDA创建和使用
✅ 跨程序调用(CPI)
✅ 序列化/反序列化
✅ 租金计算
✅ 完整程序开发

🚀 下一步

  • 添加更多功能(删除、查询)
  • 实现权限控制
  • 创建前端界面
  • 部署到主网!

🌟 激励语: "你已经掌握了Solana开发的核心技能!每一行代码都让你离Web3大师更近一步!" 🚀

#SolanaState #Web3Storage #BlockchainDev 🤠💾✨