如何在 Solana 上创建一个 CRUD dApp

在本指南中,你将学习如何创建和部署 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 模板:counterprogram

通过选择counter作为 Anchor 模板,将为你生成一个使用 Anchor 框架用 rust 编写的 简单计数器程序 。 在我们开始编辑这个生成的模板程 序之前,让我们确保一切按预期工作:

cd my-journal-dapp
 
npm install
 
npm run dev

使用 Anchor 编写 Solana 程序 #

如果你是 Anchor 的新 手,The Anchor BookAnchor 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的指令。

在创建日记条目时,用户需要提供日记条目的titlemessage。 因此,我们需要将这 两个变量作为附加参数添加。

在调用这个指令处理函数时,我们希望将账户的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 通过seedsbumpsinit_if_needed约束进行初始化。 init_if_needed约束还需要payerspace约束来定义谁支 付租金以在链上保存此账户的数据以及需要为该数据分配 多少空间。

注意:通过在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时账户可以多次更新

seedsbump约束仍然需要以便找到我们要更新的特定 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约束来关闭链上的账户,并将租金退还给日志条目的所 有者。

seedsbump约束用于验证账户。

构建和部署你的 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,以接收用户输入 的titlemessage,用于创建日志条目:

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>
  );
}

资源 #