Cargo: プロジェクト作成、依存関係管理、ワークスペース構成

1. 概要

CargoはRustの公式ビルドツールおよびパッケージマネージャーです。プロジェクトを作成し、依存関係を取得し、コンパイルし、テストを実行し、同じリポジトリ内の複数のcrateを管理します。

このガイドではCargoを使う3つの日常的なタスクを紹介します。第1は、バイナリとライブラリプロジェクトの作成です。第2は、依存関係の追加と設定です。第3は、共有設定を持つ複数のcrateで構成されるワークスペースの構成です。読み終わると、複数のモジュールを持つRustプロジェクトを構築できるようになり、依存関係のバージョンをあちこちにコピーする必要がなくなります。

2. 新しいプロジェクトの作成

2.1. バイナリとライブラリ

cargo newコマンドは新しいcrateを作成します。デフォルトではバイナリcrateで、つまりmain関数を持つプログラムです:

cargo new order-service
cd order-service

生成されるディレクトリ構造:

order-service/
├── Cargo.toml
├── .gitignore
└── src/
    └── main.rs

他のcrateが使用するライブラリを作成したい場合は、--libフラグを追加します:

cargo new pricing --lib

この場合、Cargoはsrc/main.rsの代わりにsrc/lib.rsを作成します。1つのcrateは両方のファイルを持つことができます。その場合、main.rsはcrateの名前を通じてlib.rs内のロジックを呼び出します。

2.2. Cargo.tomlファイル

Cargo.tomlはcrateのマニフェストです:

[package]
name = "order-service"
version = "0.1.0"
edition = "2024"

[dependencies]
  • name: crateの名前。名前内の-はコードで使用する場合、_になります。例えば、order_service。
  • version: セマンティックバージョニングに従ったバージョン。
  • edition: 言語のバージョン。Edition 2024はRust 1.85以降で利用可能で、cargo newは現在のツールチェーンでそれをデフォルトとして使用します。
  • [dependencies]: 外部crateのリスト。現在は空です。

2.3. 基本的なbuildコマンド

cargo check        # コンパイルエラーをチェック、実行ファイルは生成しない
cargo build        # target/debug にデバッグビルドを出力
cargo run          # ビルドして実行
cargo build --release   # target/release に最適化版を出力

コードを書いているときは、cargo checkを使用する必要があります。このコマンドは機械コード生成のステップをスキップするため、cargo buildよりもはるかに高速です。パフォーマンスの測定またはデプロイ用のビルドのときのみ--releaseを実行してください。デバッグビルドはリリースビルドよりも数十倍遅い場合があります。

3. 依存関係の追加

3.1. cargo addの使用

OrderをJSONにシリアル化する必要があると仮定します。このための一般的なcrateはserdeとserde_jsonです:

cargo add serde --features derive
cargo add serde_json

Cargoは自動的にCargo.tomlを修正します:

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

このファイルを手動で編集することもできます。cargo addはタイプミスを避け、互換性のある最新バージョンを自動的に選択するのに役立つだけです。

3.2. バージョン要件の意味

文字列"1.0"はバージョン1.0.0を正確に意味しないことに注意してください。Cargoはこれを^1.0として解釈し、つまり>= 1.0.0および< 2.0.0のすべてのバージョンです。他の書き方:

[dependencies]
regex = "1.10"          # >= 1.10.0, < 2.0.0
chrono = "=0.4.38"      # 正確に 0.4.38
rand = "~0.8.5"         # >= 0.8.5, < 0.9.0
uuid = ">=1.4, <1.9"    # カスタム範囲

バージョン0.xの場合、Cargoは2番目の数字を「メジャー」部分と見なします。したがって、"0.4"は0.4.*のみを許可し、0.5にアップグレードしません。

実際に使用されるバージョンはCargo.lockに含まれています。Cargoは最初のビルド時にこのファイルを作成します。その後のビルドでは、以下を実行するまでこれらのバージョンを正確に使用します:

cargo update              # 許可された範囲内で全依存関係を更新
cargo update -p serde     # serde のみを更新

3.3. コード内での依存関係の使用

src/main.rsを編集します:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct Order {
    id: u64,
    customer: String,
    total_cents: u64,
}

fn main() {
    let order = Order {
        id: 42,
        customer: "An".to_string(),
        total_cents: 150_000,
    };

    let json = serde_json::to_string(&order).expect("serialize Order");
    println!("{json}");

    let parsed: Order = serde_json::from_str(&json).expect("deserialize Order");
    println!("{parsed:?}");
}
  • #[derive(Serialize, Deserialize)]はserdeのderive featureをオンにした場合のみ利用可能です。このfeatureがないと、コードはマクロが見つからないとエラーを報告します。
  • serde_json::to_stringはstructをJSON文字列に変換します。
  • serde_json::from_strは逆の操作を行います。ターゲット型はOrderアノテーションから推論されます。

cargo runを実行します:

{"id":42,"customer":"An","total_cents":150000}
Order { id: 42, customer: "An", total_cents: 150000 }

3.4. その他の依存関係の種類

[dependencies]の他に、Cargoには一般的に使用される2つのグループがあります:

[dev-dependencies]
pretty_assertions = "1.4"   # テスト、サンプル、ベンチマーク実行時のみ

[build-dependencies]
cc = "1.0"                  # build.rs 用のみ

[dev-dependencies]のcrateは最終バイナリに含まれず、ライブラリのユーザーに影響を与えません。テストのみに役立つライブラリをここに配置してください。

また、依存関係をローカルディレクトリまたはGitに指すこともできます:

[dependencies]
pricing = { path = "../pricing" }
payment-sdk = { git = "https://example.com/payment-sdk.git", tag = "v2.1.0" }

4. ワークスペースの構成

プロジェクトが大きくなるにつれて、それを複数のcrateに分割したいと思うようになります。例えば、ビジネスロジックを含むdomain crateと実行プログラムであるapi crateなど。ワークスペースを使用すると、これらのcrateは共有Cargo.lock、共有target/ディレクトリ、および共有の依存関係バージョンを使用できます。

4.1. ルートワークスペースの作成

mkdir shop && cd shop

shop/Cargo.tomlを作成します:

[workspace]
resolver = "3"
members = ["crates/*"]

[workspace.package]
version = "0.1.0"
edition = "2024"

[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
  • このファイルには[package]がないため、仮想マニフェストです。サブcrateを統合するためにのみ使用されます。
  • 仮想マニフェストではresolver = "3"を明示的に宣言する必要があります。[package]がないため、Cargoはeditionからresolverを推論できず、この行を省略するとその旨の警告が表示されます。
  • members = ["crates/*"]はcrates/ディレクトリ内のすべてのcrateをまとめます。
  • [workspace.package]と[workspace.dependencies]は、サブcrateが継承するための共有値です。

2つのサブcrateを作成します:

cargo new crates/domain --lib
cargo new crates/api

得られるディレクトリ構造:

shop/
├── Cargo.toml
├── Cargo.lock
├── target/
└── crates/
    ├── domain/
    │   ├── Cargo.toml
    │   └── src/lib.rs
    └── api/
        ├── Cargo.toml
        └── src/main.rs

4.2. ワークスペースから設定を継承

crates/domain/Cargo.toml:

[package]
name = "domain"
version.workspace = true
edition.workspace = true

[dependencies]
serde.workspace = true

crates/api/Cargo.toml:

[package]
name = "api"
version.workspace = true
edition.workspace = true

[dependencies]
domain = { path = "../domain" }
serde_json.workspace = true

serde.workspace = trueはこのcrateが[workspace.dependencies]からバージョンとfeatureを取得することを意味します。serdeをアップグレードする必要がある場合、ルートファイルの1か所だけを編集します。

サブcrateは追加のfeatureを有効にすることができます:

[dependencies]
serde = { workspace = true, features = ["rc"] }

ここのfeatureはワークスペースで宣言されたfeatureに追加され、それらを置き換えるのではありません。

4.3. domain crate内のコード

crates/domain/src/lib.rs:

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderLine {
    pub product: String,
    pub unit_price_cents: u64,
    pub quantity: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Order {
    pub id: u64,
    pub customer: String,
    pub lines: Vec<OrderLine>,
}

impl Order {
    pub fn total_cents(&self) -> u64 {
        self.lines
            .iter()
            .map(|line| line.unit_price_cents * u64::from(line.quantity))
            .sum()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn line(product: &str, price: u64, qty: u32) -> OrderLine {
        OrderLine {
            product: product.to_string(),
            unit_price_cents: price,
            quantity: qty,
        }
    }

    #[test]
    fn total_sums_all_lines() {
        let order = Order {
            id: 1,
            customer: "Binh".to_string(),
            lines: vec![line("Coffee", 35_000, 2), line("Cake", 25_000, 1)],
        };

        assert_eq!(order.total_cents(), 95_000);
    }

    #[test]
    fn empty_order_has_zero_total() {
        let order = Order { id: 2, customer: "Chi".to_string(), lines: vec![] };

        assert_eq!(order.total_cents(), 0);
    }
}
  • struct型とフィールドのpubにより、crateapiはそれらにアクセスできます。
  • u64::from(line.quantity)はu32をu64に変換し、データは失われません。
  • #[cfg(test)]により、testモジュールはcargo testを実行する場合にのみコンパイルされます。

domain crateのテストのみを実行します:

cargo test -p domain
running 2 tests
test tests::empty_order_has_zero_total ... ok
test tests::total_sums_all_lines ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

ルートディレクトリからcargo testを実行すると、ワークスペース内のすべてのcrateのテストが実行されます。

4.4. apiからdomainを使用

crates/api/src/main.rs:

use domain::{Order, OrderLine};

fn main() {
    let order = Order {
        id: 7,
        customer: "Dung".to_string(),
        lines: vec![OrderLine {
            product: "Tea".to_string(),
            unit_price_cents: 20_000,
            quantity: 3,
        }],
    };

    println!("Total: {}", order.total_cents());
    println!("{}", serde_json::to_string_pretty(&order).expect("serialize"));
}

ルートディレクトリから:

cargo run -p api
Total: 60000
{
  "id": 7,
  "customer": "Dung",
  "lines": [
    {
      "product": "Tea",
      "unit_price_cents": 20000,
      "quantity": 3
    }
  ]
}

crateapiはserdeを宣言していませんが、Orderをシリアル化することはできます。これは、traitSerializeがcratedomainの内部にすでに実装されているためです。apiは変換関数を呼び出すためにserde_jsonが必要なだけです。

4.5. ワークスペース内の便利なコマンド

cargo build --workspace     # すべてのcrateをビルド
cargo check -p api          # crateapi とその依存関係のみをチェック
cargo tree -p api           # 依存関係ツリーを表示
cargo tree -d               # 重複するcrateを一覧表示

cargo tree -dはバイナリのサイズが異常に大きい場合に非常に便利です。結果は通常、同じcrateの2つのバージョンが含まれていることを示します。

5. よくあるエラー

注意: Cargoのfeatureは統合されます(feature unification)。crateAがtokio/fullを有効にし、crateB がtokio/rtのみが必要だと仮定します。ワークスペース内で共にビルドする場合、Bもfullを受け取ります。ワークスペース内で実行されるコードはBを個別にビルドするときにエラーになる可能性があります。CIで各crateに対してcargo check -p <crate-name>を実行してください。

  • featureを有効にするのを忘れる。 「cannot find derive macro Serialize」というエラーはほぼ常にfeatures = ["derive"]の欠落が原因です。
  • Cargo.lockをコミットしない。 Cargoの現在のガイダンスでは、バイナリとライブラリの両方でこのファイルをコミットすることをお勧めしています。CIでのビルドはこれにより実行間で再現可能になります。
  • パブリッシュ時のパス依存関係。 domain = { path = "../domain" }はversionがない場合、crates.ioにパブリッシュできません。パブリッシュするには、両方を宣言します: { path = "../domain", version = "0.1.0" }。
  • ネストされたワークスペース。 1つのcrateは1つのワークスペースにのみ属しています。別のワークスペースのサブディレクトリ内でcargo newを実行すると、Cargoはエラーを報告するか、crateをmembersに自動的に追加します。作成後、ルートファイルを確認してください。
  • ワークスペースの過度な使用。 小さなプログラムの場合、1つのcrate内でモジュールを分割するだけで十分です(mod order;)。crateを分割するとマニフェストの数と構成時間が増加します。部分が明確な境界を持つ場合、個別のビルドまたはテストが必要な場合、または再利用が必要な場合にのみワークスペースを使用してください。

6. まとめ

このガイドではCargoの3つのコアタスクを説明しました:

  • cargo newでバイナリまたはライブラリプロジェクトを作成し、cargo checkを使用して高速なフィードバックループを実現します。
  • cargo addで依存関係を追加し、バージョン要件とCargo.lockがどのように実際のバージョンを決定するかを理解し、dev-dependenciesをメイン依存関係から分離します。
  • 複数のcrateを1つのワークスペースにまとめ、[workspace.dependencies]で共有バージョンを宣言し、workspace = trueで継承します。

プロジェクトが成長するにつれて、1つのシンプルな原則を守る必要があります:すべての依存関係バージョンはルートファイルに配置し、各crateは実際に使用するものだけを宣言します。この方法により、アップグレードとバイナリサイズの制御がはるかに簡単になります。

rustcargodependencyworkspacebuild-tool