じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

退屈なことはRust Build Scripts にやらせよう

こちらの記事は Rust Advent Calendar 2024 シリーズ 3 3日目の記事です!

qiita.com

はじめに

Rustのビルドスクリプト(build.rs)は、コンパイル前のデータ処理や環境設定を自動化する強力なツールです。しかし、大いなる力には、大いなる責任が伴います。コードの生成、リソースの最適化、プラットフォーム固有の設定管理など、ビルド時の様々なタスクを効率的に処理できます。今回は、そのユースケースの1つとして、郵便番号データを処理するビルドスクリプトの実装を詳しく解説します。この例を通じて、build.rsの基本的な使い方から実践的な活用方法まで、段階的に理解を深めていきましょう。

doc.rust-lang.org

ユースケース:郵便番号データの処理

このビルドスクリプトは、複数のJSONファイルに分散された郵便番号データを1つのファイルにマージする処理を行います。

github.com

実装の全体像

use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use walkdir::WalkDir;

fn main() {
    println!("cargo:rerun-if-changed=jpostcode-data/data/json");

    let json_dir = Path::new("jpostcode-data/data/json");
    let out_dir = std::env::var("OUT_DIR").unwrap();
    let dest_path = Path::new(&out_dir).join("address_data.json");

    let mut merged_data = HashMap::new();
    // ... データ処理ロジック ...
}

実装の詳細解説

1. ファイル変更の監視設定

println!("cargo:rerun-if-changed=jpostcode-data/data/json");

この行は、指定したディレクトリ内のファイルが変更された場合にのみビルドスクリプトを再実行するように設定します。これにより、不必要なビルド時間を削減できます。

2. パスの設定

let json_dir = Path::new("jpostcode-data/data/json");
let out_dir = std::env::var("OUT_DIR").unwrap();
let dest_path = Path::new(&out_dir).join("address_data.json");
  • json_dir: 入力となるJSONファイルが格納されているディレクト
  • out_dir: Cargoが提供するビルド出力ディレクト
  • dest_path: 生成されるファイルの出力先

3. データの処理

for entry in WalkDir::new(json_dir).into_iter().filter_map(|e| e.ok()) {
    if entry.file_type().is_file()
        && entry.path().extension().map_or(false, |ext| ext == "json")
    {
        let content = fs::read_to_string(entry.path()).unwrap();
        let file_data: HashMap<String, Value> = serde_json::from_str(&content).unwrap();

        let prefix = entry.path().file_stem().unwrap().to_str().unwrap();
        for (suffix, data) in file_data {
            let full_postcode = format!("{}{}", prefix, suffix);
            merged_data.insert(full_postcode, data);
        }
    }
}

このコードブロックでは以下の処理を行っています。

  1. WalkDirを使用してディレクトリを再帰的に走査
  2. JSONファイルのみを対象にフィルタリング
  3. 各ファイルの内容を読み込みとパース
  4. ファイル名とデータを組み合わせて完全な郵便番号を生成
  5. マージされたデータに追加

4. 結果の出力

fs::write(dest_path, serde_json::to_string(&merged_data).unwrap()).unwrap();

処理したデータを1つのJSONファイルとして出力します。

生成したデータの利用方法

1. アプリケーションでのデータ読み込み

use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Debug, Serialize, Deserialize)]
struct Address {
    postcode: String,
    prefecture: String,
    city: String,
    // ... 他のフィールド
}

static ADDRESS_MAP: Lazy<HashMap<String, Vec<Address>>> = Lazy::new(|| {
    let data = include_str!(concat!(env!("OUT_DIR"), "/address_data.json"));
    serde_json::from_str(data).expect("Failed to parse address data")
});

2. 検索機能の実装

fn lookup_address(postal_code: &str) -> Option<&Vec<Address>> {
    ADDRESS_MAP.get(postal_code)
}

fn search_by_prefecture(prefecture: &str) -> Vec<&Address> {
    ADDRESS_MAP
        .values()
        .flat_map(|addresses| addresses.iter())
        .filter(|addr| addr.prefecture == prefecture)
        .collect()
}

build.rsの主要な機能

1. 環境変数の設定

// コンパイル時の条件設定
println!("cargo:rustc-cfg=feature=\"custom_feature\"");

// 環境変数の設定
println!("cargo:rustc-env=APP_VERSION=1.0.0");

2. リンカ設定

// 外部ライブラリのリンク
println!("cargo:rustc-link-lib=sqlite3");
println!("cargo:rustc-link-search=native=/usr/local/lib");

3. コードの生成

// バージョン情報の生成
let version_code = format!(
    "pub const VERSION: &str = \"{}\";\n",
    env!("CARGO_PKG_VERSION")
);
fs::write("version.rs", version_code)?;

実践的な利用シーン

1. 設定ファイルの統合と生成

複数の環境向けの設定ファイルを1つに統合する例:

use std::collections::HashMap;
use serde_json::Value;

fn main() {
    println!("cargo:rerun-if-changed=config/");
    
    let environments = ["development", "staging", "production"];
    let mut merged_config = HashMap::new();
    
    for env in environments {
        let config_path = format!("config/{}.json", env);
        let config_content = std::fs::read_to_string(&config_path).unwrap();
        let config: Value = serde_json::from_str(&config_content).unwrap();
        
        merged_config.insert(env, config);
    }
    
    let out_dir = std::env::var("OUT_DIR").unwrap();
    let dest_path = Path::new(&out_dir).join("config.rs");
    
    // 設定をRustのコードとして出力
    let config_code = format!(
        "pub static CONFIG: Lazy<HashMap<&str, Value>> = Lazy::new(|| {{
            serde_json::from_str({}).unwrap()
        }});",
        serde_json::to_string(&merged_config).unwrap()
    );
    
    std::fs::write(dest_path, config_code).unwrap();
}

使用例:

// main.rs
use once_cell::sync::Lazy;
include!(concat!(env!("OUT_DIR"), "/config.rs"));

fn get_database_url(env: &str) -> String {
    CONFIG[env]["database"]["url"].as_str().unwrap().to_string()
}

2. プロトコル定義ファイルの生成

Protocol Buffersの定義ファイルからRustコードを生成する例:

use std::process::Command;

fn main() {
    println!("cargo:rerun-if-changed=proto/");
    
    // protoファイルのコンパイル
    let status = Command::new("protoc")
        .args(&[
            "--rust_out=src/generated",
            "--proto_path=proto",
            "service.proto"
        ])
        .status()
        .unwrap();
        
    if !status.success() {
        panic!("Failed to compile proto files");
    }
    
    // 生成されたコードをモジュールとして登録
    let mod_content = r#"
        pub mod generated {
            include!("generated/service.rs");
        }
    "#;
    
    std::fs::write("src/proto_mod.rs", mod_content).unwrap();
}

使用例:

// lib.rs
mod proto_mod;
use proto_mod::generated::{UserRequest, UserResponse};

pub async fn handle_user_request(req: UserRequest) -> UserResponse {
    // プロトコル定義に基づいた処理
}

3. アセットファイルの埋め込み

画像やテキストファイルをバイナリに埋め込む例:

use std::collections::HashMap;
use base64;

fn main() {
    println!("cargo:rerun-if-changed=assets/");
    
    let mut assets = HashMap::new();
    
    // 画像ファイルの埋め込み
    for entry in std::fs::read_dir("assets").unwrap() {
        let entry = entry.unwrap();
        let path = entry.path();
        
        if path.extension().map_or(false, |ext| ext == "png" || ext == "jpg") {
            let content = std::fs::read(&path).unwrap();
            let encoded = base64::encode(&content);
            
            let asset_name = path.file_name().unwrap().to_str().unwrap();
            assets.insert(asset_name.to_string(), encoded);
        }
    }
    
    // アセットデータをRustコードとして出力
    let out_dir = std::env::var("OUT_DIR").unwrap();
    let dest_path = Path::new(&out_dir).join("assets.rs");
    
    let assets_code = format!(
        "pub static ASSETS: Lazy<HashMap<String, String>> = Lazy::new(|| {{
            let mut m = HashMap::new();
            {}
            m
        }});",
        assets.iter().map(|(k, v)| {
            format!("m.insert(\"{}\".to_string(), \"{}\".to_string());", k, v)
        }).collect::<Vec<_>>().join("\n")
    );
    
    std::fs::write(dest_path, assets_code).unwrap();
}

使用例:

// lib.rs
use once_cell::sync::Lazy;
include!(concat!(env!("OUT_DIR"), "/assets.rs"));

pub fn get_image_data(name: &str) -> Option<Vec<u8>> {
    ASSETS.get(name)
        .map(|encoded| base64::decode(encoded).unwrap())
}

4. データベースマイグレーションファイルの統合

SQLマイグレーションファイルを1つのモジュールにまとめる例:

fn main() {
    println!("cargo:rerun-if-changed=migrations/");
    
    let mut migrations = Vec::new();
    
    // マイグレーションファイルの収集
    for entry in std::fs::read_dir("migrations").unwrap() {
        let entry = entry.unwrap();
        let path = entry.path();
        
        if path.extension().map_or(false, |ext| ext == "sql") {
            let version = path.file_stem().unwrap().to_str().unwrap()
                .split('_').next().unwrap();
            let content = std::fs::read_to_string(&path).unwrap();
            
            migrations.push((version.to_string(), content));
        }
    }
    
    // マイグレーションをRustコードとして出力
    let migrations_code = format!(
        "pub static MIGRATIONS: &[(&str, &str)] = &[{}];",
        migrations.iter()
            .map(|(ver, sql)| format!("(\"{}\", \"{}\")", ver, sql.replace("\"", "\\\"")))
            .collect::<Vec<_>>()
            .join(",\n")
    );
    
    let out_dir = std::env::var("OUT_DIR").unwrap();
    let dest_path = Path::new(&out_dir).join("migrations.rs");
    std::fs::write(dest_path, migrations_code).unwrap();
}

使用例:

// database.rs
include!(concat!(env!("OUT_DIR"), "/migrations.rs"));

pub async fn run_migrations(db: &SqlitePool) -> Result<()> {
    for (version, sql) in MIGRATIONS {
        db.execute(sql).await?;
        println!("Applied migration version {}", version);
    }
    Ok(())
}

これらの例は、build.rsの実践的な使用方法を示しています。各例で以下のような利点があります。

  • コンパイル時のリソース最適化
  • 開発時の利便性向上
  • ランタイムパフォーマンスの改善
  • コードの保守性向上

実際のプロジェクトでは、これらの手法を組み合わせたり、プロジェクトの要件に合わせてカスタマイズしたりすることで、より効率的な開発環境を構築できます。しかし、魔環境もしくはビルド地獄を顕現させることもできるので注意が必要だと思いました。

参考資料

まとめ

このビルドスクリプトの実装例を通じて、build.rsの有用性が明確になりました。コンパイル時のデータ最適化複数ファイルの統合処理動的なコード生成、そしてプラットフォーム固有の設定管理など、多岐にわたる機能を提供します。実際のプロジェクトでは、これらの機能を組み合わせることで、効率的な開発環境とビルドプロセスを実現できます。build.rsを活用することで、コンパイル時に必要なリソースの最適化や設定の自動化が可能となり、開発効率の向上とコードの保守性改善に大きく貢献します。