🧰

Rust大好きっ子のためのデータベース考

2022/01/20に公開

経緯

趣味プロジェクトとしてRustをサーバーサイドに採用したWebアプリケーションをぼんやり考えています。その過程で、データベースのチョイスについてかなり悩んだので、一度ここでまとめておきます。

前提

データベースに入れるデータは
・ユーザーデータ(名前、メールアドレスなど)
・アイテムデータ(ユーザーが登録するもの。アイテム名、更新時間など)
といったごく一般的なものを考えます。
サーバーサイドのフレームワークは、actix-web…で考えていましたが、最終的には非同期ランタイムとしてtokioを使うaxumにしました(理由は後述)。
また、データ量やアクセス頻度については仮定をおきません。巨大なデータを扱うということになれば、おそらくその時点でかなり候補が絞られることになるかと思いますが、今回は運用面でのパフォーマンスの差については扱わないものとします。

特に今回は「趣味」なので、趣味ならではの観点で各データベース/クレートを見ています。具体的には、

  • Rustのコードに組み入れたときに違和感がないか/書き心地がよいか
  • クレートの使いやすさ、成熟度
  • 非同期処理の有無と導入のしやすさ
  • 使っていて楽しいか
    といった点(特に最後)に注目します。

TL;DR

  • SQL系ならクレートはとりあえずsqlxがおすすめ
  • NoSQL系ならMongoDBがかなり良さそう

SQL系と関連クレート

まずはSQL(RDBMS)を見ていきます。といっても「どのSQLか」というのは今回のケースでは大差はなく、むしろ「どのクレートを使うか」が書き心地に直結する大事なポイントです。

diesel

Rustのデータベース系クレートでは、長らくORMのdieselがデファクトスタンダードとして各入門系テキスト/書籍でも扱われていましたが、ここ最近はdieselの名前を見かけることはあまり多くないように思います。

非同期処理

一番大きいのは「非同期処理を扱っていない」ことだと思います。特にRustにおいては、サーバーサイドのフレームワークは軒並み非同期処理がほぼ前提となっている感があり、その中でデータベースへのアクセスだけ同期というのは確かに微妙なのかもしれません。

巨大な仕様

また、diesel自体の仕様がかなり大きく、軽くプロトタイピングしてデータベースをいじってみようという作り方にはあまりフィットしないものになっていることもあるように思います。r/rustでもdieselをファーストチョイスとするのではなく、他のクレートをまず使ってみて、というアドバイスを目にすることが多いです。

sqlx

代わってよく推奨されているのはsqlxというクレートです。

launchbadge/sqlx: 🧰 The Rust SQL Toolkit. An async, pure Rust SQL crate featuring compile-time checked queries without a DSL. Supports PostgreSQL, MySQL, SQLite, and MSSQL.

これはdieselと違い、ORMではないのですが、その分コネクションの確保からクエリの記述までかなりシンプルに行えるのがポイントです。
また、非同期処理に対応しており、非同期ランタイムもtokioやactixなどサーバーサイドのフレームワークに合わせたチョイスができる、というのも気配りが行き届いていて素晴らしいですね。とりあえずactix-webで始めてみた、なんてときにも困らない仕様になっています。

クエリの記述

というわけで、データベースならSQL、という場合にはおそらくsqlxがファーストチョイスになるだろうと思います。
個人的な難癖というかいちゃもんというかわがままを書いておくと、sqlxの場合、クエリをマクロで記述するのですが…

// provides `try_next`
use futures::TryStreamExt;

let mut rows = sqlx::query("SELECT * FROM users WHERE email = ?")
    .bind(email)
    .fetch(&mut conn);

while let Some(row) = rows.try_next().await? {
    // map the row into a user-defined domain type
    let email: &str = row.try_get("email")?;
}

マクロ内にクエリ文をそのまま突っ込んでいる感じが個人的にちょっとこう…アレです。
つまりRustだけ書いていたい、ということなんだな、と自分で気がつきました。

各SQL専用クレート

たとえばPostgreSQLの場合、postgresもしくは非同期対応のtokio_postgresがあります。
mysqlなども同様で、すでにこのSQLを使う!と決まっていれば、専用クレートは十分候補になりうると思います。
文法に関してはおおむねsqlxと一緒なので、あとは好みですね。

NoSQL系と関連crate

SQL with Rustを探索しているうちに結局sqlxに戻ってきてしまう…ということを何度か繰り返した後、NoSQL系への旅に出ることにしました。
といってもこの世界も大変広く、とてもじゃないですが全部を触ることはできません。クレートの対応状況に自分の好みも加えつつ、いくつかのデータベースについて書いておきます。

MongoDB

ドキュメント指向データベースの雄・MongoDBはちゃんと公式ドライバーも提供してくれていて好感度〇です。しかもメジャーリリース済。

mongodb

非同期ランタイムもtokio / async-stdに対応しています。acitx-webの場合は別途tokioを入れることになる…のかな…? もしくはコネクションプール用のクレートr2d2などを使うというのが手のようです。このあたりは要検証。

cf: actix-webでmongodbを利用する - Qiita

結局、組んでいくとこの辺のことが気になってはくるので、tokioに巻かれたほうが色々と都合がいい、という面があるのは確かです。

話を戻してmongodb。コードもRustっぽさがしっかりあってとても良いです。

use mongodb::{Client, options::ClientOptions};
let mut client_options = ClientOptions::parse("mongodb://localhost:27017").await?;
let client = Client::with_options(client_options)?;
for db_name in client.list_database_names(None, None).await? {
    println!("{}", db_name);
}

mongodbとはまた別にfuturesクレートが必要になってきますが、findメソッドがドキュメントのイテレータを返してくれるので、Rustっぽい処理が可能です。

// This trait is required to use `try_next()` on the cursor
use futures::stream::TryStreamExt;
use mongodb::{bson::doc, options::FindOptions};

// Query the books in the collection with a filter and an option.
let filter = doc! { "author": "George Orwell" };
let find_options = FindOptions::builder().sort(doc! { "title": 1 }).build();
let mut cursor = typed_collection.find(filter, find_options).await?;

// Iterate over the results of the cursor.
while let Some(book) = cursor.try_next().await? {
    println!("title: {}", book.title);
}

ユースケースがドキュメント指向を許すのであれば、選択肢にはバッチリ入ってくるのではないでしょうか。

redis

Redisは基本的にインメモリなので今回は採用しませんが、クレートの状況についてはこちらもかなり良いように思います。

redis

まだメジャーリリースはされていませんが、シンタックスはかなりモダンな感じです。

use redis::Commands;

let client = redis::Client::open("redis://127.0.0.1/")?;
let mut con = client.get_connection()?;
con.set("my_key", 42)?;
assert_eq!(con.get("my_key"), Ok(42));

ユースケース次第では十分選択肢でしょう。

Apache CouchDB

日本語ドキュメントはかなり少ないですが、REST APIでjsonをやりとりするシンプルなドキュメント指向のデータベースです。その性質上、専用のクレートというのは特になく(ラッパーはあるようです 66Origin/sofa: Sofa - CouchDB for Rust )、RustであればreqwestなどのHTTPまわりのクレートを使うことでCRUD操作を行います。

    //ドキュメントを新しく作成するコード
    //まずUUIDを取得してPUT先のURLを生成
    let uuid = reqwest::get("http://localhost:5984/_uuids")
        .await?
        .json::<Uuids>()
        .await?;
    let uuid = &uuid.uuids[0];
    let url = format!(
        "http://admin:admin@localhost:5984/{}/{}",
        state.env.db_name, uuid
    );

    //PUTメソッドで送信
    let client = reqwest::Client::new();
    let res = client.put(url).json(&payload).send().await?;

REST APIを使うというところにコードを落とし込めますし、dockerでデータベースサーバーを立てておけば、あとはcurl / xhなどを使ってコマンドラインで操作を行えるのも体感としてはかなりよかったです。

actix-webのランタイム問題

ただし、reqwestを非同期関数の中で使用する場合はtokioランタイムが必要になります。actix-webの場合は独自ランタイムなのでそのままでは動きません。僕はこの時点でactix-webをあきらめてaxumに降りました。

cf: actix-webのRequest HandlerでHTTPリクエストを送りたかった - ちゃっくのメモ帳

サーバーサイドでREST APIを叩かない!という気持ちを貫き通せるケースがどれくらいあるのか寡聞にして知らないのですが、actix-webとreqwestの相性問題というのは意外と悩ましそうです。

仕様が独特

couchDB自体は最初のリリースが2005年と、よく言えば枯れている、悪く言うと仕様が独特な(古い)ところがあり、これはこれで好みが分かれるポイントです。
具体的には、ドキュメントを引っ張ってくるときにviewと呼ばれる専用の関数を事前に準備しておくのが常道で、しかもその関数はJavaScriptで書く必要があります。viewを設定するにはGUIの設定画面を使うか、これもまたHTTPリクエストで送るかの二択ですが、そのviewのコード自体はどうやって手元で管理するのか、というのも微妙です。
このためにJs書くの?感と、gitが前提になっていないつらさとで、結局採用はあきらめました。REST APIというのは今の時代においてもナイスチョイスだと思うのですが…。
もう少し裏側がモダンな、REST APIベースのデータベースが出てくると色々と楽しそうだなと思います。

結論のようなもの

冒頭にも書きましたが、SQL系ならとりあえずsqlx、NoSQLならユースケース次第だけどmongoDBが頭一つ抜けてそう、というのが結論です。

ただ、今は各種クラウドデータベースも強いのは誰もが知っている通り。Rustとの相性で言えば、中でもAWS loves Rustはかなり分かりやすく、LambdaをRustで書けるだけでなく、SDKもアルファ版ですが最近出ましたし、その中でDynamoDBをいじれるみたいなので、Rustの優先度が高い場合でも余裕で選択肢に入ってくるでしょう。(逆に言うとGCPは特にRustとの相性が良いとは感じませんでした)

Rustが大好きな皆さんの参考になれば幸いです。

Discussion