Rustでserde_dynamoを使ってDynamoDBのデータを登録・取得してみた
こんばんは、CX事業本部のmorimorikochanです!
先日の2023/11/27に、AWS SDK for RustがGAになりましたね。弊社ブログでも以下の通り取り上げられています。
GAになった機会に一度は触っておきたいと思った、DynamoDBへのデータ登録・取得を実装をしてみました。
また、RustからDynamoDBをさらに扱いやすくするためのserde_dynamo
というライブラリを利用して同様の実装をして比較した結果をまとめてみたいと思います。
前提
Rust製のWebアプリケーションの中で以下のようなUser
という構造体を扱っているとします。今回はいろいろな型で検証したいので様々なフィールドを持たせています
#[derive(Debug)] pub struct User { pub id: String, pub name: String, pub age: u8, pub is_married: bool, pub friends: Vec<String>, pub metadata: HashMap<String, Vec<String>>, pub sikaku: HashSet<String>, pub pet_name: Option<String>, }
このUserのデータをDynamoDBに登録し、その後DynamoDBから取得してUserのデータに変換するユースケースを考えてみましょう。特に、User
型とAWS SDKが受け付ける型との相互変換について着目していきます。
AWS SDK for Rustを使った場合
AWS SDK for Rustを利用してDynamoDBを操作するには、 aws_sdk_dynamodbというcrateを利用します。
これを利用して、DynamoDBから項目の登録処理・取得処理を実装してみました。
登録処理
まずは登録処理から見ていきましょう。
use std::collections::{HashMap, HashSet}; use ddb_test::util::{create_dynamodb_client, User}; use uuid::Uuid; use aws_sdk_dynamodb::types::AttributeValue::{Bool, Null, Ss, L, M, N, S}; #[tokio::main] async fn main() { let client = create_dynamodb_client().await; let user_a = User { id: Uuid::new_v4().to_string(), name: "John".to_string(), age: 20, is_married: false, friends: vec![Uuid::new_v4().to_string(), Uuid::new_v4().to_string()], metadata: HashMap::from([( "favorite_songs".to_string(), vec!["song1".to_string(), "song2".to_string()], )]), sikaku: HashSet::from(["AWS SAP".to_string(), "DB Specialist".to_string()]), pet_name: None, }; // 1.登録処理 client .put_item() .table_name("users") .set_item(Some(HashMap::from([ ("id".to_string(), S(user_a.id)), ("name".to_string(), S(user_a.name)), ("age".to_string(), N(user_a.age.to_string())), ("is_married".to_string(), Bool(user_a.is_married)), ( "friends".to_string(), L(user_a.friends.into_iter().map(|friend| S(friend)).collect()), ), ( "metadata".to_string(), M(user_a .metadata .into_iter() .map(|(key, value)| (key, Ss(value))) .collect()), ), ( "sikaku".to_string(), Ss(user_a.sikaku.into_iter().collect()), ), ( "pet_name".to_string(), user_a .pet_name .map_or_else(|| Null(true), |pet_name| S(pet_name)), ), ]))) .send() .await .unwrap(); }
DynamoDBに登録する項目はset_item()
に設定します。引数の型はOption<HashMap<String, AttributeValue>>
となっているので、項目の中の各属性の名称はHasMap
のKeyであるString
型、各属性の値はHasMap
のValueであるAttributeValue
に設定するということがわかりますね。
このAttributeValue
はaws_sdk_dynamodb
で定義されているenumで、DynamoDBで定義されているデータ型の分だけenumが定義されています。
上記の通り、name
やage
などの値をそのままaws_sdk_dynamodb
に渡すことはできず、全てAttributeValue
に変換する必要があります。
また、基本的にaws_sdk_dynamodb
とDynamoDBとの間の全てのデータは文字列としてやりとりがされるため、以下のようにu8
などの数値は全て文字列に変換する必要があります。
("age".to_string(), N(user_a.age.to_string())),
また、単純なString
やVec<String>
やbool
はそのままAttributeValue
に変換できますが、HashMap
やHashSet
のような複雑な型をDynamoDBの対応する型(マップ型や文字列セット)に変換する場合はAttributeValue
に変換するために自前で実装する必要があります。特に文字列セットに変換する場合は、HashMap<String>
ではなくVec<String>
を求められるので注意が必要です。
// ex. HashMap<String, Vec<String>>をAttributeValueに変換する M(user_a .metadata .into_iter() .map(|(key, value)| (key, Ss(value))) .collect()); // ex. Option<String>をAttributeValueに変換する。Noneの場合はNullを設定 user_a .pet_name .map_or_else(|| Null(true), |pet_name| S(pet_name)),
取得処理
次に取得処理を見ていきましょう。
ここでは取得方法にこだわりはないのでScan処理を行っています。
#[tokio::main] async fn main() { // ... let results = client.scan().table_name("users").send().await.unwrap(); for user_ddb_item in results.items.unwrap() { // 2.取得処理 let new_user_a = User { id: user_ddb_item.get("id").unwrap().as_s().unwrap().to_string(), name: user_ddb_item .get("name") .unwrap() .as_s() .unwrap() .to_string(), age: user_ddb_item .get("age") .unwrap() .as_n() .unwrap() .parse::<u8>() .unwrap(), is_married: user_ddb_item .get("is_married") .unwrap() .as_bool() .unwrap() .to_owned(), friends: user_ddb_item .get("friends") .unwrap() .as_l() .unwrap() .into_iter() .map(|attribute_value| attribute_value.as_s().unwrap().to_string()) .collect(), sikaku: user_ddb_item .get("sikaku") .unwrap() .as_ss() .unwrap() .to_vec() .into_iter() .collect(), metadata: user_ddb_item .get("metadata") .unwrap() .as_m() .unwrap() .into_iter() .map(|(key, value)| (key.to_string(), value.as_ss().unwrap().to_vec())) .collect(), pet_name: user_ddb_item .get("pet_name") .unwrap() .as_s() .map_or(None, |pet_name| Some(pet_name.to_string())), }; dbg!(new_user_a); }
取得結果のItems
の型はHashMap<String, AttributeValue>
となっています。登録処理のset_item()
の引数と同様の型ですね。
HashMap
から値を取得するためにget()
を行い、さらにAttributeValue
から具体的な型に沿った値を取り出すためにas_s()
やas_n()
を行っています。
登録処理と同じく、数値を取得した結果は文字列になっています。なのでRustのu8
などに変換したい場合はparse::<u8>()
などの型変換が必要になります。
また、マップ型やリストなどの複雑な属性はHashMap<String, AttributeValue>
やVec<AttributeValue>
で取得されるのでさらに各値をAttributeValue
から適切に変換することも必要になります。
全体的にunwrap()
が多用され、コードの記述量が増えています。これは主にHashMap
からの値の取得やenumであるAttributeValue
の値の切り分けによるものです。
serde_dynamoを利用した場合
serde_dynamoは、serdeを利用してRustの構造体とDynamoDBの項目との間の変換(シリアライズ/デシリアライズ)を簡単にするcrateです。
serde_dynamoでは、aws_sdk_dynamodbがサポートされているので、構造体とaws_sdk_dynamodbが受け付ける型をとても簡単に相互に変換することが可能です。
使い方は簡単で、まずは変換したい構造体にSerializeとDeserializeを実装します。
#[derive(Debug, Serialize, Deserialize)] pub struct User { pub id: String, pub name: String, pub age: u8, pub is_married: bool, pub friends: Vec<String>, pub metadata: HashMap<String, Vec<String>>, pub sikaku: HashSet<String>, pub pet_name: Option<String>, }
構造体からaws_sdk_dynamodb
が受け付ける型(HashMap<String, Attribute>
)へ変換したい場合は、serde_dynamoが提供するto_item()
を使うことができます。
実行すると、Result<HashMap<String, AttributeValue>>
が取得できるのでこれを使って簡単にDynamoDBに項目を登録・更新することができます。
また、逆にaws_sdk_dynamodb
が受け付ける型(HashMap<String, Attribute>
)から構造体へ変換したい場合はserde_dynamoが提供するfrom_item()
を使うことができます。
具体的にどの構造体に変換するかは、from_item()
のジェネリクスで設定する必要があります。
また、複数の項目を一括して変換したい場合はfrom_items()
を使うことができます。
serde_dynamoを利用して先ほどのコードを具体的に書き直すと、以下のようになりました。
use std::collections::{HashMap, HashSet}; use ddb_test::util::{create_dynamodb_client, User}; use serde_dynamo::aws_sdk_dynamodb_1::{from_item, to_item}; use uuid::Uuid; #[tokio::main] async fn main() { let client = create_dynamodb_client().await; let user_a = User { id: Uuid::new_v4().to_string(), name: "John".to_string(), age: 20, is_married: false, friends: vec![Uuid::new_v4().to_string(), Uuid::new_v4().to_string()], metadata: HashMap::from([( "favorite_songs".to_string(), vec!["song1".to_string(), "song2".to_string()], )]), sikaku: HashSet::from(["AWS SAP".to_string(), "DB Specialist".to_string()]), pet_name: None, }; client .put_item() .table_name("users") .set_item(Some(to_item(user_a).unwrap())) .send() .await .unwrap(); let results = client.scan().table_name("users").send().await.unwrap(); for user_ddb_item in results.items.unwrap() { let new_user_a: User = from_item(user_ddb_item).unwrap(); dbg!(new_user_a); } }
最初の例と比較して、コードの記述量が大幅に減少していることがわかります。
また、Vec<String>
やHashMap<String, Vec<String>>
のような複雑なフィールドでも、正しくDynamoDBに登録・DynamoDBから取得されていることも確認できました。
ただし1点だけ注意する必要があり、sikaku
フィールドはHashSet<String>
型で定義されているので、DynamoDBには文字列セットとして登録されることを期待していたのですが、実際はリストとして登録されていました。
おそらくですが、serde_dynamo側ではHashSet
の中身の型までチェックしていないので機械的にリストとして登録されているのだと思います。
この問題については、後述するserdeのwith
アトリビュートを利用することで、DynamoDBに文字列セットとして登録することができました。
#[derive(Debug, Serialize, Deserialize)] pub struct User { // ... #[serde(with = "serde_dynamo::string_set")] pub sikaku: HashSet<String> }
上記のように、serde_dynamoを利用すると簡潔にDynamoDBとRustの構造体との相互変換を行うことができました。
serde_dynamoの特徴
他にもserde_dynamoには以下のような特徴があります。
1. aws_sdk_dynamodbとrusotoのサポート
serde_dynamoは、aws_sdk_dynamodbの他にもrusotoもサポートしています。
また、これらcrateを利用する際にはそのcrateのバージョンに合わせてfeatures
の設定が必要です。
今回の記事ではaws_sdk_dynamodbのバージョン1.3.0を利用しているので、featuresには対応するaws-sdk-dynamodb+1
を設定しています。
[dependencies] # ... serde_dynamo = { version = "4", features = ["aws-sdk-dynamodb+1"] }
2. 豊富なserdeの機能が利用できる
serde_dynamoはserdeのデータフォーマットの1種であるため、serdeの機能が利用できます。
例えば、serdeで扱う構造体には各フィールドのシリアライズ/デシリアライズをカスタマイズできる便利なアトリビュートを設定することができます。
例えば、上記例でpet_nameの値が未設定である場合はDynamoDBではNULL
として登録されますが、以下のようにアトリビュートを指定することで、pet_name
の属性自体が存在しないようにDynamoDBに登録されます。
機能追加で既存のDynamoDBのテーブルに項目を追加したいような方互換性を保ちたい場面でとても役立ちそうですね。
#[derive(Debug, Serialize, Deserialize)] pub struct User { // ... #[serde(skip_serializing_if = "Option::is_none")] pub pet_name: String, }
また、すでに上記で挙げたHashSet<String>
を文字列セットに変換する手法もserdeのアトリビュートの機能を利用しています。
serde_dynamoには文字列セットに変換するためのstring_set
以外にもバイナリセットに変換できるbinary_set
や数値セットに変換できるnumber_set
が用意されているようです。
まとめ
- AWS SDK for Rust(
aws_sdk_dynamodb
)を利用すると、DynamoDBとRustの構造体との相互変換が必要になります- 属性は
AttributeValue
で表現されます - 項目は
HashMap<String, AttributeValue>
で表現されます - 数値は文字列に変換する必要があります
- 属性は
- serde_dynamoを使うことで、DynamoDBとRustの構造体との相互変換を簡単に行うことができます
- これによりボイラープレートコードを大幅に減らすことができます。
- serde_dynamoではserdeの機能が利用できるため、シリアライズ/デシリアライズ処理を簡単にカスタマイズすることができます
ソースコードはこちらに置いてます。