Một mã nguồn Go, nhưng bản chạy trên Linux cần epoll, bản Windows cần IOCP, bản dev dùng SQLite còn prod dùng PostgreSQL. Làm sao một cây mã cho ra nhiều binary khác nhau mà không nhét if runtime.GOOS == ... khắp nơi? Câu trả lời của Go là build tags (ràng buộc biên dịch) — cơ chế quyết định tệp nào được đưa vào build trước cả khi compiler chạy. Đây là nền cho khả năng cross-compile trứ danh của Go và cho các build "dev/prod/debug" sạch sẽ. Bài này đo thật hai cơ chế và chứng minh chính xác tệp nào lọt vào từng build.
Hai cơ chế chọn tệp
Go có đúng hai cách khai một ràng buộc biên dịch, và chúng hoạt động ở tầng tệp (cả tệp được lấy hoặc bỏ, không phải từng dòng).
Cách 1 — chỉ thị //go:build đặt ở đầu tệp, trước dòng package, theo sau là một dòng trống:
//go:build prod
package main
func Backend() string { return "PostgreSQL (prod)" }
//go:build !prod
package main
func Backend() string { return "SQLite (dev)" }
Hai tệp cùng khai hàm Backend(), nhưng ràng buộc prod và !prod loại trừ nhau — đúng một tệp lọt vào mỗi build, nên không có xung đột "hàm khai hai lần".
Cách 2 — hậu tố tên tệp. Go tự nhận _GOOS.go và _GOARCH.go là tag ngầm: plat_linux.go chỉ build khi GOOS=linux, plat_windows.go chỉ khi GOOS=windows, không cần viết //go:build gì cả.

Hình 1: Hai cơ chế — chỉ thị //go:build (biểu thức logic đầy đủ && || !) và hậu tố tên tệp _GOOS.go/_GOARCH.go. Bật tag tùy biến bằng go build -tags prod.
Đo thật: cùng lệnh, tag khác nhau, tệp khác nhau
Chạy thật trong go-lab (Go 1.23) với hai tệp prod.go/dev.go:
go run .→backend = SQLite (dev)(mặc định, không tag →!prodđúng).go run -tags prod .→backend = PostgreSQL (prod).
Nhưng đừng tin lời — dùng go list -f {{.GoFiles}} để xem chính xác tệp nào được biên dịch:

Hình 2: go list chứng minh — mặc định build [dev.go main.go] (prod.go bị loại); -tags prod build [main.go prod.go] (dev.go bị loại). Hậu tố tệp tự chọn theo GOOS. Biểu thức linux && (amd64 || arm64): combo.go lọt trên linux/arm64, bị loại trên windows/amd64.
go list không nói dối: mặc định là [dev.go main.go], -tags prod là [main.go prod.go]. Đúng một tệp biến thể mỗi build. Đây là công cụ chẩn đoán quan trọng nhất — khi một hàm "biến mất" hay "khai hai lần" bí ẩn, go list -f {{.GoFiles}} cho thấy ngay tag đang lọc gì.
Cross-compile và biểu thức phức
Hậu tố GOOS là xương sống của cross-compile. Đo thật, cùng ba tệp plat_linux.go/plat_windows.go/plat_darwin.go:
GOOS=linux -> [main.go plat_linux.go]
GOOS=windows -> [main.go plat_windows.go]
GOOS=darwin -> [main.go plat_darwin.go]
Không đổi một dòng mã, chỉ đặt GOOS, Go chọn đúng tệp cho nền tảng đích — đây là lý do GOOS=windows go build trên máy Linux ra được .exe chạy trên Windows. Biểu thức //go:build còn hỗ trợ logic tổ hợp đầy đủ với &&, ||, ! và ngoặc:
//go:build linux && (amd64 || arm64)
Đo thật: tệp combo.go với ràng buộc trên lọt vào build trên linux/arm64 (máy này) — [combo.go main.go plat_linux.go] — nhưng bị loại trên windows/amd64 (vì không phải linux) — [main.go plat_windows.go]. Biểu thức được đánh giá đúng như logic Boole: linux && (amd64 || arm64) sai khi GOOS là windows, bất kể kiến trúc.
Đánh đổi cần cân nhắc
Mọi biến thể phải cung cấp đủ API mà phần chung dùng. Đây là bẫy phổ biến nhất. Nếu main.go gọi Backend() mà bạn chỉ viết prod.go (không có bản !prod), thì build mặc định lỗi undefined: Backend. Khi cross-compile sang một HĐH bạn quên viết file cho nó, lỗi này nổ ra lúc build — không phải lúc chạy. Luôn đảm bảo mọi tổ hợp tag mục tiêu đều có đủ hàm.
Dùng cú pháp //go:build mới, không dùng // +build cũ. Trước Go 1.17 ràng buộc viết là // +build prod với luật khoảng trắng khó nhớ (dấu cách là OR, dấu phẩy là AND). Từ 1.17, //go:build dùng biểu thức Boole thường (&&, ||), dễ đọc hơn hẳn. gofmt tự thêm dòng // +build tương ứng để tương thích ngược nếu cần, nhưng code mới chỉ nên viết //go:build.
Build tags mạnh nhưng khó test toàn diện. Vì mỗi tổ hợp tag là một tập tệp khác nhau, một lỗi biên dịch trong nhánh windows không lộ ra khi bạn chỉ build trên Linux. CI nên go build (hoặc go vet) cho mọi GOOS/GOARCH và tổ hợp tag quan trọng — nếu không, một nhánh hiếm dùng có thể hỏng lặng lẽ hàng tháng. Đừng lạm dụng tag tùy biến cho logic mà lẽ ra nên là cấu hình runtime.
Ba ý mang về
- Build tags chọn tệp nào được biên dịch ở tầng tệp, qua hai cơ chế: chỉ thị
//go:build(đặt trướcpackage, hỗ trợ biểu thức&& || !và ngoặc) và hậu tố tên tệp_GOOS.go/_GOARCH.go(tag ngầm theo nền tảng). go list -f {{.GoFiles}}chứng minh chính xác tệp nào lọt vào build — đo thật,-tags prodcho[main.go prod.go]còn mặc định cho[dev.go main.go]; đây là công cụ chẩn đoán khi một hàm "biến mất" hay xung đột bí ẩn.- Cross-compile dựa trên hậu tố GOOS (đặt
GOOS=windowslà Go tự chọnplat_windows.go), và biểu thức phức được đánh giá đúng logic Boole — nhưng mọi biến thể phải cung cấp đủ API, nếu không cross-compile báoundefinedlúc build.
Phần sau ta xét một công cụ toolchain khác sinh mã tự động thay vì viết tay: Phần sau mổ xẻ go:generate và stringer — cách sinh mã lặp đi lặp lại (như phương thức String() cho enum) ngay trong quy trình build.