Go modules: go.mod, версионирование и управление зависимостями

go.mod, go.sum, семантический импорт версий, minimal version selection, replace/exclude, vendoring и workspaces (go.work). Как Go решает, какие версии зависимостей брать, почему MVS отличается от других менеджеров пакетов, и как этим управлять на практике

Система модулей Go выглядит обманчиво просто: go mod init, go build — и зависимости «как-то подтянулись». Но за этой простотой стоят два нетривиальных инженерных решения — семантический импорт версий и minimal version selection, — которые делают сборки Go воспроизводимыми без lock-файла и решателя ограничений. Если понимать, почему Go выбирает именно эти версии, перестаёшь бороться с инструментом и начинаешь им пользоваться: осознанно ставить replace, чинить конфликт версий, собирать герметично.

Эта статья — про модель зависимостей Go «изнутри»: что на самом деле хранят go.mod и go.sum, чем правило major-версии в пути импорта отличается от привычного semver, почему MVS даёт предсказуемость там, где менеджеры на диапазонах дают дрейф, и как всем этим управлять командами go. Примеры кода — иллюстративные фрагменты go.mod и команд, отдельного стенда под тему тулинга нет.

Схема «Полдень»: модули Go — манифесты go.mod/go.sum с проверкой целостности, минимальный выбор версии (MVS: max нижних границ, v1.4.0, не latest), семантическое версионирование (lib/v1 и lib/v2 как разные пути импорта), рабочие пространства go.work

В статье

Модуль, go.mod и go.sum

Модуль — это единица версионирования и распространения в Go: дерево пакетов с общим корнем, у которого есть файл go.mod. Пакет — единица компиляции (каталог с .go-файлами одного package); модуль — набор пакетов, версионируемый и публикуемый как целое. Один репозиторий обычно = один модуль, хотя в корне могут жить несколько (мультимодульный репозиторий).

Файл go.mod — манифест модуля. Минимальный содержит три вещи: путь модуля, требуемую версию языка и список прямых зависимостей.

module github.com/khorost/example

go 1.26

require (
	github.com/jackc/pgx/v5 v5.6.0
	golang.org/x/sync v0.8.0
)

require (
	github.com/jackc/puddle/v2 v2.2.1 // indirect
)
  • module задаёт путь модуля (module path) — префикс, под которым импортируются все его пакеты. Это же и адрес, по которому модуль скачивается (VCS-путь).
  • go — начиная с Go 1.21 это не «пожелание», а минимальная требуемая версия языка и тулчейна: если ваш go старее, он откажется собирать модуль (или, по правилам ниже, доскачает нужный). Влияет и на доступные фичи языка, и на дефолты поведения самого go (например, автоматический -mod=vendor при наличии vendor/ включился с go 1.14).
  • toolchain (Go 1.21+) — необязательная директива с конкретной версией тулчейна для сборки (toolchain go1.26.1), которая может быть выше строки go. Если установленный тулчейн старее требуемого, команда go по умолчанию сама скачает и запустит нужную версию. Поведением рулит GOTOOLCHAIN: auto (дефолт — доскачивать по необходимости), local (использовать только установленный и упасть, если версии не хватает), либо жёсткая фиксация вроде go1.26.1. Так Go 1.21 развёл два прежде слитых смысла: минимум языка (go) и фактический тулчейн сборки (toolchain).
  • require — прямые и косвенные зависимости с их версиями. Пометка // indirect означает, что зависимость не импортируется напрямую кодом модуля, а нужна транзитивно (или её версия зафиксирована явно, хотя прямого требования нет).

Ключевая идея: go.mod перечисляет минимально необходимые версии, а не диапазоны и не «что получилось». Это манифест намерений, а не снимок результата.

Файл go.sum — про целостность, а не про выбор версий. На каждую версию модуля в нём обычно две строки: хеш содержимого модуля и отдельно хеш его go.mod.

github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAe...=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:aP5A0jLGZQD9glPTKrsd+2rvWyi...=

Хеши (алгоритм h1:, поверх SHA-256) вычисляются при первой загрузке и потом сверяются при каждой сборке: если содержимое версии в кеше или в сети не совпало с записанным — go откажется собирать. Дополнительно версии сверяются с публичной checksum database (sum.golang.org), чтобы обнаружить подмену модуля на стороне источника. go.sum если он появился — а он появляется, как только есть хоть одна зависимость, тянущаяся по сети, — нужно коммитить в репозиторий: это гарантия, что все собирают ровно тот код, который прошёл проверку. (У модуля без внешних зависимостей — или где всё заменено локальными replacego.sum может быть пустым или отсутствовать вовсе, и это нормально.) Управляют этими файлами не руками, а командами go (см. ниже).

Семантический импорт версий

Версии модулей — это semver-теги вида vMAJOR.MINOR.PATCH (обязательный префикс v): v1.4.2, v0.8.0, v2.0.0. Semver задаёт контракт совместимости: patch — багфиксы, minor — обратно совместимые добавления, major — ломающие изменения.

Go делает из этого контракта жёсткое техническое правило, которого нет в большинстве экосистем, — semantic import versioning (SIV). Формулируется как import compatibility rule:

Если у старого и нового пакета одинаковый путь импорта, новый пакет должен быть обратно совместим со старым.

Отсюда следствие: раз major-версия по определению ломает совместимость, у неё обязан быть другой путь импорта. Реализовано это добавлением суффикса major-версии к пути модуля, начиная с v2:

import (
	"github.com/jackc/pgx/v4" // модуль major-версии v4
	"github.com/jackc/pgx/v5" // major-версия v5 — ДРУГОЙ путь
)

Правила:

  • v0 и v1 суффикс не получают. v0.x — нестабильный API (semver не даёт гарантий), v1 — путь без суффикса.
  • v2 и выше обязаны иметь суффикс /vN и в module-строке своего go.mod (module github.com/foo/bar/v2), и во всех путях импорта.

Практический смысл радикален: разные major-версии — это, с точки зрения Go, разные модули. Их можно импортировать одновременно в одной сборке, они не конфликтуют. Это снимает «diamond dependency» проблему на major-уровне: если библиотека A тянет lib/v1, а библиотека B — lib/v2, обе версии спокойно сосуществуют, каждая по своему пути. Плата — при выпуске v2 автору приходится осознанно менять module-путь, а не просто ставить тег.

Minimal Version Selection

Вот место, где Go расходится почти со всеми: как из требований разных модулей выбирается итоговая версия каждой зависимости. Большинство менеджеров (npm, Cargo, pip с диапазонами) решают SAT-подобную задачу: находят новейшую версию, удовлетворяющую всем ограничениям-диапазонам. Детали resolver’ов у них отличаются, но общий контраст один — диапазоны плюс lock-файл против конкретных минимальных требований. Go делает противоположное — применяет minimal version selection (MVS).

Принцип MVS формулируется в одну фразу: для каждой зависимости выбирается минимальная версия, которая удовлетворяет всем требованиям. «Минимальная, удовлетворяющая всем» — это максимум из нижних границ. Если модуль A требует lib v1.2.0, а модуль B требует lib v1.4.0, MVS берёт v1.4.0 — самую низкую версию, которая при этом не ниже ни одного из требований. Не v1.5.0, вышедшую вчера; не «latest». Ровно то, что запрошено.

Алгоритм построения списка сборки (build list):

  1. Начать с прямых требований главного модуля.
  2. Для каждого требования рекурсивно прочитать go.mod этой версии и собрать её требования.
  3. Для каждого уникального модуля взять максимум из всех запрошенных версий.
  4. Полученный набор — и есть версии, которые пойдут в сборку.

Диапазонов в go.mod нет вообще — только конкретные минимальные версии. Отсюда свойства, ради которых MVS и придуман:

  • Воспроизводимость без lock-файла. Результат — чистая функция от набора go.mod в графе. Нет решателя с эвристиками, нет «повезло с порядком установки». go.sum фиксирует целостность, но не выбор — выбор уже детерминирован самим go.mod.
  • Предсказуемость и «high-fidelity builds». Обновление приходит только тогда, когда кто-то явно поднял требование в своём go.mod. Новый релиз зависимости не втягивается в вашу сборку сам собой — в отличие от менеджеров на диапазонах, где ^1.2.0 завтра молча подтянет 1.9.0. Вы собираете максимально близко к тому, что тестировал автор.
  • Минимум сюрпризов при обновлении. «Поднять версию» — это осознанное действие (go get), а не побочный эффект переустановки.

Контраст стоит проговорить прямо: SAT-решатель оптимизирует под «новее» и потому нуждается в lock-файле, чтобы зафиксировать «повезло». MVS оптимизирует под «ровно как заказано» и потому lock-файл ему не нужен — детерминизм встроен в саму модель. Обратная сторона — свежие версии не приезжают сами; за обновлениями (в том числе security) нужно ходить командами явно.

Рабочие команды

Небольшой набор команд закрывает почти всё повседневное управление зависимостями.

# добавить/обновить конкретную зависимость (пишет в go.mod и go.sum)
go get github.com/jackc/pgx/v5@v5.6.0
go get github.com/jackc/pgx/v5@latest

# обновить прямые и косвенные зависимости до новых minor/patch
go get -u ./...
# только патчи (без подъёма minor)
go get -u=patch ./...

# привести go.mod/go.sum в порядок: добавить недостающее, убрать лишнее
go mod tidy

# скачать модули в локальный кеш (без сборки)
go mod download

# показать полный список сборки (все модули, что реально войдут)
go list -m all

Что делает каждая:

  • go get pkg@version — меняет требование к модулю в go.mod (и обновляет go.sum). Версию можно задать тегом (@v5.6.0), псевдонимами @latest/@upgrade/@patch, коммитом или веткой. Важно: начиная с Go 1.16, go get больше не ставит бинарники — для разовой установки исполняемого инструмента в $GOBIN используется go install pkg@version. А для инструментов, которые нужны самому проекту (кодогенераторы, линтеры, stringer и т.п.), с Go 1.24 есть отдельный механизм: go get -tool pkg@version записывает инструмент директивой tool в go.mod (версия закреплена и воспроизводима, как у обычной зависимости), а запускают его через go tool <имя>. Это заменяет старый приём с tools.go и пустым импортом — раньше зависимость-инструмент держали живой только фиктивным import _, теперь она объявлена явно.
  • go get -u — поднимает зависимости до новых minor и patch версий (major не трогает — это сломало бы путь импорта по SIV). -u=patch ограничивает подъём патч-уровнем.
  • go mod tidy — синхронизирует go.mod/go.sum с реальными импортами кода: добавляет то, что импортируется, но не записано; удаляет то, что записано, но не используется; выравнивает пометки // indirect. Запускать после любых правок импортов — это «привести в порядок».
  • go mod download — просто раскладывает модули по кешу (GOMODCACHE), не собирая проект. Полезно в CI для отдельного слоя кеширования и прогрева.
  • go list -m all — печатает итоговый build list, тот самый результат MVS: какие именно версии всех модулей (прямых и транзитивных) войдут в сборку. Первый инструмент при разборе «откуда взялась эта версия». Рядом полезен go mod graph (рёбра графа зависимостей) и go mod why pkg (зачем пакет в сборке).

Отдельно — project-pinned инструменты (Go 1.24+): версия закрепляется в go.mod и воспроизводится вместе с остальными зависимостями, а запуск идёт через тулчейн:

# добавить инструмент: пишет директиву tool И require в go.mod
go get -tool golang.org/x/tools/cmd/stringer@latest
# запустить закреплённой версией
go tool stringer -help

tool в go.mod — не просто пометка: рядом go get -tool добавляет обычное require на модуль инструмента, поэтому его версия участвует в том же MVS и go.sum, что и рантайм-зависимости, — и одинакова у всей команды и в CI.

replace и exclude

Две директивы go.mod позволяют вмешаться в выбор версий, когда обычного require мало.

require github.com/some/lib v1.4.0

// 1. локальная разработка: подставить путь на диске
replace github.com/some/lib => ../lib

// 2. форк: перенаправить на свой модуль и версию
replace github.com/some/lib => github.com/khorost/lib v1.4.1-fix

// 3. пиновать проблемную зависимость на конкретную версию
replace golang.org/x/net => golang.org/x/net v0.28.0

// исключить заведомо битую версию из рассмотрения MVS
exclude github.com/broken/dep v1.3.0

replace подменяет источник и/или версию модуля:

  • Локальная замена (=> ../lib) — направляет модуль на каталог на диске. Незаменимо при параллельной правке библиотеки и потребителя без публикации промежуточных версий (но для этого сценария сегодня чаще берут workspaces — не пачкают go.mod).
  • Форк (=> github.com/you/lib v1.2.3) — перенаправляет на другой модуль с явной версией. Так живут с патчем, пока фикс не влит в апстрим.
  • Пин (X => X vНиже) — форсирует конкретную версию, обходя MVS-выбор.

Критично помнить: replace действует только в go.mod главного модуля. Когда ваш модуль подключают как зависимость, его replace-директивы игнорируются. Поэтому replace — инструмент для приложения/сервиса, но не способ «за всех» починить транзитивную зависимость в публикуемой библиотеке.

exclude запрещает загрузку конкретной версии. Уместно, когда известная версия битая (сломанный релиз, уязвимость): если граф зависимостей ссылается именно на неё, это требование при построении build list игнорируется. Дальше версию поднимают уже явно — go get/go mod tidy могут добавить в go.mod требование на более высокую (это отличается от старого поведения, когда MVS сам «перескакивал» на следующую доступную; с Go 1.16 исключённое требование просто отбрасывается, а подъём — отдельное явное действие команд). Тоже действует только в главном модуле.

retract: отзыв версий

replace и exclude — инструменты потребителя (действуют в главном модуле). retract решает зеркальную задачу со стороны автора модуля: пометить уже опубликованные версии как отозванные — выпущенные по ошибке, со случайным тегом, с найденной уязвимостью. Физически удалить версию в SIV нельзя: прокси и checksum database неизменяемы, а удаление тега сломало бы всех, кто на него закрепился. retract — способ сказать «эту версию брать не стоит», не нарушая неизменяемость.

retract (
    v1.3.0            // выпущено по ошибке  не использовать
    [v1.2.0, v1.2.3]  // отозвать диапазон версий
)

Отозванная версия остаётся скачиваемой — сборки, уже закрепившие её, не ломаются. Но go перестаёт предлагать её при go get pkg@latest и go get -u, а в go list -m -u all помечает как (retracted) с предупреждением. Механика выпуска слегка выворачивает мозг: чтобы отзыв доехал до потребителей, автор добавляет retract в go.mod и тегирует новую, более высокую версию с этой записью — потому что go читает список отзывов из последней доступной версии модуля. Отзыв самой свежей версии, включая ту, что несёт запись, — тоже валиден.

Vendoring

Vendoring — это складывание копий всех зависимостей прямо в репозиторий, в каталог vendor/.

go mod vendor        # создаёт ./vendor с исходниками зависимостей
go build ./...       # при наличии vendor/ и go>=1.14 сборка идёт из него
go build -mod=mod    # явно игнорировать vendor, брать из кеша/сети

go mod vendor копирует в vendor/ исходники всех модулей из build list и пишет vendor/modules.txt с описью версий. Если vendor/ присутствует и в go.mod указан go 1.14+, команды go по умолчанию собирают из него (-mod=vendor), не обращаясь к кешу и сети. По vendor/modules.txt при этом сверяется согласованность версий с go.mod — что в каталоге лежат ровно те модули и версии, что заявлены. Важно не переоценить: это проверка описи, а не криптографическая целостность содержимого vendor/. go не проверяет, что файлы в каталоге не правили руками; такую подмену ловят пересозданием (go mod vendor заново) и git diff — поэтому vendor/ и коммитят в репозиторий и ревьюят при изменениях, как обычный код.

Когда это оправдано:

  • Герметичные и офлайн-сборки — CI без доступа в интернет, окружения с закрытой сетью, воздушный зазор. Всё нужное — в репозитории.
  • Устойчивость к исчезновению — код зависимостей не пропадёт, даже если удалят исходный репозиторий или прокси (эту же задачу частично решает прокси).
  • Аудит и ревью — изменения зависимостей видны в diff как обычный код.

Минусы:

  • Раздувание репозитория и «шумные» diff при обновлении зависимостей.
  • vendor/ надо пересобирать (go mod vendor) после каждой правки зависимостей и держать в синхроне с go.mod — иначе сборка расходится с манифестом.
  • Дублирование того, что и так кешируется в GOMODCACHE и защищается go.sum + прокси.

Тренд последних лет — vendoring по умолчанию не нужен: go.sum даёт целостность при загрузке модулей в кеш, а кеширующий прокси — доступность. Тонкость: при сборке из vendor/ (-mod=vendor) хеши go.sum содержимое каталога уже не проверяют — go.sum работает на этапе скачивания в module cache, а не на этапе чтения из vendor/. Поэтому «есть go.sum и есть vendor/» — это две разные гарантии, а не двойная защита одного и того же: первая закрывает загрузку, вторую (неизменность vendor/) обеспечивает уже git-ревью, а не тулчейн. Vendoring остаётся нишевым инструментом для герметичных пайплайнов и жёстких политик поставки.

Workspaces (go.work)

До Go 1.18 разработка нескольких взаимозависимых модулей локально означала засорять go.mod временными replace => ../other, которые легко случайно закоммитить. Workspaces (Go 1.18) решают это чисто: файл go.work объединяет несколько модулей в одно рабочее пространство, не трогая их go.mod.

go work init ./service ./lib   # создать go.work с двумя модулями
go work use ./another          # добавить ещё один модуль
go work sync                   # синхронизировать версии зависимостей
go 1.26

use (
	./service
	./lib
)

Пока активен go.work (лежит в текущем каталоге или выше), команды go видят перечисленные в use модули как локальные: service импортирует lib прямо из соседнего каталога, минуя опубликованные версии и без единой строки replace. Импорт lib резолвится в ./lib на диске.

Когда применять:

  • Одновременная разработка приложения и его библиотеки (или нескольких библиотек) до публикации версий.
  • Мультимодульные монорепозитории, где модули тесно завязаны друг на друга.
  • Быстрая проверка правки в зависимости без выпуска промежуточного тега.

go.work — про локальное окружение, а не про распространение: обычно его добавляют в .gitignore (это личная настройка разработчика), тогда как replace в go.mod виден всем и участвует в сборке потребителей. В монорепозитории go.work иногда всё же коммитят — как единую точку сборки. Главное — не путать роли: go.work не заменяет require, он лишь переопределяет, откуда брать перечисленные модули на этой машине.

Публикация своего модуля: теги и свой домен

В Go нет реестра-регистратуры вроде npm или PyPI, куда что-то «пушат». Модуль публикуется самим фактом того, что по его пути доступен VCS-репозиторий с нужным тегом. Отсюда весь процесс релиза сводится к работе с git.

Шаг первый — путь модуля. module-строка в go.mod это и есть адрес, по которому go будет скачивать код. Для публичного модуля на GitHub он совпадает с URL репозитория:

# путь модуля = адрес, по которому его будут импортировать и скачивать
go mod init github.com/khorost/mylib
git init && git add . && git commit -m "init"
git remote add origin git@github.com:khorost/mylib.git
git push -u origin main

Шаг второй — версия. Версия модуля — это git-тег формата vMAJOR.MINOR.PATCH. Публикация версии = отправка тега; никакой отдельной команды «залить в реестр» нет:

git tag v1.0.0
git push origin v1.0.0
# теперь любой может: go get github.com/khorost/mylib@v1.0.0

Дальше потребитель ставит версию через go get …@v1.0.0 (или @latest — публичный прокси и go увидят новый тег в течение минут). До первого стабильного релиза держат v0.x.y: semver прямо разрешает v0 ломать API между minor, и потребитель этим предупреждён.

Выпуск v2 и выше — единственное место, где мало просто поставить тег. По правилу semantic import versioning major-версия обязана иметь /vN в пути импорта, а значит и в module-строке. Два рабочих способа: подкаталог v2/ со своим go.mod (module github.com/khorost/mylib/v2) либо отдельная мажор-ветка. После этого v2.0.0 тегается и живёт параллельно v1, не конфликтуя — это прямое следствие того, что для Go разные мажоры суть разные модули.

module github.com/khorost/mylib/v2

go 1.26

Свой домен вместо github.com/... — это vanity import path (кастомный путь импорта). Идея: адрес импорта указывает на ваш домен, а физически код по-прежнему живёт на GitHub. Тогда импорт не привязан к хостингу — можно сменить GitHub на что угодно, не ломая ни одного import у потребителей. Так устроены реальные vanity-пути вроде golang.org/x/....

Ключевой момент, где легко ошибиться с порядком: префикс пути должен быть настоящим хостом, к которому go обратится по HTTPS. То есть путь начинается с домена ровно так, как он резолвится — khorost.tech/mylib, а не «перевёрнутого» tech.khorost/mylib. Обратный порядок (tech.khorost/...) — это стиль обратного DNS, как имена пакетов в Java: он годится для модулей, которые используют только локально или приватно и никогда не тянут по сети (тогда резолвить путь не требуется), но опубликовать под ним нельзя — хоста tech.khorost не существует, и go get упрётся в DNS.

Механизм — HTML-мета-тег go-import. Когда go разрешает путь khorost.tech/mylib, он запрашивает https://khorost.tech/mylib?go-get=1 и ищет в ответе:

<meta name="go-import"
      content="khorost.tech/mylib git https://github.com/khorost-tech/mylib">

Три поля: префикс пути модуля, система контроля версий (git), и реальный URL репозитория. Получив это, go идёт клонировать/скачивать уже с GitHub — читатель импортирует khorost.tech/mylib, а код тянется из github.com/khorost-tech/mylib. Отдать такой мета-тег может любой статический ответ по этому URL; на практике настраивают либо крошечный обработчик, либо готовый сервис вроде проекта govanityurls. Версии при этом остаются теми же git-тегами в целевом репозитории — vanity-путь меняет только адрес импорта, не механику версионирования.

Требования к тегам, путям и метаданным

  • Тег — валидный semver с префиксом v. v1.4.2 Go увидит как версию; 1.4.2 без v, release-3 и подобное — нет. Пре-релизы (v1.5.0-rc.1) semver допускает, но @latest предпочитает стабильные версии.
  • Опубликованная версия неизменяема. Как только тег кто-то скачал через публичный прокси (proxy.golang.org) и его хеш попал в checksum DB, содержимое версии зафиксировано навсегда. Перетегировать (force-push тега на другой коммит) бесполезно: те, кто уже получил версию, возьмут закешированную, а несовпадение с записью в go.sum даст ошибку сборки. Ошиблись в релизе — не «переиздавайте тот же тег», а выпускайте новый патч; вредную версию помечайте директивой retract.
  • Регистр в пути значим. Пути модулей регистрозависимы, а файловые системы и протокол прокси — нет, поэтому заглавные буквы кодируются восклицательным знаком: github.com/Azure/azure-sdk-for-go в кеше и запросах прокси выглядит как github.com/!azure/.... Практический вывод — по возможности держите путь в нижнем регистре.
  • LICENSE — для витрины, не для сборки. Собрать и импортировать модуль без лицензии можно. Но pkg.go.dev без распознанного файла лицензии (LICENSE/COPYING) в корне показывает только ограниченную информацию — не рендерит полную документацию и предупреждает о неясных условиях использования. Для публичной библиотеки внятная лицензия в корне — де-факто требование «опубликовано по-человечески».
  • Приватный модуль публикуется так же — тегом, но мимо публичной инфраструктуры. Тот же git tag/push. Дальше два независимых факта, которые легко смешать. Первый — про вашу сборку: путь заносят в GOPRIVATE (см. приватные модули), и локальная команда go перестаёт ходить за ним в proxy.golang.org и сверять с публичной checksum DB — тянет напрямую из VCS по вашим кредам. Второй — про витрину: pkg.go.dev не увидит модуль не из-за GOPRIVATE (это настройка на вашей машине, о которой сервис ничего не знает), а просто потому, что репозиторий приватный и недоступен публичному индексатору. Итог: «публикация» приватного модуля — это доступный репозиторий с тегом внутри вашей сети, а GOPRIVATE лишь настраивает, как ваш тулчейн его достаёт.

Корневой каталог и раскладка

Корень модуля — это каталог, где лежит go.mod. Обычно это корень репозитория. Import-путь любого пакета = путь модуля + относительный путь его каталога от корня: пакет в ./auth/jwt/ модуля github.com/khorost/mylib импортируется как github.com/khorost/mylib/auth/jwt. Каталог = пакет; имя пакета берётся из строк package в его файлах, не из имени каталога.

mylib/
  go.mod                # ← корень модуля (module github.com/khorost/mylib)
  go.sum
  LICENSE
  mylib.go              # пакет mylib — публичный API
  auth/jwt/jwt.go       # импортируется как .../mylib/auth/jwt
  internal/db/db.go     # виден только внутри mylib
  cmd/server/main.go    # бинарник (package main)

Три каталога различаются по смыслу — и путать их не стоит:

  • internal/ — единственный каталог со специальным правилом компилятора. Пакеты под internal/ импортируются только из кода, чей корень — родитель этого internal/. .../mylib/internal/db доступен всему внутри mylib, но недоступен любому внешнему модулю. Так прячут детали реализации, не вынося их в публичный API, который потом придётся поддерживать по semver.
  • cmd/ — просто соглашение: сюда кладут main-пакеты, по подкаталогу на бинарник (cmd/server, cmd/migrate). Для go это обычный каталог, но соглашение узнаваемо всем.
  • pkg/ — часто встречающийся каталог, у которого нет никакого особого смысла для тулчейна; это лишь стиль раскладки, который одни проекты используют, другие считают лишним слоем. Ключевое отличие от internal/: pkg/ ничего не ограничивает, а internal/ ограничивает импорт по-настоящему.

Несколько мажорных версий: один репозиторий, ветки и теги

Несколько major-версий, как правило, живут в одном репозитории — разными тегами, а физически v2+ отделяется одним из двух способов:

  • Мажор-ветка. Ветка v2 в том же репозитории: в её go.modmodule .../v2, а v2.x.y тегается на коммит в этой ветке; в main продолжает жить v1 с тегами v1.x.y. Важная точность: go/прокси берут код по тегу — тег указывает на конкретный коммит, и после этого ветка уже не важна для загрузки; ветка нужна как линия разработки, где этот коммит появился и куда лягут следующие.
  • Подкаталог /v2. В том же дереве заводится каталог v2/ со своим go.mod (module .../v2); теги общие на репозиторий, но для пути .../v2 go берёт код из подкаталога. Удобно, когда v1 и v2 надо править в одном рабочем дереве.

Практика сопровождения важнее выбора между ними: v1 и v2 — это два параллельно поддерживаемых кодовых потока. Багфикс, актуальный для обоих, приходится бэкпортить и тегать дваждыv1.5.1 в потоке v1 и v2.3.1 в v2. Это и есть настоящая цена мажора: не «поставил тег», а «удвоил поддержку». Поэтому major поднимают только при действительно ломающем изменении публичного API, а не ради косметики — и лишний раз проверяют, нельзя ли обойтись обратно совместимым добавлением в текущем мажоре.

Отдельный исторический случай — суффикс +incompatible. Если старая библиотека доросла до тега v2.0.0 и выше, но без go.mod и без /vN в пути (по правилам домодульной эпохи), go подхватывает такой тег с пометкой вида v2.0.0+incompatible — «это major без SIV-суффикса, берём как есть». В новом коде так не делают: это совместимость со старым миром, а не образец для своих модулей.

Прокси и приватные модули

По умолчанию go качает модули не напрямую из VCS, а через module proxyproxy.golang.org. Это ускоряет и стабилизирует сборки: прокси кеширует неизменяемые версии, так что удаление или force-push в исходном репозитории уже не ломает тех, кто зависел от тега.

# цепочка прокси; direct — фоллбэк на прямой VCS-доступ
go env -w GOPROXY=https://proxy.golang.org,direct

# приватные модули: не через прокси и не сверять с checksum DB
go env -w GOPRIVATE=git.corp.example.com,github.com/khorost/*

# дефолтные флаги для всех команд go (напр. всегда собирать из vendor)
go env -w GOFLAGS=-mod=vendor
  • GOPROXY — список прокси через запятую; спец-значение direct означает «взять напрямую из VCS», off — «запретить сеть вовсе». В организации сюда ставят собственный кеширующий прокси (Athens, JFrog, GitLab), чтобы не зависеть от публичного и держать копию всех версий у себя.
  • GOSUMDB — checksum database (по умолчанию sum.golang.org); проверяет, что скачанная версия совпадает с той, что видел весь мир. off отключает сверку.
  • GOPRIVATE — glob-паттерны путей приватных модулей. Модуль, попавший под паттерн, go не тянет через публичный прокси и не сверяет с публичной checksum DB (внутренний код туда попадать не должен). Это «зонтичная» переменная: она задаёт умолчания сразу для GONOPROXY (что не гонять через прокси) и GONOSUMDB (что не сверять с sumdb), которые при нужде переопределяются точечно.
  • GOFLAGS — флаги, добавляемые ко всем вызовам go (например, -mod=vendor или -mod=readonly), чтобы не повторять их руками.

Практический минимум для приватных репозиториев: выставить GOPRIVATE под свой префикс и настроить доступ к VCS (.netrc/SSH/токен) — этого достаточно, чтобы приватные модули качались напрямую, а публичные продолжали идти через прокси с проверкой контрольных сумм.

Что дальше

Управление зависимостями смыкается с остальным тулингом Go — соберите проект, покройте тестами, замерьте:

Первоисточники — строго официальная документация, все правила SIV/MVS/команд выше сверены по ней:

  • Go Modules Reference — исчерпывающий справочник: формат go.mod/go.sum, semantic import versioning, алгоритм minimal version selection, replace/exclude/retract, vendoring, протокол прокси, приватные модули.
  • Using Go Modules — серия из официального блога (using-go-modules, migrating-to-go-modules, publishing-go-modules, v2-go-modules) — практическое введение по шагам.
  • Go Modules: v2 and Beyond — детальный разбор правила major-версии в пути импорта и выпуска v2+.
  • Managing dependencies — рабочие рецепты команд go get/go mod tidy/go work в официальной документации.

Обсуждение в Telegram

Присоединиться →

Комментарии