【Rust】DynamoDBのテーブルをSerdeとTraitで管理してTypeSafeなKey-Value Storeとして利用する
この記事で最終的に実現するもの
Type-SafeにDynamoDBを利用する!
つまり
- RDBのようにKeyとValueの型が決定していて不適合なデータ構造を保存できない
- 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にテーブルを作ります。
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};
[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ということで
- テーブル名
- KeyとValueの型
- 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
, Deserialize
をDerive
してやりましょう。
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 = "*"
Discussion