Rustでデータベースを扱う
ここからは Rust でプログラムを書いてデータベースを扱っていきます。task up
を実行してデータベースが立ち上がっていることを確認してください。 まずは VSCode で先ほどクローンしてきたリポジトリを開きましょう。画像のようなファイルが入っているはずです。 main.rs を開いてください。
データベースに接続する
Rustのプログラムを書く
サンプルのプログラムが書いてありますが、データベースと接続できるように書き換えます。 Rust でデータベースに接続するためのライブラリは様々ありますが、今回は SQL 文を書く SQLx を使います。
use anyhow::Ok;
use sqlx::mysql::MySqlConnectOptions;
use std::env;
// #region city
#[derive(sqlx::FromRow)]
#[sqlx(rename_all = "PascalCase")]
#[allow(dead_code)] // 使用していないフィールドへの警告を抑制
struct City {
#[sqlx(rename = "ID")]
id: i32,
name: String,
country_code: String,
district: String,
population: i32,
}
// #endregion city
fn get_option() -> anyhow::Result<MySqlConnectOptions> {
let host = env::var("DB_HOSTNAME")?;
let port = env::var("DB_PORT")?.parse()?;
let username = env::var("DB_USERNAME")?;
let password = env::var("DB_PASSWORD")?;
let database = env::var("DB_DATABASE")?;
let timezone = Some(String::from("Asia/Tokyo"));
let collation = String::from("utf8mb4_unicode_ci");
Ok(MySqlConnectOptions::new()
.host(&host)
.port(port)
.username(&username)
.password(&password)
.database(&database)
.timezone(timezone)
.collation(&collation))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let options = get_option()?;
let pool = sqlx::MySqlPool::connect_with(options).await?;
println!("Connected");
// #region get
let city = sqlx::query_as::<_, City>("SELECT * FROM city WHERE Name = ?")
.bind("Tokyo")
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => anyhow::anyhow!("no such city Name = {}\n", "Tokyo"),
_ => anyhow::anyhow!("DB error: {}", e),
})?;
// #endregion get
println!("Tokyoの人口は{}人です", &city.population);
Ok(())
}
get_option
関数により、データベースに接続するための設定を構成し、MySqlPool::connect_with
でデータベースに接続しています。 env::var
により、環境変数を読み込んでいます。環境変数を使うことで、プログラムの動作を変えることなく、データベースの接続情報を変更できます。
環境変数を設定する
.env
というファイルを作り、以下の内容を書いてください。
export DB_USERNAME="root"
export DB_PASSWORD="password"
export DB_HOSTNAME="localhost"
export DB_PORT="3306"
export DB_DATABASE="world"
今回は手元で動いているデータベースを使うのでパスワードなどが知られても問題ありませんが、実際には環境変数など外部の人に知られたくない、GitHub などに上げたくないファイルもあります。そのような場合は、.gitignore
というファイルを使うことで特定のファイルやフォルダを Git 管理の対象外にできます。.gitignore
ファイルの最後に.env
を追記しましょう。
...
# Added by cargo
/target
.env
.gitignore
ファイルは便利ですが、既に Git の追跡対象になっているファイルを書いても追跡対象から外れないので注意しましょう。
https://docs.github.com/ja/get-started/getting-started-with-git/ignoring-files
最後に環境変数ファイル.env
を環境変数として読み込むために、ターミナルでsource .env
を実行してください。
WARNING
$ source .env
このコマンドによって読み込んだ環境変数は、コマンドを入力したターミナルを終了すると消えてしまいます。また、コマンドを入力したターミナル以外では環境変数として読み込まれません。新しくターミナルを開きなおした場合などは、もう一度実行してください。
クレートの依存関係を追加する
$ cargo add axum anyhow serde serde_json tokio --features tokio/full,serde/derive
$ cargo add sqlx --features mysql,migrate,chrono,runtime-tokio
実行する
$ cargo run
出力はこのようになります。
connected
Tokyoの人口は7980230人です
main.rs
を解説してきます。
#[derive(sqlx::FromRow)]
#[sqlx(rename_all = "PascalCase")]
#[allow(dead_code)] // 使用していないフィールドへの警告を抑制
struct City {
#[sqlx(rename = "ID")]
id: i32,
name: String,
country_code: String,
district: String,
population: i32,
}
#[derive(sqlx::FromRow)]
を使うことで、SQL 文で取得したレコードを構造体へ変換できるようになります。#[sqlx(rename_all = "PascalCase")]
によって、データベースのカラム名がPascalCase
に変換されます。また、#[sqlx(rename = "ID")]
によって、ID
というカラム名をid
というフィールドに変換しています。
let city = sqlx::query_as::<_, City>("SELECT * FROM city WHERE Name = ?")
.bind("Tokyo")
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => anyhow::anyhow!("no such city Name = {}\n", "Tokyo"),
_ => anyhow::anyhow!("DB error: {}", e),
})?;
sqlx::query_as
により、SQL 文を実行して結果を構造体に変換しています。SQL 文中の ?
に対して、bind
で値を順番に結び付けることができます。 fetch_one
により 1 つのレコードを取得しています。
基本問題
$ cargo run {都市の名前}
と入力して、同様に人口を表示するようにしましょう。
ヒント:コマンドライン引数を受け付ける - The Rust Programming Language 日本語版
答え
use anyhow::Ok;
use sqlx::mysql::MySqlConnectOptions;
use std::env;
// #region city
#[derive(sqlx::FromRow)]
#[sqlx(rename_all = "PascalCase")]
#[allow(dead_code)] // 使用していないフィールドへの警告を抑制
struct City {
#[sqlx(rename = "ID")]
id: i32,
name: String,
country_code: String,
district: String,
population: i32,
}
// #endregion city
fn get_option() -> anyhow::Result<MySqlConnectOptions> {
let host = env::var("DB_HOSTNAME")?;
let port = env::var("DB_PORT")?.parse()?;
let username = env::var("DB_USERNAME")?;
let password = env::var("DB_PASSWORD")?;
let database = env::var("DB_DATABASE")?;
let timezone = Some(String::from("Asia/Tokyo"));
let collation = String::from("utf8mb4_unicode_ci");
Ok(MySqlConnectOptions::new()
.host(&host)
.port(port)
.username(&username)
.password(&password)
.database(&database)
.timezone(timezone)
.collation(&collation))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let options = get_option()?;
let pool = sqlx::MySqlPool::connect_with(options).await?;
let city_name = env::args().nth(1).expect("city name is required");
println!("Connected");
// #region get
let city = sqlx::query_as::<_, City>("SELECT * FROM city WHERE Name = ?")
.bind("Tokyo")
.bind(&city_name)
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => anyhow::anyhow!("no such city Name = {}\n", "Tokyo"),
sqlx::Error::RowNotFound => anyhow::anyhow!("no such city Name = {}\n", &city_name),
_ => anyhow::anyhow!("DB error: {}", e),
})?;
// #endregion get
println!("Tokyoの人口は{}人です", &city.population);
println!("{}の人口は{}人です", &city_name, &city.population);
Ok(())
}
応用問題
基本問題 1 と同様に都市を入力したとき、その都市の人口がその国の人口の何%かを表示してみましょう。
ヒント: 1 回のクエリでも取得できますが、2 回に分けた方が楽に考えられます。
答え
use anyhow::Ok;
use sqlx::mysql::MySqlConnectOptions;
use std::env;
#[derive(sqlx::FromRow)]
#[sqlx(rename_all = "PascalCase")]
#[allow(dead_code)] // 使用していないフィールドへの警告を抑制
struct City {
#[sqlx(rename = "ID")]
id: i32,
name: String,
country_code: String,
district: String,
population: i32,
}
fn get_option() -> anyhow::Result<MySqlConnectOptions> {
let host = env::var("DB_HOSTNAME")?;
let port = env::var("DB_PORT")?.parse()?;
let username = env::var("DB_USERNAME")?;
let password = env::var("DB_PASSWORD")?;
let database = env::var("DB_DATABASE")?;
let timezone = Some(String::from("Asia/Tokyo"));
let collation = String::from("utf8mb4_unicode_ci");
Ok(MySqlConnectOptions::new()
.host(&host)
.port(port)
.username(&username)
.password(&password)
.database(&database)
.timezone(timezone)
.collation(&collation))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let options = get_option()?;
let pool = sqlx::MySqlPool::connect_with(options).await?;
let city_name = env::args().nth(1).expect("city name is required");
println!("Connected");
let city = sqlx::query_as::<_, City>("SELECT * FROM city WHERE Name = ?")
.bind(&city_name)
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => anyhow::anyhow!("no such city Name = {}\n", &city_name),
_ => anyhow::anyhow!("DB error: {}", e),
})?;
println!("{}の人口は{}人です", &city.name, &city.population);
let population: i64 = sqlx::query_scalar("SELECT Population FROM country WHERE Code = ?")
.bind(&city.country_code)
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => {
anyhow::anyhow!("no such country Code = {}\n", &city.country_code)
}
_ => anyhow::anyhow!("DB error: {}", e),
})?;
let percent = city.population as f64 / population as f64 * 100.0;
println!("これは、{}の人口の{:.2}%です", &city.country_code, percent);
Ok(())
}
複数レコードを取得する
fetch_one
関数の代わりにfetch_all
関数を使い、第 1 引数を配列のポインタに変えると、複数レコードを取得できます。main.rs
のmain
関数を以下のように書き換えて実行してみましょう。
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let options = get_option()?;
let pool = sqlx::MySqlPool::connect_with(options).await?;
println!("Connected");
let cities = sqlx::query_as::<_, City>("SELECT * FROM city WHERE CountryCode = ?")
.bind("JPN")
.fetch_all(&pool)
.await?;
println!("日本の都市一覧");
for city in cities {
println!("都市名: {}, 人口: {}", city.name, city.population);
}
Ok(())
}
以下のように日本の都市一覧を取得できます。
connected
日本の都市一覧
都市名: Tokyo, 人口: 7980230
都市名: Jokohama [Yokohama], 人口: 3339594
都市名: Osaka, 人口: 2595674
都市名: Nagoya, 人口: 2154376
都市名: Sapporo, 人口: 1790886
都市名: Kioto, 人口: 1461974
...省略
日本の都市一覧を取得出来たら、スクリーンショットを講習会用チャンネルに投稿しましょう。
レコードを書き換える
INSERT
やUPDATE
、DELETE
を実行したい場合は、query
関数を使うことができます。
let result = sqlx::query("INSERT INTO city (Name, CountryCode, District, Population) VALUES (?, ?, ?, ?)")
.bind(city.name)
.bind(city.country_code)
.bind(city.district)
.bind(city.population)
.execute(&pool)
.await?;
例えばINSERT
ならば、このように使うことができます。return で返ってくるresult
には、INSERT
で何件のレコードが追加されたかなどの情報が入っています。
詳しく知りたい人向け
なぜSQL文で「?
」を使うのか
sqlx で変数を含む SQL を使いたいときは「?
」を使わなくてはいけません。これはセキュリティ上の問題です。例として、国のコードからその国の都市の情報一覧を取得することを考えましょう。format!
を使って SQL 文を作成すると以下のようになります。
sqlx::query_as::<_, City>(
format!("SELECT * FROM city WHERE CountryCode = '{}'", code).as_str(),
)
code
に入っている値がただの国名コードなら問題はないのですが、JPN' OR 'A' = 'A
という値が入っていたらどうなるでしょうか。データベースで実行されるとき、SQL 文は下のようになります。
SELECT * FROM city WHERE CountryCode = 'JPN' OR 'A' = 'A'
OR
でつなげた条件文のうち、「'A' = 'A'
」は常に成り立つので、WHERE
句の条件は常に真です。よって、この SQL を実行すると、作成者が意図しない方法で全ての都市が取得できてしまいます。このような攻撃は「SQL インジェクション」と呼ばれます。
sqlx ではこれを防ぐために?
を使うことができ、SQL 文が意図しない動きをしないようになっています。