📝

【Rust】モダンなKey-Value型データベースSledのテーブルをSerdeとTraitで管理してORMapperを実現する

2022/02/15に公開

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

Type-SafeにKey-Value Store(KVS)を利用する!

つまり

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

を満たすKVSを作っていきます!

Sledを利用したKey-Value型データベースを

Zenn初投稿のYosematです。
趣味でRustを書き始めたら美しい言語すぎて仕事でPythonを書くのが鬱になりました。
今一番好きなことだけが楽しめるタイプです。

さて今日はRustでDBを操作するお話です。

Rustには有能なORMapperがいくつかあってDieselなんかは代表格ですよね。

で実際ドキュメントをよんでみるとまぁたいへん!
DjangoのORMapperを利用していた僕は「えっこんなにいろいろ書かなきゃいけないの?」ってなってしまいました。

でもっと簡単に直感的に使えるものを探してたらありました。
Key−Value StoreのSledですよ。

RDBにもいいところがあるのは重々承知でいいますが、たいていのデータベースはRDBよりKey-Value Storeのほうが簡単にサクっと構築できると思うんです。

今回はSledの簡単な使い方と個人的に気にいっているテーブルの管理方法を解説しちゃいます。

Sledの基本

Sledはめちゃくちゃ簡単なKey-Value Storeです。

API similar to a threadsafe BTreeMap<[u8], [u8]>

と公式にある通り基本的にバイト列[u8]をキーにバイト列[u8]を保存するようになっています。

もちろん「えっバイト列しか保存できないの!?」って方にもこの記事のコードには満足していただけるようになっています。

データの保存

まずは適当なバイト列を作って保存してみましょう。
エラー処理にはAnyhowを使っています。

fn main() -> Result<()> {
    let key = "my_key".as_bytes();
    let value = "my_value".as_bytes();
    let db = sled::open("my_database")?;
    db.insert(key, value)?;
    Ok(())
}

データの取り出し

上のコードを実行した後に次のコードを実行してみるとデータが取得できます。

fn main() -> Result<()> {
    let key = "my_key".as_bytes();
    let db = sled::open("my_database")?;
    let result = db.get(key)?; // Option<IVec>
    let ivec = result.unwrap(); // IVec
    let string_value = String::from_utf8(ivec.to_vec())?;
    assert_eq!(string_value, "my_value".to_string());
    Ok(())
}

db.get()?はもしkeyに対応するvalueがなかった場合はNoneをリターンします。
戻り値がバイト列[u8]ではなくIVecなのが少し面倒なので今回はStringに変換してみました。
どうですか。とっても簡単でしょう。

テーブル分割

Key-Value StoreにはTreeとよばれる概念があります。
KVSにおけるテーブルみたいなものです。

TreeはDbオブジェクトのopen_tree関数を叩くとあっさり取得できて、おまけに最初のDbオブジェクトと同じように振る舞うのです。
というかDbDeref<Target = Tree>を実装しているので、Dbが最も根っこにあるTreeとして振る舞っているんですけどね。

何はともあれデータ保存用のコード。1行増えただけですけど。

fn main() -> Result<()> {
    let key = "my_key".as_bytes();
    let value = "my_value".as_bytes();
    let db = sled::open("my_database")?;
    let tree = db.open_tree("my_tree")?;
    tree.insert(key, value)?;
    Ok(())
}

簡単ですね。

TraitでType-SafeなKey−Value Store

Table Trait

個人的にこのままのSledのKVSを使うのには

  1. 取り出し操作で得られる値がIVec(実質バイト列)なので欲しい情報に再度整形する必要がある
  2. 取り出したバイト列が欲しい型に変形できるような情報である保証がない(Type-Safeでない)

の2つの問題があると思っています。
そこでTraitによってテーブルを管理し、このTraitによってIOを行う限りType-Safeであることが保証されるようにしてみました。

データの変換にはRustユーザーならみんな大好きSerdeを用います。
[derive(Serialize, Deserialize)]が付与された型ならなんでも扱うことができます。

trait SledTable {
    const TABLE_NAME: &'static str;
    type SledKey: AsRef<[u8]>;
    type SledValue: Serialize + DeserializeOwned;
    fn get_db(&self) -> &Db;
    fn upsert(&self, key: &Self::SledKey, value: &Self::SledValue) -> Result<()> {
        let key = key.as_ref();
        let value = serde_json::to_string(value)?;
        let byte_key = value.as_bytes();
        let db = self.get_db();
        db.open_tree(Self::TABLE_NAME)?.insert(key, byte_key)?;
        Ok(())
    }

    fn read(&self, key: &Self::SledKey) -> Result<Option<Self::SledValue>> {
        let db = self.get_db();
        let byte_key = key.as_ref();
        let ret = db.open_tree(Self::TABLE_NAME)?.get(byte_key)?;
        match ret {
            Some(ivec) => {
                let string = String::from_utf8(ivec.to_vec())?;
                let value = serde_json::from_str::<Self::SledValue>(&string)?;
                Ok(Some(value))
            }
            None => Ok(None),
        }
    }
}

これまでのIOに加えてValueをSerdeJsonでSerialize/Deserializeする処理を加えました。

新たなテーブルを定義するのに必要なのは

  1. テーブルネームを書くこと
  2. Keyに使う型を宣言すること(AsRef<[u8]>が実装してある型(String, &str, ...)ならなんでもよい)
  3. Valueに使う型を宣言すること(Serialize, DeserializeをDeriveすればなんでもよい)
  4. dbへのアクセス権限を渡す関数を実装すること

です。

4つもあるんですが、実際にテーブルを定義しているコードを見るとすごくスッキリしているし、テーブルを作るのに必要な最低限の情報にとどまっていることがわかると思います。

実際にテーブルを定義してみる

新しくテーブルを定義してみましょう。Key-Value Storeなので何をKeyとして何をValueとして保存するのかを決めなければいけません。

今回はKeyはStringに、ValueはよくわからないMyValueなるStructを利用してみます。
StringはPrimitiveですがValueはそうではないので、さっそく保存したいデータ型としてMyValue型を作ってみましょう。

#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
struct MyValue {
    id: String,
    name: String,
    number: i64,
}

impl MyValue {
    fn new(id: String, name: String, number: i64) -> Self {
        Self { id, name, number }
    }
}

このようにして自由にデータ構造を定義できました。
本当はDebugEqPartialEqはいらないですが、あとでassert_eq!して確認作業をするためにつけておきます。

それではKeyとValueの型を用意したのでテーブルを用意します。

struct MyTable<'a> {
    db: &'a Db,
}

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

テーブルを作る土台となるDbがないといけないので、それを保持するStructにしました。
(面倒なライフタイムなんかつけて)参照にしたのは、他のテーブルともDbを共有したいからです。

そして気になるTrait実装。

impl<'a> SledTable for MyTable<'a> {
    const TABLE_NAME: &'static str = "MyTable";
    type SledKey = String;
    type SledValue = MyValue;
    fn get_db(&self) -> &Db {
        self.db
    }
}

スッキリ!

RDBでテーブル作ってPrimaryKeyとその他の型を決めるのと同じように、KeyとValueの型を決めるだけです。

しかも宣言するのに必要なのはDatabase固有の毎回ググりたくなるよくわからない型ではなくRustで自由に定義したデータ構造です。そのままRustアプリケーション内で利用できます。

全体コード

動かして遊ぶ用の最終的なコードです。

use anyhow::Result;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use sled::Db;

fn main() -> Result<()> {
    let db = sled::open("my_database")?;
    let my_table = MyTable::new(&db);
    let key = "my_key".to_string();
    let value = MyValue::new("my_id".to_string(), "some_name".to_string(), 1);
    my_table.upsert(&key, &value)?;
    let retrieved_value = my_table.read(&key)?.unwrap();
    assert_eq!(value, retrieved_value);
    Ok(())
}
trait SledTable {
    const TABLE_NAME: &'static str;
    type SledKey: AsRef<[u8]>;
    type SledValue: Serialize + DeserializeOwned;
    fn get_db(&self) -> &Db;
    fn upsert(&self, key: &Self::SledKey, value: &Self::SledValue) -> Result<()> {
        let key = key.as_ref();
        let value = serde_json::to_string(value)?;
        let byte_key = value.as_bytes();
        let db = self.get_db();
        db.open_tree(Self::TABLE_NAME)?.insert(key, byte_key)?;
        Ok(())
    }

    fn read(&self, key: &Self::SledKey) -> Result<Option<Self::SledValue>> {
        let db = self.get_db();
        let byte_key = key.as_ref();
        let ret = db.open_tree(Self::TABLE_NAME)?.get(byte_key)?;
        match ret {
            Some(ivec) => {
                let string = String::from_utf8(ivec.to_vec())?;
                let value = serde_json::from_str::<Self::SledValue>(&string)?;
                Ok(Some(value))
            }
            None => Ok(None),
        }
    }
}

#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
struct MyValue {
    id: String,
    name: String,
    number: i64,
}

impl MyValue {
    fn new(id: String, name: String, number: i64) -> Self {
        Self { id, name, number }
    }
}

struct MyTable<'a> {
    db: &'a Db,
}

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

impl<'a> SledTable for MyTable<'a> {
    const TABLE_NAME: &'static str = "MyTable";
    type SledKey = String;
    type SledValue = MyValue;
    fn get_db(&self) -> &Db {
        self.db
    }
}

// [dependencies]
// serde = { version = "1.0", features = ["derive"] }
// serde_json = "1"
// serde_urlencoded = "0.7.0"
// sled = "0.34"
// anyhow = "1.0"

まとめ

以上です。KeyとValueに任意の型が利用できる拡張性とテーブル宣言の簡単さが気に入ってます。
DieselとかそのへんのORMapperは僕が使いこなせてないのか、とても大変な印象がありますが、こちらはとても簡単なのでよかったら使ってみてください。

Zennには初めて記事を書いたので、なんらかのレスポンスをいただけるととても嬉しいです。最後まで読んでいただきありがとうございました。

Discussion