在本指南中,你将学习如何创建和部署 Solana 程序和 UI,用于一个基本的链上 CRUD dApp。 这个 dApp 将允许你通过链上交易创建日记条目、更新日记条目、读取日记条目和 删除日记条目。
你将学习的内容 #
- 设置你的环境
- 使用
npx create-solana-dapp
- Anchor 程序开发
- Anchor PDAs 和账户
- 部署一个 Solana 程序
- 测试一个链上程序
- 将链上程序连接到 React UI
前提条件 #
对于本指南,你需要在本地开发环境中设置一些工具:
设置项目 #
npx create-solana-dapp
这个 CLI 命令可以快速创建 Solana dApp。 你可以 在这里找到源代码。
现在按如下提示进行操作:
- 输入项目名称:
my-journal-dapp
- 选择一个预设:
Next.js
- 选择一个 UI 库:
Tailwind
- 选择一个 Anchor 模板:
counter
program
通过选择counter
作为 Anchor 模板,将为你生成一个使用 Anchor 框架用 rust 编写的
简单计数器程序 。 在我们开始编辑这个生成的模板程
序之前,让我们确保一切按预期工作:
cd my-journal-dapp
npm install
npm run dev
使用 Anchor 编写 Solana 程序 #
如果你是 Anchor 的新 手,The Anchor Book 和 Anchor Examples 是很好的参考资料,可以帮 助你学习。
在my-journal-dapp
中,导航到anchor/programs/journal/src/lib.rs
。 这个文件夹中
已经生成了模板代码。 让我们删除它并从头开始,这样我们可以逐步讲解每一步。
定义你的 Anchor 程序 #
use anchor_lang::prelude::*;
// This is your program's public key and it will update automatically when you build the project.
declare_id!("7AGmMcgd1SjoMsCcXAAYwRgB9ihCyM8cZqjsUqriNRQt");
#[program]
pub mod journal {
use super::*;
}
定义你的程序状态 #
状态是用于定义你想要保存到账户中的信息的数据结构。 Since Solana onchain programs do not have storage, the data is stored in accounts that live on the blockchain.
使用 Anchor 时,#[account]
属性宏用于定义你的程序状态。
#[account]
#[derive(InitSpace)]
pub struct JournalEntryState {
pub owner: Pubkey,
#[max_len(50)]
pub title: String,
#[max_len(1000)]
pub message: String,
}
对于这个日记 dApp,我们将存储:
- 日记的所有者
- 每个日记条目的标题
- 每个日记条目的消息
注意:在初始化账户时必须定义空间。 上面代码中使用的InitSpace
宏将在初始化账户时
帮助计算所需的空间。 有关空间的更多信息,请阅
读这里 。
创建一个日记条目 #
现在,让我们为这个程序添加一个
instruction handler,用于创建一个新
的日记条目。 为此,我们将更新之前定义的#[program]
代码,以包含一个用
于create_journal_entry
的指令。
在创建日记条目时,用户需要提供日记条目的title
和message
。 因此,我们需要将这
两个变量作为附加参数添加。
在调用这个指令处理函数时,我们希望将账户的owner
、日记条目的title
和日记条目
的message
保存到账户的JournalEntryState
中。
#[program]
mod journal {
use super::*;
pub fn create_journal_entry(
ctx: Context<CreateEntry>,
title: String,
message: String,
) -> Result<()> {
msg!("Journal Entry Created");
msg!("Title: {}", title);
msg!("Message: {}", message);
let journal_entry = &mut ctx.accounts.journal_entry;
journal_entry.owner = ctx.accounts.owner.key();
journal_entry.title = title;
journal_entry.message = message;
Ok(())
}
}
使用 Anchor 框架,每个指令都将Context
类型作为其第一个参数。 Context
宏用于定
义一个结构体,该结构体封装了将传递给给定指令处理程序的账户。 因此,每
个Context
必须具有相对于指令处理程序的指定类型。 在我们的例子中,我们需要
为CreateEntry
定义一个数据结构:
#[derive(Accounts)]
#[instruction(title: String, message: String)]
pub struct CreateEntry<'info> {
#[account(
init_if_needed,
seeds = [title.as_bytes(), owner.key().as_ref()],
bump,
payer = owner,
space = 8 + JournalEntryState::INIT_SPACE
)]
pub journal_entry: Account<'info, JournalEntryState>,
#[account(mut)]
pub owner: Signer<'info>,
pub system_program: Program<'info, System>,
}
在上面的代码中,我们使用了以下宏:
#[derive(Accounts)]
宏用于反序列化和验证结构体中指定的账户列表#[instruction(...)]
属性宏用于访问传递给指令处理程序的指令数据#[account(...)]
属性宏然后在账户上指定附加约束
Each journal entry is a Program Derived Address ( PDA) that
stores the entries state on-chain. Since we are creating a new journal entry
here, it needs to be initialized using the init_if_needed
constraint.
使用 Anchor,PDA 通过seeds
、bumps
和init_if_needed
约束进行初始化。
init_if_needed
约束还需要payer
和space
约束来定义谁支
付租金以在链上保存此账户的数据以及需要为该数据分配
多少空间。
注意:通过在JournalEntryState
中使用InitSpace
宏,我们可以使用INIT_SPACE
常量
并在空间约束中添加8
来计算空间,以用于 Anchor 的内部鉴别器。
更新一个日记条目 #
现在我们可以创建一个新的日记条目,让我们添加一个update_journal_entry
指令处理程
序,其上下文具有UpdateEntry
类型。
为此,指令需要重写/更新当日记条目的所有者调用update_journal_entry
指令时保存到
账户的JournalEntryState
的特定 PDA 的数据。
#[program]
mod journal {
use super::*;
...
pub fn update_journal_entry(
ctx: Context<UpdateEntry>,
title: String,
message: String,
) -> Result<()> {
msg!("Journal Entry Updated");
msg!("Title: {}", title);
msg!("Message: {}", message);
let journal_entry = &mut ctx.accounts.journal_entry;
journal_entry.message = message;
Ok(())
}
}
#[derive(Accounts)]
#[instruction(title: String, message: String)]
pub struct UpdateEntry<'info> {
#[account(
mut,
seeds = [title.as_bytes(), owner.key().as_ref()],
bump,
realloc = 8 + 32 + 1 + 4 + title.len() + 4 + message.len(),
realloc::payer = owner,
realloc::zero = true,
)]
pub journal_entry: Account<'info, JournalEntryState>,
#[account(mut)]
pub owner: Signer<'info>,
pub system_program: Program<'info, System>,
}
在上面的代码中,你应该注意到它与创建日记条目非常相似,但有几个关键区别。 由
于update_journal_entry
正在编辑一个已经存在的 PDA,我们不需要初始化它。 然而,
传递给指令处理程序的消息可能需要不同的空间大小来存储它(即消息可能更短或更长),
因此我们需要使用一些特定的realloc
约束来重新分配链上账户的空间:
realloc
- 设置所需的新空间realloc::payer
- 定义将支付或退还新所需 lamports 的账户realloc::zero
- 定义当设置为true
时账户可以多次更新
seeds
和bump
约束仍然需要以便找到我们要更新的特定 PDA。
mut
约束允许我们改变账户中的数据。由于 Solana 区块链处理读取账户和写入账户的方
式不同,我们必须明确定义哪些账户是可变的,以便 Solana 运行时可以正确处理它们。
注意:在 Solana 中,当你执行重新分配以更改账户的大小时,交易必须覆盖新账户大小的
租金。 realloc::payer = owner
属性表示所有者账户将支付租金。 为了使账户能够支付
租金,它通常需要是签名者(以授权扣款),并且在 Anchor 中,它还需要是可变的,以便
运行时可以从账户中扣除 lamports 来支付租金。
删除日志条目 #
最后,我们将添加一个带有DeleteEntry
类型上下文的delete_journal_entry
指令处理
程序。
为此,我们只需关闭指定日志条目的账户。
#[program]
mod journal {
use super::*;
...
pub fn delete_journal_entry(_ctx: Context<DeleteEntry>, title: String) -> Result<()> {
msg!("Journal entry titled {} deleted", title);
Ok(())
}
}
#[derive(Accounts)]
#[instruction(title: String)]
pub struct DeleteEntry<'info> {
#[account(
mut,
seeds = [title.as_bytes(), owner.key().as_ref()],
bump,
close = owner,
)]
pub journal_entry: Account<'info, JournalEntryState>,
#[account(mut)]
pub owner: Signer<'info>,
pub system_program: Program<'info, System>,
}
在上面的代码中,我们使用close
约束来关闭链上的账户,并将租金退还给日志条目的所
有者。
seeds
和bump
约束用于验证账户。
构建和部署你的 Anchor 程序 #
npm run anchor build
npm run anchor deploy
将 Solana 程序连接到 UI #
create-solana-dapp
已经为你设置了一个带有钱包连接器的 UI。我们只需要简单地修改
它以适应你新创建的程序。 我们只需要简单地修改它以适应你新创建的程序。
由于这个日志程序有三个指令,我们需要在 UI 中添加能够调用这些指令的组件:
- 创建条目
- 更新条目
- 删除条目
在你的项目仓库中,打开web/components/journal/journal-data-access.tsx
,添加代码
以调用我们的每个指令。
更新useJournalProgram
函数以能够创建条目:
const createEntry = useMutation<string, Error, CreateEntryArgs>({
mutationKey: ["journalEntry", "create", { cluster }],
mutationFn: async ({ title, message, owner }) => {
const [journalEntryAddress] = await PublicKey.findProgramAddress(
[Buffer.from(title), owner.toBuffer()],
programId,
);
return program.methods
.createJournalEntry(title, message)
.accounts({
journalEntry: journalEntryAddress,
})
.rpc();
},
onSuccess: signature => {
transactionToast(signature);
accounts.refetch();
},
onError: error => {
toast.error(`Failed to create journal entry: ${error.message}`);
},
});
然后更新useJournalProgramAccount
函数以能够更新和删除条目:
const updateEntry = useMutation<string, Error, CreateEntryArgs>({
mutationKey: ["journalEntry", "update", { cluster }],
mutationFn: async ({ title, message, owner }) => {
const [journalEntryAddress] = await PublicKey.findProgramAddress(
[Buffer.from(title), owner.toBuffer()],
programId,
);
return program.methods
.updateJournalEntry(title, message)
.accounts({
journalEntry: journalEntryAddress,
})
.rpc();
},
onSuccess: signature => {
transactionToast(signature);
accounts.refetch();
},
onError: error => {
toast.error(`Failed to update journal entry: ${error.message}`);
},
});
const deleteEntry = useMutation({
mutationKey: ["journal", "deleteEntry", { cluster, account }],
mutationFn: (title: string) =>
program.methods
.deleteJournalEntry(title)
.accounts({ journalEntry: account })
.rpc(),
onSuccess: tx => {
transactionToast(tx);
return accounts.refetch();
},
});
接下来,更新web/components/journal/journal-ui.tsx
中的 UI,以接收用户输入
的title
和message
,用于创建日志条目:
export function JournalCreate() {
const { createEntry } = useJournalProgram();
const { publicKey } = useWallet();
const [title, setTitle] = useState("");
const [message, setMessage] = useState("");
const isFormValid = title.trim() !== "" && message.trim() !== "";
const handleSubmit = () => {
if (publicKey && isFormValid) {
createEntry.mutateAsync({ title, message, owner: publicKey });
}
};
if (!publicKey) {
return <p>Connect your wallet</p>;
}
return (
<div>
<input
type="text"
placeholder="Title"
value={title}
onChange={e => setTitle(e.target.value)}
className="input input-bordered w-full max-w-xs"
/>
<textarea
placeholder="Message"
value={message}
onChange={e => setMessage(e.target.value)}
className="textarea textarea-bordered w-full max-w-xs"
/>
<br></br>
<button
type="button"
className="btn btn-xs lg:btn-md btn-primary"
onClick={handleSubmit}
disabled={createEntry.isPending || !isFormValid}
>
Create Journal Entry {createEntry.isPending && "..."}
</button>
</div>
);
}
最后,更新journal-ui.tsx
中的 UI,以接收用户输入的message
,用于更新日志条目:
function JournalCard({ account }: { account: PublicKey }) {
const { accountQuery, updateEntry, deleteEntry } = useJournalProgramAccount({
account,
});
const { publicKey } = useWallet();
const [message, setMessage] = useState("");
const title = accountQuery.data?.title;
const isFormValid = message.trim() !== "";
const handleSubmit = () => {
if (publicKey && isFormValid && title) {
updateEntry.mutateAsync({ title, message, owner: publicKey });
}
};
if (!publicKey) {
return <p>Connect your wallet</p>;
}
return accountQuery.isLoading ? (
<span className="loading loading-spinner loading-lg"></span>
) : (
<div className="card card-bordered border-base-300 border-4 text-neutral-content">
<div className="card-body items-center text-center">
<div className="space-y-6">
<h2
className="card-title justify-center text-3xl cursor-pointer"
onClick={() => accountQuery.refetch()}
>
{accountQuery.data?.title}
</h2>
<p>{accountQuery.data?.message}</p>
<div className="card-actions justify-around">
<textarea
placeholder="Update message here"
value={message}
onChange={e => setMessage(e.target.value)}
className="textarea textarea-bordered w-full max-w-xs"
/>
<button
className="btn btn-xs lg:btn-md btn-primary"
onClick={handleSubmit}
disabled={updateEntry.isPending || !isFormValid}
>
Update Journal Entry {updateEntry.isPending && "..."}
</button>
</div>
<div className="text-center space-y-4">
<p>
<ExplorerLink
path={`account/${account}`}
label={ellipsify(account.toString())}
/>
</p>
<button
className="btn btn-xs btn-secondary btn-outline"
onClick={() => {
if (
!window.confirm(
"Are you sure you want to close this account?",
)
) {
return;
}
const title = accountQuery.data?.title;
if (title) {
return deleteEntry.mutateAsync(title);
}
}}
disabled={deleteEntry.isPending}
>
Close
</button>
</div>
</div>
</div>
</div>
);
}