🫙

Stable Structures

2024/07/11に公開

はじめに

分散型クラウド環境『Internet Computer(IC)』で動作するCanisterをRust言語で開発するために参考となる情報をまとめています。

本記事では、Stable Structuresと呼ばれるライブラリを用いてCanister内のデータを管理する方法について解説します。

Heap MemoryとStable Memory

Canisterのデータ記憶領域にはHeap MemoryとStable Memoryの2種類があります。

Heap Memoryは一般的なアプリケーションの実行環境におけるメモリーに相当するものですので、扱いやすくアクセスコストも低いですが、Canisterのアップグレード時に内容はクリアされます。また、最大4GiBまでという制限があります。

一方、Stable Memoryはストレージに相当するものですので、Canisterのアップグレード時もデータは保持され、最大400GiBまでデータを保持することができます。

Caniserのアップグレード後も、データを保持し続ける方法として、以下の2つがあります。

  1. データ格納先にHeap Memoryを使用しアップグレード時にStable Memoryへ一時退避
  2. データ格納先にStable Memoryを使用

1の方法は、アプリケーションで扱うデータの規模が十分に小さい場合に有効です。

扱うデータ量が多いとHeap Memoryに収まらなくなったり、アップグレード時に時間がかかりすぎて失敗するなどの問題も発生しますので、その場合は、2の方法のようにデータ格納先としてHeap Memoryの代わりにStable Memoryを使用する設計にする必要があります。

Stable Structures

アプリケーションからStable Memoryへアクセスする際、個人的にはStable Memoryをファイルのように扱えるとうれしいのですが、現時点ではCanisterはWASIに未対応であり、ファイルI/Oやファイルシステムのような標準的な仕組みは用意されていません。

その代わりに、Stable Memoryをアプリケーションから扱いやすくしたRust用ライブラリがいくつか公開されています。(ic-stable-memoryicfsStable Structures・・・)

本記事で取り上げる『Stable Structures』はDfinityが開発しているライブラリであることと、RustのBTreeMap相当のI/Fを備えたStableBTreeMapというコレクション型が用意されていて、アプリケーション側はStable Memoryを意識することなく利用できますので、執筆時点では最適なライブラリであると考えています。

サンプル概要

作成するサンプルは、Canisterの公開インタフェースとしてregisterメソッドとgetメソッドを用意し、UserデータをStable Memoryへ格納、取得するものです。
Stable Structuresの使い方を例示したサンプルにすぎませんので、Principalによるアクセス制御などは行わない単純なつくりとしています。

自動採番するid値をキー、以下のUser構造体データを値としてStableBTreeMapへ格納します。

開発

1. プロジェクトの作成

dfx newコマンドでプロジェクトのひな型を生成します。

$ dfx new icptest
✔ Select a backend language: · Rust
✔ Select a frontend framework: · No frontend canister
✔ Add extra features (space to select, enter to confirm) · 
Fetching manifest https://sdk.dfinity.org/manifest.json
Creating new project "icptest"...
︙

作成されたプロジェクトディレクトリに移動します。

$ cd icptest

シリアライズを行うserdeと、ic-stable-structuresを追加します。

$ cargo add serde ic-stable-structures

本サンプルは、2024年7月時点で最新のv0.6.5を利用しています。
今後のバージョンアップにより仕様が変わる可能性がありますのでご注意ください。

2. 編集

src/icptest_backend/icptest_backend.did

Canisterの公開インタフェースは以下とします。

src/icptest_backend/icptest_backend.did
type User = record {
  name : text;
  age : nat8;
  profile : text;
};

service : {
    "get": (nat64) -> (opt User) query;
    "register": (User) -> (nat64);
}

icptest_upgrade/src/icptest_backend/src/lib.rs

icptest_upgrade/src/icptest_backend/src/lib.rs
use candid::{CandidType, Decode, Deserialize, Encode};
use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory};
use ic_stable_structures::{
    storable::Bound, DefaultMemoryImpl, StableBTreeMap, Storable,
};
use std::{borrow::Cow, cell::RefCell};

type Memory = VirtualMemory<DefaultMemoryImpl>;

#[derive(CandidType, Deserialize)]
struct User {
    name: String,
    age: u8,
    profile: String,
}

impl Storable for User {
    fn to_bytes(&self) -> std::borrow::Cow<[u8]> {
        Cow::Owned(Encode!(self).unwrap())
    }

    fn from_bytes(bytes: std::borrow::Cow<[u8]>) -> Self {
        Decode!(bytes.as_ref(), Self).unwrap()
    }

    const BOUND: Bound = Bound::Bounded {
        max_size: 100,
        is_fixed_size: false,
    };
}

thread_local! {
    static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
        RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));

    static USERS: RefCell<StableBTreeMap<u64, User, Memory>> = RefCell::new(
        StableBTreeMap::init(
            MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0))),
        )
    );
}

#[ic_cdk::query]
fn get(id: u64) -> Option<User> {
    USERS.with(|users| users.borrow().get(&id))
}

#[ic_cdk::update]
fn register(user: User) -> u64 {
    USERS.with(|users| {
        let id = users.borrow().len() as u64;
        users.borrow_mut().insert(id, user);
        id
    })
}

プログラムの解説

StableBTreeMapに格納するUser構造体には、Storableのtraitを実装します。
to_bytes()from_bytesでbyte列へのSeriaize / Deserializeの処理を記述し、Boundには、データの最大サイズと可変長/固定長の指定を行います。

thread_local!内では、MemoryManager型の変数を宣言し、データはStableBTreeMap型で、キーはu64のid、値はUser構造体とします。

StableBTreeMapinit()関数の引数には、MemoryManager#get()の復帰値を渡します。
MemoryManager#get()の引数には、本サンプルでは管理するデータはStableBTreeMap1件のみなのでMemoryId::new(0)を指定し、もし複数データを管理する場合には、MemoryId::new(1)MemoryId::new(2)、…と指定するようです。

なお、StableBtreeMapの値にStableBtreeMapを持つといったNest構造には対応していないようですのでご注意ください。

3. Local Canister実行環境の起動

Local Canister実行環境が起動していない場合には、dfx startコマンドで起動します。

$ dfx start --background --clean

4. Deploy

アプリケーションをビルドし、Local Canister実行環境にDeployします。

$ dfx deploy
Deploying all canisters.
Creating canisters...
Creating canister icptest_backend...
︙
URLs:
  Backend canister via Candid interface:
    icptest_backend: http://127.0.0.1:4943/?canisterId=bd3sg-teaaa-aaaaa-qaaba-cai&id=bkyz2-fmaaa-aaaaa-qaaaq-cai

5. プログラム実行

dfx deployコマンドを実行した後に表示されるCandid UI画面のURLにアクセスします。

Casniterのプログラムを適当に修正して再度Deployした後もデータは消えずに保存されていることが確認できます。

6. Local Canister実行環境の停止

$ dfx stop

Discussion