voidlearn
 

1.8Модули, пакеты, инструментарий

Проследим, как Go выбирает версии модулей и почему пакет, модуль и репозиторий имеют разные границы.

Модуль, пакет, репозиторий: три разные вещи

Пакет Модуль
Что это Единица компиляции и области видимости Единица версионирования и распространения
Границы Одна директория (без вложенных) Дерево директорий от go.mod вниз
Объявляется package name в каждом файле go.mod в корне
Имя Короткое: http, json Путь: github.com/org/repo
Видимость Экспорт по заглавной букве Ограничение через internal/
Импортируется Да, по полному пути Нет - импортируют пакеты внутри него
Версия Нет Есть, semver
Сколько Много в модуле Обычно один на репозиторий

Формулировка на собес: модуль объединяет пакеты, которые версионируют и выпускают вместе. Импортируешь всегда пакет, а не модуль; путь импорта складывается из пути модуля и пути директории внутри него. Один репозиторий может держать несколько модулей (монорепо с подмодулями), но тогда начинаются теги вида subdir/v1.2.0 и ручная синхронизация версий, поэтому так делают редко.

Репозиторий github.com/acme/billing МОДУЛЬ - от go.mod и вниз по дереву go.mod go.sum граница модуля и версии / package main main.go, wire.go /invoice package invoice · 4 файла /internal/store виден только внутри модуля /invoice/pdf отдельный пакет! ПАКЕТ = ровно одна директория. Вложенная папка - это другой пакет, никакой «вложенности» и наследования видимости между ними нет. Как складывается путь импорта module path + dir import "github.com/acme/billing/invoice/pdf" это модуль это директория внутри него Имя пакета в коде и последний сегмент пути - РАЗНЫЕ вещи: import "gopkg.in/yaml.v3" yaml.Marshal(...) // не v3.Marshal Поэтому goimports иногда ставит алиас. Формулировка: модуль - набор пакетов, которые версионируются, тегируются и выпускаются ВМЕСТЕ. Импортируют всегда пакет. Один репозиторий обычно = один модуль. Несколько модулей в репо возможны, но тянут за собой теги вида subdir/v1.2.0 и рассинхрон версий.
Репозиторий → модуль → пакеты. Границу модуля задаёт файл go.mod, границу пакета задаёт директория. Путь импорта склеивается из пути модуля и относительного пути папки.

go.mod: что там лежит

module github.com/acme/billing      // путь модуля = префикс всех путей импорта

go 1.24.0                           // языковая версия: какие фичи разрешены и какая семантика

toolchain go1.24.3                  // какой тулчейн скачать, если локальный младше (1.21+)

require (
    github.com/jackc/pgx/v5 v5.6.0          // прямые зависимости
    go.uber.org/zap         v1.27.0
)

require (
    github.com/pkg/errors v0.9.1 // indirect   // косвенные: нужны зависимостям
)

replace github.com/broken/lib => ../local-fork   // подмена: путь или другая версия

exclude github.com/bad/lib v1.2.3                 // «эту версию не брать никогда»

retract [v1.0.0, v1.0.4]                          // автор отзывает свои версии (1.16+)

tool golang.org/x/tools/cmd/stringer               // Go 1.24: инструменты как зависимости

godebug default=go1.23                             // Go 1.23: зафиксировать старое поведение
Директива go задаёт не только «минимальную версию компилятора»

Она задаёт языковую версию для всех пакетов модуля: какие фичи доступны и по какой семантике всё работает. Самый громкий пример: go 1.22 включает новую семантику переменной цикла, а модуль с go 1.21 в том же билде продолжит работать по-старому. С Go 1.21 директива стала жёсткой. Тулчейн младше указанного не предупредит, а откажется собирать, и (при разрешённом GOTOOLCHAIN) сам скачает нужный. Ещё тонкость: указать go 1.24 и собирать старым компилятором нельзя. Наоборот можно и нужно: новый компилятор соберёт модуль с go 1.19.

Про replace, exclude и retract
  • replace действует только в главном модуле. Оставишь replace в своей библиотеке, и у потребителей он просто проигнорируется: классическое «у меня работает, у них нет». Для локальной разработки лучше go work (workspaces, Go 1.18): делает то же самое, но живёт в go.work, который не коммитят.
  • exclude убирает конкретную версию из рассмотрения MVS. Дальше берётся следующее по величине требование, а оно может оказаться ниже исключённой версии, а не выше. Выкинешь битый v1.4.0 и уедешь на v1.1.0, хотя v1.9.0 лежит рядом. Поэтому exclude обычно дополняют явным require на здоровую версию.
  • retract остаётся единственным способом «отозвать» уже опубликованный тег. Удалить версию из прокси нельзя (immutable), зато можно пометить её отозванной, и go get перестанет её предлагать.

go.sum, checksum database и приватные модули

go.sum работает не как lock-файл. Версии фиксирует go.mod плюс алгоритм MVS, а go.sum отвечает за целостность. В нём лежат криптографические хеши (SHA-256, кодировка h1:) для каждой версии, которая когда-либо участвовала в сборке, включая те, что в итоге не выбраны.

# Две строки на каждый модуль:
github.com/jackc/pgx/v5 v5.6.0        h1:SWJze...   # хеш дерева файлов модуля
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/...   # хеш только его go.mod

Вторая строка нужна, чтобы считать граф зависимостей, не скачивая сами модули: для MVS хватает go.mod-файлов. Поэтому в go.sum регулярно оказываются модули, чей код в сборку не попал. Руками их не чисти, для этого есть go mod tidy.

Переменная Что делает Когда трогать
GOPROXY Список прокси через ,; direct - идти в VCS напрямую. По умолчанию https://proxy.golang.org,direct Свой Athens/Nexus, закрытый контур
GOSUMDB Прозрачный лог контрольных сумм, по умолчанию sum.golang.org off - только в закрытом контуре
GONOSUMDB Шаблоны путей, для которых не сверяться с checksum database. Сам go.sum при этом продолжает работать Обычно выставляется автоматически из GOPRIVATE
GONOSUMCHECK Несуществующее имя: ни в vgo, ни в одном релизе Go такой переменной не было, тулчейн её не читает Если увидели в CI - менять на GOPRIVATE
GOPRIVATE Главная ручка: шаблоны путей, которые считаются приватными. Автоматически задаёт GONOPROXY и GONOSUMDB GOPRIVATE=github.com/acme/*
GONOPROXY Шаблоны, которые не идут через прокси Тонкая настройка отдельно от GOPRIVATE
GOFLAGS Флаги по умолчанию для всех go-команд GOFLAGS=-mod=mod в CI
GOINSECURE Разрешить HTTP или невалидный TLS для шаблонов путей Внутренний Git без нормального сертификата
GOMODCACHE Где лежит кэш модулей, по умолчанию $GOPATH/pkg/mod Кэш в CI, отдельный диск
GOTOOLCHAIN Разрешено ли автоматически скачивать тулчейн под директиву go (Go 1.21+) local - запретить скачивание в закрытом контуре
Самая частая боль в проде

Приватный репозиторий + дефолтные настройки = go mod download уходит в proxy.golang.org, получает 404, потом пытается сверить хеш с sum.golang.org и падает с verifying module: ... 404 Not Found. Лечится одной строкой: GOPRIVATE=github.com/acme/* сразу выключает и прокси, и sumdb для этих путей, плюс git config --global url."git@github.com:".insteadOf "https://github.com/" для аутентификации. А вот выкручивать GOFLAGS=-mod=mod и GONOSUMDB=* «чтобы просто заработало» не стоит: так ты теряешь защиту от подмены и для публичных зависимостей тоже.

Что такое checksum database и зачем он вообще

sum.golang.org держит append-only прозрачный лог (по типу Certificate Transparency) на дереве Меркла. Он берёт на себя то, чего go.sum не умеет: go.sum ловит подмену кода после того, как ты один раз зафиксировал хеш, а sumdb страхует первый раз и гарантирует, что все в мире видят один и тот же контент для этой версии. Отсюда и запрет «перевыпускать» тег. Автор сделал git tag -f v1.2.3, запушил другой код, новый хеш не сошёлся с записанным в логе, и сборка сломалась у всех, кто берёт модуль мимо прокси (прокси отдаёт старую копию): checksum mismatch ... SECURITY ERROR. Реагировать надо не командой go clean -modcache и не флагом GOFLAGS=-mod=mod, а разбирательством: либо кто-то переписал тег, либо в кэше или прокси лежит подменённый артефакт.

Semver и major-версии в пути импорта

Версии модулей подчиняются строгому semver: vMAJOR.MINOR.PATCH, обязательно с префиксом v. MAJOR меняется при ломающих изменениях, MINOR при обратно совместимых добавлениях, PATCH при исправлениях. А дальше правило, которого нет больше нигде:

Import compatibility rule

«Если старый и новый пакет имеют один и тот же путь импорта, новый пакет должен быть обратно совместим со старым.» Отсюда следствие: несовместимость обязана менять путь. Поэтому начиная с v2 мажорная версия входит в путь модуля: github.com/acme/lib/v2. Версии v0 и v1 вынесли в исключение: у v0 гарантий совместимости нет вообще, а v1 оставили без суффикса ради совместимости со старыми путями импорта.

// Обе версии могут жить в одной программе одновременно: это разные модули
import (
    pgx4 "github.com/jackc/pgx/v4"
    pgx5 "github.com/jackc/pgx/v5"
)
// На практике большой сервис можно мигрировать по одному пакету за раз,
// а не «большим взрывом» в один коммит.
# Как выпускается v2 (два рабочих подхода)
# 1) Major subdirectory: копия кода в подпапке, работает даже со старыми тулчейнами
mkdir v2 && cp -r *.go v2/ && cd v2 && go mod init github.com/acme/lib/v2
git add . && git commit -m "v2" && git tag v2.0.0 && git push origin main v2.0.0

# 2) Major branch: отдельная ветка, где go.mod уже содержит /v2. Компактнее, но
#    требует аккуратности с тегами
git checkout -b v2
go mod edit -module github.com/acme/lib/v2
# Внутренние импорты модуля тоже надо переписать на .../v2/...
git commit -am "v2" && git tag v2.0.0 && git push origin v2 v2.0.0

# Для модуля без тегов Go сам делает псевдоверсию:
#   v0.0.0-20240115143022-a1b2c3d4e5f6
#   base-версия   UTC-время коммита   первые 12 символов хеша
# Псевдоверсии сортируются по времени между собой. Но «всегда ниже тега» - миф:
# базовая версия зависит от того, есть ли тег выше по истории.
#   тегов нет вообще        -> v0.0.0-<время>-<хеш>   (ниже любого релиза)
#   есть тег v1.5.2         -> v1.5.3-0.<время>-<хеш> (выше v1.5.2, ниже v1.5.3)
# То есть псевдоверсия перебивает тег, от которого построена, и MVS выберет её.

# Версия v2+ модуля без go.mod получает суффикс +incompatible:
#   github.com/old/lib v2.0.0+incompatible
Три классических промаха с v2+
  • Поставили тег v2.0.0, но забыли дописать /v2 в module. go get отказывается ставить и ругается: «.../lib@v2.0.0: invalid version: module contains a go.mod file, so module path must match major version (".../lib/v2")».
  • Переименовали модуль в /v2, а внутренние импорты оставили старыми. Тогда в бинарь приедут обе копии пакета, с двумя независимыми наборами глобальных переменных и init().
  • Считают, что v0.x защищён semver. Нет: в v0 ломать можно в любом минорном релизе, и MVS это не считает нарушением.

MVS - minimal version selection

Go выбирает версии не так, как npm, Cargo и pip. Всё правило умещается в одну фразу: для каждого модуля берётся максимум из минимально требуемых версий. Никакого солвера, никаких SAT-задач, никакого «взять самое свежее, что подходит под ^1.2.0».

Один и тот же граф зависимостей main A v1.2.0 B v1.5.0 C v1.1.0 C v1.3.0 В репозитории уже есть C v1.9.0 Go: MVS Собрать все требования на C: { v1.1.0, v1.3.0 } Взять МАКСИМУМ из них: C v1.3.0 v1.9.0 НЕ берётся: её никто не просил npm / Cargo Требования - диапазоны: ^1.1.0 и ^1.3.0 Решатель ищет свежайшее в пересечении: C v1.9.0 Сборка меняется без правки кода Что из этого следует практически + Воспроизводимость по построению: go.mod + go.sum дают ОДИН И ТОТ ЖЕ билд сегодня и через два года, без lock-файла. + Нет «фантомных» обновлений: новая версия зависимости не приедет, пока кто-то явно не поднимет требование. + Алгоритм линейный и понятный: нет NP-полного решателя, нет «npm resolution hell», нет ситуации «lock протух - пересобери». + Нет дублей версий в одном билде: ровно одна версия модуля на major (в отличие от node_modules с деревом копий). − Патчи безопасности НЕ приезжают сами: нужен go get -u или Dependabot/Renovate. Это осознанный обмен свежести на предсказуемость. − Ответственность на мейнтейнере: если модуль требует старую версию с CVE, поднимать её придётся вручную во всех потребителях.
MVS против решателя диапазонов. Go не спрашивает «какая самая новая версия подойдёт», он спрашивает «какая самая старая версия удовлетворяет всех», и берёт её. Отсюда воспроизводимость сборок без lock-файла.
# Инструменты, чтобы увидеть решение MVS
go list -m all                  # итоговый выбор версий (build list)
go mod graph                    # весь граф требований, ребро = "кто кого требует"
go mod why -m github.com/x/y    # зачем этот модуль вообще в сборке: цепочка импортов
go list -m -u all               # какие обновления доступны
go mod tidy                     # привести require и go.sum к тому, что реально импортируется

# Обновления всегда явные:
go get github.com/x/y@v1.5.0    # конкретная версия
go get -u ./...                 # поднять минорные и патчи всех зависимостей, включая косвенные
go get -u=patch ./...           # только патчи
go get github.com/x/y@none      # выкинуть из графа
Почему MVS считается более безопасным дизайном

Из-за «высокоточности»: набор версий определяет только содержимое go.mod всех участников графа. Ни дата сборки, ни состояние реестра, ни наличие lock-файла на него не влияют. Атака «опубликовать вредоносный патч-релиз и ждать, пока CI сам его подтянет» в Go не работает: пока никто не поднял требование, версия не приедет. Обратная сторона в том, что за CVE придётся следить самому, поэтому в проде рядом с MVS всегда стоят govulncheck и автоматический бампер.

GOPROXY, кэш модулей и вендоринг

Модуль скачивается по цепочке: локальный кэш → прокси из GOPROXY → VCS (direct). Кэш лежит в $GOPATH/pkg/mod (или GOMODCACHE), общий на всю машину. Внутри распакованные модули в режиме read-only плюс cache/download, то есть «зеркало прокси» в том же формате.

Режим Как включить Плюсы Минусы
Прокси по умолчанию ничего не делать Быстро, immutable, есть даже удалённые из GitHub модули Внешняя зависимость CI от сети
Свой прокси (Athens, Nexus, JFrog) GOPROXY=https://athens.corp,direct Работает в закрытом контуре, кэш внутри периметра, аудит Надо поддерживать
Вендоринг go mod vendor Сборка вообще без сети, код зависимостей в ревью и в диффе Репозиторий пухнет, конфликты при мерже, надо не забывать пересобирать
Кэш в CI кэшировать GOMODCACHE Простое ускорение, никаких изменений в репо Кэш может протухнуть или испортиться
go mod vendor        # создаёт ./vendor с кодом зависимостей + vendor/modules.txt
# Дальше go build автоматически использует -mod=vendor, если:
#   директория vendor/ существует и директива go >= 1.14
go build -mod=mod    # явно проигнорировать vendor
go mod verify        # проверить, что кэш не меняли после скачивания (с go.sum не сверяет)
go clean -modcache   # снести кэш целиком: read-only он снимет сам, rm -rf упрётся в права

# Воспроизводимая сборка в CI: три флага, которые стоит знать
go build -mod=readonly ./...   # запретить менять go.mod (в 1.16+ это дефолт)
go mod download                # только скачать: go.mod не трогает, но может дописать go.sum
go mod tidy -diff              # Go 1.23: показать, что бы изменилось, и упасть если есть diff
Вендорить или нет: как отвечать

Современный дефолт: не вендорить. MVS плюс go.sum уже дают воспроизводимость, а прокси даёт доступность. Вендоринг оправдан в трёх случаях: сборка в закрытом контуре без своего прокси, регуляторное требование держать весь код зависимостей в репозитории, обязанность ревьюить любые изменения в сторонних библиотеках (финтех, безопасность). Если вендоришь, запускай go mod vendor в CI и роняй сборку при расхождении, иначе vendor/ тихо разъедется с go.mod.

Циклические импорты: почему запрещены и как разруливать

Go не допускает циклов в графе импортов вообще: ни прямых (a → b → a), ни транзитивных. Попытка даёт ошибку компиляции import cycle not allowed со всей цепочкой. Причин три, и назвать лучше все:

  • Скорость компиляции. Пакет компилируется один раз, результат ложится в .a-архив и переиспользуется. Ациклический граф даёт топологическую сортировку: каждый пакет собирается после всех, от кого зависит, и параллельно с независимыми. С циклами группу пакетов пришлось бы компилировать совместно, и любая правка пересобирала бы всю группу.
  • Детерминированная инициализация. Порядок init() задаёт топологическая сортировка графа. В цикле «кто первый» не определить.
  • Дизайн. Цикл почти всегда означает, что границы пакетов проведены неправильно: либо два пакета на самом деле составляют один, либо между ними не хватает третьего.
Проблема order user import cycle not allowed Решение 1 - выделить общий пакет order user domain типы, которые нужны обоим, - вниз Решение 2 - интерфейс у ПОТРЕБИТЕЛЯ package order type Users interface{...} user (реализует) компилируемой зависимости больше нет Решение 3 - колбэк / DI order.New( getUser func(id) User) main связывает оба граф зависимостей поднят в main Почему «интерфейс у потребителя» - идиоматический Go В Java интерфейс объявляют рядом с реализацией и импортируют вниз. В Go реализация НЕЯВНАЯ, поэтому интерфейс объявляют там, где он ИСПОЛЬЗУЕТСЯ, и стрелка зависимости разворачивается: пакет-реализация больше не импортируется тем, кто его вызывает. Это же лечит и цикл, и тестируемость сразу. Чего делать НЕ надо · Схлопывать два пакета в один «god package» models/ на 200 типов - цикл уйдёт, архитектура развалится. · Городить глобальную переменную-реестр с init(), чтобы «зарегистрировать» реализацию - это скрытый цикл во время выполнения. · Использовать interface{} и рефлексию, лишь бы не импортировать пакет.
Разрыв цикла. Три рабочих приёма. В 90% случаев на собесе ждут второй: объявить интерфейс на стороне потребителя и тем самым инвертировать зависимость.
// ── Было: цикл ──────────────────────────────────────────────
// package order
import "app/user"
func (s *Service) Create(uid int64) error {
    u, err := user.Get(uid)      // order → user
    ...
}
// package user
import "app/order"
func Get(id int64) (User, error) {
    last := order.LastFor(id)    // user → order  ← цикл
    ...
}

// ── Стало: интерфейс у потребителя ──────────────────────────
// package order объявляет то, что ему нужно, и ничего не импортирует
type UserGetter interface {
    Get(ctx context.Context, id int64) (User, error)
}
type Service struct {
    users UserGetter
}

func New(users UserGetter) *Service { return &Service{users: users} }

func (s *Service) Create(ctx context.Context, uid int64) error {
    u, err := s.users.Get(ctx, uid)   // никакого import "app/user"
    ...
}

// package user просто имеет подходящий метод, про интерфейс не знает
func (s *Store) Get(ctx context.Context, id int64) (order.User, error) { ... }

// package main - единственное место, где встречаются оба
svc := order.New(user.NewStore(db))
Тест на цикл, о котором забывают

Тестовый файл foo_test.go с package foo живёт внутри пакета и участвует в графе импортов: из него нельзя импортировать пакет, который импортирует foo. А файл с package foo_test (external test package) компилируется отдельным пакетом и может импортировать что угодно, включая зависимых от foo. Так штатно проверяют связку двух пакетов, не создавая цикл, и заодно получают ответ на вопрос «зачем вообще нужен package x_test».

internal/: единственный механизм видимости на уровне пакетов

Правило одно, и его проверяет тулчейн при сборке: пакет из директории internal/ можно импортировать только из кода, чей путь начинается с родителя этой internal/. Всё остальное получает ошибку use of internal package ... not allowed.

github.com/acme/app/ /cmd/api /pkg/client /internal - «родитель» = /acme/app /internal/store /internal/auth /internal/auth/ internal/jwt виден только auth оба - внутри /acme/app → можно Снаружи github.com/other/svc import "github.com/acme/app/internal/store" → use of internal package not allowed Проверяет СБОРКА (команда go), а не соглашение и не линтер. Правило: пакет .../X/internal/Y импортируется только из дерева, начинающегося с .../X. Уровень internal может быть любым и вложенным - так внутри модуля строится настоящая иерархия видимости: /internal - для всего модуля, /internal/auth/internal - только для auth.
internal/. Единственный способ в Go сказать «это деталь реализации, трогать снаружи нельзя», и единственный, который проверяет сборка, а не код-ревью.
Практика: что класть в internal/

По умолчанию всё. Стандартная раскладка сервиса: /cmd/<binary> с тремя строками main, весь остальной код в /internal/..., а /pkg/... только для того, что ты намеренно публикуешь как API для чужих сервисов. Отсюда свобода рефакторинга: пока пакет в internal, меняй его сигнатуры сколько угодно, мажорную версию выпускать не придётся. В стандартной библиотеке internal работает по тому же правилу, например net/http/internal.

init() и полный порядок инициализации

Полный порядок инициализации укладывается в пять шагов.

Порядок инициализации - сверху вниз 1. Зависимости Импортируемые пакеты - полностью, до текущего 2. Переменные пакета В порядке ЗАВИСИМОСТЕЙ между ними, а не в порядке объявления в файле 3. init() пакета Файлы в порядке имён, внутри файла - сверху вниз 4. main.main() и только теперь Пример: почему b вычисляется раньше a package main var a = b + 1 // 3 var b = f() // 2 func f() int { return c } var c = 1 // 1 Компилятор строит граф: c → b → a Из готовых берётся первая по порядку объявления. Цикл в графе = ошибка компиляции. Порядок ФАЙЛОВ Спецификация не обязывает, но go build передаёт файлы компилятору отсортированными по имени: a.go → m.go → z.go. Закладываться на это - плохо. Что происходит ДО всего этого Рантайм: инициализация планировщика, аллокатора и GC, создание g0/m0, разбор argv и переменных окружения, настройка сигналов. Всё это уже работает к моменту первого init(), поэтому в init можно запускать горутины и делать аллокации - но НЕ стоит: см. ниже.
Инициализация. Переменные пакета инициализируются в порядке зависимостей между ними, а не в порядке строк. Порядок файлов остаётся деталью реализации go build, и полагаться на неё нельзя.
// ── Полные правила ──────────────────────────────────────────
// 1. Пакет инициализируется ровно один раз, даже если импортирован из десяти мест.
// 2. Сначала все импортируемые пакеты, рекурсивно, в порядке топологической сортировки.
// 3. Внутри пакета: переменные уровня пакета в порядке зависимостей между ними.
// 4. Затем все init() этого пакета. Их может быть несколько в одном файле.
// 5. init() нельзя вызвать вручную, нельзя взять его адрес, нет параметров и результата.
// 6. Только после инициализации всех пакетов запускается main.main().

package config

var (
    Timeout = defaultTimeout * 2      // зависит от defaultTimeout → будет вторым
    defaultTimeout = 15 * time.Second // будет первым
)

func init() { fmt.Println("init 1") }
func init() { fmt.Println("init 2") } // обе выполнятся, в порядке появления в файле

// Импорт ради побочного эффекта - главное хорошее применение init:
import (
    _ "github.com/lib/pq"                       // регистрирует драйвер в database/sql
    _ "net/http/pprof"                          // вешает хендлеры на DefaultServeMux
    _ "go.uber.org/automaxprocs"                // правил GOMAXPROCS под cgroup;
                                                // с Go 1.25 рантайм умеет это сам
)
Чем плохи init(): развёрнутый ответ
  • Невозможно вернуть ошибку. На сбой остаётся ответить только panic или log.Fatal: процесс падает на старте без внятного контекста.
  • Невозможно передать параметры. Конфиг берётся из глобальных переменных или прямо из os.Getenv, и зависимость прячется.
  • Ломает тесты. init выполняется при любом запуске тестов пакета, даже если тестируется не он. Пакет, который в init лезет в базу или читает файл, делает свои тесты нелокальными.
  • Порядок неочевиден. Читая файл, ты не видишь, что раньше отработали три init из импортов. Отладка «почему переменная уже не nil» превращается в квест.
  • Транзитивные побочные эффекты. Классика: кто-то в глубине зависимостей импортировал net/http/pprof, и твой публичный сервер на дефолтном мультиплексоре внезапно отдаёт /debug/pprof/ в интернет.
  • Замедляет старт. Всё, что в init, выполняется до main и до того, как заработает readiness-проба.
// ── Как надо вместо init ────────────────────────────────────
// Плохо: скрытая зависимость, паника на старте, нетестируемо
var db *sql.DB
func init() {
    var err error
    db, err = sql.Open("postgres", os.Getenv("DSN"))
    if err != nil { log.Fatal(err) }      // никакого контекста, defer-ы не отработают
}

// Хорошо: явный конструктор с ошибкой, вызывается из main
func NewDB(ctx context.Context, dsn string) (*sql.DB, error) {
    db, err := sql.Open("postgres", dsn)
    if err != nil { return nil, fmt.Errorf("open db: %w", err) }
    if err := db.PingContext(ctx); err != nil { return nil, fmt.Errorf("ping db: %w", err) }
    return db, nil
}

// Ленивая инициализация вместо init, если объект дорогой
var (
    once   sync.Once
    client *http.Client
)
func Client() *http.Client {
    once.Do(func() { client = buildClient() })
    return client
}
// Go 1.21: sync.OnceValue / sync.OnceValues - то же самое, но короче
var Client = sync.OnceValue(buildClient)
Когда init всё-таки уместен

Три случая: (1) регистрация в реестре, то есть драйверы database/sql, кодеки image, форматы encoding; (2) константы, которые нельзя выразить литералом и приходится собирать на старте: regexp.MustCompile, template.Must; (3) проверка инвариантов сборки, например что таблица переходов заполнена. Общее у них одно: работа детерминированная, локальная и без внешнего мира, то есть ни сети, ни файлов, ни базы, ни переменных окружения.

Инструментарий

Инструмент Что делает Где живёт Ловит примеры
gofmt Канонический формат кода. Не настраивается - в этом весь смысл В тулчейне Споры о стиле в ревью
goimports gofmt + автоматические импорты и их группировка x/tools Забытые/лишние импорты
go vet Набор анализаторов на «подозрительное, но компилируемое» В тулчейне, часть проверок запускается сама при go test Printf-формат, копирование мьютекса, недостижимый код, потерянный cancel
staticcheck Самый мощный отдельный анализатор: ~150 проверок SA/S/ST/QF honnef.co/go/tools Мёртвый код, неверные сравнения, неэффективные конструкции, деприкейты
golangci-lint Раннер десятков линтеров с кэшем и параллелизмом. Сам ничего не анализирует Отдельный бинарь Всё вышеперечисленное разом + errcheck, revive, gosec, bodyclose…
go generate Выполняет команды из комментариев //go:generate. Не запускается сам при build В тулчейне mockgen, stringer, protoc, sqlc, ent
go test -race Детектор гонок (ThreadSanitizer) В тулчейне Гонки данных - но только на исполненном пути
govulncheck CVE в зависимостях, с проверкой достижимости уязвимой функции x/vuln Уязвимости, которые реально вызываются вашим кодом
pprof / trace Профили CPU/heap/block/mutex и трасса планировщика В тулчейне Горячие места, аллокации, блокировки
# Минимальный набор в CI, который стоит уметь назвать
gofmt -l .                      # список неотформатированных файлов; пусто = ок
go vet ./...
go build ./...
go test -race -count=1 ./...
go mod tidy -diff               # Go 1.23: упасть, если go.mod/go.sum не в порядке
golangci-lint run --timeout=5m
govulncheck ./...
# .golangci.yml - рабочий минимум без фанатизма (формат golangci-lint v2)
version: "2"
linters:
  enable:
    - errcheck        # непроверенные ошибки
    - govet
    - staticcheck
    - revive          # замена golint: стиль и именование
    - ineffassign     # присваивания, которые никто не читает
    - bodyclose       # незакрытый resp.Body
    - rowserrcheck    # забытый rows.Err()
    - sqlclosecheck
    - errorlint       # неправильные сравнения ошибок вместо errors.Is
    - noctx           # HTTP-запрос без context
  exclusions:
    rules:
      - path: _test\.go
        linters: [errcheck, gosec]
// go:generate - код рядом с тем, что он генерирует
//go:generate mockgen -source=$GOFILE -destination=mocks/store_mock.go -package=mocks
//go:generate stringer -type=Status -linecomment

type Status int

const (
    StatusNew     Status = iota // new
    StatusPaid                  // paid
    StatusShipped               // shipped
)
// stringer сгенерирует Status.String() без рефлексии и без ручного switch.

// Go 1.24: инструменты фиксируются в go.mod, а не в файле tools.go с build-тегом
//   go get -tool golang.org/x/tools/cmd/stringer
//   go tool stringer -type=Status
// Раньше приходилось держать такое:
//   //go:build tools
//   package tools
//   import _ "golang.org/x/tools/cmd/stringer"
Как отвечать про разницу vet / staticcheck / golangci-lint

«go vet встроенный, консервативный, почти без ложных срабатываний; часть его проверок запускается автоматически при go test, а остальные гоняю в CI, и его находки чиню всегда. staticcheck отдельный и гораздо более глубокий анализатор. А golangci-lint вообще не линтер, а раннер: гоняет и vet, и staticcheck, и ещё три десятка, переиспользуя один разбор AST и кэшируя результат, поэтому в CI это на порядок быстрее, чем запускать их по одному.»

Билд-теги, кросс-компиляция и CGO

//go:build linux && amd64 && !nocgo
// Синтаксис с Go 1.17. Старая форма // +build до сих пор встречается в чужом коде.
// Строка должна идти до package. Пустую строку после неё ставит gofmt, а для // +build она обязательна.

package storage
// Выражение поддерживает && || ! и скобки. Доступные теги: GOOS, GOARCH,
// имя компилятора (gc, gccgo), cgo, версия тулчейна (go1.21 и выше), и свои через -tags.
# Неявные теги по имени файла работают без строки //go:build
store_linux.go        # GOOS=linux (и android)
store_windows_amd64.go # GOOS=windows и GOARCH=amd64
store_test.go         # только при go test
export_test.go        # обычный тестовый файл, приём «открыть приватное для тестов»

# Свои теги
go build -tags="integration,e2e" ./...
go test  -tags=integration ./...

# Кросс-компиляция: обычно одна строка, без тулчейнов и sysroot
GOOS=linux GOARCH=amd64 go build -o app .
GOOS=darwin GOARCH=arm64 go build -o app-mac .
GOOS=windows GOARCH=amd64 go build -o app.exe .
go tool dist list          # полный список поддерживаемых пар

# Прод-сборка контейнерного бинаря
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
  go build -trimpath -ldflags="-s -w -X main.version=$(git describe --tags)" -o /app .
#   CGO_ENABLED=0  → полностью статический бинарь, работает в scratch/distroless
#   -trimpath      → убирает абсолютные пути из бинаря (воспроизводимость + не течёт /home/user)
#   -s -w          → выкинуть таблицу символов и DWARF: минус ~30% размера
#   -X             → вшить версию в переменную на этапе линковки
Что даёт CGO Чего стоит
Доступ к любым C-библиотекам (SQLite, libpq, OpenCV, ffmpeg) Нужен C-компилятор → кросс-компиляция ломается, требуется тулчейн под каждую цель
Системные API, которых нет в стандартной библиотеке Бинарь становится динамически слинкованным: scratch-образ больше не работает
Полная реализация os/user и DNS через системный резолвер Каждый вызов C - переход в состояние syscall (затянется вызов - sysmon отберёт у горутины P) и переключение на системный стек. Оверхед порядка десятков наносекунд
Постепенная миграция legacy C-кода C-код не под наблюдением GC: утечки, отсутствие panic/recover, падения ломают весь процесс
- Ломается часть инструментов: -race не видит гонки внутри C, профилировщик хуже показывает C-стеки
- Растёт время сборки, усложняется Dockerfile, появляется зависимость от glibc/musl
Правило про CGO в проде

CGO_ENABLED=0 для сервисов идёт дефолтом. Включай только под конкретную нужду и лучше выноси её в отдельный бинарь, а не тащи в основной. Самый частый случай: mattn/go-sqlite3 требует CGO, хотя есть чистая замена modernc.org/sqlite. Второй случай, DNS: с CGO_ENABLED=0 работает чистый Go-резолвер, который игнорирует часть настроек nsswitch.conf. В Kubernetes это обычно не проблема, в гибридных сетях иногда всплывает.

Что нового в свежих версиях Go

Этим вопросом проверяют одно: следишь ли ты за языком вообще. Достаточно назвать по 2–3 значимых пункта на релиз и объяснить, зачем их сделали. Релизы выходят строго дважды в год, в феврале и августе: 1.24 в феврале 2025, 1.25 в августе 2025, 1.26 в феврале 2026, 1.27 в августе 2026, текущая.

Версия Главное Зачем
1.22
фев 2024
Новая семантика переменной цикла: своя копия на каждую итерацию Убрана самая известная ловушка языка. Включается строкой go 1.22 в go.mod - по модулю, а не по тулчейну
Паттерны в http.ServeMux: методы и wildcard-сегменты Роутер в stdlib наконец покрывает 80% случаев без chi/gorilla
for i := range 10 - range по целому Убирает шум for i := 0; i < 10; i++
math/rand/v2, slices.Concat, PGO-сборки быстрее на 2–14% Первый /v2 в самой стандартной библиотеке
1.23
авг 2024
Итераторы: range over func + пакет iter Единый протокол обхода для любых коллекций - до этого каждый писал свой
Итераторные функции в slices/maps slices.Sorted(maps.Keys(m)) вместо шести строк
Пакет unique; таймеры стали GC-безопасными, канал у Timer теперь без буфера Интернирование строк; убрана классическая утечка неостановленного time.After в select
1.24
фев 2025
Swiss Tables - новая реализация мап Поиск сразу по 8 контрольным байтам (на amd64 через SIMD): до 60% быстрее на микробенчмарках, меньше памяти. Прозрачно для кода
Generic type aliases: type Set[T comparable] = map[T]struct{} Закрыт пробел дженериков: алиасы наконец могут иметь параметры типа
tool-директива в go.mod + go tool Хак с tools.go и build-тегом больше не нужен
b.Loop(), os.Root, weak, runtime.AddCleanup for b.Loop() чинит целый класс ошибок в бенчмарках; os.Root - защита от path traversal
1.25
авг 2025
WaitGroup.Go Один метод вместо связки Add(1) + go + defer Done() - убирает забытый Done и рассинхрон счётчика
Container-aware GOMAXPROCS Рантайм читает CPU-лимит cgroup и подстраивается на лету. automaxprocs больше не нужен
testing/synctest - стабильный Детерминированные тесты конкурентного кода с «виртуальным» временем
GC Green Tea и encoding/json/v2 - тогда ещё эксперименты, flight recorder в runtime/trace Оба эксперимента с тех пор доехали до дефолта: Green Tea - в 1.26, json/v2 - в 1.27
1.26
фев 2026
Green Tea GC включён по умолчанию Минус 10–40% накладных расходов сборщика, плюс ещё около 10% на свежих amd64 за счёт векторных инструкций. Выключить: GOEXPERIMENT=nogreenteagc
Два изменения в языке: new принимает выражение (p := new(compute())) и дженерик-тип может ссылаться на себя в констрейнте (type Adder[A Adder[A]] interface{ Add(A) A }) Первое убивает самописные ptr[T]-помощники, второе позволяет выразить «тип, который складывается сам с собой» без any
errors.AsType[T](err), slog.NewMultiHandler, io.ReadAll вдвое быстрее AsType - дженерик-версия errors.As: типобезопасно и без объявления переменной заранее
Экспериментальный профиль goroutineleak; вызовы cgo дешевле на ~30%; редирект ServeMux на слэш стал 307 вместо 301; go fix переписан в набор «модернизаторов» Профиль находит горутины, навсегда заблокированные на недостижимых примитивах - до этого утечки ловили только NumGoroutine и goleak
1.27
авг 2026
У методов появились собственные параметры типа Снято главное ограничение дженериков. Запрет остался на стыке с интерфейсами: у метода интерфейса параметров типа нет, а дженерик-метод не засчитывается в реализацию. В stdlib пример - Rand.N() в math/rand/v2
encoding/json/v2 как отдельный пакет Строже старого: отвергает невалидный UTF-8 и дублирующиеся ключи объекта, Unmarshal заметно быстрее. Сам encoding/json теперь работает поверх v2, но ведёт себя по-старому (могут отличаться тексты ошибок) - строгость включает только импорт v2. Рядом появился низкоуровневый encoding/json/jsontext. Вернуть прежнюю реализацию: GOEXPERIMENT=nojsonv2, пакеты v2 при этом пропадут
Пакет uuid в стандартной библиотеке (RFC 9562) NewV4(), NewV7(), Parse. NewV7 сортируется по времени - то, что нужно для первичных ключей. Внешний github.com/google/uuid больше не обязателен
Профиль goroutineleak стал общедоступным; strings.CutLast; часть мелких аллокаций дешевле до 30%; метки горутин попадают в трейсбеки; удалён GODEBUG=asynctimerchan Утечки горутин теперь ищутся штатным профилем - /debug/pprof/goroutineleak. Метки в трейсбеке сразу показывают, какой запрос обрабатывала упавшая горутина
// ── 1.22: ServeMux ──────────────────────────────────────────
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")            // новый метод для wildcard-сегментов
    ...
})
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /files/{path...}", serveFiles)   // многосегментный wildcard
// Выигрывает более «специфичный» паттерн, порядок регистрации не важен.
// Конфликтующие паттерны вызывают панику при регистрации, а не тихо перекрывают друг друга.

// ── 1.23: итераторы ─────────────────────────────────────────
// Три сигнатуры, которые понимает range:
//   func(yield func() bool)
//   func(yield func(V) bool)          → iter.Seq[V]
//   func(yield func(K, V) bool)       → iter.Seq2[K, V]

func Lines(r io.Reader) iter.Seq2[string, error] {
    return func(yield func(string, error) bool) {
        sc := bufio.NewScanner(r)
        for sc.Scan() {
            if !yield(sc.Text(), nil) { return }   // false = потребитель сделал break
        }
        if err := sc.Err(); err != nil { yield("", err) }
    }
}

for line, err := range Lines(f) {      // выглядит как обычный range
    if err != nil { return err }
    process(line)
}

// ── 1.24: b.Loop ────────────────────────────────────────────
func BenchmarkParse(b *testing.B) {
    data := load()
    for b.Loop() {          // вместо for i := 0; i < b.N; i++
        _ = Parse(data)     // аргументы и результат не «оптимизируются» компилятором,
    }                       // а setup вне цикла не попадает в замер
}

// ── 1.25: WaitGroup.Go ──────────────────────────────────────
var wg sync.WaitGroup
for _, u := range urls {
    wg.Go(func() { fetch(u) })    // Add(1) + go + defer Done() внутри
}
wg.Wait()

// ── 1.26: new от выражения и errors.AsType ──────────────────
type Req struct {
    Limit *int
}
r := Req{Limit: new(50)}          // раньше: tmp := 50; r := Req{Limit: &tmp}

// errors.AsType - дженерик-версия errors.As: без объявления переменной заранее
if pe, ok := errors.AsType[*fs.PathError](err); ok {
    log.Println(pe.Path)
}
// старая форма: var pe *fs.PathError; if errors.As(err, &pe) { ... }

// 1.26: дженерик-тип ссылается на себя в собственном констрейнте
type Adder[A Adder[A]] interface{ Add(A) A }

// ── 1.27: дженерик-метод и uuid в stdlib ────────────────────
type Box struct {
    v []int
}

func (b *Box) MapTo[T any](f func(int) T) []T {     // до 1.27 не компилировалось
    out := make([]T, 0, len(b.v))
    for _, x := range b.v { out = append(out, f(x)) }
    return out
}

id := uuid.NewV7()                // "uuid" теперь в стандартной библиотеке
fmt.Println(id.String())          // сортируемый по времени → годится в PK

// 1.27: поиск утечек горутин штатным профилем
// go tool pprof http://localhost:6060/debug/pprof/goroutineleak
range over func (Go 1.23): кто кого вызывает Потребитель for v := range Seq { fmt.Println(v) if v > 2 { break } } Компилятор превращает тело цикла в функцию yield. Seq(yield) yield(v) false ← был break Итератор (push-стиль) func(yield func(V) bool) { for _, v := range src { if !yield(v) { return // прибрать за собой } } } Итератор УПРАВЛЯЕТ циклом, не потребитель. Что даёт · ленивость: нечего материализовать · композиция: Filter(Map(Seq)) · один протокол для всех коллекций · break/return/panic корректно прерывают итератор · iter.Pull - если нужен pull-стиль Почему именно такой протокол Альтернатива - pull-итератор с методами Next()/Value(), как в sql.Rows. Он требует хранить состояние обхода вручную, что для дерева или рекурсивной структуры превращается в явный стек. Push-итератор пишется как обычный рекурсивный обход - состояние держит стек вызовов. Цена: yield вызывается на КАЖДЫЙ элемент - это вызов функции, который не всегда инлайнится. В самых горячих циклах обычный for по слайсу всё ещё быстрее. И обязательно: итератор должен корректно завершаться, когда yield вернул false, иначе утекут ресурсы и горутины.
Итераторы. range над функцией работает как синтаксический сахар: тело цикла становится колбэком yield, а break превращается в возврат false. Главное тут: циклом управляет итератор, а не потребитель.
Про Swiss Tables, если спросят подробнее

Go 1.24 заменил классическую реализацию мапы (массив бакетов по 8 пар + цепочка overflow) на Swiss Table: группы по 8 слотов с отдельным control word из 8 байт, где каждый байт хранит 7 бит хеша плюс флаг занятости. Поиск сравнивает сразу все 8 контрольных байтов: на amd64 одной SIMD-инструкцией, на остальных архитектурах несколькими битовыми операциями над 64-битным словом. Поэтому промах определяется за один шаг, а не за обход цепочки. На микробенчмарках это до 60% ускорения, по реальным приложениям в среднем около 1,5% CPU, плюс меньше накладной памяти. Для кода не меняется ничего: порядок обхода по-прежнему случайный, мапа по-прежнему не потокобезопасна, адрес значения по-прежнему нельзя взять.

Вопросы

13
Суть: пакет - единица компиляции и видимости (одна директория); модуль - единица версионирования и распространения (дерево от go.mod вниз). Модуль - это набор пакетов, которые выпускаются вместе под одной версией.
Пакет Модуль
Граница Директория (без вложенных) go.mod и всё дерево ниже
Объявление package name module github.com/org/repo
Версия Нет Semver-тег
Что импортируют Пакет, по полному пути Модуль не импортируют
Видимость Заглавная буква идентификатора internal/
github.com/acme/billing/         ← модуль (тут go.mod)
├── go.mod  go.sum
├── main.go                      ← пакет main
├── invoice/                     ← пакет invoice
│   ├── invoice.go
│   ├── total.go                 ← тот же пакет: файлы в одной папке
│   └── pdf/                     ← ДРУГОЙ пакет invoice/pdf
└── internal/store/              ← пакет store, виден только внутри модуля

# Путь импорта = путь модуля + относительный путь директории:
import "github.com/acme/billing/invoice/pdf"

Несколько модулей в репозитории

Технически можно: положи go.mod в подпапку, и она станет отдельным модулем (её содержимое автоматически исключается из родительского). Но цена высокая: теги превращаются в subdir/v1.2.0, версии расходятся, релиз усложняется. На практике так делают ради двух вещей: (1) вынести пакет с тяжёлыми зависимостями (например, gRPC-контракты), чтобы потребители не тянули лишнего; (2) отделить примеры или e2e-тесты. В остальных случаях: один репозиторий, один модуль.

Ловушка про имя пакета

Имя пакета в коде и последний сегмент пути импорта совпадать не обязаны. import "gopkg.in/yaml.v3" даёт идентификатор yaml, а не yaml.v3; github.com/mattn/go-sqlite3 даёт пакет sqlite3. Отсюда алиасы импорта и отсюда же промахи goimports, который иногда «угадывает» неверно.

Суть: go.mod хранит путь модуля, языковую версию, список минимально требуемых версий зависимостей и локальные корректировки графа. Директива go - это языковая версия, а с Go 1.21 ещё и минимальная версия тулчейна: от неё зависит доступная семантика.
module github.com/acme/billing

go 1.24.0                       // языковая версия для всех пакетов модуля
toolchain go1.24.3              // какой тулчейн скачать, если локальный младше

require (
    github.com/jackc/pgx/v5 v5.6.0
    go.uber.org/zap         v1.27.0
)
require github.com/pkg/errors v0.9.1 // indirect

replace github.com/broken/lib => ../fork      // подмена источника или версии
exclude github.com/bad/lib v1.2.3             // «эту версию не рассматривать»
retract [v1.0.0, v1.0.4]                      // отозвать свои выпущенные версии
tool golang.org/x/tools/cmd/stringer          // Go 1.24: инструменты как зависимости

Директива go - три факта

  • Задаёт, какие языковые фичи разрешены и по какой семантике работает код. Пример: go 1.22 включает новую семантику переменной цикла, а модуль с go 1.21 в том же билде продолжает работать по-старому. Семантику определяет модуль, а не версия тулчейна.
  • С Go 1.21 она жёсткая: тулчейн младше указанного откажется собирать, а не выдаст предупреждение. При GOTOOLCHAIN=auto нужная версия скачается сама.
  • Собрать модуль с go 1.19 новым компилятором можно и нужно: обратная совместимость соблюдается.

replace vs exclude vs retract

Директива Что делает Кто видит Типичное применение
replace Подменяет модуль другим путём/версией Только главный модуль Локальный форк, отладка, монорепо
exclude Убирает версию из рассмотрения MVS Только главный модуль Битый релиз с CVE
retract Помечает собственную версию отозванной Все потребители Ошибочно выпущенный тег
Главная ловушка replace

В библиотеке replace не работает: он применяется, только когда модуль главный. Классический инцидент: разработчик добавил replace для локальной отладки, забыл убрать, выпустил тег. У него всё собирается, а потребителям приезжает оригинальная (сломанная) версия. Для локальной разработки бери workspaces: go work init . ../lib, файл go.work не коммитят, go.mod при этом вообще не трогают.

Суть: go.sum - не lock-файл. Версии фиксирует go.mod + MVS; go.sum отвечает за целостность: хранит хеши контента каждой версии. Checksum database (sum.golang.org) - глобальный append-only лог, который гарантирует, что все в мире видят один и тот же контент.
github.com/jackc/pgx/v5 v5.6.0        h1:SWJzexB...   # хеш дерева файлов модуля
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlr...   # хеш только его go.mod

Почему две строки

Чтобы посчитать граф зависимостей, MVS нужны только go.mod-файлы всех участников, сам код скачивать не обязательно. Поэтому хеш go.mod хранится отдельно. Отсюда же модули в go.sum, чей код в сборку не попал: их go.mod участвовал в вычислении версий. Чистить руками не надо, для этого есть go mod tidy.

Чем это отличается от package-lock.json

go.sum package-lock.json
Фиксирует версии Нет - это делает go.mod + MVS Да, это его основная роль
Фиксирует хеши Да Да
Если файла нет go build падает: missing go.sum entry Установка идёт, но версии плывут
Конфликты при мерже Редко и легко: go mod tidy Регулярно и болезненно
Глобальная проверка Да - sumdb Нет

Checksum database

sum.golang.org ведёт прозрачный лог на дереве Меркла, только на добавление, по устройству похожий на Certificate Transparency. Он закрывает дыру, которую go.sum не закрывает: go.sum защищает тебя после того, как ты один раз зафиксировал хеш, а sumdb защищает первое скачивание, чтобы никто не подсунул тебе одну версию контента, а всему миру другую. Побочный, но важный эффект: теги становятся неизменяемыми де-факто.

# Что делать при SECURITY ERROR: checksum mismatch
# 1. Не делать go clean -modcache и не стирать go.sum «чтобы заработало»
# 2. Понять, чей хеш не сошёлся:
go mod download -x github.com/x/y
curl -s https://sum.golang.org/lookup/github.com/x/y@v1.2.3   # хеш, который видят все

# 3. Три реальные причины по убыванию частоты:
#    а) автор переписал тег (git tag -f + push --force), а модуль берут мимо прокси: писать автору, пинить другую версию
#    б) в корпоративном прокси лежит подменённый/битый артефакт: чистить прокси
#    в) go.sum поехал при мерже: восстановить и пересобрать (go mod tidy)

# Приватные модули настраиваются одной переменной:
go env -w GOPRIVATE=github.com/acme   # выключает и прокси, и sumdb для этих путей
git config --global url."git@github.com:".insteadOf "https://github.com/"
Ответ в одну фразу

«go.sum не про версии, а про то, что скачанный контент тот же, что был при первой сборке. Версии определяют go.mod и MVS, и они детерминированы сами по себе, поэтому Go обходится без lock-файла. И go.sum обязательно коммитим: без него теряется вся защита цепочки поставки.»

Суть: из-за import compatibility rule: одинаковый путь импорта обязан означать обратно совместимый код. Значит, ломающее изменение должно менять путь - отсюда суффикс /v2, /v3. Побочный и очень полезный эффект: две мажорные версии могут сосуществовать в одной программе.
// v0 и v1 - без суффикса
import "github.com/acme/lib"          // v0.x.x или v1.x.x

// v2+ - суффикс обязателен и в go.mod, и в импорте
import "github.com/acme/lib/v2"       // module github.com/acme/lib/v2

// Побочный эффект: обе версии живут рядом, это разные модули
import (
    pgx4 "github.com/jackc/pgx/v4"
    pgx5 "github.com/jackc/pgx/v5"
)
// Так большой сервис переводят по пакету за раз, а не одним коммитом.

Три формы версии, которые надо узнавать

Форма Пример Что означает
Обычный тег v1.5.2 Semver-тег в репозитории
Псевдоверсия v0.0.0-20240115143022-a1b2c3d4e5f6 На коммите нет тега: базовая версия + UTC-время коммита + 12 символов хеша. Если тег есть выше по истории, базой берётся следующий патч: коммит после v1.5.2 даёт v1.5.3-0.…, что выше v1.5.2
+incompatible v2.0.0+incompatible У модуля тег v2+, но нет go.mod - доисторический пакет, Go делает исключение
Пререлиз v1.5.0-rc.1 Ниже v1.5.0; go get -u с релиза на него не перейдёт, а с псевдоверсии или пререлиза пониже - может

Как выпускают v2

# Вариант 1 - подпапка (major subdirectory)
mkdir v2 && cp *.go v2/ && cd v2    # v1 остаётся в корне для старых импортов
go mod init github.com/acme/lib/v2      # плюс переписать все внутренние импорты на /v2
git add . && git commit -m "v2" && git tag v2.0.0 && git push origin main v2.0.0

# Вариант 2 - ветка (major branch)
git checkout -b v2
# в go.mod: module github.com/acme/lib/v2, внутренние импорты тоже на /v2
git commit -am "v2" && git tag v2.0.0 && git push origin v2 v2.0.0
Чем это оборачивается, если ошибиться
  • Тег v2.0.0 есть, а /v2 в module нет → go get ругается «module path must match major version» и версия просто не устанавливается.
  • Путь модуля поменяли, а внутренние импорты забыли → в бинарь попадут обе копии пакета: два набора глобальных переменных, два init(), две регистрации в реестрах, значения одного типа не присваиваются другому.
  • Считать, что v0.x защищён semver. Не защищён: в нулевой мажорке ломать разрешено в любом минорном релизе, и это не нарушение правил.
Суть: Minimal Version Selection - для каждого модуля берётся максимум из минимально требуемых версий по всему графу. Никаких диапазонов, никакого солвера, «самая свежая подходящая» не берётся никогда. Отсюда воспроизводимость сборок без lock-файла.

Алгоритм в трёх шагах

  1. Обойти граф: go.mod главного модуля → go.mod его зависимостей → и так далее (нужны только go.mod-файлы, код не скачивается).
  2. Для каждого пути модуля собрать множество требуемых версий.
  3. Выбрать из множества максимальную. На выходе build list.
# main → A v1.2.0 → C v1.1.0
# main → B v1.5.0 → C v1.3.0
# В репозитории уже есть C v1.9.0
#
# Go:  требования на C = {v1.1.0, v1.3.0} → берём v1.3.0
# npm: диапазоны ^1.1.0 и ^1.3.0 → берём самое свежее в пересечении, v1.9.0

go list -m all                   # итоговый build list
go mod graph                     # весь граф требований
go mod why -m github.com/x/y     # зачем модуль в сборке: цепочка импортов
go list -m -u all                # какие обновления доступны
Go (MVS) npm / Cargo / pip
Форма требования Точная минимальная версия Диапазон (^1.2, ~1.2)
Что выбирается Максимум из минимумов Максимум, попадающий в диапазоны
Алгоритм Обход графа, линейный Решатель ограничений (в пределе NP-полный)
Lock-файл Не нужен Обязателен
Сборка вчера и через год Идентична Может отличаться без lock-файла
Дубли версий Одна версия на major Дерево копий в node_modules
Патчи безопасности Только явно Часто приезжают сами

Плюсы и минусы - оба надо назвать

  • + High-fidelity builds. Набор версий определяет только содержимое go.mod в графе, ни дата сборки, ни состояние реестра на него не влияют.
  • + Нет «фантомных» апдейтов. Новая версия зависимости не приедет, пока кто-то явно не поднимет требование. Атака «выпустить вредоносный патч и ждать, пока CI сам подтянет» в Go не работает.
  • + Простота. Нет NP-полного солвера, нет «resolution hell», нет протухшего лока.
  • − Обновления только вручную. Патчи безопасности сами не приезжают: нужен go get -u, Renovate/Dependabot и govulncheck в CI.
  • − Ответственность распределена. Если популярная библиотека требует старую версию с CVE, поднимать придётся в каждом потребителе через go get или exclude.
Тонкость про «максимум из минимумов»

Название сбивает с толку: берётся не самая маленькая версия из всех существующих, а самая большая из тех, что кто-то потребовал. «Минимальность» здесь значит «не берём больше, чем нужно»: v1.9.0 из репозитория не приедет, пока её никто не запросил. Ещё важно, что MVS работает по major-версиям раздельно, ведь lib и lib/v2 задают разные пути модуля, а значит и разные модули.

Суть: скачивание идёт по цепочке кэш → GOPROXY → VCS. Вендоринг кладёт код зависимостей прямо в репозиторий (vendor/) и позволяет собирать вообще без сети. Современный дефолт - не вендорить: MVS + go.sum уже дают воспроизводимость, а прокси - доступность.

Цепочка загрузки

  1. Локальный кэш $GOMODCACHE (по умолчанию $GOPATH/pkg/mod). Распакованные модули read-only плюс подкаталог cache/download, зеркало прокси в том же формате.
  2. Прокси из GOPROXY задаётся списком через запятую, по умолчанию https://proxy.golang.org,direct. Прокси иммутабелен: раз опубликованную версию не изменить, а удаление тега в репозитории её из прокси обычно не убирает.
  3. direct отправляет прямо в VCS (git clone). Срабатывает, если модуль не нашёлся в прокси или путь попал в GONOPROXY.
go mod vendor         # ./vendor + vendor/modules.txt
# go build сам включает -mod=vendor, если vendor/ существует и go >= 1.14
go build -mod=mod     # явно игнорировать vendor
go mod verify         # проверить, что кэш не меняли после скачивания (с go.sum не сверяет)
go mod download       # только скачать
go clean -modcache    # снести кэш целиком

# Воспроизводимая сборка в CI
go build -mod=readonly ./...   # запретить неявную правку go.mod (дефолт с 1.16)
go mod tidy -diff              # Go 1.23: упасть, если tidy что-то изменил бы
Подход Когда оправдан Цена
Публичный прокси Дефолт для большинства CI зависит от внешней сети
Свой прокси (Athens/Nexus/JFrog) Закрытый контур, аудит, скорость Надо поддерживать инфраструктуру
Вендоринг Сборка без сети; регуляторное требование; ревью изменений в зависимостях Репозиторий пухнет, конфликты при мерже, легко забыть пересобрать
Кэш GOMODCACHE в CI Простое ускорение сборок Кэш может протухнуть
Главная проблема вендоринга

Разъезд версий ловится сам: если кто-то сделал go get и не запустил go mod vendor, сборка упадёт с «inconsistent vendoring». А вот vendor/ тихо разъезжается с go.mod, когда в vendor/ правят файлы руками: сборка молча берёт изменённый код, и go mod verify этого не заметит, он проверяет только кэш. Поэтому в CI обязательна проверка go mod vendor && git diff --exit-code vendor/. Сам go сверяет с go.mod только vendor/modules.txt, а не код рядом с ним.

Суть: не допускает - ни прямых, ни транзитивных, ошибка import cycle not allowed. Причины: скорость компиляции (пакет собирается один раз, граф топологически сортируется), детерминированный порядок init() и качество дизайна. Разруливается тремя приёмами, из которых главный - интерфейс у потребителя.

Три причины запрета

  • Компиляция. Каждый пакет компилируется ровно один раз, результат ложится в архив и переиспользуется. Ациклический граф даёт топологический порядок и параллельную сборку независимых веток. Циклы потребовали бы компилировать группу пакетов совместно, и любая правка пересобирала бы всю группу.
  • Инициализация. Порядок init() задаёт топологическая сортировка. В цикле «кто первый» не определено.
  • Дизайн. Цикл почти всегда значит, что граница между пакетами проведена неправильно.

Четыре способа разорвать

Приём Когда подходит
Интерфейс у потребителя - объявить нужный контракт в том пакете, который его использует Дефолт. Идиоматичный Go, попутно даёт тестируемость
Выделить общий пакет - вынести типы, нужные обоим, вниз по графу Когда цикл из-за общих доменных типов
Колбэк / DI - передать функцию, связывание в main Одна-две точки взаимодействия, интерфейс избыточен
Объединить пакеты Когда они и правда об одном и том же и разделены искусственно
// package order объявляет то, что ему нужно, и ничего не импортирует из user
type UserGetter interface {
    Get(ctx context.Context, id int64) (User, error)
}

type Service struct {
    users UserGetter
}
func New(users UserGetter) *Service { return &Service{users: users} }

func (s *Service) Create(ctx context.Context, uid int64) error {
    u, err := s.users.Get(ctx, uid)      // цикла нет: импорта user тут нет
    ...
}

// package user просто имеет подходящий метод, про интерфейс не знает
func (s *Store) Get(ctx context.Context, id int64) (order.User, error) { ... }

// package main - единственное место, где встречаются оба
svc := order.New(user.NewStore(db))
Почему интерфейс объявляют у потребителя: отдельный вопрос

В Java интерфейс живёт рядом с реализацией, и потребитель импортирует пакет с интерфейсом. В Go реализация неявная: типу не нужно знать про интерфейс, чтобы ему удовлетворять. Поэтому интерфейс объявляют там, где его используют, и стрелка зависимости разворачивается: пакет-реализацию больше не импортируют вообще. Разом уходит цикл, интерфейс сужается до нужных методов, а в тестах его легко подменить. Короткая формулировка ходит в сообществе, в документации Go её нет: «Accept interfaces, return structs».

Чего делать не надо
  • Схлопывать всё в общий пакет models/ на 200 типов: цикл уйдёт, модульность умрёт.
  • Городить глобальный реестр с регистрацией через init(): тот же цикл, только теперь во время выполнения и без проверки компилятором.
  • Переходить на any и рефлексию, лишь бы не импортировать пакет.
Про тесты

foo_test.go с package foo входит в пакет foo, и запрет цикла на него распространяется. А файл с package foo_test (external test package) компилируется отдельным пакетом и может импортировать что угодно, включая тех, кто зависит от foo. Штатный способ проверить связку двух пакетов, не создавая цикл, и заодно ответ на вопрос «зачем вообще нужен package x_test».

Суть: пакет из .../X/internal/Y может импортировать только код, чей путь начинается с .../X - то есть из дерева, начинающегося с родителя директории internal. Проверяет тулчейн при сборке (команда go), а не соглашение и не линтер.
github.com/acme/app/
├── cmd/api/           → может импортировать internal/*   (внутри acme/app)
├── pkg/client/        → может импортировать internal/*   (внутри acme/app)
└── internal/
    ├── store/         ← родитель internal = github.com/acme/app
    └── auth/
        └── internal/jwt/   ← родитель = .../app/internal/auth
                             → виден ТОЛЬКО из auth и его поддерева

# А так не скомпилируется:
# package github.com/other/svc
import "github.com/acme/app/internal/store"
# → use of internal package github.com/acme/app/internal/store not allowed

Что важно понимать

  • Уровень internal может быть любым и вложенным, поэтому внутри модуля строится настоящая иерархия видимости, а не один плоский «приватный» слой.
  • Правило работает и в стандартной библиотеке, по тому же принципу: net/http/internal, crypto/internal/....
  • Ограничение действует на уровне путей импорта, а не модулей. Если два модуля лежат в одном дереве путей, internal между ними пропустит. Случай редкий, но в монорепо встречается.
  • Это единственный механизм видимости на уровне пакетов. Всё остальное сводится к заглавной или строчной букве идентификатора внутри одного пакета.
Практика раскладки сервиса

По умолчанию всё в internal/. Типичная структура: /cmd/<binary>/main.go на три строки, вся логика в /internal/..., а /pkg/... только для того, что ты намеренно публикуешь как API для чужих сервисов. Выгода: пока пакет в internal, его сигнатуры можно менять сколько угодно, не выпуская мажорную версию и не ломая чужой код. Сборка гарантирует, что снаружи на него никто не завязался.

Частое заблуждение

«internal это как private». Не совсем: internal ограничивает импорт пакета, а не видимость идентификаторов. Внутри internal/store экспортированные имена так и остаются экспортированными, просто добраться до них может только код из того же дерева. И обратное: на рефлексию и линковку internal не влияет никак, это правило сборки для разрешения импортов.

Суть: сначала полностью инициализируются импортируемые пакеты (топологически), затем переменные текущего пакета в порядке зависимостей между ними (а не в порядке строк), затем все init(), и только потом main.main(). Каждый пакет инициализируется ровно один раз.

Полный список правил

  1. Рантайм поднимается раньше всего: планировщик, аллокатор, GC, обработка сигналов.
  2. Импортируемые пакеты: рекурсивно и полностью, в порядке топологической сортировки графа импортов. Цикл невозможен, поэтому порядок всегда определён.
  3. Переменные уровня пакета: в порядке зависимостей между ними. Порядок объявления в файле важен только между независимыми переменными: из готовых к инициализации берётся первая по объявлению. Цикл в зависимостях переменных даёт ошибку компиляции initialization cycle.
  4. Все init() пакета. Их может быть несколько в одном файле и несколько файлов. Порядок между файлами не гарантирован спецификацией, но go build передаёт файлы отсортированными по имени.
  5. init() нельзя вызвать вручную, нельзя взять его адрес, у него нет параметров и результата.
  6. Только после инициализации всех пакетов запускается main.main().
package main

var a = b + 1        // третьей: зависит от b
var b = f()          // второй:  зависит от c через f()
func f() int { return c }
var c = 1            // первой:  ни от чего не зависит

func init() { fmt.Println("init 1", a, b, c) }
func init() { fmt.Println("init 2") }     // обе выполнятся, сверху вниз

func main() { fmt.Println("main", a) }
// вывод:
// init 1 2 1 1     (c = 1 → b = f() = 1 → a = b+1 = 2)
// init 2
// main 2

Чем плохи init()

  • Нельзя вернуть ошибку. На сбой остаётся только паника или log.Fatal: падение на старте без контекста и без выполнения defer-ов.
  • Нельзя передать параметры. Конфиг берётся из глобальных переменных или прямо из os.Getenv, и зависимость становится скрытой.
  • Ломают тесты. init выполняется при любом запуске тестов пакета. Пакет, который в init ходит в базу или читает файл, делает свои тесты нелокальными и нестабильными.
  • Порядок неочевиден. Читая код, ты не видишь, что до него отработали три init из зависимостей.
  • Транзитивные побочные эффекты. Классика: кто-то в глубине зависимостей импортировал net/http/pprof, и твой публичный сервер на дефолтном мультиплексоре отдаёт /debug/pprof/ наружу.
  • Замедляют старт. Всё это происходит до main и до готовности readiness-пробы.
// Плохо
var db *sql.DB
func init() {
    var err error
    db, err = sql.Open("postgres", os.Getenv("DSN"))
    if err != nil { log.Fatal(err) }
}

// Хорошо: явный конструктор, ошибка возвращается, вызывается из main
func NewDB(ctx context.Context, dsn string) (*sql.DB, error) {
    db, err := sql.Open("postgres", dsn)
    if err != nil { return nil, fmt.Errorf("open db: %w", err) }
    if err := db.PingContext(ctx); err != nil { return nil, fmt.Errorf("ping db: %w", err) }
    return db, nil
}

// Дорогой объект создают лениво через sync.Once, а не в init
var Client = sync.OnceValue(buildClient)   // Go 1.21
Когда init уместен

Три случая: регистрация в реестре (драйверы database/sql, кодеки image и encoding); «вычисляемые константы», которые нельзя выразить литералом (regexp.MustCompile, template.Must); проверка инвариантов сборки, например что таблица переходов заполнена целиком. Общее у них: работа детерминированная, локальная, без внешнего мира, то есть ни сети, ни файлов, ни базы, ни переменных окружения. И отдельно про import _ "...": единственная идиома, где init не костыль, а сам смысл импорта.

Суть: go vet - встроенный консервативный анализатор (часть проверок запускается сама при go test); staticcheck - внешний глубокий анализатор на ~150 проверок; golangci-lint - не линтер, а раннер десятков линтеров с общим разбором AST и кэшем.
Инструмент Роль Примеры находок
gofmt Канонический формат, не настраивается Убирает споры о стиле из ревью как класс
goimports gofmt + импорты: добавить, убрать, сгруппировать Забытые и лишние импорты
go vet Встроенный, мало ложных срабатываний. Часть проверок запускается автоматически при go test Несовпадение Printf-формата и аргументов, копирование структуры с мьютексом, потерянный cancel, недостижимый код, теги структур с опечаткой
staticcheck Внешний, самый глубокий одиночный анализатор Мёртвый код, бесполезные присваивания, неверные сравнения, использование deprecated API, неэффективные конструкции
golangci-lint Раннер: запускает vet, staticcheck и ещё десятки, переиспользуя один разбор Всё вышеперечисленное + errcheck, revive, gosec, bodyclose, errorlint
go generate Выполняет команды из //go:generate. Не запускается при go build mockgen, stringer, protoc, sqlc, ent
govulncheck CVE с проверкой достижимости уязвимой функции из вашего кода Отсеивает шум «уязвимость есть, но вы её не вызываете»
# Минимальный набор в CI
gofmt -l .                     # пусто = ок
go vet ./...
go test -race -count=1 ./...
go mod tidy -diff              # Go 1.23
golangci-lint run --timeout=5m
govulncheck ./...
//go:generate mockgen -source=$GOFILE -destination=mocks/store_mock.go -package=mocks
//go:generate stringer -type=Status -linecomment

type Status int
const (
    StatusNew  Status = iota // new
    StatusPaid               // paid
)

// Go 1.24: инструменты фиксируются прямо в go.mod
//   go get -tool golang.org/x/tools/cmd/stringer
//   go tool stringer -type=Status
// Раньше нужен был файл tools.go с build-тегом //go:build tools
// и пустыми импортами, только чтобы версии инструментов попали в go.mod.
Что говорить про gofmt

Главное, что он не настраивается, и это фича, а не недоработка. Один формат на всю экосистему: любой чужой код читается без адаптации, ревью не тратится на стиль, а инструменты (переписывалки, рефакторинги, go fix) безопасно меняют код. Отсюда и цитата из Go proverbs: «Gofmt's style is no one's favorite, yet gofmt is everyone's favorite». В CI проверка формата должна падать, а не предупреждать.

Суть: билд-теги - условная компиляция файлов целиком, задаётся строкой //go:build или суффиксом в имени файла. Кросс-компиляция - это две переменные окружения GOOS/GOARCH и ничего больше, пока выключен CGO.
//go:build linux && amd64 && !nocgo

package storage
// Синтаксис с Go 1.17; старая форма // +build до сих пор встречается в чужом коде.
// Строка обязана идти до package; пустую строку после неё gofmt поставит сам.
// Поддерживает && || ! и скобки.
# Неявные теги по имени файла работают без строки //go:build
store_linux.go           # GOOS=linux (и android)
store_windows_amd64.go   # GOOS=windows и GOARCH=amd64
store_test.go            # только при go test

# Свои теги
go build -tags="integration,e2e" ./...
go test  -tags=integration ./...

# Кросс-компиляция
GOOS=linux   GOARCH=amd64 go build -o app .
GOOS=darwin  GOARCH=arm64 go build -o app-mac .
GOOS=windows GOARCH=amd64 go build -o app.exe .
go tool dist list                      # все поддерживаемые пары

# Прод-сборка контейнерного бинаря
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
  go build -trimpath -ldflags="-s -w -X main.version=$(git describe --tags)" -o /app .
Флаг Что делает Зачем в проде
CGO_ENABLED=0 Отключает CGO, бинарь статический Работает в scratch/distroless
-trimpath Убирает абсолютные пути сборки Воспроизводимость; в трейсах не течёт /Users/...
-ldflags="-s -w" Выкидывает таблицу символов и DWARF Минус ~30% размера. Но ломает читаемость стека в отладчике
-ldflags="-X pkg.Var=val" Вшивает строку в переменную при линковке Версия, коммит, дата сборки без правки кода
-pgo=default.pgo Profile-guided optimization 2–14% CPU бесплатно, если есть профиль с прода
-buildvcs=false Не вшивать данные VCS Сборка в контейнере, где git падает на чужом .git
Где кросс-компиляция ломается

Ровно в одном месте, на CGO. С CGO_ENABLED=1 нужен C-компилятор и заголовки под целевую платформу, то есть полноценный кросс-тулчейн (zig cc, musl-cross, сборка в контейнере целевой платформы). Поэтому в 99% случаев ответ звучит так: «выключаю CGO, и кросс-компиляция становится одной строкой». Второй по частоте сюрприз: с CGO_ENABLED=0 работает чистый Go-резолвер DNS, который игнорирует часть настроек nsswitch.conf. В Kubernetes это обычно не проблема, в гибридных корпоративных сетях иногда всплывает.

Билд-теги для интеграционных тестов: рабочий приём

Поставить //go:build integration в файлы, которым нужна база или брокер. Тогда go test ./... в обычном прогоне их вообще не видит (они не компилируются), а в ночном пайплайне идёт go test -tags=integration ./.... Выигрыш перед testing.Short() в том, что отсечение происходит на уровне компиляции: unit-прогон не тянет зависимости интеграционных тестов вообще.

Суть: CGO - механизм вызова C-кода из Go (и обратно). Даёт доступ к любым C-библиотекам, но ломает кросс-компиляцию, делает бинарь динамическим, добавляет ощутимый оверхед на каждый вызов и выводит память C из-под контроля GC.
/*
#cgo LDFLAGS: -lz
#include <zlib.h>
*/
import "C"        // псевдопакет: строка import "C" должна идти сразу за комментарием

func Version() string {
    return C.GoString(C.zlibVersion())   // конверсии C.GoString / C.CString
}
// C.CString выделяет память malloc'ом, освобождать её надо вручную:
//   p := C.CString(s); defer C.free(unsafe.Pointer(p))

Цена - по пунктам

Что ломается Детали
Кросс-компиляция Нужен C-тулчейн под каждую целевую платформу вместо двух переменных окружения
Статическая линковка Бинарь тянет glibc/musl → scratch-образ не работает, появляется зависимость от базового образа
Производительность вызова Каждый переход Go→C переводит горутину в состояние syscall (затянется вызов - sysmon отберёт у неё P) и переключает на системный стек. Порядок - десятки наносекунд против единиц у обычного вызова. В горячем цикле это разница в разы
Память и GC Всё, что выделил C, GC не видит и не собирает. Правила передачи указателей (cgo pointer passing rules) жёсткие, нарушение ловится не всегда
Надёжность Segfault в C кладёт весь процесс, recover тут бессилен. Стек C не растёт как горутинный - переполнение возможно
Инструменты -race не видит гонки внутри C, профилировщик хуже показывает C-стеки, отладка сложнее
Сборка Медленнее, Dockerfile сложнее, кэш хуже

Когда CGO всё-таки нужен

  • Библиотека существует только на C и переписывать её нереально: ffmpeg, OpenCV, librdkafka, ODBC-драйверы, аппаратные SDK.
  • Системные API без Go-обёртки.
  • Постепенная миграция legacy C-кодовой базы.
Как это выглядит в проде

CGO_ENABLED=0 для сервисов идёт дефолтом. Если CGO действительно нужен, лучше изолировать его в отдельный бинарь или сайдкар, а не тащить в основной сервис. Два самых частых случая: mattn/go-sqlite3 требует CGO, но есть чистая замена modernc.org/sqlite; confluent-kafka-go тянет librdkafka, а segmentio/kafka-go и IBM/sarama написаны на чистом Go. Правило: прежде чем включать CGO, проверь, нет ли pure-Go альтернативы.

Суть: 1.22 - семантика переменной цикла и паттерны в ServeMux; 1.23 - итераторы range over func; 1.24 - Swiss Tables, generic type aliases, tool в go.mod, b.Loop; 1.25 - WaitGroup.Go и container-aware GOMAXPROCS; 1.26 - Green Tea GC по умолчанию, new от выражения, errors.AsType; 1.27 - параметры типа у методов, encoding/json/v2 отдельным пакетом, uuid в stdlib.

Go 1.22 (февраль 2024)

  • Переменная цикла теперь своя на каждой итерации. Классическая ловушка с замыканиями и &v исчезла. Включает её строка go 1.22 в go.mod: решает модуль, а не версия тулчейна, чтобы старый код не сломался молча.
  • Паттерны в http.ServeMux: "GET /users/{id}", r.PathValue("id"), многосегментный {path...}. Приоритет считается по специфичности, а конфликтующие паттерны паникуют при регистрации.
  • for i := range 10: range по целому.
  • math/rand/v2, первый /v2 внутри самой stdlib; slices.Concat; PGO-сборки быстрее на 2–14%.

Go 1.23 (август 2024)

  • Итераторы: range над функцией + пакет iter (Seq[V], Seq2[K,V], Pull). Тело цикла становится колбэком yield, а break превращается в false из yield.
  • Итераторные функции в slices/maps: slices.Sorted(maps.Keys(m)), slices.All, slices.Values, slices.Collect.
  • Таймеры переделаны: канал Timer/Ticker теперь без буфера, и неостановленные таймеры собираются GC, поэтому классическая утечка time.After внутри select в цикле больше не утекает.
  • Пакет unique для интернирования значений.

Go 1.24 (февраль 2025)

  • Swiss Tables, новая реализация мап: группы слотов с control word, на amd64 поиск через SIMD. До 60% быстрее на микробенчмарках, около 1,5% по реальным приложениям, меньше памяти. Для кода прозрачно. Плюс новая реализация sync.Map и аллокатор мелких объектов.
  • Generic type aliases: type Set[T comparable] = map[T]struct{}.
  • tool-директива в go.mod и команда go tool: хак с tools.go и build-тегом больше не нужен.
  • testing.B.Loop: for b.Loop() вместо for i := 0; i < b.N; i++. Setup не попадает в замер, а аргументы и результат защищены от выбрасывания оптимизатором.
  • os.Root: операции с файлами, ограниченные деревом директории (защита от path traversal); пакет weak; runtime.AddCleanup вместо SetFinalizer.

Go 1.25 (август 2025)

  • WaitGroup.Go: wg.Go(func(){...}) вместо связки Add(1) + go + defer Done(). Убирает целый класс ошибок с забытым Done и рассинхроном счётчика.
  • Container-aware GOMAXPROCS: рантайм читает CPU-лимит cgroup и подстраивается динамически. Библиотека automaxprocs, которую раньше тащили в каждый сервис, стала не нужна.
  • testing/synctest, стабильный пакет для детерминированных тестов конкурентного кода с «виртуальным» временем.
  • Экспериментальные на тот момент: сборщик Green Tea (GOEXPERIMENT=greenteagc) и encoding/json/v2. Оба с тех пор стали дефолтными, см. 1.26 и 1.27. Плюс flight recorder в runtime/trace.
  • net/http.CrossOriginProtection, штатная CSRF-защита на Fetch metadata (Sec-Fetch-Site), без токенов и кук.

Go 1.26 (февраль 2026)

  • Green Tea GC включён по умолчанию. Минус 10–40% накладных расходов сборщика, плюс примерно ещё 10% на свежих amd64 (Intel Ice Lake, AMD Zen 4+) за счёт векторных инструкций. Откат: GOEXPERIMENT=nogreenteagc.
  • Два изменения в самом языке. Первое: new теперь принимает выражение, а не один лишь тип: p := new(compute()), r := Req{Limit: new(50)}. Самописный func ptr[T any](v T) *T больше не нужен. Второе: дженерик-тип может ссылаться на себя в собственном констрейнте: type Adder[A Adder[A]] interface{ Add(A) A }.
  • errors.AsType[T](err) (T, bool), дженерик-версия errors.As: типобезопасно, быстрее и без объявления переменной заранее. Плюс slog.NewMultiHandler и io.ReadAll примерно вдвое быстрее.
  • Экспериментальный профиль goroutineleak (GOEXPERIMENT=goroutineleakprofile) находит горутины, заблокированные на примитивах, до которых уже никто не дотянется. Вызовы cgo подешевели примерно на 30%.
  • Мелочи, на которых ловят: редирект ServeMux на завершающий слэш стал 307 вместо 301; os/signal.NotifyContext отменяет контекст через CancelCauseFunc, так что context.Cause покажет, какой сигнал пришёл; go fix переписан в набор «модернизаторов»; go mod init в 1.26.0 стал писать версию на минор ниже, но в 1.26.1 это откатили, и он снова пишет версию тулчейна целиком, вместе с патчем: go 1.27.1 на go1.27.1.

Go 1.27 (август 2026) - текущая

  • У методов появились собственные параметры типа, то есть снято ограничение, которое годами называли главным недостатком дженериков в Go: func (b *Box) MapTo[T any](f func(int) T) []T. Запрет остался только на стыке с интерфейсами: у метода интерфейса параметров типа быть не может, а дженерик-метод не засчитывается в реализацию интерфейса, иначе интерфейс нельзя проверить статически и собрать itab. Пример в stdlib: дженерик-метод Rand.N() в math/rand/v2.
  • encoding/json/v2 стал отдельным пакетом, и на нём же теперь реализован старый encoding/json (откат: GOEXPERIMENT=nojsonv2). Но поведение v1 сохранили специально: проверено на go1.27.0, json.Unmarshal по-прежнему молча ест и {"a":1,"a":2}, и невалидный UTF-8. Строгость появляется только при явном импорте v2, поэтому апгрейд тулчейна ничего не ломает, а вот переход на v2 «сломает» приём кривого JSON, который раньше проглатывался. Unmarshal заметно быстрее. Рядом лежит низкоуровневый encoding/json/jsontext; опции тега format и unknown убраны, inline переименован в embed.
  • Пакет uuid в стандартной библиотеке (RFC 9562): New(), NewV4(), NewV7(), Parse. NewV7 сортируется по времени, ровно то, что нужно для первичных ключей в БД. Внешний github.com/google/uuid больше не обязателен.
  • Профиль goroutineleak стал общедоступным: виден в pprof.Profiles() и по /debug/pprof/goroutineleak. Штатный инструмент поиска утечек горутин в проде, а раньше обходились runtime.NumGoroutine и goleak в тестах.
  • Ещё из полезного: strings.CutLast / bytes.CutLast; testing/synctest.Sleep; Server.MaxHeaderValueCount против заголовочного флуда; часть мелких аллокаций (<80 байт) дешевле до 30%; метки горутин из runtime/pprof попадают в заголовки трейсбеков, и падение сразу показывает, какой запрос обрабатывала горутина. Удалён GODEBUG=asynctimerchan: каналы таймеров теперь всегда небуферизованные.
Как отвечать, если версий не помнишь дословно

Перечислять весь changelog не надо, это не проверяется. Достаточно назвать по 2–3 изменения, которые ты реально применил, и объяснить зачем: «в 1.22 убрали ловушку с переменной цикла, после апгрейда мы снесли из кода все v := v»; «в 1.23 появились итераторы, переписали обход пагинации, ушёл ручной курсор»; «в 1.25 GOMAXPROCS стал container-aware, выкинули automaxprocs из всех сервисов»; «в 1.27 перевели приём JSON на json/v2, а он отвергает дублирующиеся ключи, пришлось чинить одного поставщика данных». По такому ответу видно, что ты следишь за релизами и понимаешь, что они дают, а не заучил список.

Что стоит запомнить

  • Пакет компилируется как единица импорта, модуль задаёт границу зависимостей.
  • MVS выбирает версию по требованиям графа.
  • go.sum хранит контрольные суммы.

Частые ошибки

  • go.mod не фиксирует единственную версию каждого транзитивного пакета.
  • Major v2+ входит в путь импорта.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Проследи значения

Что напечатает фрагмент? Объясни, почему получился такой результат.

// go.mod: module example.com/app
// файл cmd/server/main.go: package main
import "example.com/app/internal/config"
Ответ и разбор

Путь example.com/app называет модуль; cmd/server и internal/config - отдельные пакеты внутри его файловой структуры. Импорт задаётся путём пакета, а не именем репозитория как таковым.

Код-ревью

2. Найди ошибку и исправь

Зависимость имеет major version 2, но путь импорта оставлен без /v2. Исправь согласованно модуль и import.

require example.com/lib v2.3.0

import "example.com/lib"
Ответ и разбор

Для модулей v2+ major версия входит в module path. Зависимость должна объявлять module example.com/lib/v2, а потребитель импортирует example.com/lib/v2. Одной версии v2.3.0 в require недостаточно, если путь остаётся v1.

Применение

3. Реши задачу

Требования графа: приложение просит lib v1.2.0, пакет A просит lib v1.4.0. Какую версию выберет MVS?

Ответ и разбор

MVS выбирает v1.4.0 как наибольшую из минимально требуемых версий в графе. Обновление зависимости A выше этого не означает: MVS не ищет «последнюю опубликованную» версию автоматически.

Применение

4. Реши задачу

В go.sum появилась контрольная сумма транзитивной зависимости, хотя её нет в go.mod. Это само по себе ошибка? Что защищает запись?

Ответ и разбор

Само по себе это нормально: go.sum может хранить суммы модулей, загруженных при разрешении графа. Запись позволяет проверить, что полученные содержимое и go.mod совпадают с ранее подтверждёнными контрольными суммами; она не выбирает версию зависимости вместо go.mod.