Skip to content

Rustでデータベースを扱う

ここからは Rust でプログラムを書いてデータベースを扱っていきます。task upを実行してデータベースが立ち上がっていることを確認してください。 まずは VSCode で先ほどクローンしてきたリポジトリを開きましょう。画像のようなファイルが入っているはずです。 main.rs を開いてください。

データベースに接続する

Rustのプログラムを書く

サンプルのプログラムが書いてありますが、データベースと接続できるように書き換えます。 Rust でデータベースに接続するためのライブラリは様々ありますが、今回は SQL 文を書く SQLx を使います。

rust
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というファイルを作り、以下の内容を書いてください。

sh
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を追記しましょう。

txt
...
# Added by cargo

/target
.env

.gitignoreファイルは便利ですが、既に Git の追跡対象になっているファイルを書いても追跡対象から外れないので注意しましょう。

https://docs.github.com/ja/get-started/getting-started-with-git/ignoring-files

最後に環境変数ファイル.envを環境変数として読み込むために、ターミナルでsource .envを実行してください。

WARNING

sh
$ source .env

このコマンドによって読み込んだ環境変数は、コマンドを入力したターミナルを終了すると消えてしまいます。また、コマンドを入力したターミナル以外では環境変数として読み込まれません。新しくターミナルを開きなおした場合などは、もう一度実行してください。

クレートの依存関係を追加する

sh
$ cargo add axum anyhow serde serde_json tokio --features tokio/full,serde/derive
$ cargo add sqlx --features mysql,migrate,chrono,runtime-tokio

実行する

sh
$ cargo run

出力はこのようになります。

txt
connected
Tokyoの人口は7980230人です

main.rsを解説してきます。

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というフィールドに変換しています。

rs
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 つのレコードを取得しています。

基本問題

sh
$ cargo run {都市の名前}

と入力して、同様に人口を表示するようにしましょう。

ヒント:コマンドライン引数を受け付ける - The Rust Programming Language 日本語版

答え
rs
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 回に分けた方が楽に考えられます。

答え
rs
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.rsmain関数を以下のように書き換えて実行してみましょう。

rs
#[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(())
}

以下のように日本の都市一覧を取得できます。

txt
connected
日本の都市一覧
都市名: Tokyo, 人口: 7980230
都市名: Jokohama [Yokohama], 人口: 3339594
都市名: Osaka, 人口: 2595674
都市名: Nagoya, 人口: 2154376
都市名: Sapporo, 人口: 1790886
都市名: Kioto, 人口: 1461974
...省略

日本の都市一覧を取得出来たら、スクリーンショットを講習会用チャンネルに投稿しましょう。

レコードを書き換える

INSERTUPDATEDELETEを実行したい場合は、query関数を使うことができます。

rs
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 文を作成すると以下のようになります。

rs
sqlx::query_as::<_, City>(
    format!("SELECT * FROM city WHERE CountryCode = '{}'", code).as_str(),
)

codeに入っている値がただの国名コードなら問題はないのですが、JPN' OR 'A' = 'Aという値が入っていたらどうなるでしょうか。データベースで実行されるとき、SQL 文は下のようになります。

sql
SELECT * FROM city WHERE CountryCode = 'JPN' OR 'A' = 'A'

ORでつなげた条件文のうち、「'A' = 'A'」は常に成り立つので、WHERE句の条件は常に真です。よって、この SQL を実行すると、作成者が意図しない方法で全ての都市が取得できてしまいます。このような攻撃は「SQL インジェクション」と呼ばれます。

sqlx ではこれを防ぐために?を使うことができ、SQL 文が意図しない動きをしないようになっています。