Danh sách bài viết

Bài 261: Dependencies Versioning — SemVer & Cargo

Bài 261 của series Rust Cơ Bản — mỗi lần bạn viết serde = "1.0" trong Cargo.toml, Cargo đang chạy một solver dependency dựa trên chuẩn Semantic Versioning (SemVer). Bài này mổ xẻ chính xác cú pháp version spec mà Cargo hỗ trợ: caret ^1.2.3 (mặc định, cho phép update trong cùng major), tilde ~1.2.3 (chỉ cho update patch), exact =1.2.3 (pin chính xác), và wildcard */1.* (Cargo từ chối publish). Đồng thời giải thích quy tắc đặc biệt với crate pre-1.0 (mỗi bump minor coi như breaking change), cơ chế cargo update chỉ chạy trong phạm vi SemVer, và phân biệt vai trò Cargo.toml (ghi requirement) vs Cargo.lock (ghi snapshot exact). Hiểu đúng những quy tắc này giúp bạn tránh được hai cạm bẫy phổ biến: dependency tự động lên major mới bẻ build, hoặc pin quá chặt khiến không nhận được patch bảo mật.

10/06/2026
10 phút đọc
0 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu cấu trúc major.minor.patch của Semantic Versioning và ý nghĩa của mỗi vị trí.
  • Biết 4 dạng version spec trong Cargo: caret ^ (mặc định), tilde ~, exact =, wildcard * — kèm range cụ thể mỗi operator dịch ra.
  • Hiểu vì sao crate pre-1.0 (version 0.x.y) bị Cargo treat khác biệt: mỗi bump 0.x coi như breaking.
  • Biết khi nào Cargo từ chối publish (wildcard) và khi nào chấp nhận pin chính xác.
  • Phân biệt rạch ròi vai trò Cargo.toml (requirement, dải version chấp nhận) vs Cargo.lock (snapshot exact version đang dùng).
  • Biết cách dùng cargo updatecargo update -p <crate> --precise <ver> để control upgrade trong phạm vi SemVer.
2

SemVer Là Gì

Semantic Versioning (semver.org) là quy ước đánh số version theo cấu trúc MAJOR.MINOR.PATCH, mỗi vị trí mang một ý nghĩa cố định:

  • MAJOR: tăng khi có breaking change — API public thay đổi không tương thích ngược (xoá function, đổi signature, đổi struct field public).
  • MINOR: tăng khi thêm feature mới tương thích ngược — function mới, trait mới, default field mới (với #[non_exhaustive]).
  • PATCH: tăng khi fix bug mà không đổi API — sửa logic, vá bảo mật, tối ưu performance.

Ví dụ serde 1.0.219: major 1, minor 0, patch 219. Đã release 220 lần patch trong cùng major 1 — chứng tỏ tác giả tuân thủ SemVer nghiêm túc, ai pin ^1.0 đều nhận được vá bug miễn phí qua nhiều năm.

Có thêm phần optional sau dấu hyphen cho pre-release: 1.0.0-alpha.1, 2.5.0-rc.3. Cargo treat pre-release đặc biệt — chỉ match khi yêu cầu cụ thể ghi pre-release, không bao giờ tự upgrade từ stable lên pre-release.

SemVer chỉ là contract giữa tác giả và consumer. Cargo không cách nào ép tác giả tuân thủ — vẫn có thể bump patch nhưng vô tình bẻ API. Vì thế chọn dependency có maintainer uy tín quan trọng hơn rất nhiều việc tin vào số version.

3

Major Bump = Breaking Change

Tâm điểm của toàn bộ SemVer nằm ở quy tắc: major bump = consumer phải đọc changelog và sửa code. Ngược lại minor/patch bump phải luôn an toàn để update tự động.

Một số thay đổi điển hình bắt buộc bump major trong Rust:

  • Đổi signature function public: fn parse(s: &str) -> Result<T, E>fn parse(s: &str, opts: Opts) -> Result<T, E>.
  • Xoá hoặc rename pub item.
  • Thêm field public vào struct chưa có #[non_exhaustive] (consumer pattern-match có thể bể).
  • Bump MSRV (Minimum Supported Rust Version) — gây tranh cãi nhưng community thường coi là breaking.
  • Đổi trait bound bắt buộc cho generic public API.

Vì major bump tốn kém cho consumer, library tốt cố tránh: thay vì xoá function cũ, mark #[deprecated] và giữ vài version; thêm field qua #[non_exhaustive] ngay từ ngày đầu để có chỗ trống thêm sau này không cần bump major. Ví dụ serde 1.x tồn tại từ 2017 tới nay không hề bump major lên 2 — một thành tựu kỹ thuật của cộng đồng Rust.

Cảnh báo: không bump major khi nên bump còn tệ hơn bump không cần thiết. Nếu version 1.5.1 có thay đổi bẻ API mà tác giả ngại bump 2.0, tất cả user ^1.5 sẽ bị bể build tự động — bài học từ vụ actix-web 0.7 năm 2018.

4

Caret ^1.2.3 — Cargo Default

Caret là operator mặc định của Cargo. Khi bạn viết serde = "1.2.3", Cargo hiểu ngầm là serde = "^1.2.3" — cho phép update tự do trong cùng major:

[dependencies]
# Bốn dòng dưới đây HOÀN TOÀN tương đương
serde = "1.2.3"
serde = "^1.2.3"
serde = { version = "1.2.3" }
serde = { version = "^1.2.3" }

Range cụ thể caret dịch ra phụ thuộc vào vị trí số khác 0 đầu tiên:

  • ^1.2.3>=1.2.3, <2.0.0. Chấp nhận mọi minor và patch trong major 1.
  • ^1.2>=1.2.0, <2.0.0. Tương đương trên.
  • ^1>=1.0.0, <2.0.0. Bất kỳ minor/patch của major 1.
  • ^0.2.3>=0.2.3, <0.3.0. Khác với major-bump rule — pre-1.0 sẽ giải thích kỹ ở mục 8.
  • ^0.0.3>=0.0.3, <0.0.4. Pre-0.1 còn strict hơn nữa — chỉ exact patch.

Vì sao Cargo chọn caret làm default? Vì nó cân bằng tốt nhất giữa tự động nhận patch/featurekhông bị bẻ build do major bump bất ngờ. 90% trường hợp dùng dependency, bạn muốn nhận patch security tự động nhưng không muốn dậy buổi sáng thấy build đỏ do dependency lên major.

Idiom thực tế: ghi "1" hoặc "1.0" đủ rồi — không cần ghi rõ patch trừ khi cần feature patch cụ thể. Ghi quá chi tiết ("1.0.219") không hại gì nhưng làm Cargo.toml rối khi update sau này.

5

Tilde ~1.2.3 — Chỉ Patch

Tilde (~) giới hạn hẹp hơn caret: chỉ cho phép update patch, không cho minor:

[dependencies]
# Chỉ accept patch update, không cho minor mới
chrono = "~0.4.31"  # >=0.4.31, <0.5.0
clap   = "~4.5.2"   # >=4.5.2, <4.6.0
tokio  = "~1"       # >=1.0.0, <2.0.0 (giống ^1 khi không có dot)

Bảng dịch range:

  • ~1.2.3>=1.2.3, <1.3.0. Chỉ patch trong minor 1.2.
  • ~1.2>=1.2.0, <1.3.0. Tương đương trên.
  • ~1>=1.0.0, <2.0.0. Khi không có minor, tilde hoạt động như caret.

Khi nào nên dùng tilde thay caret? Khi bạn cố tình không muốn upgrade minor vì lo có behaviour change tinh tế dù minor là "compatible". Ví dụ: project đang chạy chrono ~0.4.31 đã test kỹ, không muốn nhận minor mới có thể đổi default timezone parsing. Vẫn nhận patch fix bug nhưng không tự lên 0.4.32, 0.5.0.

Lưu ý: tilde hạn chế đáng kể khả năng share version với dependency khác trong workspace. Nếu crate A pin ~0.4.31 mà crate B trong cùng workspace cần 0.4.40, Cargo phải tìm phiên bản duy nhất thoả mãn cả hai — không có thì build fail. Caret linh hoạt hơn vì range rộng.

6

Exact =1.2.3 — Pin Chính Xác

Dấu bằng (=) pin chính xác một version, không cho phép Cargo chọn version khác dù patch:

[dependencies]
# Bắt buộc đúng version 1.2.3, không hơn không kém
openssl-sys = "=0.9.100"
some-crate  = { version = "=2.5.0", features = ["foo"] }

Use case hợp lệ của exact rất hẹp:

  • Workaround tạm thời cho bug đã biết ở version sau (tokio-util 0.7.10 bị regression, pin =0.7.9 chờ fix).
  • Crate có -sys link C library, cần khớp đúng ABI với native lib đã cài (openssl-sys hay libgit2-sys).
  • Reproducibility cực kỳ nghiêm ngặt khi build embedded firmware cần bit-for-bit identical.

Cảnh báo lớn: không lạm dụng exact. Nếu mọi dependency đều pin exact, bạn mất sạch khả năng nhận patch bảo mật tự động, và đụng độ version trong workspace trở thành thảm hoạ. Khi pin exact, comment rõ vì sao để người maintain sau biết khi nào có thể tháo:

[dependencies]
# Pin do regression #1234, tháo khi 0.7.11 release
tokio-util = "=0.7.9"

Nhớ là Cargo.lock đã lock exact version cho binary crate rồi — pin = trong Cargo.toml chỉ thật sự cần khi muốn ép requirement tới mọi consumer downstream.

7

Wildcard * và 1.* — Cargo Từ Chối Publish

Wildcard cho phép bất kỳ version match pattern, dùng dấu *:

[dependencies]
# Bất kỳ version
foo = "*"
# Bất kỳ minor/patch của major 1
bar = "1.*"
# Bất kỳ patch của 1.2
baz = "1.2.*"

Range tương đương:

  • *>=0.0.0 (mọi version).
  • 1.*>=1.0.0, <2.0.0.
  • 1.2.*>=1.2.0, <1.3.0.

Cargo từ chối publish crate lên crates.io nếu trong Cargo.toml có wildcard * "trần" (không có version base):

$ cargo publish
error: failed to verify package tarball
Caused by:
  all dependencies must have a version specified when publishing.
  dependency `foo` does not specify a version

Vì sao? Cho phép * nghĩa là crate của bạn tuyên bố tương thích với mọi version tương lai của foo — bao gồm version chưa tồn tại. Khi foo 99.0 ra mắt và bẻ API, build của downstream bể tan tành. Cấm wildcard là cách Cargo ép tác giả khai báo intent rõ ràng.

Wildcard có dạng 1.* hay 1.2.* thì được publish, vì chúng đặt giới hạn major. Nhưng community vẫn ưu tiên caret vì cú pháp ngắn hơn: "1.2" tương đương "1.2.*" tương đương "^1.2" — cùng range.

Tóm lại: wildcard * chỉ dùng trong [dev-dependencies] của ứng dụng (binary crate) khi prototype nhanh — không bao giờ trong library publish lên crates.io.

8

Pre-1.0: Minor Cũng Là Breaking

SemVer chính thống quy định: với version 0.x.y, mọi rule chưa áp dụng — tác giả tự do break bất kỳ lúc nào trước 1.0. Cargo cụ thể hoá quy tắc này: với crate pre-1.0, mỗi bump 0.x được treat như bump major.

Cụ thể caret hoạt động khác với pre-1.0:

  • ^0.4.3>=0.4.3, <0.5.0. Không cho lên 0.5.0 dù nó là "minor bump" theo cấu trúc.
  • ^0.0.3>=0.0.3, <0.0.4. Còn strict hơn — chỉ exact patch.

Ví dụ thực tế với tokio 0.x ngày xưa (trước khi lên 1.0 năm 2020):

[dependencies]
# Pre-1.0 — bump từ 0.2 lên 0.3 BẺ API
tokio = "0.2"   # = "^0.2" = >=0.2.0, <0.3.0
# Sau khi tokio 1.0 ra:
tokio = "1"     # = "^1.0" = >=1.0.0, <2.0.0 (rộng hơn nhiều)

Hệ quả thực tế:

  • Crate 0.x bump minor là dấu hiệu bẻ API. Khi rand 0.8 lên rand 0.9, gần như chắc chắn cần đọc changelog và sửa code consumer.
  • Tránh dependency dài hạn vào crate 0.x nếu được — nó báo hiệu API chưa ổn định.
  • Nếu bạn maintain library, release 1.0 sớm để user yên tâm pin ^1.0. Tâm lý "chưa sẵn sàng 1.0" là rào cản tâm lý hơn kỹ thuật.

Pitfall: nhiều người mới đọc serde = "1.0" với rand = "0.8" và nghĩ format giống nhau. Thực tế range hai dòng này khác xa: serde chấp nhận tới 1.999.x, rand chỉ chấp nhận 0.8.x.

9

cargo update Trong Phạm Vi SemVer + Cargo.lock

Phân biệt hai file quan trọng:

  • Cargo.toml: ghi requirement — dải version chấp nhận (serde = "1.0").
  • Cargo.lock: ghi snapshot exact đang resolve được (serde 1.0.219). Cargo regenerate file này, dev không sửa tay.

Lần đầu chạy cargo build, Cargo solve dependency graph và ghi exact version vào Cargo.lock:

# Cargo.lock (đoạn trích)
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f1e9c4f7e..."
dependencies = [
 "serde_derive",
]

[[package]]
name = "tokio"
version = "1.47.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c2e9..."

Mọi cargo build sau đó dùng đúng version ghi trong lock — build reproducible. Lần build CI tuần sau vẫn lấy serde 1.0.219 dù crates.io đã có 1.0.220.

Muốn upgrade dependency, dùng cargo update:

# Upgrade TẤT CẢ dependency trong phạm vi SemVer của Cargo.toml
cargo update

# Chỉ upgrade serde, vẫn trong phạm vi ^1.0
cargo update -p serde

# Pin chính xác serde 1.0.200 (vẫn phải nằm trong range Cargo.toml)
cargo update -p serde --precise 1.0.200

# Downgrade serde về 1.0.150 — Cargo cho phép vì còn trong ^1.0
cargo update -p serde --precise 1.0.150

Điểm cốt lõi: cargo update không bao giờ vượt range SemVer ghi trong Cargo.toml. Muốn lên major mới (vd serde 2.0), phải sửa tay Cargo.toml:

[dependencies]
serde = "2"  # Sửa tay từ "1" → "2"

Rồi chạy cargo update hoặc cargo build để Cargo resolve lại. Đây là điểm chốt thiết kế của Cargo: upgrade trong cùng major thì tự động, lên major mới thì developer phải chủ động.

Quy tắc commit Cargo.lock:

  • Binary crate (có src/main.rs): commit để CI và dev khác build cùng version exact.
  • Library crate (có src/lib.rs và publish lên crates.io): không commit vì consumer của library sẽ tự resolve theo Cargo.toml requirement.

Detail commit Cargo.lock đã bàn ở Bài 23: Cargo.lock; bài này chỉ nhấn mạnh tương tác giữa cargo update và phạm vi SemVer.

10

Tổng Kết

  • SemVer = MAJOR.MINOR.PATCH: major bump là breaking, minor/patch tương thích ngược.
  • Caret ^1.2.3 (mặc định Cargo): cho phép update trong cùng major — range >=1.2.3, <2.0.0.
  • Tilde ~1.2.3: chỉ cho patch — range >=1.2.3, <1.3.0.
  • Exact =1.2.3: pin chính xác, dùng cho -sys crate hoặc workaround tạm thời. Luôn comment lý do.
  • Wildcard * trần bị Cargo từ chối publish; 1.*1.2.* chấp nhận nhưng community ưu tiên caret.
  • Pre-1.0 (0.x): Cargo coi mỗi bump 0.x là breaking — ^0.4.3 = >=0.4.3, <0.5.0. Pre-0.1 còn strict hơn.
  • Cargo.toml ghi requirement (dải); Cargo.lock ghi snapshot exact. Binary crate commit lock, library không.
  • cargo update chỉ chạy trong phạm vi SemVer của Cargo.toml; muốn lên major mới phải sửa tay Cargo.toml.
  • --precise <ver> pin tạm thời một version cụ thể, miễn còn trong range requirement.
11

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Với serde = "1.4.2" trong Cargo.toml, các version sau đây có được Cargo accept không: 1.4.2, 1.4.10, 1.5.0, 1.99.0, 2.0.0, 1.4.1?
  2. Khác biệt range cụ thể giữa "~1.2.3""^1.2.3"? Trường hợp nào bạn sẽ chọn tilde thay vì caret?
  3. Một crate library bạn maintain dùng rand = "*" và muốn publish lên crates.io. Cargo phản ứng thế nào? Vì sao? Cần sửa thành gì?
  4. Với rand = "0.8.5", các version sau có accept không: 0.8.5, 0.8.10, 0.9.0, 0.8.4? So sánh với câu 1 và giải thích vì sao quy tắc khác.
  5. Đang dùng tokio = "1", Cargo.lock ghi tokio 1.47.1. Có bug ở 1.47.1, muốn downgrade về 1.46.0. Câu lệnh nào? cargo update -p tokio có giải quyết được không?
  6. Tác giả foo 1.5.0 vô tình bẻ API mà không bump lên 2.0.0. Project bạn dùng foo = "1" và build CI hôm qua còn xanh, hôm nay đỏ. Giải thích chuyện gì xảy ra. Quick fix là gì?
Đáp án
  1. serde = "1.4.2" = ^1.4.2 = >=1.4.2, <2.0.0. Accept: 1.4.2, 1.4.10, 1.5.0, 1.99.0. Reject: 2.0.0 (vượt major), 1.4.1 (thấp hơn baseline).
  2. ~1.2.3 = >=1.2.3, <1.3.0 (chỉ patch). ^1.2.3 = >=1.2.3, <2.0.0 (cả minor và patch). Chọn tilde khi cố tình không muốn auto-upgrade minor vì lo behaviour change tinh tế dù minor là "compatible" — ví dụ project đã test kỹ một minor cụ thể, chỉ muốn nhận patch security.
  3. Cargo từ chối publish với lỗi "all dependencies must have a version specified when publishing". Lý do: * trần nghĩa là tương thích mọi version tương lai của rand — bao gồm version chưa tồn tại có thể bẻ API. Sửa thành version spec rõ ràng, ví dụ rand = "0.8" hoặc rand = "^0.8.5".
  4. rand = "0.8.5" = ^0.8.5. Với pre-1.0, Cargo treat 0.x bump như major. Range thực tế: >=0.8.5, <0.9.0. Accept: 0.8.5, 0.8.10. Reject: 0.9.0 (Cargo coi như bump major), 0.8.4 (dưới baseline). Khác câu 1 vì quy tắc pre-1.0 strict hơn — minor bump bị coi như breaking.
  5. cargo update -p tokio --precise 1.46.0. cargo update -p tokio không giải quyết — nó chỉ upgrade tới version mới nhất trong range, không thể downgrade. --precise bắt buộc Cargo dùng version chỉ định miễn vẫn nằm trong range ^1.0 của Cargo.toml1.46.0 nằm trong range nên OK.
  6. Tác giả vi phạm SemVer: bump patch hoặc minor nhưng kèm breaking change. Trước đó Cargo.lock đã có version cụ thể, hôm nay CI có thể chạy cargo update hoặc clear cache → resolve lại lên version mới có breaking → build đỏ. Quick fix: pin tạm thời foo = "=1.4.0" (version cuối cùng còn chạy) trong Cargo.toml, hoặc cargo update -p foo --precise 1.4.0 nếu đã commit Cargo.lock. Sau đó báo bug cho tác giả qua issue tracker.
12

Bài Tiếp Theo

Bài 262: Features Và Optional Dependencies — đi sâu vào cơ chế [features] của Cargo: cách khai báo feature flag, optional dependency chỉ kéo về khi feature bật, default-features = false để tắt feature mặc định, và best practice tránh feature unification gây xung đột giữa các crate trong workspace.