Перейти до змісту

Збірка, кодогенерація та cgo

Як програма на Go стає артефактом для розгортання: згенероване джерело, теги збірки, штампування версії, крос-компіляція й та одна річ, яка ускладнює все це — cgo.

GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o app ./cmd/api

go:generate

Коментар //go:generate записує команду. go generate знаходить їх і запускає; go build — ніколи:

//go:generate stringer -type=Pill

type Pill int

const (
    Placebo Pill = iota
    Aspirin
    Ibuprofen
)
go generate ./...

Це розділення свідоме: генерація — це крок, який ви робите й комітите, а не те, що відбувається на кожній збірці. Будь-хто, хто клонує репозиторій, може зібрати проєкт без встановленого генератора.

Директива може запустити що завгодно — генератор коду, компілятор схеми, інструмент шаблонів. Вона виконується в каталозі файлу з оточенням пакета.

Багато проєктів пропускають go:generate і запускають генерацію з Makefile натомість, що тримає кожен крок збірки в одному видимому місці, а не розкиданим по вихідних файлах. Обидва підходи прийнятні; змішування їх означає, що ніхто не знає, що саме що виробило.

Згенеровані файли — лише для читання

Згенерований код на Go несе стандартний перший рядок:

// Code generated by stringer. DO NOT EDIT.

Саме ця форма — що відповідає ^// Code generated .* DO NOT EDIT\.$ — розпізнається інструментами. Лінтери пропускають ці файли, а рецензенти знають, що коментувати їх не варто.

Комітьте згенеровані файли. Це тримає збірку відтворюваною без генератора, робить diff видимим у рев'ю, і означає, що CI не потребує встановленого інструмента. Ціна — пам'ятати про регенерацію; перевірка CI, що регенерує й провалюється на diff, закриває цю прогалину.

Теги збірки

Рядок //go:build на початку файлу вирішує, чи компілювати його взагалі. Він має стояти перед секцією package, з порожнім рядком після нього:

//go:build linux

package main

const platform = "linux"
//go:build !linux

package main

const platform = "other"

Два файли, один ідентифікатор, і потрібний вибирається під кожну ціль:

$ go list -f '{{.GoFiles}}' .
[only_other.go pill.go]

$ GOOS=linux go list -f '{{.GoFiles}}' .
[only_linux.go pill.go]

go list — швидкий спосіб перевірити, що насправді обирає комбінація тегів — здогадки — це те, як ви отримуєте файл, який ніщо не компілює.

Суфікси імен файлів роблять те саме неявно: foo_linux.go, foo_windows.go, foo_amd64.go та foo_test.go. Надавайте перевагу суфіксу для варіантів платформи; використовуйте явний тег для всього іншого.

Не ховайте тести за власним тегом

//go:build integration на тестовому файлі означає, що він запускається лише з -tags integration. Якщо ні CI, ні локальний make test не передають цей прапорець, тести не запускаються ніде — і при цьому весь час виглядають присутніми в репозиторії, що гірше, ніж якби їх не було.

Замість цього ховайте на етапі виконання, де пропуск видимий:

func TestAgainstDatabase(t *testing.T) {
    dsn := os.Getenv("TEST_DATABASE_DSN")
    if dsn == "" {
        t.Skip("TEST_DATABASE_DSN not set")
    }
    // ...
}

Тоді go test -v друкує пропуск, тож відсутність повідомляється, а не замовчується.

Штампування версії через -ldflags

-X встановлює рядкову змінну на етапі лінкування — саме так бінарник дізнається власну версію без згенерованого файлу:

var version = "dev"

func main() { fmt.Println("version:", version) }
$ go run -ldflags "-X main.version=1.2.3" .
version: 1.2.3

$ go run .
version: dev

Це працює лише з рядковою (string) змінною на рівні пакета без ініціалізатора, окрім константи. -ldflags "-s -w" додатково прибирає таблицю символів і дані DWARF, даючи помітно менший бінарник — ціною читабельних трасувань стека.

runtime/debug.ReadBuildInfo() дає вам версії модулів і інформацію про VCS, яку toolchain записує автоматично, що покриває багато випадків узагалі без прапорців.

Крос-компіляція

Встановіть дві змінні. Жодного toolchain для встановлення, жодного контейнера не потрібно:

GOOS=linux   GOARCH=amd64 go build -o app-linux-amd64   ./cmd/api
GOOS=linux   GOARCH=arm64 go build -o app-linux-arm64   ./cmd/api
GOOS=windows GOARCH=amd64 go build -o app-windows.exe   ./cmd/api
GOOS=darwin  GOARCH=arm64 go build -o app-darwin-arm64  ./cmd/api

Усі чотири збираються з однієї машини. go tool dist list друкує кожну підтримувану пару.

Інші прапорці, які варто знати: -trimpath прибирає локальні шляхи файлової системи з бінарника, що потрібно для відтворюваних збірок і щоб не витікав ваш домашній каталог у трасування стека.

Контейнери

Оскільки результат — це статичний бінарник, образ може бути майже порожнім:

FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/app ./cmd/api

FROM gcr.io/distroless/static-debian12
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]

Копіювання go.mod/go.sum і запуск go mod download перед рештою джерела — це трюк кешування шарів: залежності перезавантажуються лише тоді, коли ці два файли змінюються.

CGO_ENABLED=0 — це те, що дозволяє статичний (static) базовий образ. З увімкненим cgo бінарнику потрібна libc, і фінальний етап має її надати.

cgo

import "C" дозволяє Go викликати C. Так пакет прив'язується до C-бібліотеки — tree-sitter, SQLite, кодеки зображень — і це змінює правила:

  • Крос-компіляція перестає бути безкоштовною. Тепер вам потрібен C-крос-компілятор для кожної цілі.
  • Статичне лінкування стає складнішим, тож distroless/static вже не підходить.
  • Збірки повільніші, бо запускається C-компілятор.
  • Пам'яттю керуєте ви самі. Збирач сміття Go не бачить виділень пам'яті з боку C, тож усе, що виділяє C-сторона, треба звільняти явно:
tree := parser.Parse(source)
defer tree.Close()   // звільняє пам'ять C — це не опційно

Забудьте цей Close, і ви отримаєте витік, який GC ніколи не забере, а профіль купи pprof не покаже, бо ця пам'ять не в купі Go.

  • Паніки не перетинають межу, і крах у C забирає весь процес без жодного трасування стека Go.

Тримайте CGO_ENABLED=0, доки щось справді цього не вимагає, і запишіть цю вимогу в README — розробник, чия збірка раптом потребує C-toolchain, заслуговує знати чому.

З досвіду Python: go build замінює весь стек пакування — немає ні wheel, ні virtualenv, ні інтерпретатора для постачання. Теги збірки — це умовна компіляція, а не перевірки sys.platform під час виконання. cgo — це той самий компроміс, що й ctypes чи C-розширення, і коштує приблизно стільки ж.

Швидка довідка

Задача Форма
записати генератор //go:generate cmd args, запуск через go generate ./...
позначити згенерований код // Code generated by X. DO NOT EDIT.
умовний файл //go:build tag перед package, порожній рядок після
варіанти платформи суфікси _linux.go, _windows.go
перевірити, що обрано go list -f '{{.GoFiles}}' .
ховати інтеграційні тести змінна середовища й t.Skip — не тег збірки
проштампувати версію -ldflags "-X main.version=1.2.3"
менший бінарник -ldflags "-s -w", -trimpath
крос-компіляція GOOS=… GOARCH=… go build
статичний бінарник для контейнера CGO_ENABLED=0
пам'ять C defer x.Close(), завжди

Джерела