Hình dung dự án của bạn như một nhà thầu chính, còn các thư viện là thầu phụ. Khi công trình còn nhỏ thì chỉ cần gọi tên từng thầu trong go.mod là xong — bài 3 đã nói. Nhưng khi nó lớn lên, bạn gặp những tình huống thật của một nhà thầu: muốn dùng crew riêng cho một việc, muốn giữ bản sao mọi bản vẽ phòng khi mất liên lạc, muốn sửa hai công trình cùng lúc. Bốn công cụ dưới đây là câu trả lời cho từng tình huống đó.

replace: trỏ sang bản khác

// trong go.mod
replace github.com/ai-do/thuvien => ../thuvien-cua-toi
replace github.com/ai-do/thuvien => github.com/toi/thuvien v1.2.4

Dùng khi: đang sửa một thư viện cùng lúc với dự án dùng nó, hoặc cần một bản vá chưa được merge ngược lên. Đây là tờ giấy dặn "việc này dùng crew của tôi, đừng gọi thầu trong danh sách".

Một quy tắc quan trọng và hay gây bất ngờ:

replace chỉ có tác dụng ở module CHÍNH. Nếu thư viện của bạn có replace trong go.mod, người dùng thư viện đó sẽ không nhận được nó — và build của họ hỏng nếu bạn thật sự cần bản thay thế.

Tờ dặn đó chỉ dán ở công trình của bạn; ai thuê tòa nhà của bạn làm thầu phụ cho họ thì không thấy nó. Nên với thư viện xuất bản, đừng bao giờ để replace trỏ vào đường dẫn cục bộ. Với ứng dụng thì thoải mái.

Có exclude để loại một phiên bản cụ thể, nhưng hiếm dùng — thường require một bản mới hơn là đủ.

Nâng cấp phụ thuộc

go list -m -u all              # xem có gì mới, KHÔNG đổi gì
go get -u ./...                # nâng minor và patch
go get -u=patch ./...          # chỉ patch, an toàn hơn
go get github.com/x/y@v1.5.0   # ghim một phiên bản
go get github.com/x/y@latest
go mod tidy                    # dọn sau mỗi lần

Nhớ Minimal Version Selection ở bài 3: Go chọn phiên bản thấp nhất thoả mãn, nên nâng cấp là hành động tường minh. Đây là điểm khác Maven và npm, và nó làm build tái lập được.

Tôi gặp đúng tình huống này khi viết bài 41: go get golang.org/x/sync mới nhất từ chối cài vì đòi Go ≥ 1.25 trong khi môi trường chạy 1.23:

  golang.org/x/sync@v0.22.0 requires go >= 1.25.0 (running go 1.23.12)

Phải ghim @v0.8.0. Thông báo lỗi của Go rất rõ, và đó là hành vi đúng — thà từ chối còn hơn build ra binary hỏng.

Đường dẫn module và phiên bản major

Từ v2 trở đi, đường dẫn module phải đổi:

module github.com/toi/thuvien/v2

Và người dùng import github.com/toi/thuvien/v2. Nghe phiền, nhưng nó cho phép v1 và v2 cùng tồn tại trong một binary — như cho v2 một địa chỉ đường phố riêng để cả hai cùng có tên trên sổ. Thứ Maven không làm được, và là lý do "địa ngục phụ thuộc" ít gặp hơn trong Go.

vendor: chép phụ thuộc vào kho mã

go mod vendor          # tạo thư mục vendor/
go build -mod=vendor   # tự động nếu có vendor/ và go 1.14+

Toàn bộ mã nguồn phụ thuộc nằm trong dự án — giữ bản sao mọi bản vẽ của thầu phụ ngay trong tủ hồ sơ của mình. Build không cần mạng, không cần proxy.

Khi nào đáng: môi trường build không có internet, yêu cầu kiểm toán phải thấy hết mã, hoặc muốn chắc chắn build tái lập được sau nhiều năm.

Cái giá: kho mã phình to, và mọi lần nâng cấp là một diff khổng lồ.

Với đa số dự án, proxy module cộng go.sum đã đủ — bài 3 đã nói proxy.golang.org giữ bản sao vĩnh viễn. Tôi chỉ dùng vendor khi có yêu cầu cụ thể.

Workspace: nhiều module cùng lúc

Từ Go 1.18:

go work init ./api ./thuvien

Tạo go.work, và các module trong đó thấy nhau trực tiếp mà không cần replace — một bản kế hoạch chung khi bạn đang cải tạo hai công trình một lúc.

Đây là cách đúng để làm việc trên nhiều module cùng lúc, thay cho mẹo replace trỏ đường dẫn cục bộ mà người ta hay quên xoá trước khi commit.

Đừng commit go.work — thêm nó vào .gitignore. Nó là cấu hình của máy bạn, không phải của dự án.

Riêng tư và proxy

GOPRIVATE=github.com/congty/*     # không qua proxy công cộng, không kiểm checksum
GOPROXY=https://proxy.golang.org,direct
GONOSUMDB=...

GOPRIVATE là thứ bạn cần cho kho nội bộ công ty. Không đặt thì Go cố gọi proxy công cộng cho module riêng tư — chậm, và về lý thuyết là lộ tên kho.

Kiểm tra sức khoẻ phụ thuộc

go mod graph | wc -l           # số cạnh trong đồ thị phụ thuộc
go mod why github.com/x/y      # AI kéo module này vào
go list -m all | wc -l         # tổng số module

go mod why là công cụ tôi dùng nhiều nhất khi thấy một phụ thuộc lạ:

  # github.com/x/y
  vd/app
  github.com/a/b
  github.com/x/y

Nó in ra chuỗi import dẫn tới module đó. Tương đương mvn dependency:tree nhưng trả lời đúng câu hỏi bạn hỏi.

Nếu muốn một thứ chạy ngay hôm nay, liệt kê những thầu phụ đã có bản mới hơn:

go list -m -u all | grep '\[' | head -20

Mỗi dòng có ngoặc vuông là một phụ thuộc đã có bản mới hơn. Danh sách đó chính là đầu vào cho bài 59 về lỗ hổng bảo mật — và như bài 99 sê-ri Java đã đo, phần lớn lỗ hổng chỉ cần nâng phiên bản là xong.

Mẫu số chung

Bỏ qua tên lệnh, mọi trình quản lý phụ thuộc trên đời đều xoay quanh đúng hai câu hỏi — và chỗ Go khác người là ở cách nó trả lời.

Câu thứ nhất: build có tái lập được không, và nhờ cái gì? Tái lập đòi phải ghim được phiên bản chính xác. npm dùng package-lock.json, Cargo dùng Cargo.lock, Python có poetry.lock — tất cả giải ra bản mới nhất rồi khoá lại bằng một tệp lock riêng. Go đi đường lạ: MVS chọn phiên bản thấp nhất thoả mãn, nên kết quả tất định ngay từ thuật toán, không cần bước khoá — go.sum chỉ xác thực checksum chứ không quyết định phiên bản. Vì vậy nâng cấp trong Go luôn là hành động tường minh, không có chuyện "tự nhiên lên bản mới".

Câu thứ hai: hai thư viện đòi hai phiên bản khác nhau của cùng một thứ thứ ba thì sao? — bài toán kim cương, trung tâm của mọi hệ. Làm phẳng về một bản: Maven, "gần nhất thắng" — gọn nhưng có thể gãy. Cho cùng tồn tại: npm lồng nhiều bản trong node_modules, Cargo cho các bản khác-semver sống chung, và Go thì bắt đổi đường dẫn khi lên major (/v2) — cùng một ý tưởng "cho mỗi bản một địa chỉ riêng", nhưng làm tường minh nhất.

Sợi chỉ chung đáng mang theo: "chạy được trên máy tôi" là vô nghĩa nếu việc giải phiên bản không tái lập, và chuyện hai thư viện đòi hai phiên bản xung khắc của một thư viện thứ ba không phải ca hiếm — nó là bài toán mà cả cái hệ quản lý phụ thuộc được dựng ra để giải. Biết hệ của mình trả lời hai câu đó thế nào là biết trước nó sẽ cứu bạn hay phản bội bạn vào đúng cái ngày đó.

Ngày mai: build và cross-compile — nhúng số phiên bản vào binary.

Bài tập làm thử

Bài 1 (đọc hiểu). Thư viện thuvien-xuat-ban của bạn có dòng sau trong go.mod, và bạn publish thư viện này cho người khác dùng:

replace github.com/ai-do/thuvien => ../thuvien-cua-toi

Người dùng thư viện của bạn có nhận được bản thay thế ../thuvien-cua-toi khi họ build dự án của họ không? Giải thích.

Đáp án

Không. replace chỉ có tác dụng ở module chính — tức module gốc đang được build trực tiếp. Nếu thư viện của bạn (không phải module chính khi người khác dùng nó) có replace, người dùng thư viện đó sẽ không nhận được nó, và build của họ có thể hỏng nếu bạn thật sự cần bản thay thế đó để thư viện hoạt động đúng. Vì vậy, với thư viện xuất bản, không nên để replace trỏ vào đường dẫn cục bộ.

Bài 2 (vận dụng thực tế — sửa lỗi). Bạn chạy go get golang.org/x/sync@latest trong một dự án dùng Go 1.23, nhưng lệnh bị từ chối với lỗi:

golang.org/x/sync@v0.22.0 requires go >= 1.25.0 (running go 1.23.12)

Nêu cách khắc phục đúng theo bài viết, và giải thích vì sao Go từ chối thay vì cứ cài bừa.

Đáp án

Ghim một phiên bản cũ hơn, tương thích với Go 1.23:

go get golang.org/x/sync@v0.8.0

Go từ chối cài bản mới vì bản đó đòi Go ≥ 1.25 — đây là hành vi đúng: thà từ chối ngay lúc go get còn hơn để build ra một binary có thể hỏng ngầm vì thiếu tính năng của phiên bản Go mới hơn.

Bài 3 (đọc hiểu — MVS). Bạn chạy go list -m -u all và thấy vài dòng có dấu ngoặc vuông như [v1.5.2]. Điều này nghĩa là gì, và tại sao chạy lệnh này không tự động nâng cấp phụ thuộc của bạn?

Đáp án

Dấu ngoặc vuông đánh dấu một phụ thuộc đã có bản mới hơn bản đang dùng trong go.mod. go list -m -u all chỉ liệt kê, không đổi gì — vì Go dùng Minimal Version Selection (MVS): chọn phiên bản thấp nhất thoả mãn mọi ràng buộc, nên kết quả giải phiên bản tất định ngay từ thuật toán, không tự "trôi" lên bản mới. Muốn nâng cấp phải làm tường minh bằng go get -u ./... (nâng minor/patch) hoặc go get pkg@version (ghim một bản cụ thể).

Bài 4 (bẫy/đánh đổi). Một đồng nghiệp đề xuất luôn commit tệp go.work vào kho mã để "cả nhóm dùng chung cấu hình workspace". Theo bài viết, đây có phải là thực hành tốt không? Giải thích.

Đáp án

Không nên. Bài viết nói rõ: "Đừng commit go.work" — nên thêm nó vào .gitignore. go.work là cấu hình cục bộ của từng máy (ví dụ đường dẫn tới các module bạn đang sửa cùng lúc), không phải cấu hình chung của dự án; commit nó có thể áp đặt đường dẫn cục bộ của một người lên máy người khác, nơi cấu trúc thư mục khác đi.

Bài 5 (vận dụng thực tế — go mod why). Dự án của bạn bất ngờ kéo vào một phụ thuộc lạ tên github.com/x/y mà không ai chủ động thêm. Viết lệnh để tìm ra chuỗi import nào đã kéo module này vào, và giải thích lệnh đó tương đương công cụ gì ở hệ sinh thái Java (Maven).

Đáp án
go mod why github.com/x/y

Lệnh này in ra chuỗi import dẫn tới module đó, ví dụ:

# github.com/x/y
vd/app
github.com/a/b
github.com/x/y

nghĩa là vd/app import github.com/a/b, mà github.com/a/b lại import github.com/x/y. Đây là công cụ tương đương mvn dependency:tree của Maven, nhưng trả lời trực tiếp và gọn hơn cho đúng câu hỏi "ai kéo module này vào".