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ả.

Ảnh chụp đoạn mã Go nền tối minh hoạ build tags một mã nguồn nhiều binary theo điều kiện, hai cơ chế chọn tệp lúc biên dịch, cách 1 chỉ thị go build ở đầu tệp trước package go build prod package main func Backend trả PostgreSQL prod, go build phủ định not prod khi không có prod func Backend trả SQLite dev, cách 2 hậu tố tên tệp gạch dưới GOOS chấm go gạch dưới GOARCH chấm go tag ngầm plat linux go plat windows go plat darwin go Go tự chọn tệp khớp GOOS GOARCH không cần go build, biểu thức tag và và hoặc phủ định và ngoặc go build linux và amd64 hoặc arm64, go build not windows mọi HĐH trừ windows, go build prod và cgo nhiều tag cùng lúc, bật tag tùy biến khi build go build tags prod chấm go build tags prod phẩy metrics nhiều tag, luật quan trọng go build phải đứng trước dòng package có dòng trống dưới cú pháp cũ cộng build đã lỗi thời dùng go build mọi biến thể phải cùng cung cấp API mà main dùng nếu không cross-compile sang HĐH thiếu file lỗi undefined go list f GoFiles cho biết tệp nào thực sự được build

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:

Ảnh chụp bảng kết quả đo thật nền tối cùng lệnh tag khác nhau biên dịch tệp khác nhau, go run cộng go list f GoFiles Go 1.23 arm64 10 core, tag tùy biến prod dev chỉ một tệp được build go run chấm backend bằng SQLite dev not prod go run tags prod chấm backend bằng PostgreSQL prod prod, go list f GoFiles chứng minh mặc định dev go main go prod go bị loại tags prod main go prod go dev go bị loại, hậu tố tệp tự chọn theo GOOS cross-compile GOOS linux main go plat linux go GOOS windows main go plat windows go GOOS darwin main go plat darwin go, chạy thật trên linux plat bằng Linux theo hậu tố linux go không cần go build chỉ đặt tên linux go windows go là Go tự lọc theo GOOS tương tự amd64 go arm64 go, biểu thức phức linux và amd64 hoặc arm64 go build linux và amd64 hoặc arm64 linux arm64 máy này combo go main go plat linux go combo go lọt windows amd64 main go plat windows go combo go bị loại không linux, cốt lõi 2 cơ chế go build đầu tệp và hậu tố GOOS GOARCH go biểu thức và hoặc phủ định và ngoặc logic tổ hợp đầy đủ tags bật tag tùy biến lúc build go list f GoFiles cho biết chính xác tệp nào được build bẫy mọi biến thể phải đủ API nếu không cross-compile lỗi

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ề

  1. 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ước package, 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).
  2. go list -f {{.GoFiles}} chứng minh chính xác tệp nào lọt vào build — đo thật, -tags prod cho [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.
  3. Cross-compile dựa trên hậu tố GOOS (đặt GOOS=windows là Go tự chọn plat_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áo undefined lú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.