DynamoDBを型安全に扱うためのraiden-dynamo
2026-11-01
RustからDynamoDBを使うとき、SDKが提供するAPIは必要な機能をひと通り備えている。一方で、アプリケーションの構造体とDynamoDBの属性値の変換、式の組み立て、キーの指定など、アプリケーションごとに繰り返し書くことも多い。
そのあたりを少し楽にしつつ、Rustの型を活かしてDynamoDBを扱えるようにするライブラリとしてraiden-dynamoを作った。(かれこれ6年くらい使っているが大きな問題はない。)
目次
今回は、何を解決しようとしているのかと、実装していて工夫したところを紹介する。
モチベーション
RusotoやAWS SDK for Rustをそのまま使うと、DynamoDBのAPIに合わせたコードを書くことになる。たとえばRusotoでuserテーブルから1件読み、User構造体に変換するだけでも、次のようになる。
use std::collections::HashMap;
use rusoto_dynamodb::{AttributeValue, DynamoDb, DynamoDbClient, GetItemInput};
struct User {
id: String,
name: String,
age: u8,
}
async fn get_user(client: &DynamoDbClient, id: &str) -> Result<Option<User>, String> {
let mut key = HashMap::new();
key.insert(
"id".to_owned(),
AttributeValue {
s: Some(id.to_owned()),
..Default::default()
},
);
let response = client
.get_item(GetItemInput {
table_name: "user".to_owned(),
key,
..Default::default()
})
.await
.map_err(|e| e.to_string())?;
let Some(mut item) = response.item else {
return Ok(None);
};
let id = item
.remove("id")
.and_then(|value| value.s)
.ok_or("id is missing or not a string")?;
let name = item
.remove("name")
.and_then(|value| value.s)
.ok_or("name is missing or not a string")?;
let age = item
.remove("age")
.and_then(|value| value.n)
.ok_or("age is missing or not a number")?
.parse::<u8>()
.map_err(|e| e.to_string())?;
Ok(Some(User { id, name, age }))
}
まずキーをHashMap<String, AttributeValue>として作り、戻り値のitemがない場合を扱う。アイテムがあっても、各フィールドについて属性の有無とDynamoDB上の型を確かめなければならない。数値属性のnも文字列なので、ageに使うにはさらにu8への変換が必要だ。ここでは説明を短くするためエラーをStringにしているが、アプリケーションでは欠損・型違い・変換失敗をどう表すかも決めることになる。
"id"や"age"といった属性名は構造体のフィールドとは別に文字列で書く。名前を変えたときに片方を直し忘れてもコンパイルは通る。QueryやUpdateでは、さらに式の文字列、式属性名、式属性値を組み立てる必要がある。キーに指定できない属性を指定しても、コンパイル時には分からない。
SDKはDynamoDBの機能を細かく制御できる一方、こうした変換や確認を操作のたびに書くのはつらい。普段の読み書きをRustの構造体や型付きのビルダーから書けるようにしたいと思った。
成果物
リポジトリは以下。
基本的には#[derive(Raiden)]を付けた構造体から、DynamoDBのアイテム変換と操作用のAPIを生成する。内部クライアントとしてRusotoとAWS SDK for Rustを選択できる。
まずはモデルを定義する。
use raiden::*;
#[derive(Raiden)]
#[raiden(table_name = "user")]
struct User {
#[raiden(partition_key)]
id: String,
name: String,
age: u8,
}
この定義から、クライアントやアイテムの変換処理、操作用のビルダーなどが生成される。先ほどの読み取りは、次のように書ける。
let user: User = client.get("user#1").run().await?.item;
キーを渡すと、取得した属性がUserに変換されて返る。呼び出し側でHashMapからフィールドを取り出したり、AttributeValueの型や数値の文字列を確認したりする必要がない。アイテムが見つからない場合や必須属性を変換できない場合はエラーになる。
書き込みも、生成されたPut用のビルダーから組み立てられる。
let input = User::put_item_builder()
.id("user#1".to_owned())
.name("bokuweb".to_owned())
.age(20)
.build();
client.put(&input).run().await?;
フィールドごとの型はビルダーに反映される。必須フィールドを埋めないままbuild()することや、ageに文字列を渡すことはできない。フィールドを追加・変更したときにも、利用側のコードで直すべき場所をコンパイラが教えてくれる。
キー条件を型で組み立てる
Queryでは、パーティションキー条件から始め、必要であればソートキー条件を追加する。
let key = User::partition_key_condition().eq("user#1");
let result = client
.query()
.key_condition(key)
.run()
.await?;
partition_key_condition()は#[raiden(partition_key)]から生成され、.eq(...)だけを受け付ける。ソートキーを定義したモデルではsort_key_condition()も生成され、パーティションキー条件の.and(...)に続けられる。
#[derive(Raiden)]
struct Event {
#[raiden(partition_key)]
user_id: String,
#[raiden(sort_key)]
created_at: String,
name: String,
}
let key = Event::partition_key_condition()
.eq("user#1")
.and(Event::sort_key_condition().begins_with("2026-09"));
パーティションキーにbegins_withを使う、ソートキー条件だけでQueryを始める、.and(...)にキーではない属性を渡す、といった呼び方はコンパイル時に止まる。
GSIの定義と型付きQuery
当初はインデックス名を.index("userIndex")と書く方法をとっていたが、この場合名前の間違いも、射影されていないフィールドを結果の型に含めてしまうことも防げない。
そこでGSIをモデルに宣言すると、インデックス用のメソッドと射影型を生成するように最近改善した。
#[derive(Raiden)]
#[raiden(table_name = "user")]
#[raiden(gsi(name = "userIndex", partition_key = "org_id", sort_key = "created_at"))]
struct User {
#[raiden(partition_key)]
id: String,
org_id: String,
created_at: String,
name: String,
#[raiden(omit_gsi = "userIndex")]
internal_note: String,
}
let key = UserIndexItem::user_index_key_condition()
.eq("org#1")
.and(UserIndexItem::user_index_sort_key_condition().begins_with("2026-09"));
let users = UserIndexItem::query(&client)
.key_condition(key)
.run()
.await?;
user_index_key_condition()とuser_index_sort_key_condition()は宣言したキーを参照するので、ここでは別のフィールドを渡せない。omit_gsiを付けたフィールドは生成されるUserIndexItemから外れる。Queryの結果をテーブル全体のUserとして読むのではなく、インデックスが返す形で受け取れる。射影の形や型名を自分で決めたい場合にはRaidenIndexも使える。
client.query().user_index().project::<UserIndexItem>()から始めても同じ型付きQueryになる。実際のDynamoDBのインデックススキーマや射影まで、このderiveが作成・検証するわけではないので、宣言はテーブル定義と合わせる必要がある。
LSIも#[raiden(lsi(...))]で宣言できる。LSIはテーブルと同じパーティションキーを共有し、別のソートキーを使う。
#[derive(Raiden)]
#[raiden(lsi(name = "createdIndex", sort_key = "created_at"))]
struct Event {
#[raiden(partition_key)]
account_id: String,
#[raiden(sort_key)]
event_id: String,
created_at: String,
#[raiden(omit_lsi = "createdIndex")]
private_note: String,
}
let key = Event::created_index_key_condition()
.eq("account#1")
.and(Event::created_index_sort_key_condition().begins_with("2026-09"));
let events = client.query()
.created_index()
.project::<EventCreatedIndexItem>()
.key_condition(key)
.run()
.await?;
GSIと同様に、宣言したLSIのキーに対応する条件メソッドと射影型が生成される。射影を自分で定義する場合にはRaidenIndexも使える。LSIの実体は別途DynamoDB側に用意する必要があり、deriveの宣言だけで作成されるわけではない。
式を文字列から組み立てない
SDKを直接使う場合、条件式そのものと、式属性名・式属性値の対応を保ちながら管理する必要がある。属性名には予約語もあるため、名前のエスケープも考えなくてはならない。
たとえば属性名がDynamoDBの予約語と重なる場合、SDKでは#nのような別名を式に置き、ExpressionAttributeNamesにも対応を追加する。値には:vとExpressionAttributeValuesが必要になる。式だけ変更してマップを直し忘れると実行時エラーになる。
raiden-dynamoでは、モデルのアクセサから条件を組み立てる。
let filter = User::filter_expression(User::age()).ge(18);
let result = client
.query()
.key_condition(User::partition_key_condition().eq("user#1"))
.filter(filter)
.run()
.await?;
条件値は式属性値として束縛され、属性名もプレースホルダー経由で出力される。たとえばnameなら式には#nameを使い、属性名のマップに#nameとnameの対応を入れる。予約語かどうかを呼び出し側で判定する必要がない。入れ子のパスなら各要素を別々の属性名として扱う。andやor、not、contains、attribute_existsなども同じ形で合成できる。
Condition Builderで書き込み条件を表す
Filterは取得した結果を絞るための式だが、Conditionは「この条件を満たすときだけ書き込む」を表す。たとえば同じIDでの上書きを防ぐPutは次のように書ける。
let input = User::put_item_builder()
.id("user#1".to_owned())
.name("bokuweb".to_owned())
.age(20)
.build();
let only_new = User::condition().attr_not_exists(User::id());
client.put(&input).condition(only_new).run().await?;
条件は.and()や.or()でつなげられる。attr_exists、attr_not_existsのほか、属性同士または属性と値の比較、begins_with、contains、sizeが使える。たとえばUser::condition().contains(User::name(), "boku")は名前に文字列を含むことを確認する。size(User::name()).ge(2_usize)は数値に変換できる値だけを受け取る。生成された条件にはモデルごとの型が付くので、別のモデルのConditionをそのまま渡すこともできない。
一方、attr(...).eq_value(...)の値は汎用のIntoAttributeで受ける。個々のフィールド型と比較値の型の一致まで保証しているわけではない。型で守れる境界を明確にしながら、条件式の文字列やプレースホルダーの管理をライブラリ側に寄せた。
更新も、属性アクセサと値を組み合わせて表現する。
let set_name = User::update_expression()
.set(User::name())
.value("updated name");
client.update("user#1").set(set_name).run().await?;
ここでも属性名を文字列で繰り返さずに済む。条件付きPut、Update、Deleteやトランザクションにも条件ビルダーを渡せる。
入れ子のドキュメント
DynamoDBのMapやListを含むデータを扱うため、RaidenDocument deriveを用意している。Serdeを通してネストした構造体やタグ付きenumを変換でき、入れ子の属性もパスとして指定できる。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, RaidenDocument)]
struct Profile {
display_name: String,
level: usize,
}
#[derive(Raiden)]
#[raiden(table_name = "user")]
struct User {
#[raiden(partition_key)]
id: String,
profile: Profile,
}
let filter = User::filter_expression(
User::profile().field(Profile::level())
).ge(3);
動的なMapキーには.key("score")、List要素には.index(usize)を使う。パスの各要素は式属性名として出力されるため、予約語を含む属性名も扱いやすい。なお、ネストした属性をQueryのパーティションキーやソートキーとして使えるわけではない。キー条件はDynamoDBの仕様に従い、テーブルまたはインデックス上のキーに限られる。
enumも、Serialize、Deserialize、RaidenDocumentをderiveすればモデルのフィールドとして格納できる。たとえばタグ付きenumなら、バリアントを自分で文字列やMapに変換せず、そのままPutのビルダーに渡せる。
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, RaidenDocument)]
#[serde(tag = "type")]
enum Message {
Request { method: String },
Response { result: String },
}
#[derive(Raiden)]
#[raiden(table_name = "event")]
struct Event {
#[raiden(partition_key)]
id: String,
message: Message,
}
let input = Event::put_item_builder()
.id("event#1".to_owned())
.message(Message::Request { method: "send".to_owned() })
.build();
client.put(&input).run().await?;
let message: Message = client.get("event#1").run().await?.item.message;
messageはDynamoDBのMap属性になり、typeとバリアントのフィールドが保存される。読み出すときもSerdeのタグを使って元のバリアントに戻る。ここで直接渡せるのは、名前付きフィールドを持つモデルの**フィールド値としてのenum**である。
#[derive(Raiden)]自体は名前付きフィールドを持つ構造体が対象なので、enumをテーブルモデルにしたり、struct A(B);のようなタプル構造体をテーブルモデルにしたりはできない。
RaidenDocumentもタプル構造体には対応していない。enumをフィールドとして使う場合は、SerdeとRaidenDocumentのderiveが必要になる。
クライアント実装を選べる
RusotoとAWS SDK for Rustは、Cargo featureで切り替える。モデル定義や操作ビルダーを共通にしながら、既存のSDKクライアントを渡すこともできる。
リトライの扱いはSDKごとに異なる。AWS SDK for Rustには標準のリトライが組み込まれており、スロットリングや一時的なサーバーエラーなどを再試行する。回数や待機時間もaws_configなどから設定できる。そのため、AWS SDKを使う場合はraiden独自のリトライを既定で無効にしている。
一方、RusotoにはAPI呼び出しの失敗を自動で再試行する仕組みがない。スループット超過などをそのまま呼び出し側で処理するのは大変なので、raiden側にRetryStrategyを用意し、Rusotoを使う場合は既定で有効にしている。
Rusoto側の既定戦略は、スループット超過、リクエスト制限、サーバーエラーなどを再試行対象にする。ポリシーは最大5回の再試行、初期待機50msの指数バックオフとジッター。エラーの種類と待機方法を分けているので、必要ならRetryStrategyのshould_retry()とpolicy()を実装し、.with_retries(Box::new(...))で差し替えられる。たとえばPolicy::Limit(3)なら再試行回数を制限できる。AWS SDK側でこれを明示的に指定すると、SDK側のリトライ設定との重なりも考える必要がある。
struct MyRetryStrategy;
impl RetryStrategy for MyRetryStrategy {
fn should_retry(&self, error: &RaidenError) -> bool {
matches!(error, RaidenError::ProvisionedThroughputExceeded(_))
}
fn policy(&self) -> Policy {
Policy::Limit(3)
}
}
let client = User::client(config::Region::from_static("us-east-1"))
.with_retries(Box::new(MyRetryStrategy));
BatchWriteにはもう1つの経路がある。DynamoDBは一部のアイテムを処理できなくても、API呼び出し自体を成功としてUnprocessedItemsを返す。この場合、通常の「エラーなら再試行」だけでは取りこぼす。
raidenは未処理分だけを取り出して再送し、上限付きの指数バックオフとジッターで待つ。上限まで残ったアイテムは結果のunprocessed_itemsに残すため、呼び出し側で後続処理を判断できる。この処理はPut/Deleteのバッチ操作にあり、APIエラーに対するリトライとは別に考えている。
まとめ
raiden-dynamoはDynamoDBのすべてのAPIを別物に置き換えるというより、日々よく使うアイテム変換やQuery、Update、条件式の記述をRustの型に寄せるためのライブラリだ。SDKを直接使うと必要になる属性値変換や式用マップの管理を減らし、キーの順序やフィールドの型など、コンパイル時に確認できる部分を増やしている。
DynamoDBの機能をそのまま扱いたい場合はSDKのAPIが向いている。一方で、アプリケーションのモデルを中心に型安全な読み書きをしたい場合には、こうした層が役立つと考えている。