Cargo: tạo dự án, quản lý dependency và tổ chức workspace

1. Tổng quan

Cargo là build tool và package manager chính thức của Rust. Nó tạo dự án, tải dependency, biên dịch, chạy test và quản lý nhiều crate trong cùng một repository.

Bài này đi qua ba việc bạn làm hằng ngày với Cargo. Thứ nhất là tạo dự án binary và library. Thứ hai là thêm và cấu hình dependency. Thứ ba là tổ chức một workspace gồm nhiều crate dùng chung cấu hình. Đọc xong, bạn có thể dựng một dự án Rust nhiều module mà không phải sao chép version dependency khắp nơi.

2. Tạo dự án mới

2.1. Binary và library

Lệnh cargo new tạo một crate mới. Mặc định đó là một binary crate, tức chương trình có hàm main:

cargo new order-service
cd order-service

Cấu trúc thư mục sinh ra:

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

Nếu muốn viết thư viện cho crate khác dùng, bạn thêm cờ --lib:

cargo new pricing --lib

Lúc này Cargo tạo src/lib.rs thay cho src/main.rs. Một crate có thể có cả hai file. Khi đó main.rs gọi vào logic trong lib.rs qua tên crate.

2.2. File Cargo.toml

Cargo.toml là manifest của crate:

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

[dependencies]
  • name: tên crate. Dấu - trong tên sẽ thành _ khi bạn dùng trong code, ví dụ order_service.
  • version: version theo Semantic Versioning.
  • edition: phiên bản ngôn ngữ. Edition 2024 có từ Rust 1.85, và cargo new dùng nó làm mặc định trên toolchain hiện tại.
  • [dependencies]: danh sách crate bên ngoài, hiện đang trống.

2.3. Các lệnh build cơ bản

cargo check        # kiểm tra lỗi biên dịch, không sinh file thực thi
cargo build        # build bản debug vào target/debug
cargo run          # build rồi chạy
cargo build --release   # build tối ưu vào target/release

Khi đang viết code, bạn nên dùng cargo check. Lệnh này nhanh hơn cargo build nhiều vì bỏ qua bước sinh mã máy. Chỉ chạy --release khi đo hiệu năng hoặc build để triển khai. Bản debug có thể chậm hơn bản release hàng chục lần.

3. Thêm dependency

3.1. Dùng cargo add

Giả sử bạn cần serialize một Order sang JSON. Hai crate phổ biến cho việc này là serde và serde_json:

cargo add serde --features derive
cargo add serde_json

Cargo tự sửa Cargo.toml:

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

Bạn có thể sửa file này bằng tay. cargo add chỉ giúp bạn tránh gõ sai và tự chọn version mới nhất tương thích.

3.2. Version requirement nghĩa là gì

Chuỗi "1.0" không có nghĩa là đúng version 1.0.0. Cargo hiểu nó là ^1.0, tức mọi version >= 1.0.0 và < 2.0.0. Một số cách viết khác:

[dependencies]
regex = "1.10"          # >= 1.10.0, < 2.0.0
chrono = "=0.4.38"      # đúng 0.4.38
rand = "~0.8.5"         # >= 0.8.5, < 0.9.0
uuid = ">=1.4, <1.9"    # khoảng tùy chỉnh

Với version 0.x, Cargo coi số thứ hai là phần “major”. Vì vậy "0.4" chỉ cho phép 0.4.* và không nâng lên 0.5.

Version thực sự được dùng nằm trong Cargo.lock. Cargo ghi file này ở lần build đầu tiên. Các lần build sau sẽ dùng đúng những version đó cho tới khi bạn chạy:

cargo update              # cập nhật mọi dependency trong khoảng cho phép
cargo update -p serde     # chỉ cập nhật serde

3.3. Dùng dependency trong code

Sửa 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)] chỉ có được khi bật feature derive của serde. Thiếu feature này, code báo lỗi không tìm thấy macro.
  • serde_json::to_string chuyển struct thành chuỗi JSON.
  • serde_json::from_str làm chiều ngược lại. Kiểu đích được suy ra từ annotation Order.

Chạy cargo run:

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

3.4. Các loại dependency khác

Ngoài [dependencies], Cargo còn hai nhóm thường dùng:

[dev-dependencies]
pretty_assertions = "1.4"   # chỉ dùng khi chạy test, example, benchmark

[build-dependencies]
cc = "1.0"                  # chỉ dùng cho build.rs

Crate trong [dev-dependencies] không bị kéo vào binary cuối và không ảnh hưởng tới người dùng thư viện của bạn. Hãy đặt các thư viện chỉ phục vụ test vào đây.

Bạn cũng có thể trỏ dependency tới thư mục cục bộ hoặc Git:

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

4. Tổ chức workspace

Khi dự án lớn dần, bạn sẽ muốn tách nó thành nhiều crate. Ví dụ: một crate domain chứa logic nghiệp vụ, một crate api là chương trình chạy. Workspace cho phép các crate này dùng chung Cargo.lock, chung thư mục target/ và chung version dependency.

4.1. Tạo workspace gốc

mkdir shop && cd shop

Tạo 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"
  • File này không có [package], nên nó là một virtual manifest. Nó chỉ dùng để gom các crate con.
  • resolver = "3" phải khai báo rõ trong virtual manifest. Không có [package] thì Cargo không suy được resolver từ edition, và sẽ cảnh báo nếu bạn bỏ dòng này.
  • members = ["crates/*"] gom mọi crate nằm trong thư mục crates/.
  • [workspace.package] và [workspace.dependencies] là các giá trị dùng chung để crate con kế thừa.

Tạo hai crate con:

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

Cấu trúc thu được:

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

4.2. Kế thừa cấu hình từ workspace

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 nghĩa là crate này lấy version và feature từ [workspace.dependencies]. Khi cần nâng serde, bạn chỉ sửa đúng một chỗ ở file gốc.

Crate con vẫn có thể bật thêm feature riêng:

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

Feature ở đây được cộng thêm vào feature khai báo ở workspace, không thay thế chúng.

4.3. Code trong crate domain

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);
    }
}
  • pub trên struct và field giúp crate api truy cập được chúng.
  • u64::from(line.quantity) đổi u32 sang u64 mà không mất dữ liệu.
  • #[cfg(test)] đảm bảo module test chỉ được biên dịch khi chạy cargo test.

Chạy test cho riêng crate domain:

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

Chạy cargo test ở thư mục gốc sẽ chạy test của mọi crate trong workspace.

4.4. Dùng domain từ api

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"));
}

Từ thư mục gốc:

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

Crate api không khai báo serde, nhưng vẫn serialize được Order. Lý do là trait Serialize đã được implement sẵn bên trong crate domain. api chỉ cần serde_json để gọi hàm chuyển đổi.

4.5. Lệnh hữu ích trong workspace

cargo build --workspace     # build mọi crate
cargo check -p api          # chỉ kiểm tra crate api và dependency của nó
cargo tree -p api           # xem cây dependency
cargo tree -d               # liệt kê crate bị trùng nhiều version

cargo tree -d rất có ích khi binary phình to bất thường. Kết quả thường cho thấy có hai version của cùng một crate bị kéo vào.

5. Lỗi hay gặp

Lưu ý: Feature trong Cargo được hợp nhất (feature unification). Giả sử crate A bật tokio/full và crate B chỉ cần tokio/rt. Khi build chung trong workspace, B cũng nhận đủ full. Code chạy được trong workspace có thể lỗi khi bạn build B riêng lẻ. Hãy chạy cargo check -p <tên crate> cho từng crate trong CI.

  • Quên bật feature. Lỗi kiểu “cannot find derive macro Serialize” gần như luôn do thiếu features = ["derive"].
  • Không commit Cargo.lock. Hướng dẫn hiện tại của Cargo khuyên commit file này cho cả binary lẫn library. Build trong CI nhờ vậy lặp lại được giữa các lần chạy.
  • Path dependency khi publish. domain = { path = "../domain" } không publish lên crates.io được nếu thiếu version. Muốn publish, bạn khai báo cả hai: { path = "../domain", version = "0.1.0" }.
  • Workspace lồng nhau. Một crate chỉ thuộc về một workspace. Nếu bạn cargo new bên trong thư mục con của một workspace khác, Cargo sẽ báo lỗi hoặc tự thêm crate vào members. Hãy kiểm tra lại file gốc sau khi tạo.
  • Lạm dụng workspace. Với một chương trình nhỏ, bạn chỉ cần chia module trong một crate (mod order;). Tách crate làm tăng số lượng manifest và thời gian cấu hình. Hãy dùng workspace khi các phần thật sự có ranh giới rõ, cần build hoặc test riêng, hoặc cần tái sử dụng.

6. Kết luận

Bài viết đã đi qua ba việc cốt lõi với Cargo:

  • Tạo dự án binary hoặc library bằng cargo new, và dùng cargo check để có vòng phản hồi nhanh.
  • Thêm dependency bằng cargo add, hiểu cách version requirement và Cargo.lock cùng quyết định version thực tế, và tách dev-dependencies khỏi dependency chính.
  • Gom nhiều crate vào một workspace, khai báo version dùng chung ở [workspace.dependencies] rồi kế thừa bằng workspace = true.

Khi dự án tăng trưởng, bạn nên giữ một nguyên tắc đơn giản: mọi version dependency nằm ở file gốc, mỗi crate chỉ khai báo những gì nó thực sự dùng. Cách này giúp việc nâng cấp và kiểm soát kích thước binary dễ hơn nhiều.

rustcargodependencyworkspacebuild-tool