本文深入讲解了Solana区块链开发中Borsh序列化机制,解释了数据如何在TypeScript客户端和Rust程序之间进行编码和解码。通过一个计数器程序的例子,详细展示了Borsh将结构体和枚举转换为字节以及字节反序列化为Rust类型,强调了其确定性、小端序以及对区块链应用的重要性。
客户端应用程序和Solana程序之间的数据传输方式,以及理解序列化对区块链开发至关重要的原因
如果你正在Solana上进行开发,你可能已经遇到过Borsh序列化,但没有完全理解其底层原理。你从JavaScript客户端发送一些数据,它神奇地出现在你的Rust程序中,经过处理,并以可读数据的形式返回。但是,在这个过程中究竟发生了什么?
今天,我们将通过构建一个简单的计数器程序,并探索数据如何在Solana生态系统的TypeScript和Rust之间流动,来揭开Borsh序列化的神秘面纱。
Borsh(二进制对象表示序列化器用于哈希)是一种为安全关键项目设计的序列化格式。与JSON或其他基于文本的格式不同,Borsh生成紧凑、确定性的二进制输出,这对于区块链应用程序来说非常完美,因为在区块链中,每个字节都很重要,并且一致性至关重要。
将序列化视为语言之间的翻译:
让我们从一个简单的Solana程序开始,该程序维护一个计数器并支持各种操作:
use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::{
entrypoint,
account_info::{next_account_info, AccountInfo},
entrypoint::ProgramResult,
pubkey::Pubkey,
program_error::ProgramError,
};
#[derive(BorshSerialize, BorshDeserialize)]
struct Number {
count: u32
}#[derive(BorshSerialize, BorshDeserialize)]
enum Instruction {
Init,
Double,
Half,
Add { val: u32 },
Subtract { val: u32 },
}entrypoint!(process_instruction);fn process_instruction(
program_id: &Pubkey,
accounts: &[AccountInfo],
instruction_data: &[u8] // ← 魔法发生的地方
) -> ProgramResult {
let mut iter = accounts.iter();
let data_account = next_account_info(&mut iter)?;
// Security check
if !data_account.is_signer {
return Err(ProgramError::MissingRequiredSignature);
} // Deserialize instruction from bytes
let instruction = Instruction::try_from_slice(instruction_data)?;
let mut data = Number::try_from_slice(&data_account.data.borrow())?;
// Process the instruction
match instruction {
Instruction::Init => data.count = 1,
Instruction::Double => data.count = data.count.saturating_mul(2),
Instruction::Half => data.count = data.count / 2,
Instruction::Add { val } => data.count = data.count.saturating_add(val),
Instruction::Subtract { val } => data.count = data.count.saturating_sub(val),
}
// Serialize back to account storage
data.serialize(&mut *data_account.data.borrow_mut())?;
Ok(())
}
现在,让我们看看数据如何从我们的TypeScript客户端传输到这个Rust程序:
import { TransactionInstruction, PublicKey } from "@solana/web3.js";
// 我们想给计数器加5
const addInstruction = {
type: "Add",
value: 5
};
这里变得有趣了。我们需要将指令转换为与Rust程序期望的字节匹配的格式:
enum InstructionType {
Init = 0,
Double = 1,
Half = 2,
Add = 3,
Subtract = 4,
}
function createInstructionData(instructionType: InstructionType, value?: number): Buffer {
switch (instructionType) {
case InstructionType.Add:
// 创建: [指令变体, 值(4字节)]
const buffer = Buffer.alloc(5);
buffer[0] = instructionType; // Add的枚举值为3
buffer.writeUInt32LE(value!, 1); // 5,以little-endian存储
return buffer;
// ... 其他情况
}
}// 创建: [3, 5, 0, 0, 0]
const instructionData = createInstructionData(InstructionType.Add, 5);
const instruction = new TransactionInstruction({
programId: myProgramId,
keys: [\
{ pubkey: dataAccount.publicKey, isSigner: true, isWritable: true }\
],
data: instructionData // [3, 5, 0, 0, 0]
});
const transaction = new Transaction().add(instruction);
// ... 签名并发送
当Solana处理交易时,它会调用你的程序并提供:
program_id:你的程序地址accounts:你指定的账户instruction_data:原始字节 [3, 5, 0, 0, 0]Borsh在这里施展魔法。当你的程序调用:
let instruction = Instruction::try_from_slice(instruction_data)?;
Borsh会执行以下步骤:
[3, 5, 0, 0, 0]
↑
第一个字节 = 3 → 这是"Add"变体
enum Instruction {
Init, // 0
Double, // 1
Half, // 2
Add { val: u32 }, // 3 ← 找到了!
Subtract { val: u32 }, // 4
}
[3, 5, 0, 0, 0]
↑────────↑
4个字节 = u32值
[5, 0, 0, 0] → u32值为5
Instruction::Add { val: 5 }
你可能想知道为什么 5 会变成 [5, 0, 0, 0] 而不是 [0, 0, 0, 5]。这是由于little-endian字节序,其中最低有效字节排在前面。
可以这样理解:
1234对于像我们计数器值这样的小数字,实际的数字最终会出现在第一个字节中,使得调试时易于读取。
读取状态时也会发生相同的反向序列化:
// 获取账户数据
const accountInfo = await connection.getAccountInfo(dataAccount.publicKey);
// accountInfo.data 包含: [5, 0, 0, 0] (count = 5)
const count = accountInfo.data.readUInt32LE(0); // 读取5
console.log(`计数器值: ${count}`);
这是一个完整的测试,展示了整个流程:
test("向计数器添加值", () => {
// 发送值为5的Add指令
const instruction = new TransactionInstruction({
programId,
keys: [{ pubkey: dataAccount.publicKey, isSigner: true, isWritable: true }],
data: createInstructionData(InstructionType.Add, 5) // [3, 5, 0, 0, 0]
});
const transaction = new Transaction().add(instruction);
transaction.recentBlockhash = svm.latestBlockhash();
transaction.feePayer = userAccount.publicKey;
transaction.sign(dataAccount, userAccount);
svm.sendTransaction(transaction); // 检查结果
const updatedAccountData = svm.getAccount(dataAccount.publicKey);
// 计数器值以little-endian u32存储
expect(updatedAccountData.data[0]).toBe(5); // 我们的值
expect(updatedAccountData.data[1]).toBe(0); // 高位字节
expect(updatedAccountData.data[2]).toBe(0);
expect(updatedAccountData.data[3]).toBe(0);
});
// 不要改变现有变体的顺序
enum Instruction {
Init, // 0
Add, // 1 ← 位置改变了!
Double, // 2
// 这会破坏现有的客户端代码!
}
// 始终处理反序列化错误
let instruction = Instruction::try_from_slice(instruction_data)?;
// ↑
// 不要忘记这个!
// 错误: 手动字节操作
const wrongData = Buffer.from([3, 0, 0, 0, 5]);
// 正确: 使用正确的字节序函数
buffer.writeUInt32LE(5, 1);
理解Borsh序列化对于Solana开发至关重要。它是客户端应用程序和链上程序之间的桥梁,确保了数据完整性并实现了高效通信。
下次你在Solana程序中看到 instruction_data: &[u8] 时,你就会确切地知道这些字节是如何到达那里,以及它们如何转换回有意义的Rust类型。这种理解将使调试更容易,帮助你设计更好的API,并在构建复杂的Solana应用程序时充满信心。
请记住:指令数据中的每个字节都具有意义,Borsh是解读这一切的翻译器。
- 原文链接: medium.com/@aswinsuriya1...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!