😽

【Rust】DynamoDBのテーブルをSerdeとTraitで管理してTypeSafeなKey-Value Storeとして利用する

2022/02/21に公開

この記事で最終的に実現するもの

Type-SafeにDynamoDBを利用する!

つまり

  1. RDBのようにKeyとValueの型が決定していて不適合なデータ構造を保存できない
  2. ORMapperとして全てがRust内のデータ型で完結する

を満たすDynamoDBのインターフェースを作っていきます!
AWSの使い方も含めて細かく説明しているので目次を活用して必要な情報だけ利用してみてくださいね。

出来上がるテーブルのインターフェース

my_table.create(&key, &value).await?;
my_table.update(&key, &value2).await?;
let value = my_table.read(&key).await?;
my_table.delete(&key).await?;

スッキリしていて直感的!

テーブル定義

struct MyTable<'a> {
    client: &'a Client,
}

impl<'a> Table for MyTable<'a> {
    const TABLE_NAME: &'static str = "MyTable";
    type Key = MyKey;
    type Value = MyValue;
    fn get_client(&self) -> &Client {
        self.client
    }
}

これも直感的!
KeyとValueには保存したい任意の型を指定することができRustの型システムによって不適合なデータを保存することはできないType-Safeなテーブルです。

DynamoDBを利用したType-Safeなデータベースを

Zenn2回目投稿のYosematです。
前回書いた記事に続きRustでDBを操作するお話です。

今回はAWSの目玉サービスの1つDynamoDBを利用します。
AWSサービスへのアクセスにはAWS-SDK-RUSTを利用します。
まだα版ですが新興言語のユーザーはそんなことを恐れてはいけません。

基本的な設計は前回と同じです。DynamoDBをKVSとして利用して、Rust側でSerdeを用いてデータのSe/Deserializeを行います。

下準備

前提

  • AWSマネコンへのサインイン
  • Rustの環境構築
    はこの記事では説明いたしません。

テーブル作成

今回はDynamoDBを利用するので、DynamoDBにテーブルを作ります。
table_creation
DynamoDBをKey-Value Storeとして利用するので、DynamoDB内ではデータは文字列のkeyと文字列のvalueだけを管理してもらいます。
パーティションキーに単にkeyと入力してデフォルト設定でテーブルを作成してください。

AWS CLIのインストール

詳しくはこちらの案内を参考にAWS CLIをインストールしてください。

AccessKey/Secret/Default Regionの設定

AWSマネージメントコンソールから一番右上の自分の名前をクリックしてセキュリティ認証情報⇨アクセスキー⇨新しいアクセスキーの作成を押すと新しいアクセスキーを作成できます。
この値はもう二度と表示されないので紛失しないようにしてください。

そうしたらaws configureコマンドでAccessKey/Secret/Regionを設定します。以下は1例です。

aws configure
AWS Access Key ID [None]: AKIAIOSFODNN7EXAMPLE
AWS Secret Access Key [None]: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
Default region name [None]: ap-northeast-2
Default output format [None]: (何も入力しない)

この設定は特定の場所(Linuxなら~/.aws/credentials, ~/.aws/config)に保存されていて、AWS-SDKが自動で読み取ってくれます。
Lambda環境などでファイルの読み書きを行いたくない場合はAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGIONに各値を設定することもできます。

TraitでType-SafeなDynamoDBインターフェースを作る

依存関係まわり

冗長なので下記のコードは以下のuse宣言が行われていることを前提に掲載してます。

use anyhow::{anyhow, Result};
use async_trait::async_trait;
use aws_sdk_dynamodb::Client;
use aws_sdk_dynamodb::model::{AttributeAction, AttributeValue, AttributeValueUpdate};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
Cargo.toml
[dependencies]
aws-config = "0.6.0"
aws-sdk-dynamodb = "0.6.0"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
async-trait = "0.1.52"
serde_json = "1"
anyhow = "*"

最終的に作りたいインターフェースの再掲

ここで最終的に作りたいインターフェースを再掲しておきます。

my_table.create(&key, &value).await?;
my_table.update(&key, &value2).await?;
let value = my_table.read(&key).await?;
my_table.delete(&key).await?;

Async-Trait

Traitには通常Asyncメソッドは書けません。しかしDynamoDBとやり取りするメソッドは当然非同期処理を行います。
そこでasync-traitを使います。
Trait宣言とTrait実装の両方に#[async_trait]と1行書き加えるだけで使えます。便利!

CRUD

いっきに全部いきます。
create, read, update, deleteはデフォルト実装しか使わないので特に意識する必要はありません(SDKの使い方はDocを参照)。

const KEY: &str = "key";
const VALUE: &str = "value";

#[async_trait]
trait DynamoDBTable {
    const TABLE_NAME: &'static str;
    type Key: ToString + Sync + Send;
    type Value: Serialize + DeserializeOwned + Sync + Send;
    fn get_client(&self) -> &Client;

    async fn create(&self, key: &Self::Key, value: &Self::Value) -> Result<()> {
        let client = self.get_client();
        let dynamo_key = key.to_string();
        let dynamo_value = serde_json::to_string(value)?;
        client
            .put_item()
            .table_name(Self::TABLE_NAME)
            .item(KEY, AttributeValue::S(dynamo_key))
            .item(VALUE, AttributeValue::S(dynamo_value))
            .send()
            .await?;
        Ok(())
    }

    async fn read(&self, key: &Self::Key) -> Result<Self::Value> {
        let client = self.get_client();
        let dynamo_key = key.to_string();
        let res = client
            .get_item()
            .table_name(Self::TABLE_NAME)
            .key(KEY, AttributeValue::S(dynamo_key))
            .send()
            .await?;

        let item = res
            .item
            .ok_or_else(|| anyhow!("There is no such key: {}", key.to_string()))?;
        let raw_string = item
            .get(VALUE)
            .ok_or_else(|| anyhow!("No such key in this table"))?
            .as_s()
            .map_err(|_| anyhow!("Could not parse"))?;
        Ok(serde_json::from_str::<Self::Value>(raw_string)?)
    }

    async fn update(&self, key: &Self::Key, value: &Self::Value) -> Result<()> {
        let dynamo_value = serde_json::to_string(value)?;
        let client = self.get_client();
        let dynamo_key = key.to_string();
        let attr_val_up = AttributeValueUpdate::builder()
            .action(AttributeAction::Put)
            .value(AttributeValue::S(dynamo_value))
            .build();

        client
            .update_item()
            .table_name(Self::TABLE_NAME)
            .key(KEY, AttributeValue::S(dynamo_key))
            .attribute_updates(VALUE, attr_val_up)
            .send()
            .await?;
        Ok(())
    }

    async fn delete(&self, key: &Self::Key) -> Result<()> {
        let client = self.get_client();
        let dynamo_key = key.to_string();
        client
            .delete_item()
            .table_name(Self::TABLE_NAME)
            .key(KEY, AttributeValue::S(dynamo_key))
            .send()
            .await?;
        Ok(())
    }
}

結局次の部分だけ読んで実装すればよきです。

    const TABLE_NAME: &'static str;
    type Key: ToString + Sync + Send;
    type Value: Serialize + DeserializeOwned + Sync + Send;
    fn get_client(&self) -> &Client;

シンプル!

TypeSafeなKey-Value Storeということで

  1. テーブル名
  2. KeyとValueの型
  3. AWS SDK Clientの参照へのアクセス

だけ実装すれば良いということですね!

KeyにはToStringを、ValueにはSerdeのSerialize/Deserializeを要求します。

実際にTableを定義して使ってみる

Key型定義

KeyはToStringだけ持ってればどんな型でも使えます。
今回はただのStringのラッパー型を用意してやります。

struct MyKey {
    value: String,
}

impl MyKey {
    fn new(value: String) -> Self {
        Self { value }
    }
}

impl ToString for MyKey {
    fn to_string(&self) -> String {
        self.value.clone()
    }
}

Value型定義

ValueはSerdeのSerialize, DeserializeDeriveしてやりましょう。
Eq, PartialEqは実装上は不要ですが、あとでAssertEqして遊ぶようにつけてやります。

#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)]
struct MyValue {
    string_value: String,
    number_value: i64,
}

impl MyValue {
    fn new(string_value: String, number_value: i64) -> Self {
        Self {
            string_value,
            number_value,
        }
    }
}

Table定義

これまでに宣言したKey, Value型を使ってテーブルを利用することを宣言すればよいだけです。DynamoDB上でMyTableという名前のテーブルを作ったので、TABLE_NAME"MyTable"にしてやりましょう。

use aws_sdk_dynamodb::Client;
struct MyTable<'a> {
    client: &'a Client,
}

impl<'a> MyTable<'a> {
    fn new(client: &'a Client) -> Self {
        Self { client }
    }
}
// async methodがないので#[async_trait]を書かなくてもコンパイルが通る
impl<'a> DynamoDBTable for MyTable<'a> {
    const TABLE_NAME: &'static str = "MyTable";
    type Key = MyKey;
    type Value = MyValue;
    fn get_client(&self) -> &Client {
        self.client
    }
}

呼び出し

#[tokio::main]を利用します。余談ですがActix-Webも内部でTokioを利用しているので#[actix_web::main]でもいけます。

#[tokio::main]
async fn main() -> Result<()> {
    // Create Table
    let conf = aws_config::load_from_env().await;
    let client = Client::new(&conf);
    let my_table = MyTable::new(&client);

    // Define Two Key-Value Pair
    let key = MyKey::new("my_key".to_string());
    let value = MyValue::new("my_string_value".to_string(), 0);
    let value2 = MyValue::new("my_string_value2".to_string(), 1);

    // Create and Read Value
    my_table.create(&key, &value).await?;
    let retrieved_value = my_table.read(&key).await?;
    assert_eq!(value, retrieved_value);

    // Update and Read Value
    my_table.update(&key, &value2).await?;
    let retrieved_value2 = my_table.read(&key).await?;
    assert_eq!(value2, retrieved_value2);

    // Delete and Read Value
    my_table.delete(&key).await?;
    let invalid_read_result = my_table.read(&key).await;
    assert!(invalid_read_result.is_err());
    Ok(())
}

まとめ

以上です。前回と同じくKeyとValueに任意の型が利用できる拡張性とテーブル宣言を簡単に利用できる点に加え、DynamoDBの内部にある気持ち悪い型をコードの利用者が意識しなくて良い点が気に入っています。
以下に全体コードや依存関係も書いておくので、そのまま使って遊んでみてください。

最後まで読んでいただいてありがとうございました。
気に入っていただけましたらいいねポチッとおしていっていただけますと幸いです!初回投稿でもたくさんいいねをいただいてとても励みになってます!

Appendix

全体コード

use anyhow::{anyhow, Result};
use async_trait::async_trait;
use aws_sdk_dynamodb::model::{AttributeAction, AttributeValue, AttributeValueUpdate};
use aws_sdk_dynamodb::Client;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};

const KEY: &str = "key";
const VALUE: &str = "value";

#[async_trait]
trait DynamoDBTable {
    const TABLE_NAME: &'static str;
    type Key: ToString + Sync + Send;
    type Value: Serialize + DeserializeOwned + Sync + Send;

    fn get_client(&self) -> &Client;

    async fn create(&self, key: &Self::Key, value: &Self::Value) -> Result<()> {
        let client = self.get_client();
        let dynamo_key = key.to_string();
        let dynamo_value = serde_json::to_string(value)?;
        client
            .put_item()
            .table_name(Self::TABLE_NAME)
            .item(KEY, AttributeValue::S(dynamo_key))
            .item(VALUE, AttributeValue::S(dynamo_value))
            .send()
            .await?;
        Ok(())
    }

    async fn read(&self, key: &Self::Key) -> Result<Self::Value> {
        let client = self.get_client();
        let dynamo_key = key.to_string();
        let res = client
            .get_item()
            .table_name(Self::TABLE_NAME)
            .key(KEY, AttributeValue::S(dynamo_key))
            .send()
            .await?;

        let item = res
            .item
            .ok_or_else(|| anyhow!("There is no such key: {}", key.to_string()))?;
        let raw_string = item
            .get(VALUE)
            .ok_or_else(|| anyhow!("No such key in this table"))?
            .as_s()
            .map_err(|_| anyhow!("Could not parse"))?;
        Ok(serde_json::from_str::<Self::Value>(raw_string)?)
    }

    async fn update(&self, key: &Self::Key, value: &Self::Value) -> Result<()> {
        let dynamo_value = serde_json::to_string(value)?;
        let client = self.get_client();
        let dynamo_key = key.to_string();
        let attr_val_up = AttributeValueUpdate::builder()
            .action(AttributeAction::Put)
            .value(AttributeValue::S(dynamo_value))
            .build();

        client
            .update_item()
            .table_name(Self::TABLE_NAME)
            .key(KEY, AttributeValue::S(dynamo_key))
            .attribute_updates(VALUE, attr_val_up)
            .send()
            .await?;
        Ok(())
    }

    async fn delete(&self, key: &Self::Key) -> Result<()> {
        let client = self.get_client();
        let dynamo_key = key.to_string();
        client
            .delete_item()
            .table_name(Self::TABLE_NAME)
            .key(KEY, AttributeValue::S(dynamo_key))
            .send()
            .await?;
        Ok(())
    }
}

struct MyKey {
    value: String,
}

impl MyKey {
    fn new(value: String) -> Self {
        Self { value }
    }
}

impl ToString for MyKey {
    fn to_string(&self) -> String {
        self.value.clone()
    }
}

#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)]
struct MyValue {
    string_value: String,
    number_value: i64,
}

impl MyValue {
    fn new(string_value: String, number_value: i64) -> Self {
        Self {
            string_value,
            number_value,
        }
    }
}

struct MyTable<'a> {
    client: &'a Client,
}

impl<'a> MyTable<'a> {
    fn new(client: &'a Client) -> Self {
        Self { client }
    }
}

impl<'a> DynamoDBTable for MyTable<'a> {
    const TABLE_NAME: &'static str = "MyTable";
    type Key = MyKey;
    type Value = MyValue;
    fn get_client(&self) -> &Client {
        self.client
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    // Create Table
    let conf = aws_config::load_from_env().await;
    let client = Client::new(&conf);
    let my_table = MyTable::new(&client);

    // Define Two Key-Value Pair
    let key = MyKey::new("my_key".to_string());
    let value = MyValue::new("my_string_value".to_string(), 0);
    let value2 = MyValue::new("my_string_value2".to_string(), 1);

    // Create and Read Value
    my_table.create(&key, &value).await?;
    let retrieved_value = my_table.read(&key).await?;
    assert_eq!(value, retrieved_value);

    // Update and Read Value
    my_table.update(&key, &value2).await?;
    let retrieved_value2 = my_table.read(&key).await?;
    assert_eq!(value2, retrieved_value2);

    // Delete and Read Value
    my_table.delete(&key).await?;
    let invalid_read_result = my_table.read(&key).await;
    assert!(invalid_read_result.is_err());
    Ok(())
}

// [dependencies]
// aws-config = "0.6.0"
// aws-sdk-dynamodb = "0.6.0"
// tokio = { version = "1", features = ["full"] }
// serde = { version = "1.0", features = ["derive"] }
// chrono = { version = "0.4", features = ["serde"] }
// async-trait = "0.1.52"
// serde_json = "1"
// anyhow = "*"

参考

AWS SDK for RustでDynamoDBへのCRUDを実装する

Discussion