RustからPostgreSQLを使う

RustでDB接続するcrate(Rustパッケージ)にはいくつかのプロダクトがありますが、非同期処理を重視した非ORマッパーのcrateとしては、tokio-postgresを使えます。

接続

SQLドライバには、 tokio-postgresまたは同プロジェクト内のpostgresがあります。tokioの有無は、非同期接続の必要性に応じて検討します。
また、tokio-postgresをストレートに拡張した deadpoolを用いると、接続処理の差し替えだけで比較的手軽にコネクションプールを導入できます。

クエリ構成については、 struct Clientのリファレンスに比較的情報がまとまっています。execute()query()を中心にクエリ発行することになります。

なお、SQLに誤りがある場合には実行時にpanicします。idなどのように複数テーブルで同じ名前のカラムが存在する場合にも厳密に特定できない書き方ではpanicします。

クエリパラメータ

rust-postgersの大きな注意ポイントとして、クエリパラメータを的確に書くことが求められます。パラメータを適切に構築できないと、コンパイルが通りません。

クエリは以下のような構成になっており、構成じたいは他言語にも似たようなものがあります。

  • SQLステートメントはSELECT * FROM some_table WHERE point > $1 AND last_act < $2のようにパラメータ化する
  • パラメータには&[$1, $2]のように対応する要素の参照をスライスに詰めて渡す($1, $2は対応を示しているだけで、このように書いても動作しない)。

PostgreSQLの型に合わせる

SQLステートメントは文字列ですが、クエリパラメータのデータ型はPostgreSQLのカラムに合わせて指定する必要があります。RustとPostgreSQLの型の対応は、 postgres_types::ToSqlを参照します。

型が一致していないと実行時にpanicすることがあります。UUIDなども単なる文字列形式の一致ではなく、Uuid::parse_str(&str)でUuid型に変換する必要があります。

また、try_get()などの失敗を許容するメソッドでアクセスしたうえunwrap_or()などで取り出す場合、型定義を合わせないとNULL相当のデフォルト値を安全に得られる挙動となり、非常に分かりにくい展開になります。

スキーマ定義していないパラメータにも型はあり、たとえばLIMIT句の引数はInt8しか受け付けないため、Rustではi64である必要があります。

クエリパラメータにコレクションを使う

SQLで複数のパラメータを指定する場合、一般的にはIN句にパラメータを羅列しますが、rust-postgresでは以下のように書くと動作します。

  • SQLにはWHERE some_column = ANY($1)を指定する
  • パラメータにはVec[&T]型のコレクションを指定する。TはToSqlの要件を満たす型であること

また、INSERTでは unnest()を利用する手があります。

3rdPartyクレートが必要な型導入

DATE型やUUIDなど postgres_types::ToSqlの2つ目の表に掲載されている型は、クレート併用で実装します。

These are disabled by default; to opt into one of these implementations, activate the Cargo feature corresponding to the crate’s name prefixed by with-.

対応するクレートをインストールすると同時に、postgres-typesのfeaturesで該当する機能を有効にします。UUID型を導入するCargo.tomlの記述例(抜粋)は以下のとおりです。

[dependencies]
postgres-types = { version = "0.1.2", features = ["with-uuid-0_8"] }
uuid = { version = "~0.8", features = ["v4"] }

また、自分で定義した複合型も利用できます。ToSqlFromSqlをderiveする必要がありますが、deriveマクロを有効にするにはCargo.tomlのfeaturesにderiveを追加しておく必要がある点には注意が必要です。

戻り値のparse

以下のように、クエリで取得した値をループ処理で1行ずつ取り出せます。

let rows = client.query("SELECT id, name FROM user_table LIMIT 10");
let res = rows.iter().map(|row| row.get("name") ).collect();

この例のnameの場合、get("name")またはzero-indexedのget(1)で取り出せます。同様にidはget("id")またはget(0)です。
この過程で、row.get("id").to_string()のように型変換を行う場合、コンパイラが変換元の型指定を求める場合があります。形式はget::<'a, I, T>()ですが、このうちライフタイム'aは省略できます。 Iはget()引数のインデックス(“id"や0)の型、TはPostgreSQLのカラム型(に対応するrust型)です。
この例でUUID型とすると、get::<&str, uuid::Uuid>("id")という指定で動作します。

結果を格納する型とDBの型が一致していない場合にはエラーになります。数値のようにFromトレイトを実装した型であれば、get().into()のようにキャストできます。

数値の型は、想像以上に推論が効きにくい面があり、たとえばSMALLINT(INT2)の場合、以下のようにPostgresに対応するi16を指定したうえでi32にキャストするといった明示が必要になるケースがあります。

let score = row.get::<&str, i16>("score") as i32;

また、NULLが返る場合はget()はpanicします。NOT NULLでないカラムについてはtry_get()を用いるとResult型でparseできます。try_get('col').unwrap_or(0)のようなイディオムでデフォルト値をセットできます。
メソッドの詳細は、 struct Rowのリファレンスが参考になります。

まとめ

tokio-postgresを用いて、async/awaitで非同期処理をシンプルに書けます。全体的にundocumentedながら、パラメータにコレクションを渡すことも可能であり記述力も十分でしょう。
ORマッパーを使わないアプローチのため、クエリの前後で型を適切に対応させることがポイントとなります。

また、コンパイル済のバイナリは、各種ライブラリを同梱し数MB程度〜のコンパクトな実行ファイルになります。lddで確認すると依存ライブラリに以下のようなものが表示され、libpqにも依存しない形式になっていることが分かります。

linux-vdso.so.1 (0x00007ffff5b6c000)
/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 (0x00007ffb2ecf8000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007ffb2ecef000)
librt.so.1 => /lib/x86_64-linux-gnu/librt.so.1 (0x00007ffb2ece5000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007ffb2ecc4000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007ffb2ecaa000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ffb2eae9000)
/lib64/ld-linux-x86-64.so.2 (0x00007ffb2f86b000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007ffb2e964000)
libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007ffb2e7e0000)

Rustプロダクト全般のメリットですが、 debian:buster-slimのコンテナイメージにバイナリを置くだけで動作するため、デプロイも考えうる限り最もコンパクトです。

中馬崇尋
Chuma Takahiro