Rustでデータベースにアクセスしよう:Webアプリ実装で学ぶ、現場で役立つRust入門(15)
最終回となる第15回では、Rustにおけるデータベースシステムの利用を紹介します。ここまでのサンプルではデータソースはJSONファイルやWeb Storageでしたが、ここでデータソースにデータベースを使うようにしてアプリを強化します。
本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。
データベースの利用
今回は、Webアプリのデータをデータベースに保存するように改変します。これまでは、Webアプリの仕組みに集中するために、データをテキストファイルに保存していました。実用的なWebアプリでは、パフォーマンスや安全性などを考慮すれば、データベースへの保存が必須となります。第11回などで提供してきた、actix-webのWebアプリに実装します。
Rustでは、SQL系とNoSQL系の双方について、データベース利用のためのクレートが充実しています。SQL系も、SQL文を記述して直接クエリを発行するタイプと、ORM(下記の補足を参照)によってデータベースへのアクセスを抽象化するタイプなどに分かれます。データベースに保存するデータの構造や複雑さ、プログラミングする上での導入の容易さなどを勘案して、どのクレートを利用するかを決めることになります。
【補足】ORM
ORMはオブジェクト関係マッピング(Object-Relational Mapping)の略で、オブジェクト指向プログラミングとリレーショナルデータベース間の互換性向上のための技術です。ORMには、プログラミング言語のクラスとデータベースのテーブルをマッピングすることで、クエリを直接記述しなくともデータベースを利用できるというメリットがあります。
SQL系データベースのクレート
SQL系データベースの最初の選択肢として、SQLite、MySQL、PostgreSQLなどのRDBMSに特化したクレートの利用があります。SQLiteならsqlite、MySQLならmysql、PostgreSQLならpostgresというようにそのものズバリのクレートです。利用するRDBMSが決まっていれば、これらは有力な選択肢になるでしょう。ただしRDBMSが流動的であるとか、開発と本番で環境が変わるといった場合には、次に紹介するSQLxを検討すべきです。
SQLxは、非ORMタイプのSQLデータベースクレートです。公式に、「SQLx is not an ORM!」とうたっているように、ORMをサポートしません。そのためSQL文の記述が必要ですが、コンパイル時にクエリの正当性をチェックする仕組みが備わっています。ここでテーブルと構造体のフィールドをバインドするなど、ORMではないですがそれに準ずる使い勝手の良さを持ちます。非同期処理にも対応しており、非同期ランタイムもTokioやActixに対応するなど、幅広いサポートとなっています。
Dieselは、RustのためのORMタイプのデータベースクレートです。登場は2015年とRustとほぼ時期を同じにしており、長い間にわたりRustにおけるデータベースアクセスのためのクレートとして利用されています。豊富な実績と充実した機能が特徴ですが、仕様が膨大であり手軽に導入するにはハードルが高いようです。また、非同期処理に対応していないなど、パフォーマンスを要求される場合には適さないという弱点もあります。
SeaORMも、RustのためのORMタイプのデータベースクレートです。2021年のリリースとDieselに遅れての登場ですが、SQLxを内部的に利用しているので非同期処理に対応しており、これがDieselに対するアドバンテージとなり利用者を伸ばしています。
【補足】非同期ランタイムTokio
Tokioは、Rustにおける非同期実行のためのライブラリです。ランタイムと関数を提供します。RustにはFutureという非同期実行のサポートがありますが、実行に関しては外部のランタイムに依存しています。その一つがTokioであり、async-stdやActixなども使える現時点でも、実績のあるTokioが使われることが多いようです。
NoSQL系データベースのクレート
NoSQL系としては、MongoDBのためのmongodb、Redisのためのredisなど、特に前者はMongoDBによる公式のドライバであるので、NoSQL系データベースをRustで使いたいという場合には有力な選択肢でしょう。
本記事では、GitHubのStar数がDieselに匹敵し、導入の敷居が比較的低くActixにも対応しているなどの理由で、SQLxによるデータベースアクセスを取り上げます。利用するRDBMSを、あらかじめインストール、必要なセットアップを済ませておいてください。なお、本記事ではデータベースにSQLiteを利用するとして解説を進めます。MySQLやPostgreSQLを利用する場合については、必要に応じて言及します。
SQLx利用のための準備
SQLxを活用するには、CLIツール(sqlx-cli)をインストールしておくと便利です。cargo installコマンドにて、以下のようにCLIツールをインストールします。--featuresオプションで、SQLiteを使うことを明示しています。
% cargo install sqlx-cli --no-default-features --features sqlite
SQLiteを使う場合には、sqlx database createコマンドでデータベースを作成しておきます。あらかじめ、プロジェクトのルート(actix-posts)に移動しておいてください。データベースファイル名はdatabase.dbとして、--database-urlオプションで指定します。また、環境変数DATABASE_URLを設定しておいても同等です。
% sqlx database create --database-url "sqlite:./database.db"
なお、データベースを削除するには、sqlx database dropコマンドを実行します。
マイグレーションする
続けて、マイグレーション用のファイルを作成します。マイグレーションとは、データベースのスキーマ(構造)を新旧のバージョン間で移行させることをいいます。ここでは、テーブルも何もない状態に、何らかのテーブルを追加する、あるいはその逆と捉えればよいでしょう。マイグレーションは、sqlx migrate addコマンドで追加します。-rオプションを指定すると、バージョンアップに加えて元に戻すためのファイルも作成されます。また、ここで指定するcreate_posts_tableは、テーブルを作成する(削除する)マイグレーションであることを意味しています。
% sqlx migrate add -r create_posts_table Creating migrations/20241110044413_create_posts_table.up.sql Creating migrations/20241110044413_create_posts_table.down.sql
マイグレーションファイルは、作成されるmigrationsフォルダに置かれます。ここでは詳しく触れませんが、マイグレーションファイルには日時が含まれることに注意してください。これによって、マイグレーションの時系列が管理されます。
作成されたファイルはコメント行のみで実質的に空なので、マイグレーションの目的であるテーブル作成のDDL文をリスト1のように記述します。idを主キーとして、その他のフィールドに全て非NULL制約を付与します。
CREATE TABLE posts ( id INTEGER PRIMARY KEY, posted DATETIME NOT NULL, sender TEXT NOT NULL, content TEXT NOT NULL );
元に戻すためのファイルにも、テーブル削除のDDL文を記述しておきます(リスト2)。
DROP TABLE posts;
ファイルが準備できたら、sqlx migrate runコマンドでマイグレーションを実行します。runは、バージョンアップのマイグレーションを実行するサブコマンドです。
% sqlx migrate run --database-url sqlite:./database.db Applied 20241110044413/migrate create posts table (539.545µs)
「Applied…」と出力されれば、テーブルは作成されています。これを、SQLiteのCLIツールであるsqlite3コマンドで確かめてみましょう。pragma table_info(posts)コマンドの実行で、各フィールドが出力されれば成功です。
% sqlite3 database.db SQLite version 3.43.2 2023-10-10 13:08:14 Enter ".help" for usage hints. sqlite> .tables テーブルの表示 _sqlx_migrations posts sqlite> pragma table_info(posts); スキーマの表示 0|id|INTEGER|0||1 1|posted|DATETIME|1||0 2|sender|TEXT|1||0 3|content|TEXT|1||0 sqlite> .q 終了
元に戻すときのコマンドは以下のようになります。テーブルを消去して最初からやり直す際に有用です。
% sqlx migrate revert --database-url sqlite:./database.db
初期データを登録する
これまでの回と同様に、初期データを登録しておきます。最初に一覧表示にて基本的な動作を確認できるようにするためです。初期データは、CSV形式でリスト3のように用意します。
id,posted,sender,content 1,2024-11-09 01:23:45,Nao,こんにちは。\nまたRustやってます。\n久しぶりなので苦労してます。 2,2024-11-10 12:34:56,Shino,それはお疲れ様です。\n私はRustさっぱりです。 3,2024-11-11 22:33:44,Yuutan,Rust面白いっす!\nやればやるほどはまるっす。
初期データは、以下のようにsqlite3コマンドで取り込めます。
% sqlite3 ./database.db SQLite version 3.43.2 2023-10-10 13:08:14 Enter ".help" for usage hints. sqlite> .mode csv CSVモードに切り替え sqlite> .import seed.csv posts --skip 1 先頭行をスキップしてインポート sqlite> select * from posts; 取り込み結果を確認 1,"2024-11-09 01:23:45",Nao,"こんにちは。\nまたRustやってます。\n久しぶりなので苦労してます。" 2,"2024-11-10 12:34:56",Shino,"それはお疲れ様です。\n私はRustさっぱりです。" 3,"2024-11-11 22:33:44",Yuutan,"Rust面白いっす!\nやればやるほどはまるっす。" sqlite> .q 終了
Actix Webアプリへの導入
ここから、Webアプリにデータベースを導入して、Web APIを対応させます。
依存関係を追加する
アプリがSQLxを使えるように、依存関係を追加します。
% cargo add sqlx --features "sqlite runtime-tokio chrono" % cargo add dotenv
cargo add sqlxコマンドの--featuresオプションは、データベースはsqlite、非同期ランタイムはtokio、日時管理はchrono、それぞれを利用する指定となっています。他のデータベースを使う場合には、mysql、postgresql、sqlserverなどに変更します。
dotenvは、Python、Rubyなどでも用いられる環境変数のためのライブラリです。.envファイルに環境変数を記述しておきそれを取り込むことができるので、環境変数を環境ごとに使い分けたいときなどに便利です。ここでは、プロジェクトのルートに.envファイルを用意し、そこにDATABASE_URL環境変数を記述しておくことにします(リスト4)。
DATABASE_URL=sqlite:./database.db
環境変数DATABASE_URLは、データベースごとに以下のように設定できます。ユーザー名、パスワード、ホスト名、ポート番号、データベース名は実際の環境に合わせる必要があります。
DATABASE_URL=sqlite:./database.db SQLite DATABASE_URL=mysql://mysql:password@localhost:3306/database MySQL DATABASE_URL=postgres://postgres:password@localhost:5432/database PostgrSQL
データベース接続プールを導入する
データベースを利用するには、まず接続プールをアプリに導入します。接続プールとは、一般的にはコネクションと呼ばれるもので、要は接続を管理するオブジェクトです(リスト5)。
// 名前空間を取り込む use dotenv; use sqlx::sqlite::SqlitePool; use std::env; …略… #[actix_rt::main] async fn main() -> Result<()> { …略… // (1)環境変数DATABASE_URLの取得と接続プールの作成 dotenv::dotenv().expect(".envの読み込み失敗"); // .envの読み込み let database_url = env::var("DATABASE_URL") // DATABASE_URLの取得 .expect("DATABASE_URLがセットされていません"); let pool = SqlitePool::connect(&database_url).await.unwrap(); // 接続プールの作成 HttpServer::new(move || { let tera = Tera::new("templates/**/*.html").unwrap(); App::new() .app_data(web::Data::new(tera)) // (2)ルーティングに接続プールを登録する .app_data(web::Data::new(pool.clone())) …略… // (3)データベース版APIを登録する .service( web::scope("/api/db") .service(handler::api_index_db) .default_service(web::to(handler::api_not_found)) ) …略…
環境変数DATABASE_URLを取得し、そこから接続プールを作成する処理が(1)です。dotenv::dotenv()の呼び出しで、.envファイルから環境変数を取得し、セットします。env::var()の呼び出しでセットされた環境変数を取得します。いずれかが失敗すればプログラムが終了します。SqlitePool::connect()メソッドに接続文字列を渡すことで、接続プールが作成されます。
作成された接続プールは、ルーティングで呼び出されるハンドラー関数で受け取れる必要があるので、web::Data構造体にラップした接続プールを、app_dataメソッドにて登録します。app_dataメソッドの働きについては、第3回と第4回で紹介しているので、そちらを参照してください。
最後に、データベース版のAPIとなるハンドラー関数をルーティングに登録します。serviceメソッドの使い方は第3回で、scopeメソッドの使い方は第6回で紹介しているので、そちらを参照してください。ここでは、"/api"より前に"/api/db"を登録していることに注意してください。
ハンドラー関数で接続プールを受け取る
ハンドラー関数では、接続プールを受け取ってデータアクセス関数を呼び出します。リスト6は、全件取得の関数api_index_withdbです。
use sqlx::sqlite::Sqlite; use sqlx::pool::Pool; #[get("/posts")] // (1)引数にweb::Data構造体を追加して接続プールを受け取る pub async fn api_index_withdb(pool: web::Data<Pool<Sqlite>>, query: web::Query<Queries>) -> impl Responder { let param = query.into_inner(); // (2)接続プールを取り出してデータベースアクセス関数を呼び出す let pool = pool.get_ref(); let posts = data::get_all_db(pool.clone()).await; // (3)以降はファイル版と同じなので省略 let mut ary = Vec::new(); …略… }
ハンドラー関数では、(1)のようにapp_dataメソッドで追加されたweb::Data構造体を受け取ります。(2)では、ラップしているPool<Sqlite>構造体への参照をget_refメソッドで取り出し、data::get_all_withdbメソッドに与えています。このメソッドは後述しますが、データベース版の一覧取得関数です。awaitが付いているのは、この関数は非同期関数として作成するからです。
(3)以降は、ファイル版と同じです。配布サンプルを参照してください。
データベースにアクセスする(参照系)
上記のdata::get_all_withdb関数は、data.rsファイルにファイル版と同じように記述します(リスト7)。
use sqlx::sqlite::Sqlite; use sqlx::pool::Pool; // (1)データベース用の構造体を用意 #[derive(Debug, Clone)] pub struct Post { pub id: i64, // ID pub posted: NaiveDateTime, // 投稿日時 pub sender: String, // 投稿者名 pub content: String, // 投稿内容 } // (2)データベース版の一覧取得関数 pub async fn get_all_withdb(pool: Pool<Sqlite>) -> Vec<Message> { // (3)クエリの作成と発行 let posts = sqlx::query_as!( Post, "select id, posted, sender, content from posts order by posted desc" ) .fetch_all(&pool) .await.unwrap(); // (4)Vec<Post>からVec<Message>を作成して返す let mut messages: Vec<Message> = Vec::new(); for post in posts { let message = Message {id: post.id as i32, posted: post.posted.to_string(), sender: post.sender, content: post.content}; messages.push(message); } messages }
データベースにはpostsテーブルを作成しましたが、このテーブルはMessage構造体と互換性がありません(日時フィールドなどを含むため)。このため、(1)のように別にPost構造体を定義し、相互にフィールドを変換してやりとりします。
データベース版の一覧取得関数get_all_withdbが(2)です。SQLxの関数は非同期であるので、非同期関数となっています。引数に、ハンドラー関数から渡される接続プールを受け取ります。
クエリを生成して発行しているのが(3)です。SQLxにはクエリ発行の方法が幾つかあり、大きく分けてマクロを使うか関数を使うか、queryを使うかquery_asを使うかの計4パターンに分かれます。ここでは、安全にクエリ結果を構造体にマップできるquery_as!マクロの例を示します。
query_as!マクロは、引数の構造体のフィールドとクエリ文字列中のフィールドが正しく対応しているかどうか、型も含めてビルド時にチェックします。例えばSQLiteの場合、主なフィールドの型(とその制約)とRustにおける型は表1のように対応します。
SQLite型 | Rust型 | 備考 |
---|---|---|
integer | i64 | i32などは使用できない |
text not null | String | NULLでないことが保証されているので |
text | Option<String> | NULLである場合にはNoneとなる |
datetime | chrono::NativeDateTime | 日付時刻管理にchronoを指定した場合 |
表1:SQLiteの型とRustの型の対応 |
型の不一致があればコンパイルエラーとなるので、存在しないフィールドや型が合っていなかったという状況を事前に回避できます。
データベースにアクセスする(更新系)
更新系も見てみましょう(リスト8)。
pub async fn create_withdb(pool: Pool<Sqlite>, mut message: Message) -> Message { // MessageをPostに変換する let post = Post { id: 0, posted: NaiveDateTime::parse_from_str(&message.posted, "%Y-%m-%d %H:%M:%S").unwrap(), sender: message.sender.clone(), content: message.content.clone()}; // (1)query!マクロでクエリを発行 let result = sqlx::query!( "insert into posts(posted, sender, content) values (?, ?, ?)", post.posted, post.sender, post.content) .execute(&pool) .await.unwrap(); // (2)挿入レコードのidをセットして返す message.id = result.last_insert_rowid() as i32; message }
更新系では、結果を構造体にマップする必要がないので、query_as!マクロに替わりquery!マクロを使います。(1)のように基本的な使い方は同じですが、マクロの引数に構造体がないこと、fetch系メソッドではなくexecuteメソッドを使うことが異なります。また、クエリがパラメーターを含むので、それらをマクロの引数に含めています。
(2)では、クエリ結果からlast_insert_rowidメソッドを呼び出して作成したレコードのidを取得し、関数の戻り値に含めています。なお、PostgreSQLではreturning文で結果を返すことができるので、この場合はquery_as!マクロを使って結果をマップすれば、同様の処理となります。また、クエリパラメーターは$1, $2……となります。
この他のAPIハンドラーメソッドやデータベースアクセス関数については、配布サンプルを参照してください。また、アプリを起動してcurlコマンドなどからリクエストを発行して動作を確認してみてください。
まとめ
今回は、Rustにおけるデータベースシステムの利用を紹介しました。極めて容易に、しかもRustならではの安全性も備えたデータベースアクセスができることをお伝えできたのではないかと思います。
これにて、Webアプリ開発を通じてRustの活用法を紹介する本連載は終了となります。読者に、難しいながらも考えられたRustによるWebアプリ開発の魅力を少しでもお伝えできたなら幸いです。
筆者紹介
WINGSプロジェクト
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
・サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/)
・RSS(https://wings.msn.to/contents/rss.php)
・X: @WingsPro_info(https://x.com/WingsPro_info)
・Facebook(https://www.facebook.com/WINGSProject)
Copyright © ITmedia, Inc. All Rights Reserved.