中級 api

Wasm + IndexedDB

IndexedDBとは?

IndexedDBは、すべてのブラウザに組み込まれた低レベルのトランザクショナルデータベースです。localStorage(文字列のみ最大約5MBまで)とは異なり、IndexedDBは構造化データ、バイナリブロブ、ファイルをほぼ無制限のサイズで保存できます。

┌─────────────────────────────────────────────────────┐
│                    ブラウザ                           │
│                                                      │
│  ┌─────────────┐   ┌────────────────────────────┐   │
│  │  Wasmモジュール│──►│       IndexedDB             │   │
│  │  (Rust)     │   │                            │   │
│  │              │   │  データベース: "my_app"       │   │
│  │  web-sys     │   │  ├── ストア: "users"        │   │
│  │  バインディング│   │  │   ├── インデックス: "email"│   │
│  │              │   │  │   └── インデックス: "name" │   │
│  │  JsFuture    │   │  └── ストア: "settings"     │   │
│  └─────────────┘   └────────────────────────────┘   │
└─────────────────────────────────────────────────────┘
機能 localStorage IndexedDB
データ型 文字列のみ 構造化データ、バイナリ、ブロブ
サイズ制限 約5〜10 MB 数百MB以上
非同期 いいえ(ブロッキング) はい
インデックス なし あり
トランザクション なし あり(ACID準拠)
カーソル なし あり

IndexedDB用のweb-sys設定

[dependencies]
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"

[dependencies.web-sys]
version = "0.3"
features = [
    "Window",
    "IdbFactory",
    "IdbOpenDbRequest",
    "IdbDatabase",
    "IdbTransaction",
    "IdbTransactionMode",
    "IdbObjectStore",
    "IdbObjectStoreParameters",
    "IdbRequest",
    "IdbKeyRange",
    "IdbIndex",
    "IdbIndexParameters",
    "IdbCursorWithValue",
    "IdbCursorDirection",
    "DomException",
    "Event",
    "IdbVersionChangeEvent",
]

データベースを開く

IndexedDBの操作はJavaScriptではコールバックベースです。RustではJsFutureや手動のPromise処理でラップします:

use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{IdbDatabase, IdbOpenDbRequest, IdbObjectStoreParameters};
use wasm_bindgen_futures::JsFuture;

pub async fn open_db(name: &str, version: u32) -> Result<IdbDatabase, JsValue> {
    let window = web_sys::window().unwrap();
    let idb_factory = window.indexed_db()?.unwrap();

    let open_request: IdbOpenDbRequest = idb_factory.open_with_u32(name, version)?;

    // スキーマアップグレードの処理
    let on_upgrade = Closure::wrap(Box::new(move |event: web_sys::IdbVersionChangeEvent| {
        let request: IdbOpenDbRequest = event.target().unwrap().dyn_into().unwrap();
        let db: IdbDatabase = request.result().unwrap().dyn_into().unwrap();

        // アップグレード中にオブジェクトストアを作成
        if !db.object_store_names().contains("users") {
            let mut params = IdbObjectStoreParameters::new();
            params.key_path(Some(&JsValue::from_str("id")));
            params.auto_increment(true);

            let store = db.create_object_store_with_optional_parameters("users", &params)?;
            store.create_index_with_str("email", "email")?;
            store.create_index_with_str("name", "name")?;
        }
    }) as Box<dyn FnMut(_)>);

    open_request.set_onupgradeneeded(Some(on_upgrade.as_ref().unchecked_ref()));
    on_upgrade.forget();

    // データベースが開くのを待つ
    let result = JsFuture::from(open_request.into()).await?;
    Ok(result.dyn_into()?)
}

CRUD操作

Create(put)

use serde::Serialize;

#[derive(Serialize)]
struct User {
    name: String,
    email: String,
    age: u32,
}

pub async fn add_user(db: &IdbDatabase, user: &User) -> Result<JsValue, JsValue> {
    let transaction = db.transaction_with_str_and_mode(
        "users",
        web_sys::IdbTransactionMode::Readwrite,
    )?;

    let store = transaction.object_store("users")?;
    let js_value = serde_wasm_bindgen::to_value(user)?;
    let request = store.put(&js_value)?;

    JsFuture::from(request).await
}

Read(get)

pub async fn get_user(db: &IdbDatabase, id: u32) -> Result<Option<JsValue>, JsValue> {
    let transaction = db.transaction_with_str("users")?;
    let store = transaction.object_store("users")?;
    let request = store.get(&JsValue::from(id))?;

    let result = JsFuture::from(request).await?;
    if result.is_undefined() {
        Ok(None)
    } else {
        Ok(Some(result))
    }
}

Update

// 既存のキーでput()するとレコードが更新される
pub async fn update_user(db: &IdbDatabase, user: &User) -> Result<JsValue, JsValue> {
    // add_userと同じ — put()はupsert操作
    add_user(db, user).await
}

Delete

pub async fn delete_user(db: &IdbDatabase, id: u32) -> Result<(), JsValue> {
    let transaction = db.transaction_with_str_and_mode(
        "users",
        web_sys::IdbTransactionMode::Readwrite,
    )?;

    let store = transaction.object_store("users")?;
    let request = store.delete(&JsValue::from(id))?;
    JsFuture::from(request).await?;
    Ok(())
}

トランザクション

IndexedDBのトランザクションはACID準拠です:

┌─────────────────────────────────────────┐
│            トランザクション                │
│                                          │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐ │
│  │  put()   │  │  get()   │  │ delete() │ │
│  │ ユーザ#1 │  │ ユーザ#2 │  │ ユーザ#3 │ │
│  └─────────┘  └─────────┘  └─────────┘ │
│                                          │
│  全て成功 ──► コミット                    │
│  一つでも失敗 ──► ロールバック(全て元に戻る)│
└─────────────────────────────────────────┘

トランザクションモード:

モード 読み取り可? 書き込み可? 他をブロック?
readonly はい いいえ いいえ
readwrite はい はい 同じストアへの書き込みをブロック
versionchange はい はい(スキーマ) 排他的
// 1つのトランザクション内で複数操作
pub async fn transfer_credits(
    db: &IdbDatabase,
    from_id: u32,
    to_id: u32,
    amount: u32,
) -> Result<(), JsValue> {
    let tx = db.transaction_with_str_and_mode(
        "users", web_sys::IdbTransactionMode::Readwrite,
    )?;
    let store = tx.object_store("users")?;

    // 両方の操作がアトミックに行われる
    let from = JsFuture::from(store.get(&from_id.into())?).await?;
    let to = JsFuture::from(store.get(&to_id.into())?).await?;

    // 変更してputで戻す
    js_sys::Reflect::set(&from, &"credits".into(),
        &(get_credits(&from) - amount).into())?;
    js_sys::Reflect::set(&to, &"credits".into(),
        &(get_credits(&to) + amount).into())?;

    store.put(&from)?;
    store.put(&to)?;

    // トランザクションは全リクエスト完了時に自動コミット
    Ok(())
}

インデックスを使ったクエリ

インデックスを使うと、主キー以外のフィールドでレコードを検索できます:

pub async fn find_by_email(
    db: &IdbDatabase,
    email: &str,
) -> Result<Option<JsValue>, JsValue> {
    let tx = db.transaction_with_str("users")?;
    let store = tx.object_store("users")?;
    let index = store.index("email")?;

    let request = index.get(&JsValue::from_str(email))?;
    let result = JsFuture::from(request).await?;

    if result.is_undefined() { Ok(None) } else { Ok(Some(result)) }
}

IdbKeyRangeによる範囲クエリ

pub async fn find_users_in_age_range(
    db: &IdbDatabase,
    min_age: u32,
    max_age: u32,
) -> Result<Vec<JsValue>, JsValue> {
    let tx = db.transaction_with_str("users")?;
    let store = tx.object_store("users")?;
    let index = store.index("age")?;

    let range = web_sys::IdbKeyRange::bound(
        &JsValue::from(min_age),
        &JsValue::from(max_age),
    )?;

    let request = index.get_all_with_key(&range)?;
    let result = JsFuture::from(request).await?;
    let array: js_sys::Array = result.dyn_into()?;

    Ok(array.iter().collect())
}

JsFuture:コールバックから非同期への橋渡し

IndexedDBはPromise以前に設計されたため、イベントコールバックを使用します。JsFutureIdbRequestをRustのFutureに変換します:

┌────────────┐     ┌──────────────┐     ┌────────────┐
│ IdbRequest │────►│   Promise    │────►│  JsFuture   │
│(コールバック)│     │  (ラッパー) │     │  (.await)  │
│            │     │              │     │             │
│ onsuccess  │     │  resolve()   │     │  Ok(value)  │
│ onerror    │     │  reject()    │     │  Err(error) │
└────────────┘     └──────────────┘     └────────────┘
use wasm_bindgen_futures::JsFuture;

// IdbRequest -> JsFuture(暗黙のPromise変換経由)
let request: IdbRequest = store.get(&key)?;
let result: JsValue = JsFuture::from(request).await?;

エラーハンドリング

pub async fn safe_db_operation(db: &IdbDatabase) -> Result<String, String> {
    let tx = db.transaction_with_str_and_mode(
        "users",
        web_sys::IdbTransactionMode::Readwrite,
    ).map_err(|e| format!("トランザクション失敗: {:?}", e))?;

    let store = tx.object_store("users")
        .map_err(|e| format!("ストアが見つかりません: {:?}", e))?;

    let request = store.get(&JsValue::from(1))
        .map_err(|e| format!("取得失敗: {:?}", e))?;

    let result = JsFuture::from(request).await
        .map_err(|e| format!("非同期エラー: {:?}", e))?;

    if result.is_undefined() {
        Err("レコードが見つかりません".to_string())
    } else {
        Ok(format!("{:?}", result))
    }
}

まとめ

  1. IndexedDBは強力なブラウザデータベース — トランザクショナルでインデックス付き、バイナリデータと大規模データセットを扱える
  2. web-sysは完全なIndexedDBバインディングを提供するが、APIは冗長でコールバックベース
  3. JsFutureはコールバックベースのAPIとRustのasync/awaitを橋渡しする
  4. トランザクションはACID準拠 — 関連する操作をグループ化してアトミック性を確保する
  5. インデックスは主キー以外のフィールドで効率的な検索を可能にする
  6. スキーマアップグレードonupgradeneededイベント内で行う — ストアやインデックスの作成/削除はここでのみ可能
  7. エラーハンドリングは慎重に — IndexedDB操作はクォータ制限、パーミッション、スキーマの不一致により失敗する可能性がある

試してみる