Rust + SQLite:開発効率を高める軽量DB連携の実践
はじめに
現代のソフトウェア開発において、データ管理は不可欠な要素です。アプリケーションの種類に関わらず、設定情報の保存、ユーザーデータの管理、ログの記録など、永続化されたデータを扱う必要性が常に存在します。数あるデータベース技術の中で、軽量かつ高速、そして高い信頼性を誇るSQLiteは、その手軽さから多くのプロジェクトで採用されています。特に、アプリケーション内部に直接組み込む「埋め込み型データベース」として非常に強力な選択肢となります。
一方、近年注目を集めているプログラミング言語Rustは、「安全性」「パフォーマンス」「並行処理」という3つの強みを核に、システムプログラミングからWeb開発、組み込みシステムまで、幅広い分野で利用が広がっています。その厳格な型システムと所有権モデルは、実行時エラーを減らし、高い信頼性を持つソフトウェア開発を可能にします。
では、このRustとSQLiteという二つの技術を組み合わせるとどうなるでしょうか? Rustの持つ安全性とパフォーマンス、そしてSQLiteの軽量性と手軽さが融合することで、開発効率を損なうことなく、堅牢で高速なデータ永続化レイヤーを構築することが期待できます。特に、デスクトップアプリケーション、モバイルアプリケーション(実験的段階のものもあります)、コマンドラインツール、あるいは設定情報を保持する小規模なサーバーサイドアプリケーションなどにおいて、外部データベースサーバーのセットアップや運用なしに、効率的なデータ管理を実現できます。
本記事では、RustでSQLiteを扱うための様々な手法に焦点を当て、それぞれの特徴や使い方、そして開発効率をどのように向上させるかについて、具体的なコード例を交えながら詳細に解説します。rusqlite
のような直接的なSQL連携から、sqlx
による非同期対応とコンパイル時検証、さらにはdiesel
やsea-orm
といったORM(Object-Relational Mapper)を使った高レベルな抽象化まで、幅広いアプローチを紹介し、皆様のプロジェクトに最適な連携方法を選択する手助けとなることを目指します。
この記事を通じて、RustとSQLiteの組み合わせが、いかに開発効率を高めつつ、高い品質のアプリケーションを構築する上で強力なツールとなりうるかを理解していただければ幸いです。
SQLiteの概要
Rustでの連携方法を学ぶ前に、まずSQLite自体について理解を深めましょう。
SQLiteとは何か?
SQLiteは、サーバーレス、自己完結型、設定不要、トランザクション対応のSQLデータベースエンジンです。他の多くのデータベース管理システム(MySQL, PostgreSQLなど)とは異なり、独立したサーバープロセスとして動作するのではなく、ライブラリとしてアプリケーションにリンクされ、データベースをディスク上の単一のファイルとして保存します。
SQLiteの主な特徴
- サーバーレス: 独立したデータベースサーバープロセスが不要です。アプリケーションが直接データベースファイルにアクセスします。これにより、セットアップや管理が非常に簡単になります。
- 自己完結型: 外部依存性がほとんどなく、単一のライブラリファイルとして提供されます。
- 設定不要: ほとんどの場合、特別な設定ファイルや起動プロセスは必要ありません。
- トランザクション対応: ACID (Atomicity, Consistency, Isolation, Durability) 特性を完全にサポートしており、信頼性の高いデータ操作が可能です。
- 軽量: コードベースが小さく、メモリ使用量も少ないため、リソースが限られた環境でも動作します。
- 標準SQL: 標準的なSQL構文の大部分をサポートしています。ただし、一部の機能(例: ユーザー定義関数、外部キーの厳密なチェックなど)には制限や差異があります。
- 動的型付け: SQLiteはカラムのデータ型を厳密に強制しません。カラムに宣言された型は「ストレージクラス」を示すヒントとして扱われます。任意の型の値を任意のカラムに格納できますが、推奨される使い方ではありません。
SQLiteのメリット
- 導入・運用が容易: サーバー不要でファイルベースなので、インストールや設定の手間がほとんどかかりません。アプリケーションの配布も容易になります。
- 移植性が高い: データベースファイルは異なるアーキテクチャやOS間でも互換性があります(エンディアンネスの問題を除く)。
- 高速: 小規模から中規模のデータセットに対しては非常に高速な読み書き性能を発揮します。
- リソース消費が少ない: メモリやCPUリソースをあまり消費しないため、組み込みシステムやモバイルデバイスに適しています。
- コスト: ライブラリはパブリックドメインであり、商用利用含め完全に無償で利用できます。
SQLiteのデメリット
- 大規模な並行書き込み性能: 複数のプロセスやスレッドからの同時に大量の書き込みが発生する場合、データベースファイルレベルでのロックが競合し、パフォーマンスが低下しやすいです。ただし、WAL (Write-Ahead Logging) モードを利用することで、ある程度の並行読み取りと書き込みを可能にできます。
- クライアント・サーバー型ではない: ネットワーク経由でのアクセスには向いていません。基本的にデータベースファイルはローカルストレージにあることを前提としています。
- 管理・監視ツール: MySQLやPostgreSQLに比べて、洗練されたGUIベースの管理・監視ツールは少ない傾向があります(ただし、基本的なツールは存在します)。
主な用途
- モバイルアプリケーション(Android, iOSなど)
- デスクトップアプリケーション
- 組み込みシステム
- Webブラウザのデータストレージ
- 設定ファイルやキャッシュの永続化
- コマンドラインツール
- 開発・テスト用の軽量データベース
このように、SQLiteはその手軽さと堅牢性から、多様なシーンで利用されています。
なぜRustでSQLiteを使うのか
RustとSQLiteの組み合わせは、それぞれの技術の強みを活かすことで、特定の種類のアプリケーション開発において非常に効果的です。
Rustの強みのおさらい
- メモリ安全性: 所有権、借用、ライフタイムといった概念により、ガベージコレクションなしにメモリ安全性を保証します。これにより、NULLポインタ参照、データ競合といったバグを防ぎ、高い信頼性を実現します。
- パフォーマンス: C/C++と同等の低レベル制御が可能で、抽象化のコストも小さいです。これにより、非常に高速なコードを実行できます。
- 並行処理: データ競合を防ぐ仕組みが言語レベルで組み込まれているため、安全かつ効率的な並行処理を容易に記述できます。
- 信頼性: 厳格なコンパイル時チェックにより、多くのエラーを実行時ではなくコンパイル時に検出できます。
RustとSQLiteの相性
- パフォーマンス: Rustの高速な処理能力は、SQLiteの軽量なデータベース操作と組み合わせることで、高いスループットを持つアプリケーションを構築できます。特に、ローカルデータへの高速アクセスが求められる場合に有効です。
- 安全性: Rustのメモリ安全性とSQLiteの堅牢なトランザクション処理が合わさることで、データ破損のリスクを低減できます。データベース操作に伴うエラー(接続エラー、クエリエラーなど)も、Rustの強力な型システムとエラー処理機構 (
Result
) を使って安全かつ明示的に扱うことができます。 - 埋め込み型アプリケーション: Rustで記述された単一のバイナリファイルに、SQLiteライブラリとデータベースファイルを同梱することで、外部依存性の少ない自己完結型のアプリケーションを簡単に配布できます。これは、デスクトップツールやCLIツールで特に役立ちます。
- 開発効率: 後述する様々なcrate(ライブラリ)を利用することで、Rustの型システムを活用しながら、安全かつ効率的にデータベース操作を行うことができます。SQL直書きから、型安全なクエリビルダ、さらにはORMまで、プロジェクトのニーズに合わせて様々なレベルの抽象化を選択できます。
開発効率の向上にどう貢献するか
- セットアップの容易さ: 外部データベースサーバーのセットアップや構成管理が不要なため、開発環境の構築が迅速に行えます。
- 依存性の削減: アプリケーションバイナリ以外に特別な依存ファイルがほとんどないため、デプロイや配布がシンプルになります。
- コンパイル時エラー検出:
sqlx
のようなcrateはSQLクエリのコンパイル時検証をサポートしており、実行前にSQL構文やスキーマとの不一致を発見できます。これは開発初期段階でのバグを減らし、デバッグ時間を短縮します。 - 型安全なデータ操作: ORMやQuery Builderを利用することで、データベーススキーマをRustのデータ構造にマッピングし、型安全な方法でデータを操作できます。これにより、実行時キャストエラーやスキーマ変更に伴うエラーのリスクを減らし、リファクタリングを容易にします。
- 優れたツールエコシステム:
diesel_cli
やsea-orm-cli
のようなコマンドラインツールは、マイグレーション管理、スキーマ定義コード生成などを自動化し、開発ワークフローを効率化します。
RustでのSQLite連携方法の選択肢
RustでSQLiteを扱うための選択肢はいくつかあります。それぞれ異なるレベルの抽象化と機能を提供するため、プロジェクトの要件に応じて最適なものを選択することが重要です。
低レベルなCライブラリバインディング
最も基本的なアプローチは、SQLiteのCライブラリ(sqlite3
)への直接的なバインディングを利用することです。libsqlite3-sys
のようなcrateがこれを提供しますが、これは非常に低レベルであり、安全性の保証や使いやすさの面でRustのエコシステムに馴染みにくい場合があります。通常、開発者はこれらを直接使うのではなく、より高レベルなラッパーcrateを利用します。
高レベルな抽象化を提供するcrate
Rustコミュニティでは、SQLiteのCライブラリをラップし、Rustらしい安全で使いやすいAPIを提供する多くのcrateが開発されています。主なものをいくつか紹介します。
-
rusqlite
:- Rustの所有権システムに適合した、SQLite C APIへの比較的薄いラッパーです。
- SQLクエリを直接文字列として記述し、パラメータバインディングや結果セットの取得を行います。
- 同期的なAPIを提供します。
- SQLiteの機能を直接的に利用したい場合に適しています。シンプルで、オーバーヘッドが少ないのが特徴です。
-
sqlx
:- データベースに依存しない非同期対応のSQLライブラリです。
- 大きな特徴は、コンパイル時に実際のデータベースに接続してSQLクエリを検証する機能を持っていることです。これにより、実行前にSQLエラーやスキーマ不一致を発見できます。
async/await
をサポートしており、非同期アプリケーションに適しています。- プリペアドステートメントのキャッシュ、接続プールなどの機能も提供します。
-
ORM (Object-Relational Mapper):
- データベーステーブルとRustの構造体(オブジェクト)間のマッピングを提供し、SQLを直接書かずにデータベース操作を行うことを可能にします。
- 型安全なクエリビルダを提供することが多いです。
- 主なORM crate:
diesel
: Rustで最も成熟したORM/Query Builderの一つです。静的型付けを最大限に活用し、コンパイル時の安全性を重視しています。同期的なAPIがメインですが、非同期ランタイム(Tokioなど)と組み合わせることも可能です。sea-orm
: 非同期対応に特化したORMです。Entity、ActiveModelといった概念を用いて、柔軟かつ非同期フレンドリーなAPIを提供します。- 他にもいくつかのORMやQuery Builderが存在します(例:
rust-orm
,refinery
など)。
どのcrateを選ぶべきか?
- シンプルさ、オーバーヘッドの少なさ、同期処理: SQLiteの機能を直接、かつ同期的に扱いたい場合は、
rusqlite
が優れた選択肢です。学習コストも比較的低いです。 - 非同期処理、コンパイル時SQL検証: 非同期アプリケーションを開発しており、かつSQLクエリの安全性をコンパイル時に保証したい場合は、
sqlx
が最適です。 - 高レベルな抽象化、型安全なクエリ、開発効率(定型処理): SQL直書きを避けたい、型安全性を高めたい、データベーススキーマとコードの同期を効率化したい場合は、ORM (
diesel
orsea-orm
) が有力な選択肢となります。同期/非同期のニーズに応じて選択できます。ORMは初期学習コストが高い場合がありますが、一度習得すれば定型的なデータベース操作の生産性を大きく向上させます。
以降のセクションでは、これらの主要なcrate(rusqlite
, sqlx
, diesel
)を使った具体的な連携方法を詳細に解説していきます。
rusqlite
crateを使った基本連携
rusqlite
は、SQLiteのCライブラリをRustから扱いやすくするための軽量なラッパーです。同期的なAPIを提供し、SQLクエリを文字列として直接記述します。SQLiteの機能をそのままRustで利用したい場合に適しています。
1. rusqlite
の導入
Cargo.toml
に以下の依存関係を追加します。
toml
[dependencies]
rusqlite = "0.30"
デフォルトではSQLiteのCライブラリがビルド時にコンパイルされます。システムにインストールされているSQLiteライブラリを使用したい場合は、pkg-config
featureを有効にします。
2. データベース接続の確立
rusqlite::Connection::open
関数を使ってデータベースファイルに接続します。ファイルが存在しない場合は新しく作成されます。:memory:
を指定すると、ディスクではなくメモリ上に一時的なデータベースを作成できます。
“`rust
use rusqlite::{Connection, Result};
fn main() -> Result<()> {
// データベースファイルに接続 (または新規作成)
// または Connection::open(“:memory:”)? でインメモリDB
let conn = Connection::open(“my_database.db”)?;
println!("データベースに接続しました。");
// ここでデータベース操作を行う
Ok(())
}
“`
Connection
構造体はデータベースへの接続を表し、ドロップされるときに自動的に接続が閉じられます。Result
型を使ってエラーハンドリングを行っています。
3. テーブル作成 (DDL)
接続が確立できたら、SQLのCREATE TABLE
文を使ってテーブルを作成できます。
“`rust
use rusqlite::{Connection, Result};
[derive(Debug)]
struct Person {
id: i32,
name: String,
age: i32,
}
fn main() -> Result<()> {
let conn = Connection::open(“my_database.db”)?;
// テーブル作成
conn.execute(
"CREATE TABLE IF NOT EXISTS person (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
age INTEGER NOT NULL
)",
[], // パラメータなし
)?;
println!("テーブル 'person' が作成されました (存在しない場合)。");
Ok(())
}
“`
conn.execute
メソッドは、INSERT, UPDATE, DELETE, CREATE TABLEなどの非クエリ操作を実行するために使用します。第二引数には、SQLクエリのプレースホルダー(?
や:name
など)に対応するパラメータのリストを渡します。ここではパラメータがないため空の配列[]
を渡しています。IF NOT EXISTS
を使うことで、テーブルが既に存在する場合にエラーになるのを防ぎます。
4. データの挿入 (INSERT)
execute
メソッドを使ってデータを挿入します。パラメータバインディングを利用して、SQLインジェクションを防ぎ、安全に値を渡します。
“`rust
// … Connection::open などは省略 …
use rusqlite::{Connection, Result, params};
fn main() -> Result<()> {
let mut conn = Connection::open(“my_database.db”)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS person (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
age INTEGER NOT NULL
)",
[],
)?;
let name = "Alice";
let age = 30;
// データの挿入
conn.execute(
"INSERT INTO person (name, age) VALUES (?1, ?2)",
params![name, age], // パラメータをバインド
)?;
println!("データが挿入されました: 名前={}, 年齢={}", name, age);
// 複数件の挿入も可能
let people = vec![
("Bob", 25),
("Charlie", 35),
];
// トランザクションを使った複数挿入 (オプション)
// 後述のトランザクション説明を参照
let tx = conn.transaction()?;
for (name, age) in people {
tx.execute(
"INSERT INTO person (name, age) VALUES (?1, ?2)",
params![name, age],
)?;
}
tx.commit()?; // または tx.rollback()?;
println!("追加のデータが挿入されました。");
Ok(())
}
“`
パラメータには?1
, ?2
のような位置指定のものや、:name
, :age
のような名前付きのものを使用できます。params!
マクロは、これらのプレースホルダーに対応する値を安全にバインドするための便利な方法です。
5. データの参照 (SELECT)
データを参照するにはconn.prepare
でプリペアドステートメントを作成し、query
またはquery_row
メソッドを使用します。
単一レコード取得 (query_row
)
特定の条件に一致する単一の行を取得する場合に使用します。
“`rust
// … Connection::open, CREATE TABLE, INSERT などは省略 …
use rusqlite::{Connection, Result, params};
[derive(Debug)]
struct Person {
id: i32,
name: String,
age: i32,
}
fn main() -> Result<()> {
let conn = Connection::open(“my_database.db”)?;
// … テーブル作成、データ挿入 …
let person_id = 1;
// 単一レコードの取得
let mut stmt = conn.prepare("SELECT id, name, age FROM person WHERE id = ?1")?;
let person = stmt.query_row(params![person_id], |row| {
// 行からデータを読み取り、Person構造体にマッピング
Ok(Person {
id: row.get(0)?, // 0番目のカラム (id) を取得
name: row.get(1)?, // 1番目のカラム (name) を取得
age: row.get(2)?, // 2番目のカラム (age) を取得
})
})?; // query_row は Result<T, Error> を返す
println!("取得した単一データ: {:?}", person);
Ok(())
}
“`
prepare
メソッドはSQLクエリ文字列を受け取り、Statement
を返します。query_row
メソッドは、パラメータとクロージャを受け取ります。クロージャは結果セットの各行(この場合は1行のみ)に対して実行され、その行からデータを抽出して目的の型に変換します。row.get(index)
でカラムの値をインデックス指定で取得できます。カラム名で取得したい場合は row.get("column_name")
を使います。
もし期待する行数と異なる場合(例: 0行または複数行が返された場合)はエラーになります。
複数レコード取得 (query
)
条件に一致する複数の行を取得する場合に使用します。query
メソッドは、結果セットをイテレータとして返します。
“`rust
// … Connection::open, CREATE TABLE, INSERT などは省略 …
use rusqlite::{Connection, Result, params, Rows};
[derive(Debug)]
struct Person {
id: i32,
name: String,
age: i32,
}
fn main() -> Result<()> {
let conn = Connection::open(“my_database.db”)?;
// … テーブル作成、データ挿入 …
let min_age = 20;
// 複数レコードの取得
let mut stmt = conn.prepare("SELECT id, name, age FROM person WHERE age > ?1")?;
let person_rows = stmt.query(params![min_age])?; // 結果セットを表すRowsイテレータを取得
let mut people = Vec::new();
for row_result in person_rows { // イテレータを順番に処理
let row = row_result?; // 各行は Result<Row, Error>
people.push(Person {
id: row.get(0)?,
name: row.get(1)?,
age: row.get(2)?,
});
}
println!("取得した複数データ:");
for person in people {
println!("{:?}", person);
}
Ok(())
}
“`
query
メソッドはRows
構造体を返します。これはイテレータとして振る舞い、.next()
メソッド(またはfor
ループ)を呼び出すたびに次の行をResult<Row, Error>
として提供します。各Row
からget
メソッドを使ってカラムの値を取得します。
6. データの更新 (UPDATE)
execute
メソッドを使ってデータを更新します。
“`rust
// … Connection::open, CREATE TABLE, INSERT などは省略 …
use rusqlite::{Connection, Result, params};
fn main() -> Result<()> {
let mut conn = Connection::open(“my_database.db”)?;
// … テーブル作成、データ挿入 …
let new_age = 31;
let person_id_to_update = 1;
// データの更新
let updated_rows = conn.execute(
"UPDATE person SET age = ?1 WHERE id = ?2",
params![new_age, person_id_to_update],
)?;
println!("{} 件のデータが更新されました。", updated_rows);
// 更新結果を確認するために再度取得することも可能
// ... query_row など ...
Ok(())
}
“`
execute
メソッドは、更新、挿入、削除された行数を返します。
7. データの削除 (DELETE)
execute
メソッドを使ってデータを削除します。
“`rust
// … Connection::open, CREATE TABLE, INSERT などは省略 …
use rusqlite::{Connection, Result, params};
fn main() -> Result<()> {
let mut conn = Connection::open(“my_database.db”)?;
// … テーブル作成、データ挿入 …
let person_id_to_delete = 2;
// データの削除
let deleted_rows = conn.execute(
"DELETE FROM person WHERE id = ?1",
params![person_id_to_delete],
)?;
println!("{} 件のデータが削除されました。", deleted_rows);
Ok(())
}
“`
同様に、execute
メソッドは削除された行数を返します。
8. トランザクション処理
複数のデータベース操作をアトミックな単位として実行したい場合は、トランザクションを使用します。rusqlite
ではconn.transaction()
メソッドでトランザクションを開始し、commit()
またはrollback()
で終了します。
“`rust
// … Connection::open などは省略 …
use rusqlite::{Connection, Result, Error, params};
fn main() -> Result<()> {
let mut conn = Connection::open(“my_database.db”)?;
// … テーブル作成、データ挿入 …
// トランザクションの開始
let tx = conn.transaction()?;
// トランザクション内での操作
tx.execute(
"INSERT INTO person (name, age) VALUES (?1, ?2)",
params!["David", 40],
)?;
tx.execute(
"UPDATE person SET age = age + 1 WHERE name = ?1",
params!["Alice"],
)?;
// 意図的にエラーを発生させてロールバックの例
let should_fail = true;
if should_fail {
println!("エラーをシミュレートしてロールバックします。");
// 例えば、存在しないテーブルへの挿入などでエラーが発生する場合
// tx.execute("INSERT INTO non_existent_table (col) VALUES (?)", params![1])?;
// あるいは明示的なロールバック
tx.rollback()?;
println!("トランザクションがロールバックされました。");
return Err(Error::ToSqlConversionFailure(Box::new("Simulated error".into())));
} else {
// エラーがなければコミット
tx.commit()?;
println!("トランザクションがコミットされました。");
}
Ok(())
}
“`
conn.transaction()
はTransaction
ガードオブジェクトを返します。このガードがドロップされるときに、明示的にcommit()
またはrollback()
が呼ばれていない場合は、デフォルトでロールバックされます。これにより、パニック時でもデータの一貫性を保つことができます。
9. エラーハンドリング
rusqlite
のメソッドはResult<T, rusqlite::Error>
を返すため、Rust標準のエラーハンドリングパターン (?
演算子、match
式、map_err
など) をそのまま利用できます。
“`rust
use rusqlite::{Connection, Result, Error};
fn some_db_operation() -> Result<()> {
let conn = Connection::open(“non_existent_dir/my_database.db”)?; // ファイル作成に失敗する例
// … データベース操作 …
Ok(())
}
fn main() {
match some_db_operation() {
Ok(_) => println!(“データベース操作成功!”),
Err(e) => {
eprintln!(“データベースエラーが発生しました: {}”, e);
// rusqlite::Errorの種類をさらに詳細に分析することも可能
match e {
Error::InvalidPath(path) => eprintln!(“無効なデータベースパス: {}”, path.display()),
Error::SqliteFailure(ext_err, Some(sql)) => {
eprintln!(“SQLiteエラー (コード: {}): {}”, ext_err.code, ext_err.message);
eprintln!(“実行しようとしたSQL: {}”, sql);
},
_ => eprintln!(“その他のrusqliteエラー: {}”, e),
}
}
}
}
“`
rusqlite::Error
enumは、データベース操作中に発生しうる様々なエラー(接続エラー、クエリ実行エラー、データ型変換エラーなど)を詳細に表現しています。
rusqlite
はシンプルで直接的なAPIを提供するため、SQLiteの挙動を細かく制御したい場合や、ORMなどの抽象化が必要ない小規模なアプリケーションで非常に有効です。SQLを直接書くことに抵抗がない場合や、既存のSQL資産を活用したい場合にも適しています。
sqlx
crateを使った非同期連携とコンパイル時検証
sqlx
は、非同期処理に特化した、データベース非依存のSQLライブラリです。rusqlite
とは異なり、非同期ランタイム(Tokioなど)との連携を前提としています。また、最大の売りは、SQLクエリの正しさをコンパイル時に検証できる機能です。
1. sqlx
の導入
Cargo.toml
に依存関係を追加します。データベースの種類(ここではsqlite
)、使用する非同期ランタイム(例: runtime-tokio
)、そしてコンパイル時検証に必要なmacros
featureを有効にします。
toml
[dependencies]
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "macros"] }
tokio = { version = "1.20", features = ["full"] } # 例としてTokioランタイムも追加
コンパイル時検証機能を使うには、sqlx-cli
ツールもインストールする必要があります。
bash
cargo install sqlx-cli --no-default-features --features sqlite
そして、プロジェクトのルートディレクトリに.env
ファイルを作成し、データベースURLを記述します。コンパイル時検証ツールはこのURLを見て実際のデータベースに接続します。
“`env
DATABASE_URL=sqlite:my_sqlx_database.db
またはインメモリ: DATABASE_URL=sqlite::memory:
“`
2. データベース接続プールの利用
sqlx
は非同期環境での利用を想定しているため、通常はデータベース接続プールを使用します。これにより、接続の確立・解放のオーバーヘッドを減らし、効率的なリソース管理を行います。
“`rust
use sqlx::{sqlite::SqlitePool, Result};
use tokio; // Tokioランタイムを使用
[tokio::main] // 非同期エントリポイント
async fn main() -> Result<()> {
// .env ファイルから DATABASE_URL を読み込み、接続プールを作成
let pool = SqlitePool::connect(“sqlite:my_sqlx_database.db”).await?;
// または SqlitePool::connect(“:memory:”).await?;
println!("データベース接続プールを作成しました。");
// ここでデータベース操作を行う
// プールはスコープを外れると自動的に閉じられる (または明示的に close() を呼ぶ)
Ok(())
}
“`
SqlitePool::connect
は非同期関数なので.await
が必要です。これは複数の非同期タスクで共有して利用できます。
3. テーブル作成 (DDL)
テーブル作成も非同期で行います。sqlx::query!
マクロを使うことで、SQLクエリのコンパイル時検証が有効になります。
“`rust
// … use 宣言、pool の作成などは省略 …
use sqlx::{SqlitePool, Result};
use tokio;
[tokio::main]
async fn main() -> Result<()> {
let pool = SqlitePool::connect(“sqlite:my_sqlx_database.db”).await?;
// テーブル作成 (コンパイル時検証あり)
sqlx::query!(
"CREATE TABLE IF NOT EXISTS person (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
age INTEGER NOT NULL
)"
)
.execute(&pool) // クエリを実行
.await?;
println!("テーブル 'person' が作成されました (存在しない場合)。");
Ok(())
}
“`
sqlx::query!
マクロは、マクロの引数として渡されたSQL文字列をコンパイル時に解析し、.env
で指定されたデータベースに対して検証を行います。検証に成功すると、そのクエリに対応する型情報を持つ構造体を生成します。.execute(&pool)
でプールから接続を取得し、非同期にクエリを実行します。
4. データの挿入 (INSERT)
データの挿入もsqlx::query!
マクロとパラメータバインディングを使って行います。
“`rust
// … use 宣言、pool の作成などは省略 …
use sqlx::{SqlitePool, Result};
use tokio;
[tokio::main]
async fn main() -> Result<()> {
let pool = SqlitePool::connect(“sqlite:my_sqlx_database.db”).await?;
// … テーブル作成 …
let name = "Alice";
let age = 30;
// データの挿入 (コンパイル時検証あり)
sqlx::query!(
"INSERT INTO person (name, age) VALUES (?, ?)",
name, // パラメータを自動的にバインド
age
)
.execute(&pool)
.await?;
println!("データが挿入されました: 名前={}, 年齢={}", name, age);
Ok(())
}
“`
sqlx::query!
マクロにSQL文字列の後に続く引数は、?
プレースホルダーに対応する値として自動的にバインドされます。rusqlite
のparams!
マクロは不要です。
5. データの参照 (SELECT)
データの参照もsqlx::query!
マクロで行います。結果を取得するには、fetch_one
(単一レコード)やfetch_all
(複数レコード)メソッドを使用します。
“`rust
// … use 宣言、pool の作成などは省略 …
use sqlx::{SqlitePool, Result};
use tokio;
// データベースから取得したデータをマッピングするための構造体
[derive(Debug, sqlx::FromRow)] // sqlx::FromRow を導出すると自動マッピング
struct Person {
id: i64, // INTEGER PRIMARY KEY は i64 にマッピングされることが多い
name: String,
age: i32,
}
[tokio::main]
async fn main() -> Result<()> {
let pool = SqlitePool::connect(“sqlite:my_sqlx_database.db”).await?;
// … テーブル作成、データ挿入 …
let person_id = 1;
// 単一レコードの取得 (コンパイル時検証あり)
let person = sqlx::query_as!(
Person, // 結果を Person 構造体にマッピング
"SELECT id, name, age FROM person WHERE id = ?",
person_id
)
.fetch_one(&pool) // 単一の行を取得
.await?;
println!("取得した単一データ: {:?}", person);
let min_age = 20;
// 複数レコードの取得 (コンパイル時検証あり)
let people = sqlx::query_as!(
Person,
"SELECT id, name, age FROM person WHERE age > ?",
min_age
)
.fetch_all(&pool) // すべての行を取得
.await?;
println!("取得した複数データ:");
for person in people {
println!("{:?}", person);
}
Ok(())
}
“`
sqlx::query_as!
マクロは、指定された構造体 (Person
) に結果セットをマッピングしようとします。構造体のフィールド名とSELECT句で指定されたカラム名が一致する必要があります。#[derive(sqlx::FromRow)]
は、この自動マッピングを可能にするためのderiveマクロです。
fetch_one()
はResult<Row, Error>
を返し、該当する行がない場合はsqlx::Error::RowNotFound
エラーになります。fetch_all()
はResult<Vec<Row>, Error>
を返します。
sqlx::query!
マクロでも結果を取得できますが、その場合は匿名構造体のような形で結果が返されるため、sqlx::query_as!
の方がRustの構造体に綺麗にマッピングできて便利です。
6. データの更新 (UPDATE)
更新もsqlx::query!
マクロとexecute
メソッドで行います。
“`rust
// … use 宣言、pool の作成などは省略 …
use sqlx::{SqlitePool, Result};
use tokio;
[tokio::main]
async fn main() -> Result<()> {
let pool = SqlitePool::connect(“sqlite:my_sqlx_database.db”).await?;
// … テーブル作成、データ挿入 …
let new_age = 31;
let person_id_to_update = 1;
// データの更新 (コンパイル時検証あり)
let result = sqlx::query!(
"UPDATE person SET age = ? WHERE id = ?",
new_age,
person_id_to_update
)
.execute(&pool)
.await?;
println!("{} 件のデータが更新されました。", result.rows_affected());
Ok(())
}
“`
execute
メソッドはExecuteResult
トレイトを実装する型を返し、そこからrows_affected()
メソッドで影響を受けた行数(更新件数)を取得できます。
7. データの削除 (DELETE)
削除も同様にsqlx::query!
マクロとexecute
メソッドで行います。
“`rust
// … use 宣言、pool の作成などは省略 …
use sqlx::{SqlitePool, Result};
use tokio;
[tokio::main]
async fn main() -> Result<()> {
let pool = SqlitePool::connect(“sqlite:my_sqlx_database.db”).await?;
// … テーブル作成、データ挿入 …
let person_id_to_delete = 2;
// データの削除 (コンパイル時検証あり)
let result = sqlx::query!(
"DELETE FROM person WHERE id = ?",
person_id_to_delete
)
.execute(&pool)
.await?;
println!("{} 件のデータが削除されました。", result.rows_affected());
Ok(())
}
“`
result.rows_affected()
で削除件数を取得できます。
8. トランザクション処理
sqlx
では、接続プールから接続を取得し、その接続に対してトランザクションを開始します。
“`rust
// … use 宣言、pool の作成などは省略 …
use sqlx::{SqlitePool, Result};
use tokio;
[tokio::main]
async fn main() -> Result<()> {
let pool = SqlitePool::connect(“sqlite:my_sqlx_database.db”).await?;
// … テーブル作成、データ挿入 …
// 接続プールから接続を取得
let mut tx = pool.begin().await?; // トランザクションを開始
// トランザクション内での操作
sqlx::query!(
"INSERT INTO person (name, age) VALUES (?, ?)",
"David",
40
)
.execute(&mut *tx) // トランザクション接続に対して実行
.await?;
sqlx::query!(
"UPDATE person SET age = age + 1 WHERE name = ?",
"Alice"
)
.execute(&mut *tx)
.await?;
// 意図的にエラーを発生させてロールバックの例
let should_fail = true;
if should_fail {
println!("エラーをシミュレートしてロールバックします。");
tx.rollback().await?;
println!("トランザクションがロールバックされました。");
// return Err(...) // エラー発生時に適切にエラーを返す
} else {
// エラーがなければコミット
tx.commit().await?;
println!("トランザクションがコミットされました。");
}
Ok(())
}
“`
pool.begin().await?
でトランザクションを開始し、SqliteTransaction
オブジェクトを取得します。このオブジェクトに対してクエリを実行します。execute
メソッドなどに&mut *tx
のように渡すことで、トランザクション内で実行されることを示します。トランザクションオブジェクトに対してcommit().await?
またはrollback().await?
を呼び出します。
9. エラーハンドリング
sqlx
のメソッドもResult<T, sqlx::Error>
を返すため、標準的なエラーハンドリングを利用できます。sqlx::Error
enumは、データベース固有のエラーや接続エラーなどを詳細に表現します。
“`rust
use sqlx::{SqlitePool, Result, Error};
use tokio;
async fn some_db_operation(pool: &SqlitePool) -> Result<()> {
// 存在しないテーブルへのクエリ (コンパイル時検証をスキップした場合など)
sqlx::query!(“SELECT * FROM non_existent_table”)
.fetch_all(pool)
.await?;
Ok(())
}
[tokio::main]
async fn main() {
let pool = SqlitePool::connect(“sqlite:my_sqlx_database.db”).await.unwrap();
match some_db_operation(&pool).await {
Ok(_) => println!("データベース操作成功!"),
Err(e) => {
eprintln!("データベースエラーが発生しました: {}", e);
// sqlx::Error の種類を詳細に分析
match e {
Error::RowNotFound => eprintln!("指定された行が見つかりません。"),
Error::Database(db_err) => {
eprintln!("データベース固有のエラー: {}", db_err);
// SQLite固有のエラーコードやメッセージを取得することも可能
if let Some(sqlite_err) = db_err.as_error::<rusqlite::Error>() {
eprintln!("SQLiteエラーコード: {}", sqlite_err.to_string()); // rusqlite::Error に変換して情報を取得
}
},
Error::PoolTimedOut => eprintln!("データベース接続プールの取得がタイムアウトしました。"),
_ => eprintln!("その他のsqlxエラー: {}", e),
}
}
}
}
“`
特にsqlx::Error::Database
は、データベースエンジン固有のエラーをラップしています。SQLiteの場合は内部的にrusqlite
を使っているため、そのエラー情報にアクセスできることがあります。
10. コンパイル時検証のメリット・デメリット
メリット:
- 早期バグ検出: SQLクエリの構文エラー、テーブル名・カラム名の間違い、パラメータ数の不一致、結果カラムの不一致などをコンパイル時に発見できます。これにより、実行してみるまで気づかないタイプのバグを大幅に削減できます。
- リファクタリングの安全性: データベーススキーマを変更した際に、それに依存するすべてのクエリが自動的にチェックされます。変更漏れによる実行時エラーを防ぎます。
- 自己文書化: クエリと期待される入出力の型がコード上で明確になります。
デメリット:
- セットアップの手間: コンパイル時検証のためには、開発環境で対象のデータベースが起動しており、接続可能な状態である必要があります。
.env
ファイルやsqlx-cli
の設定が必要です。 - ビルド時間の増加: コンパイル時にデータベースに接続してクエリ検証を行うため、ビルド時間が長くなる可能性があります。
- 動的なクエリ: 実行時に文字列結合などで動的にSQLクエリを生成するようなケースでは、コンパイル時検証の恩恵を受けられません。(ただし、これは一般的にSQLインジェクションのリスクを高めるため推奨されない手法です)。
sqlx
は、特に非同期Webアプリケーションなど、非同期処理が中心でSQLクエリの信頼性を重視したいプロジェクトで強力な選択肢となります。コンパイル時検証は開発初期段階での生産性を大きく向上させます。
ORM (diesel
または sea-orm
) を使った開発効率向上
SQL直書きは柔軟性が高い反面、定型的なCRUD操作であってもSQL文を書く必要があり、スキーマ変更時の追従や型安全性に課題があります。ORM (Object-Relational Mapper) は、データベーステーブルとRustの構造体をマッピングし、型安全なRustコードでデータベース操作を行えるようにすることで、これらの課題を解決し開発効率を向上させます。
主要なORMであるdiesel
と非同期対応のsea-orm
を紹介します。
なぜORMを使うのか?
- SQL直書きからの解放: 多くの一般的なデータベース操作(データの取得、挿入、更新、削除、リレーションシップを考慮した取得など)を、SQLを知らなくてもRustのコードで記述できます。
- 型安全: データベーススキーマがRustの型システムに取り込まれるため、存在しないカラム名を参照したり、間違った型の値を渡したりといったエラーをコンパイル時に検出できます。
- 生産性の向上: 定型的な操作を簡潔に記述でき、コードの量が減ります。
- リファクタリングの容易さ: スキーマ変更があった場合、コンパイルエラーが発生するため、どこを修正すべきかが明確になります。
- データベース非依存性(限定的): ORMによっては、異なるデータベースバックエンド(SQLite, PostgreSQL, MySQLなど)に対してほぼ同じコードで操作できるものもあります(ただし、DB固有の機能を使う場合は差が出ます)。
ORMのトレードオフ
- 学習コスト: ORM独自の概念やAPIを学ぶ必要があります。特に複雑なクエリやORMが想定していない操作を行う場合は、学習コストが高くなることがあります。
- 抽象化の限界: ORMの抽象化レベルを超える複雑なクエリやDB固有の高度な機能を利用する場合は、生のSQLを書く必要がある場面も出てきます。
- パフォーマンス: ORMが生成するSQLが、手書きの最適化されたSQLほど効率的でない場合があります。パフォーマンスが最優先される場面では、ORMを使わないか、生成されるSQLをレビュー・最適化する必要があります。
diesel
を使ったSQLite連携
diesel
は、Rustにおける最も古く、成熟したORM/Query Builderの一つです。静的型付けとコンパイル時の安全性を非常に重視しています。
1. diesel
の導入
Cargo.toml
に依存関係を追加します。データベースの種類(sqlite
)と、CLIツールが必要になります。
toml
[dependencies]
diesel = { version = "2.1", features = ["sqlite"] }
diesel_migrations = "2.1" # マイグレーション機能を使う場合
CLIツールをインストールします。
bash
cargo install diesel_cli --no-default-features --features sqlite
プロジェクトルートに.env
ファイルを作成し、データベースURLを指定します。
env
DATABASE_URL=my_diesel_database.db
diesel setup
コマンドを実行して、マイグレーション用のディレクトリ構造と設定ファイルを作成します。
bash
diesel setup
2. スキーマ定義とマイグレーション
diesel
は、データベーススキーマをコード(schema.rs
)として管理します。マイグレーションを使ってスキーマを変更します。
bash
diesel migration generate create_person_table
このコマンドでmigrations/<timestamp>_create_person_table
ディレクトリが作成され、その中にup.sql
とdown.sql
ファイルが生成されます。up.sql
にテーブル作成SQLを記述します。
migrations/<timestamp>_create_person_table/up.sql
:
sql
CREATE TABLE person (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
age INTEGER NOT NULL
);
down.sql
:
sql
DROP TABLE person;
マイグレーションを実行してデータベースにテーブルを作成します。
bash
diesel migration run
マイグレーション実行後、diesel print-schema
コマンドを実行するか、ビルド時にdiesel
が自動生成するsrc/schema.rs
ファイルに、現在のデータベーススキーマがRustコードとして出力されます。
src/schema.rs
(自動生成される):
“`rust
// @generated automatically by Diesel CLI.
diesel::table! {
person (id) {
id -> BigInt, // SQLiteの INTEGER PRIMARY KEY は BigInt にマッピングされる
name -> Text,
age -> Integer,
}
}
“`
このschema.rs
は手動で編集せず、diesel print-schema
やdiesel migration run
で更新します。
3. モデル定義
データベースの行をマッピングするためのRust構造体を定義します。diesel
のQueryable
deriveマクロを使います。
src/models.rs
:
“`rust
use diesel::prelude::*;
use crate::schema::person; // 自動生成された schema を参照
[derive(Queryable, Selectable, Debug)] // DBから読み込むための derive
[diesel(table_name = person)] // どのテーブルに対応するかを指定
[diesel(check_for_backend(diesel::sqlite::Sqlite))] // バックエンド指定
pub struct Person {
// schema.rs の型と一致させる
pub id: i64,
pub name: String,
pub age: i32,
}
// INSERT/UPDATE のための構造体 (オプション)
[derive(Insertable)] // INSERT のための derive
[diesel(table_name = person)]
[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct NewPerson {
pub name: String,
pub age: i32,
}
“`
4. データベース操作 (Query Builder)
diesel
の提供する型安全なQuery Builderを使ってデータベース操作を行います。
“`rust
// src/main.rs または lib.rs
use diesel::prelude::;
use diesel::sqlite::SqliteConnection;
use diesel::Connection;
use dotenvy::dotenv;
use std::env;
use crate::models::{Person, NewPerson};
use crate::schema::person::dsl::; // Query Builder を使いやすくするためにインポート
// データベース接続を確立するヘルパー関数
pub fn establish_connection() -> SqliteConnection {
dotenv().ok(); // .env ファイルを読み込む
let database_url = env::var(“DATABASE_URL”).expect(“DATABASE_URL must be set”);
SqliteConnection::establish(&database_url)
.unwrap_or_else(|_| panic!(“Error connecting to {}”, database_url))
}
fn main() {
let mut conn = establish_connection();
// データの挿入
let new_person = NewPerson {
name: "Alice".to_string(),
age: 30,
};
diesel::insert_into(person) // INSERT INTO person ...
.values(&new_person) // VALUES (...)
.execute(&mut conn) // 実行
.expect("Error inserting new person");
println!("データが挿入されました。");
// データの参照 (全件取得)
let results = person
.limit(5) // LIMIT 5
.select(Person::as_select()) // SELECT id, name, age ...
.load(&mut conn) // 実行して Vec<Person> を取得
.expect("Error loading people");
println!("取得したデータ:");
for p in results {
println!("{:?}", p);
}
// データの参照 (条件指定)
let person_name = "Alice";
let alice = person
.filter(name.eq(person_name)) // WHERE name = 'Alice'
.select(Person::as_select())
.first(&mut conn) // 単一レコードを取得 (なければエラー)
.expect(&format!("Error loading person by name: {}", person_name));
println!("名前で検索: {:?}", alice);
// データの更新
let person_id_to_update = alice.id;
let new_age = 31;
diesel::update(person.find(person_id_to_update)) // UPDATE person WHERE id = ...
.set(age.eq(new_age)) // SET age = ...
.execute(&mut conn)
.expect("Error updating person");
println!("データが更新されました。");
// データの削除
let person_id_to_delete = 2; // 例として id=2 のデータを削除
diesel::delete(person.filter(id.eq(person_id_to_delete))) // DELETE FROM person WHERE id = ...
.execute(&mut conn)
.expect("Error deleting person");
println!("データが削除されました。");
}
“`
Query Builderを使うことで、SQLキーワード(SELECT
, WHERE
, INSERT
, UPDATE
, DELETE
など)やテーブル名、カラム名がRustのメソッドや型として扱われます。これにより、記述ミスをコンパイル時に検出でき、SQL文字列を組み立てるよりも安全かつ効率的に開発できます。
diesel
はデフォルトで同期的なAPIを提供します。非同期で使いたい場合は、tokio-diesel
などのヘルパーcrateと組み合わせるか、独自のコネクションプール管理と非同期ランタイム上での実行を組み合わせる必要があります。
diesel
は成熟しており、強力な型安全性とQuery Builderを提供します。SQLに慣れている開発者でも、その安全性の恩恵を受けながら効率的に開発できます。
sea-orm
を使ったSQLite連携
sea-orm
は、非同期ファーストで設計された新しいRust向けORMです。async/await
との親和性が高く、Entity-ActiveModelという独自の概念を持ちます。
1. sea-orm
の導入
Cargo.toml
に依存関係を追加します。データベース種類(sqlx-sqlite
)、非同期ランタイム(runtime-tokio-rustls
など)、CLIツールが必要です。
toml
[dependencies]
sea-orm = { version = "0.12", features = [ "sqlx-sqlite", "runtime-tokio-rustls", "macros", "debug-print" ] }
tokio = { version = "1.20", features = ["full"] } # 例としてTokioランタイムも追加
CLIツールをインストールします。
bash
cargo install sea-orm-cli
プロジェクトルートに.env
ファイルを作成し、データベースURLを指定します。
env
DATABASE_URL=sqlite:my_seaorm_database.db
2. スキーマ定義 (Entity) とマイグレーション
sea-orm
もマイグレーションをサポートします。sea-orm-cli
でマイグレーションファイルを作成できます。
bash
sea-orm-cli migrate generate create_person_table
生成されたマイグレーションファイル(例: migration/src/mYYYYMMDD_HHMMSS_create_person_table.rs
)を編集します。Rustコードでスキーマを定義します。
migration/src/mYYYYMMDD_HHMMSS_create_person_table.rs
:
“`rust
use sea_orm::{Schema, ConnectionTrait, Statement};
use sea_orm::migration::prelude::*;
[derive(DeriveMigrationName)]
pub struct Migration;
[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
sea_orm::Table::create()
.table(Entity) // Person Entity を参照
.if_not_exists()
.col(
sea_orm::ColumnDef::new(Entity::Column::Id)
.integer()
.not_null()
.primary_key()
.auto_increment(),
)
.col(
sea_orm::ColumnDef::new(Entity::Column::Name)
.string()
.not_null(),
)
.col(
sea_orm::ColumnDef::new(Entity::Column::Age)
.integer()
.not_null(),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(sea_orm::Table::drop().table(Entity).to_owned())
.await
}
}
// この Entity 構造体は、モデル定義の Entity と一致させる
// migration ディレクトリ内に Entity 定義をコピーするか、共有 crate を使う
[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
[sea_orm(table_name = “person”)]
pub struct Entity {
#[sea_orm(primary_key)]
pub id: i32, // SQLite の INTEGER は i32 でも良い場合がある
pub name: String,
pub age: i32,
}
[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
[derive(Clone, Debug, PartialEq, Eq, DeriveActiveModel)]
pub struct ActiveModel {
pub id: NotSetOr
pub name: Set
pub age: Set
}
“`
マイグレーションを実行します。
bash
sea-orm-cli migrate up
3. モデル定義 (Entity, ActiveModel)
sea-orm
では、データベーステーブルをEntity、レコードのインスタンスをActiveModelとして扱います。
src/entities/person.rs
:
“`rust
use sea_orm::entity::prelude::*;
[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
[sea_orm(table_name = “person”)]
pub struct Entity {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub age: i32,
}
[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}
“`
src/entities/mod.rs
:
rust
pub mod person;
4. データベース操作 (Query Builder)
sea-orm
もQuery Builderを提供しますが、async/await
を多用します。
“`rust
// src/main.rs または lib.rs
use sea_orm::;
use dotenvy::dotenv;
use std::env;
use crate::entities::person;
use crate::entities::prelude::; // Entity::* がインポートされる
[tokio::main]
async fn main() -> Result<(), DbErr> {
dotenv().ok();
let database_url = env::var(“DATABASE_URL”).expect(“DATABASE_URL must be set”);
// データベース接続 (接続プールを使用)
let db = Database::connect(&database_url).await?;
// データの挿入
let new_person = person::ActiveModel {
name: Set("Alice".to_string()),
age: Set(30),
..Default::default() // id は auto_increment なのでデフォルト
};
let res = person::Entity::insert(new_person)
.exec(&db)
.await?;
println!("データが挿入されました。挿入されたID: {:?}", res.last_insert_id);
// データの参照 (全件取得)
let people: Vec<person::Model> = Person::find() // SELECT * FROM person
.limit(5) // LIMIT 5
.all(&db) // 実行して Vec<person::Model> を取得
.await?;
println!("取得したデータ:");
for p in people {
println!("{:?}", p);
}
// データの参照 (条件指定)
let person_name = "Alice";
let alice: Option<person::Model> = Person::find()
.filter(person::Column::Name.eq(person_name)) // WHERE name = 'Alice'
.one(&db) // 単一レコードを取得 (Option<Model>)
.await?;
if let Some(alice) = alice {
println!("名前で検索: {:?}", alice);
// データの更新
let mut alice_active_model: person::ActiveModel = alice.into_active_model(); // Model -> ActiveModel
alice_active_model.age = Set(31); // SET age = 31
let updated_person = alice_active_model.update(&db).await?; // UPDATE ... WHERE id = ...
println!("データが更新されました: {:?}", updated_person);
} else {
println!("指定の名前のユーザーは見つかりませんでした。");
}
// データの削除
let person_id_to_delete = 2; // 例として id=2 のデータを削除
let delete_result = Person::delete_by_id(person_id_to_delete) // DELETE FROM person WHERE id = ...
.exec(&db)
.await?;
println!("{} 件のデータが削除されました。", delete_result.rows_affected);
Ok(())
}
“`
sea-orm
では、レコードのインスタンスはperson::Model
型で表現されます。データを変更・挿入・削除する場合はperson::ActiveModel
型を使います。Model
からActiveModel
への変換はinto_active_model()
で行い、変更したいフィールドをSet()
でラップして指定します。
sea-orm
は非同期処理を前提としており、Query Builderの各メソッドもawait
を伴います。EntityとActiveModelの概念を理解する必要がありますが、非同期アプリケーションでの開発効率は高いです。
ORMの比較 (diesel
vs sea-orm
)
- 同期 vs 非同期:
diesel
は同期ファースト、sea-orm
は非同期ファーストです。開発しているアプリケーションが同期/非同期どちらを主とするかで選択が変わります。 - 型安全 vs 柔軟性:
diesel
はコンパイル時検証を非常に重視しており、厳格な型安全を提供します。sea-orm
はやや動的な要素も持ちますが、非同期処理や一部の複雑なクエリ表現においてより柔軟なAPIを提供します。 - 成熟度:
diesel
の方が歴史が長く、コミュニティや資料も豊富です。sea-orm
は比較的新しいですが、活発に開発されています。 - 概念:
diesel
はリレーションシップを明示的に定義して結合クエリを組みますが、sea-orm
はEntityとActiveModelという独自の概念体系を持ちます。
どちらのORMを選択するかは、プロジェクトの非同期要件、型安全へのこだわり、そしてチームの経験や好みに依存します。どちらもSQLiteを含む複数のデータベースに対応しており、マイグレーションツールも提供しています。
エラーハンドリング戦略
データベース操作は、接続エラー、認証エラー、クエリ実行エラー(構文エラー、制約違反、データ型不一致)、トランザクションエラーなど、様々な種類のエラーが発生しうる操作です。Rustの強力なエラーハンドリング機構 (Result<T, E>
) を活用して、これらのエラーを適切に処理することが重要です。
Rustにおけるエラー処理の基本
Rustでは、エラーが発生する可能性のある関数は通常Result<T, E>
型を返します。Ok(T)
は成功した場合の値、Err(E)
はエラー情報を保持します。?
演算子を使うことで、Result
の伝播を簡潔に記述できます。
データベース関連のcrate (rusqlite
, sqlx
, diesel
, sea-orm
) は、それぞれ独自のエラー型 (rusqlite::Error
, sqlx::Error
, diesel::result::Error
, sea_orm::DbErr
) を定義しており、これらは通常標準ライブラリのstd::error::Error
トレイトを実装しています。これにより、これらの異なるエラー型を独自のアプリケーションエラー型にラップしたり、統一的に扱ったりすることが容易になります。
データベースエラーへの対処
データベース操作でエラーが発生した場合、単にプログラムをパニックさせるのではなく、エラーの原因を特定し、適切に対処する必要があります。
- エラーメッセージの出力: エラーが発生した場所、原因、関連するSQLクエリなどの情報をログに出力することは、デバッグにおいて非常に重要です。各crateのエラー型は、通常
std::fmt::Display
トレイトを実装しているため、eprintln!("Error: {}", e);
のように簡単に表示できます。 - 特定のエラーへの対応: 例えば、主キー重複エラー (
UNIQUE constraint failed
) や外部キー制約違反 (FOREIGN KEY constraint failed
) など、特定のデータベースエラーが発生した場合に、ユーザーに分かりやすいメッセージを表示したり、代替処理を実行したりする場合があります。各crateのエラー型に含まれる詳細情報(SQLiteのエラーコードなど)をパターンマッチングなどで利用します。 - アプリケーションエラー型へのラップ: データベースエラーは、アプリケーション全体で見ると「データ永続化レイヤーからのエラー」という一部の種類のエラーです。アプリケーション全体のエラー型を定義し、データベースエラーをその一部としてラップすることで、エラー処理を一元化し、呼び出し元が具体的なデータベース実装の詳細を知る必要がないようにできます。
例: カスタムエラー型
“`rust
// src/error.rs
use thiserror::Error; // thiserror クレートを使うと簡単にカスタムエラー型を定義できる
[derive(Error, Debug)]
pub enum AppError {
#[error(“Database error: {0}”)]
DatabaseError(#[from] sea_orm::DbErr), // sea_orm のエラーをラップ
// 他の種類のエラーも定義...
// #[error("User not found")]
// NotFound,
}
// Result Alias
pub type Result
“`
“`rust
// src/main.rs (sea-orm の例)
use crate::error::{AppError, Result}; // 定義したカスタムエラー型と Result エイリアスを使用
use sea_orm::;
use dotenvy::dotenv;
use std::env;
use crate::entities::prelude::;
// Result を AppError に変更
async fn find_person_by_id(db: &DatabaseConnection, person_id: i32) -> Result
[tokio::main]
async fn main() -> Result<()> { // main 関数も Result
dotenv().ok();
let database_url = env::var(“DATABASE_URL”).expect(“DATABASE_URL must be set”);
let db = Database::connect(&database_url).await?; // connect エラーも DbErr なので ? で AppError に変換される
match find_person_by_id(&db, 1).await {
Ok(Some(person)) => println!("Found person: {:?}", person),
Ok(None) => println!("Person not found."),
Err(e) => eprintln!("Application error: {}", e), // AppError が表示される
}
Ok(())
}
“`
thiserror
のようなクレートを使うと、#[from]
アトリビュートで他のエラー型からの変換を自動的に実装でき、非常に便利です。
トランザクションとエラー
トランザクション内でエラーが発生した場合、そのトランザクション全体をロールバックすることが重要です。前述の通り、rusqlite
やsqlx
、diesel
、sea-orm
はいずれもトランザクション機能を提供しており、エラー発生時に安全にロールバックするためのメカニズム(例: rusqlite
のTransaction
ガード)を備えています。手動でトランザクションを管理する場合も、エラー発生時には必ずrollback
を呼び出すように注意が必要です。
エラーハンドリングは堅牢なアプリケーション開発の要です。データベース操作で発生しうる様々なエラーケースを想定し、それらを適切に処理することで、アプリケーションの信頼性を高めることができます。
パフォーマンス考慮事項
SQLiteは軽量で高速ですが、そのパフォーマンス特性を理解し、ボトルネックにならないように注意が必要です。RustでSQLiteを扱う際も、いくつかの点でパフォーマンスを考慮できます。
SQLiteのパフォーマンス特性
- ファイルI/O: SQLiteはデータベースファイルへのI/Oが中心です。ディスクのアクセス速度がパフォーマンスに大きく影響します。SSDの使用は効果的です。
- ロック: デフォルトでは、書き込み操作中はデータベース全体がロックされます。これにより、同時に複数の書き込みを行うと競合が発生し、パフォーマンスが低下します。
- WAL (Write-Ahead Logging): WALモードを有効にすると、書き込みはログファイルに行われ、読み取りはデータベースファイルとログファイルの両方から行われます。これにより、読み取りと書き込みをある程度並行して行うことが可能になり、特に並行読み取り性能が向上します。SQLite 3.7.0以降で利用可能であり、多くのケースでデフォルトのジャーナルモードよりも高いパフォーマンスを発揮します。
- インデックス: 適切なカラムにインデックスを作成することで、SELECTクエリの検索速度を大幅に向上させることができます。INSERT, UPDATE, DELETE操作には若干のオーバーヘッドが発生しますが、読み取りがボトルネックになる場合は非常に有効です。
Rustでのパフォーマンス考慮事項
- データベース接続プールの利用: 特に非同期アプリケーションにおいて、
sqlx
やsea-orm
が提供する接続プールを利用することは必須です。接続の確立・解放にはコストがかかるため、プールしておけば再利用でき、レイテンシを削減できます。同期アプリケーションでも、独自のプールを実装するか、r2d2
のような汎用的な接続プールクレートと組み合わせることで同様の効果が得られます。 - トランザクションの効率的な利用: 複数のINSERT, UPDATE, DELETE操作をまとめて一つのトランザクション内で実行すると、個別に実行するよりもはるかに高速になります。これは、トランザクションの開始・コミットに伴うオーバーヘッドやロックの回数を減らせるためです。前述のコード例のように、一連の操作を
tx.execute(...)
で実行し、最後にtx.commit()
でまとめて書き込むようにします。 - クエリの最適化:
- インデックスの活用: スキーマ設計時に、よく検索条件やJOINに使われるカラムにインデックスを作成します。SQLクエリがインデックスを利用しているか確認するには、
EXPLAIN QUERY PLAN
ステートメントを使います(SQLiteのCLIやGUIツールで実行できます)。 - 不要な列の取得を避ける:
SELECT *
ではなく、必要なカラムだけを明示的に指定して取得します (SELECT id, name FROM person WHERE ...
)。これにより、ネットワーク転送量(埋め込み型の場合は内部データ転送)とデータマッピングのコストを削減できます。ORMを使う場合も、必要なカラムのみをロードするようなQuery Builderの機能を利用します。 - N+1問題の回避: リレーションを持つデータを取得する際に、まず親レコードを取得し、その後に子レコードを1件ずつ個別のクエリで取得するパターンは、クエリ発行回数が多くなり非常に非効率です(N+1問題)。JOINを使うクエリや、ORMのリレーションシップロード機能( eager loading )を利用して、少ないクエリ回数で関連データをまとめて取得するようにします。
- インデックスの活用: スキーマ設計時に、よく検索条件やJOINに使われるカラムにインデックスを作成します。SQLクエリがインデックスを利用しているか確認するには、
- WALモードの有効化:
rusqlite
やsqlx
などの接続URLやオプションでWALモードを有効にできます。多くのワークロードでパフォーマンスが向上し、並行読み取りが可能になります。SQLiteのバージョンや特定のユースケースによってはデフォルトモード(DELETEジャーナル)が適している場合もありますが、一般的にはWALモードが推奨されます。rusqlite
:conn.execute("PRAGMA journal_mode=WAL;", [])?;
sqlx
: 接続URLにsqlite:my_database.db?mode=rwc&cache=shared&journal_mode=WAL
のようなパラメータを追加。または接続オプションで設定。diesel
/sea-orm
: 基本的にはsqlx
経由の設定を利用。
- 並行処理とSQLite: デフォルトのSQLite(DELETEジャーナルモード)は、一つの書き込み操作中にデータベース全体をロックするため、マルチスレッドからの同時書き込みは直列化されます。WALモードでは書き込みと読み取りを並行できますが、書き込み自体はログファイルへの追記操作であり、完全に並行するわけではありません。高度な並行書き込み性能が必要な場合は、SQLiteではなくクライアント・サーバー型のDBを検討するか、アプリケーション側で書き込み操作を直列化するなどの工夫が必要です。Rustでは、
Mutex
などでSQLiteへの書き込みアクセスを保護することが一般的です(rusqlite
接続はSend
ではない)。sqlx
やsea-orm
の接続プールは内部でこの辺りを考慮しています。 - バッチ処理: 複数のレコードをまとめて挿入/更新する場合、1レコードずつ個別のクエリを実行するよりも、VALUES句に複数のタプルを記述する、あるいはORMのバッチ挿入機能を使うなど、単一のSQL文でまとめて処理する方が効率的です。
これらの点を意識することで、SQLiteの性能を最大限に引き出し、Rustアプリケーションの応答性を高めることができます。
テスト方法
データベースに依存するコードのテストは、状態管理が難しいため挑戦的になりがちです。しかし、テストを書くことはコードの信頼性を高める上で不可欠です。RustとSQLiteを使ったデータベースコードのテスト方法にはいくつかのアプローチがあります。
1. インメモリデータベース (:memory:
) の利用
最も手軽で一般的な方法は、テストごとにインメモリデータベース (:memory:
) を使用することです。これにより、ディスクI/Oを伴わず高速にテストを実行でき、また各テストは独立した空の状態から開始できるため、テスト間の副作用を防ぐことができます。
“`rust
// rusqlite の例
use rusqlite::{Connection, Result};
fn create_test_db() -> Result
let conn = Connection::open(“:memory:”)?; // インメモリDB
conn.execute(
“CREATE TABLE person (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER NOT NULL)”,
[],
)?;
Ok(conn)
}
[cfg(test)]
mod tests {
use super::*;
use rusqlite::params;
#[test]
fn test_insert_and_query_person() -> Result<()> {
let conn = create_test_db()?;
conn.execute("INSERT INTO person (name, age) VALUES (?1, ?2)", params!["Alice", 30])?;
let mut stmt = conn.prepare("SELECT id, name, age FROM person WHERE name = ?1")?;
let person = stmt.query_row(params!["Alice"], |row| {
Ok((row.get::<usize, i32>(0)?, row.get::<usize, String>(1)?, row.get::<usize, i32>(2)?))
})?;
assert_eq!(person.1, "Alice");
assert_eq!(person.2, 30);
Ok(())
}
#[test]
fn test_delete_person() -> Result<()> {
let conn = create_test_db()?;
conn.execute("INSERT INTO person (name, age) VALUES (?1, ?2)", params!["Bob", 25])?;
let id = conn.last_insert_rowid();
let deleted_rows = conn.execute("DELETE FROM person WHERE id = ?1", params![id])?;
assert_eq!(deleted_rows, 1);
let mut stmt = conn.prepare("SELECT COUNT(*) FROM person WHERE id = ?1")?;
let count: i32 = stmt.query_row(params![id], |row| row.get(0))?;
assert_eq!(count, 0);
Ok(())
}
}
“`
sqlx
やORM (diesel
, sea-orm
) でも同様に :memory:
データベースURLを使って接続し、テストごとにスキーマを作成することで独立したテスト環境を構築できます。
“`rust
// sqlx の例 (async test)
use sqlx::{sqlite::SqlitePool, Result};
use tokio;
async fn create_test_pool() -> Result
let pool = SqlitePool::connect(“sqlite::memory:”).await?;
sqlx::query!(
“CREATE TABLE person (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER NOT NULL)”
)
.execute(&pool)
.await?;
Ok(pool)
}
[cfg(test)]
mod tests {
use super::*;
use sqlx::Row; // Row トレイトが必要
#[tokio::test] // async test
async fn test_insert_and_query_person_sqlx() -> Result<()> {
let pool = create_test_pool().await?;
sqlx::query!("INSERT INTO person (name, age) VALUES (?, ?)", "Alice", 30)
.execute(&pool)
.await?;
let row = sqlx::query!("SELECT id, name, age FROM person WHERE name = ?", "Alice")
.fetch_one(&pool)
.await?;
assert_eq!(row.name, "Alice");
assert_eq!(row.age, 30);
Ok(())
}
}
“`
2. トランザクションを使ったテスト isolation
テストごとにデータベースを再作成するのは、スキーマが複雑になるにつれて時間がかかる場合があります。別の方法として、各テストの開始時にトランザクションを開始し、テストの終了時(成功・失敗に関わらず)に必ずロールバックするという手法があります。これにより、データベースの状態がテスト間で影響し合わないようにテストを隔離できます。
“`rust
// rusqlite の例 (トランザクションとロールバック)
use rusqlite::{Connection, Result};
// テスト用のコネクションプールを返す関数 (または Connection を直接返す)
fn establish_test_conn() -> Result
// 通常のDBファイルに接続 (または :memory:)
let conn = Connection::open(“test_database.db”)?;
// 必要ならここでスキーマ作成
conn.execute(“CREATE TABLE IF NOT EXISTS person (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER NOT NULL)”, [])?;
Ok(conn)
}
[cfg(test)]
mod tests {
use super::*;
use rusqlite::params;
#[test]
fn test_isolated_operations() -> Result<()> {
let mut conn = establish_test_conn()?;
let tx = conn.transaction()?; // テスト開始時にトランザクション開始
// トランザクション内で操作
tx.execute("INSERT INTO person (name, age) VALUES (?1, ?2)", params!["Alice", 30])?;
let count: i32 = tx.query_row("SELECT COUNT(*) FROM person", [], |row| row.get(0))?;
assert_eq!(count, 1);
// ここで tx をドロップすると自動的にロールバックされる
// あるいは明示的に tx.rollback()?;
// tx.commit()?; // テストではコミットしない!
Ok(()) // tx がスコープを抜けてドロップされ、ロールバック
}
// 他のテストも同様にトランザクション内で実行...
}
“`
sqlx
やORMでも、接続プールから接続を取得し、その接続に対してトランザクションを開始し、テスト終了時にロールバックすることで同様のテスト隔離を実現できます。
3. テストデータの設定とクリーンアップ
テスト対象のコードが特定のデータベース状態に依存する場合、テストの開始前に必要なテストデータを挿入し、テスト終了後にクリーンアップする必要があります。トランザクションとロールバックを使う場合、クリーンアップは不要になります(ロールバックにより状態が元に戻るため)。インメモリデータベースを使う場合も、テストごとに新しいDBが作成されるためクリーンアップは不要です。
もし、ディスク上の物理ファイルでテストを行い、かつトランザクションによる隔離を使わない場合は、テストのsetup
フェーズでデータを挿入し、teardown
フェーズでデータを削除するかテーブルをtruncateするといった処理が必要になります。これは手作業で行うと忘れやすいため、テストフレームワークの機能(もしあれば)やヘルパー関数を利用すると良いでしょう。
どの方法を選ぶか?
- 開発中の速度と手軽さ: インメモリデータベースが最も手軽で高速です。開発初期段階や、スキーマが頻繁に変更される段階で有効です。
- 複雑なスキーマや外部DBの挙動: 物理ファイルや外部DBインスタンスに対してトランザクションを使った隔離テストを行う方が、実際の運用に近い環境でテストできます。SQLiteの場合はファイルベースなので、インメモリDBで十分なことが多いです。
- テスト実行環境: CI/CD環境などでテストを実行する場合、インメモリDBやトランザクションロールバックによるテスト隔離は、セットアップが簡単で並列実行時の競合も起きにくいため非常に便利です。
SQLiteのファイルベースかつ軽量な特性は、インメモリDBを使ったテスト戦略と非常に相性が良いと言えます。これにより、Rustの強力なテスト機能と組み合わせて、データベース連携部分の信頼性の高いテストを効率的に書くことが可能です。
発展的なトピック
マイグレーションツールの利用
データベーススキーマはアプリケーションの進化と共に変化します。テーブルの追加、カラムの変更、インデックスの作成など、これらの変更を管理し、開発環境、ステージング環境、本番環境で一貫性を持って適用するための仕組みがマイグレーションツールです。
前述の通り、diesel_cli
(diesel
) や sea-orm-cli
(sea-orm
) は強力なマイグレーション機能を提供しています。これらのツールを使うことで、スキーマ変更をSQLファイルまたはRustコードとしてバージョン管理し、適用・ロールバックなどの操作をコマンドラインで行うことができます。これは、チーム開発やCI/CDパイプラインにおいて不可欠なプラクティスです。
rusqlite
やsqlx
のような低~中レベルのライブラリ自体はマイグレーション機能を提供しませんが、refinery
のような独立したマイグレーションクレートと組み合わせて利用することが可能です。
スキーマの進化への対応
マイグレーションを使ってスキーマを変更するだけでなく、アプリケーションコードが新しいスキーマに対応する必要があります。ORMを使っている場合は、スキーマファイル (schema.rs
) やEntity/Model定義を更新し、それに合わせてビジネスロジックを修正します。sqlx
のコンパイル時検証を使っている場合は、sqlx database setup
やsqlx prepare
を実行して、新しいスキーマに対してクエリが検証されるようにします。
後方互換性を考慮したスキーマ変更(例: カラムの追加、NULL許容への変更など)は比較的容易ですが、破壊的な変更(例: カラムの削除、データ型の変更など)は慎重に行う必要があります。
データベースのバックアップと復元
SQLiteデータベースは単一のファイルなので、バックアップはシンプルです。ファイル全体をコピーするだけでバックアップが完了します。ただし、アプリケーションがデータベースファイルを開いている最中にコピーすると、不完全なバックアップになる可能性があります。安全なバックアップのためには、データベースを閉じるか、SQLiteのオンラインバックアップAPI(sqlite3_backup_init
など、rusqlite
でも利用可能)を利用する必要があります。
復元も、バックアップファイルで現在のデータベースファイルを置き換えるだけで可能です。
より複雑なクエリ
JOIN、サブクエリ、集計関数(COUNT
, SUM
, AVG
)、GROUP BY、HAVING、ウィンドウ関数など、複雑なSQLクエリが必要になることもあります。
rusqlite
やsqlx
を使う場合は、これらのSQL文を直接文字列として記述します。複雑なクエリは可読性が低下しやすいため、コメントなどで意図を明確にすることが望ましいです。- ORM (
diesel
,sea-orm
) は、Query Builderを使ってこれらの複雑なクエリの大部分を型安全に構築できる機能を提供しています。ただし、非常に特殊なクエリやDB固有の最適化が必要な場合は、ORMを使わずに生のSQLを実行する機能(escape hatch)を利用する必要が出てくることもあります。
複雑なクエリを扱う際は、パフォーマンスに注意し、EXPLAIN QUERY PLAN
などで実行計画を確認することが重要です。
埋め込み型アプリケーションへの統合
SQLiteを埋め込み型データベースとしてRustアプリケーションに統合する場合、データベースファイルのパスの決定、初回起動時のスキーマ作成(マイグレーション実行)、アプリケーション終了時のデータベースの安全なクローズ(通常はConnection
オブジェクトのドロップで自動的に行われる)などを考慮する必要があります。
設定情報やユーザーデータをユーザーのホームディレクトリやアプリケーション固有のデータディレクトリに保存することが一般的です。OSごとに適切なディレクトリを取得するためには、dirs
のようなクレートが役立ちます。
まとめと今後の展望
本記事では、RustでSQLiteを扱うための様々な方法、特に開発効率の向上に焦点を当てて詳細に解説しました。
- SQLiteは軽量で手軽なファイルベースのデータベースであり、特に埋め込み型アプリケーションに適しています。
- Rustはその安全性、パフォーマンス、並行処理の強みから、SQLiteの堅牢かつ高速なデータストアとして非常に相性の良い組み合わせです。
- RustでSQLiteを扱う主な選択肢として、直接的な
rusqlite
、非同期・コンパイル時検証対応のsqlx
、そして高レベルな抽象化を提供するORM (diesel
,sea-orm
) があります。プロジェクトのニーズ(同期/非同期、SQL直書きの許容度、型安全性の重視度、学習コストなど)に応じて最適なcrateを選択できます。 - 各crateを使った具体的なデータベース操作(接続、DDL、CRUD、トランザクション)のコード例を提示しました。
- データベース操作におけるエラーハンドリングの重要性と、Rustにおけるその実践方法について解説しました。
- SQLiteとRustの組み合わせでパフォーマンスを最適化するための考慮事項(WALモード、トランザクション、インデックス、クエリ最適化、接続プール)を説明しました。
- 信頼性の高いテストを書くための手法(インメモリDB、トランザクションロールバック)を紹介しました。
- マイグレーション、スキーマ進化、バックアップなど、発展的なトピックにも触れました。
RustとSQLiteの組み合わせは、以下のようなプロジェクトにおいて非常に強力な選択肢となります。
- 外部DBサーバーの運用・管理を避けたいデスクトップアプリケーションやCLIツール。
- 設定情報やキャッシュなど、比較的小規模なデータを永続化するアプリケーション。
- プロトタイピングや、開発・テスト用の軽量なデータベースが必要な場合。
- 安全性とパフォーマンスが重要視されるが、本格的なクライアント・サーバー型DBが大袈裟すぎる場合。
Rustのエコシステムは急速に発展しており、データベース関連のcrateも活発に開発・改善が進んでいます。非同期処理の普及に伴い、sqlx
やsea-orm
のような非同期ファーストのライブラリの重要性は増していくでしょう。また、SQLite自体も進化を続けており、新しい機能やパフォーマンス改善が取り込まれています。
RustとSQLiteの組み合わせは、開発効率とアプリケーションの品質を両立させるための魅力的なアプローチです。本記事が、皆様のRustプロジェクトにおけるデータ管理の強力な一助となることを願っています。それぞれのcrateを実際に試してみて、ご自身のプロジェクトに最適な道を見つけてください。