Go modules: quản lý dependency và phiên bản
1. Tổng quan
Từ Go 1.11, Go modules là cơ chế chính thức để quản lý dependency. Từ Go 1.16, đây là chế độ mặc định, thay hẳn cách làm cũ dựa trên GOPATH. Bài này đi qua toàn bộ vòng đời của một module: khởi tạo, thêm dependency, chọn và nâng cấp phiên bản, xử lý major version v2+, và làm việc với code local hoặc repo private. Đọc xong, bạn sẽ hiểu go.mod và go.sum chứa gì. Bạn cũng sẽ biết vì sao Go chọn một phiên bản cụ thể và cách sửa các lỗi dependency hay gặp.
2. Khởi tạo module
Một module là một cây thư mục có file go.mod ở gốc. Tạo module mới bằng go mod init:
mkdir order-service && cd order-service
go mod init github.com/nonla/order-service
File go.mod được tạo ra:
module github.com/nonla/order-service
go 1.24
Ý nghĩa từng dòng:
modulelà module path. Đây là tiền tố import cho mọi package bên trong module. Nếu module sẽ được người khácgo get, path này phải khớp với URL repo.go 1.24là phiên bản ngôn ngữ tối thiểu module yêu cầu. Từ Go 1.21, dòng này là yêu cầu bắt buộc. Nếu toolchain của bạn cũ hơn, Go sẽ tự tải toolchain phù hợp (khiGOTOOLCHAIN=auto) hoặc báo lỗi.
Lưu ý: Với ứng dụng nội bộ không bao giờ được import từ ngoài, bạn có thể đặt path ngắn như
order-service. Tuy vậy, dùng path dạng URL ngay từ đầu sẽ tránh phải đổi tên hàng loạt import về sau.
3. Thêm dependency
3.1. Dùng go get
Giả sử bạn cần sinh ID cho đơn hàng bằng UUID. Thêm thư viện github.com/google/uuid:
go get github.com/google/uuid@v1.6.0
Sau đó viết code sử dụng thư viện:
// order/order.go
package order
import "github.com/google/uuid"
type Order struct {
ID string
Customer string
Total int64 // đơn vị: đồng
}
func NewOrder(customer string, total int64) Order {
return Order{
ID: uuid.NewString(),
Customer: customer,
Total: total,
}
}
go.mod giờ có thêm khối require:
module github.com/nonla/order-service
go 1.24
require github.com/google/uuid v1.6.0
Bạn cũng có thể bỏ qua bước go get: viết import trước, rồi chạy go mod tidy. Go sẽ tự tìm phiên bản mới nhất và ghi vào go.mod.
3.2. File go.sum
Cùng lúc đó, Go tạo file go.sum:
github.com/google/uuid v1.6.0 h1:<hash nội dung module>
github.com/google/uuid v1.6.0/go.mod h1:<hash của file go.mod>
Mỗi dependency có hai dòng. Dòng đầu là hash của toàn bộ nội dung module, dòng sau là hash riêng file go.mod của nó. Khi tải module, Go so sánh hash thực tế với go.sum. Nó cũng đối chiếu với checksum database công khai (sum.golang.org). Nếu có ai sửa nội dung một tag đã phát hành, build sẽ thất bại thay vì âm thầm dùng code lạ.
Lưu ý: Luôn commit cả
go.modvàgo.sum. Không thêmgo.sumvào.gitignore. Nếu thiếu file này, máy CI sẽ không kiểm chứng được dependency.
3.3. Dependency trực tiếp và gián tiếp
Thêm testify để viết test:
go get github.com/stretchr/testify@v1.10.0
go.mod sẽ có hai khối require:
require (
github.com/google/uuid v1.6.0
github.com/stretchr/testify v1.10.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Comment // indirect đánh dấu module mà code của bạn không import trực tiếp nhưng cần cho dependency khác. Go ghi chúng vào go.mod để build tái lập được hoàn toàn. Nhờ vậy, Go không phải đọc lại go.mod của từng dependency để dựng đồ thị.
4. Phiên bản và cách Go chọn phiên bản
4.1. Semantic versioning
Go modules dùng semantic versioning dạng vMAJOR.MINOR.PATCH:
PATCH(v1.6.0 → v1.6.1): sửa lỗi, không đổi API.MINOR(v1.6.0 → v1.7.0): thêm tính năng, vẫn tương thích ngược.MAJOR(v1 → v2): có thay đổi phá vỡ tương thích.
Phiên bản phải là git tag có tiền tố v. Nếu bạn trỏ tới một commit chưa gắn tag, Go tạo pseudo-version:
go get github.com/google/uuid@0f3b7a2
github.com/google/uuid v1.6.1-0.20240815101530-0f3b7a2c1d4e
Pseudo-version gồm ba phần: phiên bản cơ sở, timestamp UTC của commit và 12 ký tự đầu của hash. Nhờ đó, pseudo-version vẫn sắp xếp đúng thứ tự thời gian.
4.2. Minimal Version Selection
Đây là điểm khác biệt lớn nhất so với npm hay Maven. Go dùng thuật toán Minimal Version Selection (MVS). Với mỗi module, Go chọn phiên bản cao nhất trong số các phiên bản được yêu cầu, không phải phiên bản mới nhất đang tồn tại.
Ví dụ, project của bạn có hai dependency:
order-service
├── payment-sdk → require github.com/google/uuid v1.3.0
└── shipping-sdk → require github.com/google/uuid v1.5.0
Kể cả khi uuid v1.6.0 đã phát hành, Go vẫn chọn v1.5.0. Lý do: đó là phiên bản thấp nhất thỏa mãn mọi yêu cầu. Hệ quả:
- Build có tính tái lập. Một thư viện phát hành bản mới không làm build của bạn tự thay đổi.
- Không cần lock file riêng.
go.modđã đủ để xác định phiên bản. - Muốn dùng bản mới hơn, bạn phải nâng cấp một cách chủ động.
Xem phiên bản thực tế được chọn bằng lệnh:
go list -m all
github.com/nonla/order-service
github.com/davecgh/go-spew v1.1.1
github.com/google/uuid v1.5.0
...
4.3. Nâng cấp và hạ cấp
Kiểm tra dependency nào có bản mới:
go list -m -u all
github.com/google/uuid v1.5.0 [v1.6.0]
github.com/stretchr/testify v1.10.0
Phiên bản trong ngoặc vuông là bản mới nhất có thể nâng lên. Các lệnh nâng cấp thường dùng:
go get github.com/google/uuid@latest # bản mới nhất
go get github.com/google/uuid@v1.5.0 # về đúng một phiên bản (có thể là hạ cấp)
go get -u=patch ./... # chỉ nâng PATCH cho mọi dependency
go get -u ./... # nâng MINOR + PATCH cho mọi dependency
go get github.com/google/uuid@none # gỡ hẳn dependency
Lưu ý:
go get -u ./...nâng cả dependency gián tiếp. Diff có thể rất lớn. Trên project production, bạn nên nâng từng module, chạy test, rồi mới commit.
5. Major version v2 trở lên
Go áp dụng quy tắc import compatibility: hai phiên bản có cùng import path phải tương thích ngược. Vì vậy, từ v2 trở đi, major version là một phần của module path.
Ví dụ với router chi:
go get github.com/go-chi/chi/v5@v5.1.0
package main
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
r.Get("/orders", func(w http.ResponseWriter, _ *http.Request) {
w.Write([]byte("danh sách đơn hàng"))
})
http.ListenAndServe(":8080", r)
}
Hậu tố /v5 nằm trong import path. Tên package vẫn là chi, nên bạn gọi chi.NewRouter() như bình thường. Vì chi/v4 và chi/v5 có path khác nhau, hai bản có thể cùng tồn tại trong một binary. Điều này giúp bạn migrate dần từng phần.
Nếu bạn là tác giả thư viện và muốn phát hành v2:
module github.com/nonla/pricing/v2
go 1.24
Bạn đổi dòng module thành path có hậu tố /v2, cập nhật các import nội bộ, rồi gắn tag v2.0.0.
Lưu ý: Lỗi hay gặp là chỉ gắn tag
v2.0.0mà quên đổi module path. Khi đógo get github.com/nonla/pricing@v2.0.0sẽ báo lỗi kiểuinvalid version: module contains a go.mod file, so module path must match major version.
6. Các lệnh go mod hữu ích
go mod tidy # thêm module còn thiếu, xóa module không dùng
go mod why -m gopkg.in/yaml.v3 # vì sao module này có mặt
go mod graph # in toàn bộ đồ thị phụ thuộc
go mod verify # kiểm tra cache local khớp với go.sum
go mod download # tải trước dependency (hữu ích cho Docker layer)
Ví dụ output của go mod why:
# gopkg.in/yaml.v3
github.com/nonla/order-service/order
github.com/stretchr/testify/assert
gopkg.in/yaml.v3
Đọc từ trên xuống: package order (trong test) import testify/assert, và testify/assert lại import yaml.v3.
Hãy chạy go mod tidy trước mỗi commit. Trong CI, bạn có thể kiểm tra go.mod đã gọn hay chưa:
go mod tidy -diff
Từ Go 1.23, cờ -diff in ra phần chênh lệch và trả exit code khác 0 nếu file chưa tidy. Lệnh này không ghi đè file.
7. Làm việc với code local: replace và go.work
7.1. Chỉ thị replace
Giả sử bạn đang sửa thư viện pricing ở thư mục bên cạnh và muốn order-service dùng ngay bản đang sửa:
require github.com/nonla/pricing v1.2.0
replace github.com/nonla/pricing => ../pricing
Go sẽ đọc code từ ../pricing thay vì tải từ mạng. replace cũng có thể trỏ sang một fork:
replace github.com/nonla/pricing => github.com/minh/pricing v1.2.1-fix
Lưu ý:
replacechỉ có hiệu lực ở module chính. Nếu module của bạn là thư viện, người dùng nó sẽ không nhận đượcreplacecủa bạn. Ngoài ra, đừng commitreplacetrỏ tới đường dẫn local. Build trên CI sẽ hỏng vì thư mục../pricingkhông tồn tại ở đó.
7.2. Workspace với go.work
Từ Go 1.18, workspace là cách sạch hơn cho phát triển nhiều module cùng lúc:
go work init ./order-service ./pricing
File go.work được tạo ra:
go 1.24
use (
./order-service
./pricing
)
Khi chạy go build hay go test trong workspace, mọi import tới github.com/nonla/pricing sẽ dùng code local. Bạn không cần sửa go.mod. Thông thường go.work không được commit, mỗi lập trình viên tự tạo theo cách tổ chức thư mục của mình.
7.3. exclude và retract
exclude dùng ở module chính để loại bỏ một phiên bản cụ thể:
exclude github.com/google/uuid v1.4.0
retract do tác giả thư viện khai báo để báo rằng một bản đã phát hành không nên dùng:
retract v1.2.0 // tính sai thuế VAT cho đơn hàng quốc tế
Sau khi bạn phát hành bản mới có chứa dòng này, go get @latest sẽ bỏ qua v1.2.0. Lệnh go list -m -u cũng sẽ cảnh báo những ai đang dùng bản đó.
8. Module private và proxy
Mặc định, Go tải module qua GOPROXY=https://proxy.golang.org,direct và kiểm tra checksum qua sum.golang.org. Với repo nội bộ, proxy công khai không truy cập được, và bạn cũng không muốn lộ tên module ra ngoài. Hãy khai báo:
go env -w GOPRIVATE=github.com/nonla/*,gitlab.nonla.internal/*
Các module khớp pattern trong GOPRIVATE sẽ được tải trực tiếp qua git và bỏ qua checksum database. Bạn vẫn cần cấu hình xác thực cho git, ví dụ bằng ~/.netrc hoặc SSH:
git config --global url."git@github.com:".insteadOf "https://github.com/"
Nếu công ty có proxy riêng (Athens, Artifactory…), hãy đặt proxy đó vào đầu GOPROXY:
go env -w GOPROXY=https://goproxy.nonla.internal,https://proxy.golang.org,direct
9. Kiểm thử hành vi với dependency
Viết test cho NewOrder, sử dụng testify đã thêm ở mục 3.3:
// order/order_test.go
package order
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewOrder_GeneratesValidUniqueID(t *testing.T) {
first := NewOrder("Nguyễn Văn An", 250_000)
second := NewOrder("Trần Thị Bình", 120_000)
_, err := uuid.Parse(first.ID)
require.NoError(t, err)
assert.NotEqual(t, first.ID, second.ID)
assert.Equal(t, "Nguyễn Văn An", first.Customer)
assert.EqualValues(t, 250_000, first.Total)
}
Chạy test:
go test ./...
ok github.com/nonla/order-service/order 0.004s
Test kiểm tra ba điều: ID là UUID hợp lệ, hai đơn hàng có ID khác nhau, và các trường còn lại được gán đúng. require.NoError dừng test ngay nếu ID không parse được. assert thì ghi nhận lỗi rồi tiếp tục chạy. Lần đầu chạy trên máy mới, Go sẽ tải dependency theo đúng phiên bản trong go.mod và kiểm hash với go.sum trước khi biên dịch.
10. Lỗi hay gặp
missing go.sum entry for module providing package:go.sumthiếu dòng tương ứng, thường do ai đó sửago.modbằng tay. Cách sửa: chạygo mod tidy.checksum mismatch: nội dung module không khớp hash. Hãy thửgo clean -modcache. Nếu vẫn lỗi với module public, coi đây là dấu hiệu nghiêm trọng và đừng xóa dòng tronggo.sumcho qua.go: updates to go.mod needed: CI thường chạy với-mod=readonly, trong khigo.modchưa được tidy. Bạn chạygo mod tidyở local rồi commit lại.- Import nhầm major version: dùng
github.com/go-chi/chithay vì.../chi/v5. Bạn sẽ nhận bản v1 cũ hoặc một pseudo-version lạ. Hãy kiểm tra import path trước tiên. - Lạm dụng
vendor/:go mod vendorvẫn hữu ích khi cần build offline hoàn toàn. Còn lại, proxy kết hợpgo.sumđã đảm bảo tính tái lập, nên bạn không cần commit hàng nghìn file vendor.
11. Kết luận
Go modules giải quyết bài toán dependency bằng vài ý tưởng đơn giản. go.mod khai báo yêu cầu, go.sum đảm bảo tính toàn vẹn, và Minimal Version Selection giúp build tái lập mà không cần lock file. Major version nằm ngay trong import path, nên thay đổi phá vỡ tương thích luôn hiện rõ trong code.
Với công việc hằng ngày, bạn chỉ cần nhớ bốn thói quen:
- Dùng
go get module@versionđể thêm hoặc nâng cấp dependency một cách có chủ đích. - Chạy
go mod tidytrước khi commit. - Dùng
go.workthay vìreplacekhi phát triển nhiều module cùng lúc. - Cấu hình
GOPRIVATEcho các repo nội bộ.