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.

Bài tập làm thử

Bài 1 (đọc hiểu). Cho hai tệp trong cùng một package:

//go:build prod

package main
func Backend() string { return "PostgreSQL (prod)" }
//go:build !prod

package main
func Backend() string { return "SQLite (dev)" }

Nếu chạy go run . (không có -tags), hàm Backend() trả về gì? Nếu chạy go run -tags prod . thì sao? Giải thích tại sao không có xung đột "hàm khai hai lần".

Đáp án

Không có tag prod: build mặc định đánh giá !prod là đúng nên tệp thứ hai được đưa vào build, Backend() trả về "SQLite (dev)". Với -tags prod: prod đúng nên tệp đầu được đưa vào, Backend() trả về "PostgreSQL (prod)". Không xung đột "khai hai lần" vì prod và !prod là hai điều kiện loại trừ lẫn nhau tuyệt đối — chỉ đúng một trong hai tệp lọt vào bất kỳ lần build nào, nên compiler không bao giờ thấy cả hai định nghĩa Backend() cùng lúc.

Bài 2 (sửa lỗi). Một dự án có main.go gọi hàm Backend(), và chỉ có một tệp prod.go với //go:build prod định nghĩa nó — không có tệp nào cho trường hợp không có tag prod. Điều gì xảy ra khi build mặc định (go build ., không có -tags)? Sửa cách tổ chức tệp để tránh lỗi.

Đáp án

Build mặc định sẽ báo lỗi biên dịch undefined: Backend, vì không tệp nào định nghĩa Backend() khi tag prod không được bật (tệp prod.go bị loại khỏi build đó). Đây là bẫy phổ biến nhất theo bài: "mọi biến thể phải cung cấp đủ API mà phần chung dùng." Sửa bằng cách thêm một tệp dev.go với //go:build !prod cũng định nghĩa Backend(), đảm bảo mọi tổ hợp tag đều có đủ hàm mà main.go cần.

Bài 3 (vận dụng thực tế). Bạn cần dự án của mình cross-compile ra ba binary khác nhau: một cho Linux, một cho Windows, một cho macOS (Darwin), mỗi bản có một số hàm nền tảng-cụ thể khác nhau. Nêu cách đặt tên tệp đơn giản nhất (không cần viết //go:build) để Go tự chọn đúng tệp theo hệ điều hành đích.

Đáp án

Dùng hậu tố tên tệp ngầm định mà Go tự nhận diện theo GOOS: đặt tên các tệp là plat_linux.go, plat_windows.go, plat_darwin.go (hoặc bất kỳ tên gốc nào, miễn hậu tố đúng _linux.go, _windows.go, _darwin.go). Không cần viết chỉ thị //go:build gì cả — Go tự động chỉ build đúng tệp khớp với GOOS đang đặt, ví dụ GOOS=windows go build sẽ chỉ lấy plat_windows.go và bỏ qua hai tệp còn lại.

Bài 4 (đọc hiểu code Go / biểu thức logic). Cho chỉ thị:

//go:build linux && (amd64 || arm64)

Tệp này có được đưa vào build trong bốn trường hợp sau không: (a) GOOS=linux GOARCH=amd64, (b) GOOS=linux GOARCH=arm64, (c) GOOS=linux GOARCH=386, (d) GOOS=windows GOARCH=arm64?

Đáp án

(a) Có — linux đúng và amd64 đúng nên amd64 || arm64 đúng, toàn biểu thức đúng. (b) Có — linux đúng và arm64 đúng, toàn biểu thức đúng. (c) Không — linux đúng nhưng kiến trúc là 386, không phải amd64 hay arm64, nên (amd64 || arm64) sai, toàn biểu thức sai. (d) Không — dù kiến trúc arm64 đúng, linux sai (là windows) nên toàn biểu thức && sai ngay từ vế đầu, bất kể vế sau. Biểu thức được đánh giá đúng theo logic Boole thông thường.

Bài 5 (bẫy/đánh đổi). Đội của bạn chỉ chạy CI trên Linux, nhưng dự án có các tệp _windows.go dành cho bản Windows. Một thay đổi gần đây vô tình làm hỏng cú pháp trong plat_windows.go. Theo bài học, lỗi này có bị CI phát hiện không? Đề xuất cách khắc phục quy trình CI.

Đáp án

Không, lỗi sẽ không bị phát hiện nếu CI chỉ build trên Linux, vì plat_windows.go chỉ được đưa vào biên dịch khi GOOS=windows — trên Linux nó bị loại khỏi build hoàn toàn nên lỗi cú pháp bên trong không bao giờ được compiler kiểm tra. Bài học cảnh báo đúng điều này: "một lỗi biên dịch trong nhánh windows không lộ ra khi bạn chỉ build trên Linux... một nhánh hiếm dùng có thể hỏng lặng lẽ hàng tháng." Khắc phục: cấu hình CI chạy go build (hoặc ít nhất go vet) cho mọi tổ hợp GOOS/GOARCH và tag quan trọng mà dự án hỗ trợ, không chỉ nền tảng đang chạy CI.

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.