Rustでserde_dynamoを使ってDynamoDBのデータを登録・取得してみた

serde_dynamoを利用することでボイラープレートコードが大幅に削減できました
2023.12.06

こんばんは、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に設定するということがわかりますね。
このAttributeValueaws_sdk_dynamodbで定義されているenumで、DynamoDBで定義されているデータ型の分だけenumが定義されています。

上記の通り、nameageなどの値をそのままaws_sdk_dynamodbに渡すことはできず、全てAttributeValueに変換する必要があります。 また、基本的にaws_sdk_dynamodbとDynamoDBとの間の全てのデータは文字列としてやりとりがされるため、以下のようにu8などの数値は全て文字列に変換する必要があります。

("age".to_string(), N(user_a.age.to_string())),

また、単純なStringVec<String>boolはそのままAttributeValueに変換できますが、HashMapHashSetのような複雑な型を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のアトリビュートの機能を利用しています。

Attributes · Serde

serde_dynamoには文字列セットに変換するためのstring_set以外にもバイナリセットに変換できるbinary_setや数値セットに変換できるnumber_setが用意されているようです。

https://docs.rs/serde_dynamo/latest/serde_dynamo/#modules

まとめ

  • AWS SDK for Rust(aws_sdk_dynamodb)を利用すると、DynamoDBとRustの構造体との相互変換が必要になります
    • 属性はAttributeValueで表現されます
    • 項目はHashMap<String, AttributeValue>で表現されます
    • 数値は文字列に変換する必要があります
  • serde_dynamoを使うことで、DynamoDBとRustの構造体との相互変換を簡単に行うことができます
    • これによりボイラープレートコードを大幅に減らすことができます。
  • serde_dynamoではserdeの機能が利用できるため、シリアライズ/デシリアライズ処理を簡単にカスタマイズすることができます

ソースコードはこちらに置いてます。

参考URL