Solanaのexample-helloworldを、TDD(テスト駆動開発)っぽくやってみた【Rust、Solana入門】
はじめに
このnoteは、一個人として、solana-labs/example-helloworldを触ってみたメモです。
なお、Rustについて全く分からない方でもある程度理解して頂けるように記載するつもりですが、もしそれでも分からない場合には、下記のドキュメントが参考になります。Rustに入門するのに良いリソースです。
SolanaとRustの環境構築
SolanaとRustの環境構築については、下記の以前のnoteをご覧ください。このnoteでは、Docker環境を構築してある前程で記載します。
なお、一度Docker環境を構築すると、2回目以降にDocker起動したり終了したりする際は、下記のコマンドを使用します。
Docker起動
$ docker-sync start
$ docker-compose up -d
$ docker-compose exec app /bin/bash
Docker終了
$ docker-compose down
今回は、solana-labs/example-helloworldを触ってみるために、前回構築したSolanaとRustのDocker環境を起動します。
example-helloworldの概要
公式のgithubによると、プロジェクトの内容は次のとおりです。
・オンチェーンのHelloWorldプログラム
・アカウントに「hello」を送信し、「hello」が送信された回数を返すことができるクライアント
なるほど、以前のnote同様に、これも簡単そうなプログラムですね。
以前のnoteでは、環境構築してコードだけ記載してプログラムを実行しましたが、今回はRustやSolanaについても少し踏み込んで記載したいと思います。
localhost cluster(ローカルホストクラスター)を起動する
それでは、先程記載した、Dockerを起動するための以下のコマンドを実行します。
$ docker-sync start
$ docker-compose up -d
$ docker-compose exec app /bin/bash
Dockerに入ったら下記のコマンドを実行します。前回は、devnetに接続しましたが、今回はlocalhost clusterに接続します。
$ solana config set --url localhost
そして、下記のコマンドを実行すると、localhost clusterを起動できます。
$ solana-test-validator
次に、今起動したlocalhost clusterは起動したままにして、もう1画面新たにターミナルを開いて、下記のコマンドでDockerに入ります。
$ docker-compose exec app /bin/bash
その後、下記のコマンドを実行すると、トランザクションログを確認することができます。
$ solana logs
クラスター(cluster)とは?
クラスターとは、ネットワークに接続した複数のコンピューターを連携して1つのシステムに統合するシステムのことです。Solanaには、3つのパブリッククラスター(devnet、testnet、mainnet-beta)があります。
devnet
ユーザー、トークンホルダー、アプリ開発者、または検証者として、Solanaを試してみたい人が使うためのクラスターです。devnetのトークンは本物ではなく、エアドロップによって自由にSOLを取得することができます。
testnet
特にネットワークパフォーマンス、安定性、検証ツールの動作に焦点を当てた、リリース機能のテストを行うクラスターです。testnetのトークンも本物ではありません。
mainnet-beta
文字通りメインのクラスターで、mainnet-betaで発行されるトークンは本物のSOLです。例えば、CoinListなどを通じてトークンを購入/発行するためにお金を支払った場合、これらのトークンはMainnetBetaで転送されます。
下記のコマンドを実行すると、Solanaコマンドラインツール(CLI)が現在ターゲットにしているクラスターを確認することができます。
$ solana config get
example-helloworldでは、localhost clusterをターゲットにしているため、先程下記のコマンドを実行しました。
$ solana config set --url localhost
補足〜クラスターRPCエンドポイントとは?〜
Solanaは 、各パブリッククラスターのJSON-RPCリクエストを実行するための専用APIノードがあります。JSON-RPCについては、Solanaに限った話ではないのでここでは割愛します。
このnoteを記載している2021年5月現在では、Solanaには下記のエンドポイントがあります。
devnet
【エンドポイント】https://devnet.solana.com
【レート制限】
・IPごとの10秒あたりの最大リクエスト数:100
・1つのRPCのIPあたり10秒あたりの最大リクエスト数:40
・IPあたりの最大電流接続数:40
・IPあたり10秒あたりの最大接続速度:40
・30秒あたりの最大データ量:100 MB
・testnet
【エンドポイント】https://testnet.solana.com
【レート制限】
・IPごとの10秒あたりの最大リクエスト数:100
・1つのRPCのIPあたり10秒あたりの最大リクエスト数:40
・IPあたりの最大電流接続数:40
・IPあたり10秒あたりの最大接続速度:40
・30秒あたりの最大データ量:100 MB
・mainnet-beta
【エンドポイント】https://api.mainnet-beta.solana.com 、https://solana-api.projectserum.com
【レート制限】
・IPごとの10秒あたりの最大リクエスト数:100
・1つのRPCのIPあたり10秒あたりの最大リクエスト数:40
・IPあたりの最大電流接続数:40
・IPあたり10秒あたりの最大接続速度:40
・30秒あたりの最大データ量:100 MB
example-helloworldプロジェクトを作成する
それでは、localhost clusterを、`ctrl + c` キーで一度終了し、下記のコマンドを実行してexample-helloworldのプロジェクトを作成します。
$ cargo new example-helloworld --lib
上記のコマンドは前回と少し異なります。 `--lib`を付けると、ライブラリ用のプロジェクトが作成できます。その結果、以下のようなディレクトリ構成になっていると思います。
.
├── Dockerfile
├── docker-compose.yml
├── docker-sync.yml
├── example-helloworld
│ ├── Cargo.toml
│ └── src
│ └── lib.rs
└── sample
├── ...
ここで、前回同様に、example-helloworldディレクトリ直下に、Xargo.tomlファイルを作成し、以下の内容を記載します。
Xargo.toml
[target.bpfel-unknown-unknown.dependencies.std]
features = []
また、Cargo.tomlファイルを以下の内容に修正します。
Cargo.toml
[package]
name = "solana-bpf-helloworld"
version = "0.0.1"
description = "Example template program written in Rust"
authors = ["Solana Maintainers <maintainers@solana.com>"]
repository = "https://github.com/solana-labs/solana"
license = "Apache-2.0"
homepage = "https://solana.com/"
edition = "2018"
[features]
no-entrypoint = []
[dependencies]
borsh = "0.7.1"
borsh-derive = "0.8.1"
solana-program = "=1.6.6"
[dev-dependencies]
solana-program-test = "=1.6.6"
solana-sdk = "=1.6.6"
[lib]
name = "helloworld"
crate-type = ["cdylib", "lib"]
ところで、以前のnoteから当然のようにCargoを使っていましたが、Cargoは、Rustのビルドシステム兼、パッケージマネージャです。現状、Rustプロジェクトの大多数がCargoを使用しているため、Rustで開発するためには欠かせません。
TDD(テスト駆動開発)っぽくプログラムを作成する
それでは、example-helloworldを触っていきたいと思います。
本来は、githubのコードをダウンロードして、ローカルで実行するのだと思いますが、単にexample-helloworldのプログラムをダウンロードして実行するだけだとつまらないので、今回は空のファイルからTDDっぽくコードを書いて進めてみたいと思います。
テスト駆動開発(Test-Driven Development: TDD)とは、プログラムの実装前にテストコードを書き、そのテストコードに適合するようにプログラムを実装する方法です。関連する書籍や動画がたくさんあるので、気になる方はそちらをご覧ください。
現状、src/lib.rsファイルには下記の内容のコードが記載されていると思います。 `$ cargo new project --lib` で作成したプロジェクトのlib.rsにはこのようなコードが予め記載される仕様になっています。
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
1行目の `#[cfg(test)]` を記載して、2行目の `mod tests {}` のテストモジュールの中にテストを書くと、 `$ cargo test` コマンドを実行したときだけ、そのコードをコンパイルします。
3行目にある `#[test]` の後にある関数がテストコードです。 `assert_eq!()` は、2つの値を比較して値が等しいことを確認するマクロです。マクロとは、ある機能の集合のことを指します。上記のコードでは、 `2 + 2` と `4` が等しい場合にはテストが通ります。
それでは、まずテストを実行するために、下記のコマンドを実行してみます。
$ cd example-helloworld
$ cargo test
最初は、コンパイルが走ると思いますので、少し時間がかかります。コンパイルが終わると、テストの結果が下記のように表示されます。
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests helloworld
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
1つのテストが実行されて結果がOKだったことが分かります。
また、新たなディレクトリとファイルが追加され、下記のようなディレクトリ構成になります。
example-helloworld $ tree -L 2
.
├── Cargo.lock
├── Cargo.toml
├── Xargo.toml
├── src
│ └── lib.rs
└── target
├── CACHEDIR.TAG
└── debug
3 directories, 5 files
Solanaのアカウント情報
今回のexample-helloworldでは、「アカウントに「hello」を送信し、「hello」が送信された回数を返す」という要件のため、まずは「hello」を受け取るアカウントと、「hello」が送信された回数をカウントするための箱が必要です。
TDDっぽく、まずはテストを書いて実行してみます。
src/lib.rs
#[cfg(test)]
mod tests {
#[test]
fn test_sanity() {
assert_eq!(account.counter, 0);
}
}
上記のテストは、 `account` が `counter` というフィールドを持っていて、そこに「hello」が送信された回数が保存されていることを想定したテストです。それでは、テストを実行してみます。
$ cargo test
error[E0425]: cannot find value `Account` in this scope
--> src/lib.rs:6:24
|
6 | assert_eq!(account.counter, 0);
| ^^^^^^^ not found in this scope
error: aborting due to previous error
For more information about this error, try `rustc --explain E0425`.
error: could not compile `solana-bpf-helloworld`
To learn more, run the command again with --verbose.
予想通りテストが落ちました。というより、errorになりました。errorメッセージを見ると、 `account` が見つからないよと怒られました。それではテストを通すためにコードを修正して、再度テストを実行します。
src/lib.rs
pub struct Account {
/// 「hello」が送信された回数
pub counter: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanity() {
let account = Account {
counter: 0
};
assert_eq!(account.counter, 0);
}
}
$ cargo test
running 1 test
test tests::test_sanity ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests helloworld
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
テストが通りました。上記のコードでは、 `Account` という構造体に `counter` というフィールドを定義しました。そして、テストコードの中で `account` という変数を定義し、 `Account` 構造体のインスタンスを生成しています。
testモジュールの中で、外部のモジュールを `use` すると、テストの時だけインポートされるため、本番のバイナリファイルに不要なコードを入れずに済みます。 `use super::*;`は特別なコードで、これを記載すると親ディレクトリにある全てのモジュールを指定することができます。これを記載しないと、 `Account` という構造体がスコープ外でerrorになります。Rustのスコープについての解説は、冒頭でご紹介した「The Rust Programming Language 日本語版」に委ねます。
それでは次に、SolanaのAccount情報を使ったコードに修正したいと思います。SolanaのAccount情報については、下記のリンク先に詳細がありますので、これを参考に下記のようにコードを修正してテストを実行します。
src/lib.rs
use solana_program::{
account_info::AccountInfo,
pubkey::Pubkey,
};
pub struct Account {
/// 「hello」が送信された回数
pub counter: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use solana_program::clock::Epoch;
use std::mem;
#[test]
fn test_sanity() {
// Pubkey::default()は、アカウントのアドレスの初期値を返す
let key = Pubkey::default();
let mut lamports = 0;
// dataは、プログラムによって任意に保存可能なデータ
// ここに「hello」が送信された回数を保存します。
let mut data = vec![0; mem::size_of::<u32>()];
let owner = Pubkey::default();
// AccountInfo::newでインスタンスを生成。
// solana_program::account_infoの公式ドキュメント通り。
let account = AccountInfo::new(
&key,
false,
true,
&mut lamports,
&mut data,
&owner,
false,
Epoch::default(),
);
assert_eq!(account.counter, 0);
}
}
$ cargo test
error[E0609]: no field `counter` on type `solana_program::account_info::AccountInfo<'_>`
--> src/lib.rs:38:28
|
38 | assert_eq!(account.counter, 0);
| ^^^^^^^ unknown field
|
= note: available fields are: `key`, `is_signer`, `is_writable`, `lamports`, `data` ... and 3 others
error: aborting due to previous error
For more information about this error, try `rustc --explain E0609`.
error: could not compile `solana-bpf-helloworld`
To learn more, run the command again with --verbose.
warning: build failed, waiting for other jobs to finish...
warning: 1 warning emitted
error: build failed
当然ですが、errorになりました。account.counterが無いと怒られました。それでは、テストを通すために以下のようにコードを修正してテストを実行します。
src/lib.rs
use borsh::{BorshDeserialize, BorshSerialize}; // 追加
use solana_program::{
account_info::AccountInfo,
pubkey::Pubkey,
};
#[derive(BorshSerialize, BorshDeserialize, Debug)] // 追加
pub struct Account {
/// 「hello」が送信された回数
pub counter: u32,
}
...(省略)
#[cfg(test)]
mod tests {
...(省略)
#[test]
fn test_sanity() {
...(省略)
assert_eq!(
Account::try_from_slice(&account.data.borrow())
.unwrap()
.counter,
0
); // assert_eqの中身を修正
}
}
$ cargo test
running 1 test
test tests::test_sanity ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests helloworld
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
テストが通りました。冒頭に `use borsh::{BorshDeserialize, BorshSerialize};` を追加して、 `Account` 構造体の直前に `#[derive(BorshSerialize, BorshDeserialize, Debug)]` と記載することで `Account` 構造体にトレイト(共通の振る舞い)を追加しています。 これにより、コード末尾の `Account::try_from_slice(...)` のように、try_from_sliceメソッドが使えるようになります。
`.borrow()` メソッドは、データを借用するためのメソッドです。
`.unwrap()` メソッドは、処理がOKであればその値を返し、errorになればpanicを起こすメソッドです。
Rustでのトレイトや借用、エラー処理等についての詳細な解説は、冒頭でご紹介した「The Rust Programming Language 日本語版」に委ねます。
Solanaのネットワーク上で実行するための実装
以前のnoteでも同じようにしましたが、Solanaのネットワーク上でプログラムを実行するためには、 `solana-sdk::entrypoint` を呼び出し、引数に命令処理を行う `process_instruction` メソッドを渡します。
そのために、下記のようにコードを修正します。
src/lib.rs
...(省略)
use solana_program::{
account_info::AccountInfo,
entrypoint, // 追加
entrypoint::ProgramResult, // 追加
pubkey::Pubkey,
};
...(省略)
// 下記を追加
// ここから
entrypoint!(process_instruction);
pub fn process_instruction(
// 使っていない引数名は、 `_` から始める。
_program_id: &Pubkey,
_accounts: &[AccountInfo],
_instruction_data: &[u8],
) -> ProgramResult {
Ok(())
}
// ここまで
#[cfg(test)]
mod tests {
...(省略)
コードを修正したので、念の為テストが通るか確認しておきます。
$ cargo test
running 1 test
test tests::test_sanity ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests helloworld
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
テストが通りました。このように、テストを書いていると、コードを修正した際に予期せぬバグが発生していないか確認することができます。これで、Solanaネットワーク上でプログラムを実行することができるようになります。
「hello」が送信された回数を返す処理を実装する
ここまでで、「hello」が送信された回数をカウントするための箱と、プログラムを実行するためのメソッドができたので、「hello」が送信された回数をカウントして返す処理を実装していきます。
それでは、まずテストを書いて実行します。
src/lib.rs
...(省略)
#[test]
fn test_sanity() {
let program_id = Pubkey::default(); // 追加
...(省略)
let instruction_data: Vec<u8> = Vec::new(); // 追加
let accounts = vec![account]; // 追加
assert_eq!(
// `&account` を `&accounts[0]` に修正
Account::try_from_slice(&accounts[0].data.borrow())
.unwrap()
.counter,
0
);
// 下記を追加
// ここから
process_instruction(&program_id, &accounts, &instruction_data).unwrap();
assert_eq!(
Account::try_from_slice(&accounts[0].data.borrow())
.unwrap()
.counter,
1
);
// ここまで
}
}
$ cargo test
running 1 test
test tests::test_sanity ... FAILED
failures:
---- tests::test_sanity stdout ----
thread 'tests::test_sanity' panicked at 'assertion failed: `(left == right)`
left: `0`,
right: `1`', src/lib.rs:48:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::test_sanity
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
1が返ってくるテストなのに、0が返ってきたためテストが落ちました。まだ実装していないので当然です。それでは実装してテストを通します。
src/lib.rs
...(省略)
use solana_program::{
account_info::{next_account_info, AccountInfo}, // 修正
entrypoint::ProgramResult,
program_error::ProgramError,
pubkey::Pubkey,
};
...(省略)
pub fn process_instruction(
program_id: &Pubkey, // 修正(`_` を削除)
accounts: &[AccountInfo], // 修正(`_` を削除)
_instruction_data: &[u8],
) -> ProgramResult {
// 下記を追加
// ここから
let accounts_iter = &mut accounts.iter();
let account = next_account_info(accounts_iter)?;
let mut greeting_account = Account::try_from_slice(&account.data.borrow())?;
greeting_account.counter += 1;
greeting_account.serialize(&mut &mut account.data.borrow_mut()[..])?;
// ここまで
Ok(())
}
...(省略)
$ cargo test
running 1 test
test tests::test_sanity ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests helloworld
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
テストが通りました。念の為、もう一度「hello」を送信して、カウントアップされるか確認してみます。それでは、下記のようにコードを修正してテストを実行します。
src/lib.rs
...(省略)
process_instruction(&program_id, &accounts, &instruction_data).unwrap();
assert_eq!(
Account::try_from_slice(&accounts[0].data.borrow())
.unwrap()
.counter,
1
);
// 下記を追加
// ここから
process_instruction(&program_id, &accounts, &instruction_data).unwrap();
assert_eq!(
Account::try_from_slice(&accounts[0].data.borrow())
.unwrap()
.counter,
2
);
// ここまで
}
}
$ cargo test
running 1 test
test tests::test_sanity ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests helloworld
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
問題なくテストが通りました。
さいごに
今回はexample-helloworldのコードをTDDっぽく触ってみました。参考にしたコードは下記です。
noteを書くために、一部のコードを省略したり修正したりしていますが、本質的には同じことをしています。
なお、今回のnoteのコードを実際にSolana上で実行してみたい方は、以前の下記のnoteをご覧いただき、devnetにデプロイする等で実行することができます。公式のexample-helloworldでは、localhostで実行するようになっています。
皆様も是非、Solanaを触ってみてください。
この記事が気に入ったらサポートをしてみませんか?