Содержание урока · 16 разделов
- go mod и пакеты go: из одного файла — в настоящий проект
- Пакет — единица компиляции
- Экспорт: заглавная первая буква
- Модуль и go.mod: паспорт проекта
- go mod init и go mod tidy: две команды ведут хозяйство
- Версии: semver и суффикс /v2
- Внутренние пакеты: каталог internal
- Алиасы импорта: когда два пакета зовутся одинаково
- Циклические импорты запрещены
- package main и точки входа
- Шпаргалка: пакеты и модули
- Правила гигиены пакетов и модулей
- Что дальше: ошибки, которые выносят за границы пакета
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
go mod и пакеты go: из одного файла — в настоящий проект#
Пока программа — один main.go, о структуре думать не нужно. Но как только файлов становится два, а зависимостей — три, язык требует ясности: где границы, что экспортируется, откуда приходят чужие пакеты и кто отвечает за версии. В Go за это отвечают две конструкции, и важно не путать их масштаб. Пакет (package) — единица компиляции и граница видимости: каталог с файлами, начинающимися одной строкой package. Модуль (module) — единица распространения и версий: корневой каталог с файлом go.mod, внутри которого живут один или много пакетов. А go mod — это семейство команд инструментария (go mod init, go mod tidy, go get), которое модулями управляет. Запрос «go mod» — самый частый вход в тему, и по праву: без манифеста не соберётся ни один проект с чужими библиотеками.
Первую анатомию программы мы разобрали ещё в уроке о структуре программы на курсе для новичков: package main, import, func main. Тогда этого хватило. Сегодня — углубление до уровня настоящего проекта: как пакеты компилируются и что они скрывают друг от друга, как работает экспорт и почему заглавная буква — это контракт, что такое go.mod и зачем он нужен, как приходят версии, зачем суффикс /v2, как прятать код в internal, чем спасают алиасы импорта и почему циклы запрещены. По дороге закроем долг из тринадцатого урока: там внешний пакет golang.org/x/exp/constraints отказывался импортироваться — сейчас вы узнаете точную причину.
План такой: пакет как единица компиляции; экспорт заглавной буквой; манифест go.mod и две команды go mod init / go mod tidy; версии semver и суффикс мажора; внутренние пакеты internal; алиасы импорта; циклические импорты; package main и точки входа. Финал — шпаргалка, правила гигиены и честная плашка об уроках без запуска кода.
Пакет — единица компиляции#
Начнём с определения, которое стоит запомнить дословно. Пакет — это все файлы одного каталога, в которых объявлено одно и то же имя пакета. Файлы task/task.go и task/priority.go со строкой package task — один пакет; компилируются они вместе, как единое целое, и видят все имена друг друга без всяких импортов. Если же в одном каталоге встретятся разные package-строки, сборка остановится с характерным приговором: «found packages task (task.go) and main (main.go) in …/task» — компилятор отказывается обрабатывать каталог, у которого два паспорта. Один каталог — одно имя пакета; это железное правило, и ошибка found packages — его прямое следствие.
Имя пакета и путь импорта — разные вещи. Путь импорта — это строка, которую вы пишете в import: для пакета внутри модуля путь начинается с module path (ниже) и продолжается подкаталогами: example.com/todo/task. Имя пакета — короткое слово из package-строки: task. По конвенции они совпадают в последнем сегменте (каталог task, пакет task), но совпадение — именно конвенция, а не требование: импорт связывает имя пакета, а не последнюю часть пути, и в пункте про алиасы мы увидим, что делать, когда они расходятся.
Что даёт пакет как единица компиляции? Во-первых, инкрементальность: каждый пакет собирается отдельно, результат кэшируется, и изменение одного файла не перекомпилирует весь мир — сборка идёт по графу зависимостей снизу вверх. Во-вторых, граница видимости: имена, объявленные на верхнем уровне пакета (типы, функции, константы, переменные), — это package block, общий для всех файлов пакета и невидимый снаружи; кто именно увидит его снаружи — решает заглавная буква, о ней в следующем пункте. В-третьих, порядок инициализации: если у пакета есть импорты, импортированные пакеты инициализируются раньше самого пакета — гарантия языка, которую мы использовали, не замечая, каждый раз, когда импортировали fmt.
Мы уже пожинали плоды этой системы: девятый урок о контексте тянул за собой пакет context — стандартную библиотеку, собранную и закэшированную где-то у вас на диске ещё при установке Go. Пакеты стандартной библиотеки живут по тем же правилам: путь «context», пакет context, экспортируемый тип Context. Разница лишь в том, что за них отвечает команда Go, а не ваш go.mod.
Проект из двух файлов: пакет task и пакет main
GO// файл task/task.go — пакет task, каталог task
package task
// Task — рабочая задача. Заглавное имя: тип экспортируется.
type Task struct {
ID int // экспортируемое поле
Title string // экспортируемое поле
done bool // неэкспортируемое: видно только внутри пакета task
}
// New — конструктор с заглавной буквы: доступен другим пакетам.
func New(title string) *Task {
return &Task{ID: 1, Title: title}
}
// complete — неэкспортируемый метод: деталь внутренностей пакета.
func (t *Task) complete() {
t.done = true
}Всё, что объявлено в пакете task, видно его файлам целиком — и Task, и done, и complete. Другой пакет получит только то, что начинается с заглавной буквы: Task, New, ID, Title. Строка импорта для этого пакета — example.com/todo/task: module path плюс подкаталог.
main.go: импорт своего пакета по пути модуля
GO// файл main.go — пакет main, корень модуля
package main
import (
"fmt"
"example.com/todo/task" // путь импорта = module path + подкаталог
)
func main() {
t := task.New("написать урок")
fmt.Println(t.Title) // легально: Title экспортировано
// fmt.Println(t.done) // НЕ соберётся: done неэкспортируемое
// t.complete() // НЕ соберётся: complete неэкспортирован
}Импорт своего пакета устроен как импорт fmt — отличие только в пути: он начинается с module path вашего модуля. Пустая строка внутри import — группировка стандартной библиотеки отдельно от остального; это конвенция gofmt-мира, а не синтаксис.
Экспорт: заглавная первая буква#
В прошлых уроках мы пользовались этим правилом на автомате: Println, Unmarshal, Stringer — все с заглавной. Теперь сформулируем его точно, по спецификации языка. Идентификатор экспортируется тогда и только тогда, когда выполняются два условия: первый символ имени — буква в верхнем регистре Unicode (категория Lu — неважно, латиница это или кириллица), и идентификатор объявлен в package block либо это имя поля или метода. Всё остальное — неэкспортируемое: строчные имена, имена с подчёркиванием, локальные переменные функций. Слово public/private здесь не нужно: экспорт в Go — это не модификатор, а свойство первой буквы имени.
Из этого следует три неочевидных вещи. Первая: экспорт — граница между пакетами, а не между файлами. Внутри одного пакета заглавность ничего не прячет — файл task/task.go видит done соседнего файла task/priority.go сколько угодно. Вторая: экспортируемость поля не спасает структуру целиком — тип Task с неэкспортируемым полем done может использоваться снаружи (конструировать, передавать, читать Title), но поле done для чужого пакета просто не существует: компилятор отвечает «t.done undefined», хотя поле есть — об этом первый predict ниже. Третья: заглавная буква — это обещание. Всё, что вы экспортировали, становится вашим API: чужой код может зависеть от имени, и убрав его, вы ломаете сборки людей, которые ничего у вас не спрашивали. Экспортируйте минимум — это не эстетика, а дисциплина совместимости.
Обратите внимание и на обратную сторону: неэкспортируемое — тоже контракт, отрицательный. Оно говорит: «это внутренняя механика, мы имеем право её менять без предупреждения». Именно на этом строится internal-правило из середины урока и именно поэтому стандартная библиотека полна строчных helper-ов, о которых никто, кроме авторов пакета, не обязан знать.
Проверьте себя
Пакет user объявляет: type Profile struct { Name string; email string }, func Save() error и func helper() int. Что доступно другому пакету?
Что выведет сборка?
Прочитайте код и выберите, что он выведет:
// файл task/task.go
package task
type Task struct {
ID int
Title string
done bool
}
func New(title string) *Task {
return &Task{ID: 1, Title: title}
}
// файл main.go
package main
import (
"fmt"
"example.com/todo/task"
)
func main() {
t := task.New("написать урок")
fmt.Println(t.Title, t.done)
}Модуль и go.mod: паспорт проекта#
Пакеты отвечают за границы видимости, но не за вопросы «откуда чужой код» и «какая версия». Это территория модулей. Модуль — дерево каталогов с файлом go.mod в корне; файл объявляет module path, и всё, что ниже корня, — пакеты этого модуля. По документации go.dev/ref/mod путь модуля — каноническое имя, объявляемое директивой module в go.mod, и одновременно префикс путей импорта всех пакетов модуля: поэтому наш пакет импортируется как example.com/todo/task — module path example.com/todo плюс подкаталог task. Путь модуля обычно совпадает с адресом репозитория: там, где человек видит example.com/todo, инструмент Go находит хранилище и скачивает код.
Из чего состоит манифест? Три директивы закрывают 95% потребностей. module — путь модуля, его каноническое имя. go — минимальная версия языка, на которой модуль собирается: до Go 1.21 директива была советом, теперь — обязательство, и тулчейн отказывается собирать модуль, заявивший более новую версию, чем у вас. require — список зависимостей: путь чужого модуля и его версия, например require github.com/google/uuid v1.6.0. Рядом с go.mod живёт go.sum — файл контрольных сумм: он фиксирует криптографические отпечатки скачанных модулей и бережёт сборку от подмены; go.mod отвечает за «что», go.sum — за «точно оно».
Важно понять разделение труда. go.mod — ваш паспорт и ваша ведомость зависимостей; версии в require не появляются по волшебству — их заносят команды go mod, о которых следующий пункт. А package main и package task из наших листингов становятся частями целого только потому, что где-то в корне лежит go.mod со строкой module example.com/todo: без неё пути импорта example.com/todo/task для компилятора не значат ничего.
go.mod: манифест и его три главные директивы
GO// go.mod — манифест модуля, лежит в корне каталога todo
module example.com/todo
go 1.23
require github.com/google/uuid v1.6.0module example.com/todo — каноническое имя модуля и префикс всех его путей импорта. go 1.23 — минимальная версия языка; с Go 1.21 тулчейн не соберёт модуль, требующий версию новее установленной. require github.com/google/uuid v1.6.0 — зависимость с точной семантической версией. Рядом с манифестом живёт go.sum: контрольные суммы для каждой скачанной версии.
go mod init и go mod tidy: две команды ведут хозяйство#
С go.mod работают десятки команд, но повседневно нужны две. go mod init создаёт модуль: пишет в текущем каталоге свежий go.mod — по документации команда «инициализирует и записывает новый go.mod в текущем каталоге, фактически создавая новый модуль с корнем в этом каталоге», и манифест там уже существовать не должен. В аргументе ей передают module path: go mod init example.com/todo. Для открытого проекта путь — адрес репозитория (github.com/user/repo); для локальной поделки сойдёт любое имя вида example.com/todo или todo.local/hello — просто выбирайте осмысленное, потому что именно с этого префикса вас будут импортировать.
go mod tidy — хранитель порядка в require. Формулировка из документации точна и полезна: tidy приводит go.mod в соответствие с исходным кодом — добавляет недостающие требования, необходимые для сборки пакетов модуля и их зависимостей, убирает требования на модули, которые больше не поставляют релевантных пакетов, и заодно чинит go.sum — добавляет недостающие записи и удаляет ненужные. Практический смысл: написали import чужого пакета — запустили go mod tidy — require появился сам, с правильной версией. Удалили последний import — tidy вычистил строку из манифеста. Редактировать go.mod руками, конечно, можно, но tidy делает это без опечаток и синхронно с кодом.
Названные команды не отменяют и «go get» — команду добавления и обновления зависимости (go get github.com/google/uuid@v1.6.0 поднимет require до указанной версии). В связке тринадцатого урока это объясняет загадку: пакет golang.org/x/exp/constraints не импортировался на странице курса, потому что браузерный интерпретатор не имеет ни файловой системы, ни модуля с манифестом — а в настоящем проекте достаточно добавить import и позвать go mod tidy: внешний модуль появится в require и будет скачан. Модульность — это не препятствие, а протокол знакомства: сначала манифест, потом импорт.
Проверьте себя ещё раз
Вы дописали в коде import "github.com/google/uuid", но в go.mod require нет. Что произойдёт?
Версии: semver и суффикс /v2#
В require версии выглядят как v1.6.0 — это семантическая версия (semver). Схема из документации по модулям: три неотрицательных числа — major, minor, patch — слева направо, разделённые точками; за patch может следовать строка пре-релиза через дефис, например v1.2.3-pre, а кроме того — метаданные сборки через плюс. Числа — это сообщения об изменениях. Major растёт при ломающих изменениях: совместимость со старым кодом больше не гарантируется. Minor — новые возможности с сохранением совместимости. Patch — исправления без изменений API. Из этого вытекают правила жизни: версии с major 0 считаются нестабильными — v0.11.0 ничего не обещает; v1.0.0 — это не «стало лучше», а «теперь мы гарантируем совместимость»; пре-релиз сортируется раньше релиза — v1.2.3-pre идёт перед v1.2.3, поэтому его скачают только те, кто попросит явно.
Минимальный выбор версии у Go устроен скромно и надёжно: если два разных зависимых модуля требуют одну библиотеку в разных версиях, выбирается более высокая — сборка всегда консервативна и берёт максимум заявленного, а не пытается угодить всем. Отсюда и философия модулей: между несовместимыми версиями не существует «обновления» — существует переход на другой мажор, который обязан быть другим пакетом по имени. Как это выглядит технически — вторая половина пункта и самая обсуждаемая деталь системы модулей, суффикс /v2.
И одна служебная подробность для полноты: коммит без тега в системе модулей выглядит как псевдоверсия — специальная строка вида v0.0.0-20191109021931-daa7c04131f5: базовая версия, метка времени и префикс хеша коммита. В go.mod такие строки появляются, когда вы просите версию по хешу или ветке; руками их писать не нужно — а вот прочитать в чужом манифесте стоит уметь: это «такая-то версия, чуть позже неё».
Теперь правило, ради которого ломающие изменения в Go выглядят иначе, чем в большинстве экосистем. Если модуль выпущен с major 2 или выше, module path обязан заканчиваться суффиксом мажора: example.com/todo превращается в example.com/todo/v2. Суффикс — часть пути модуля, а путь — префикс путей пакетов, поэтому пакеты второй версии импортируются как example.com/todo/v2/task. Суффиксы запрещены у v0 и v1: между v0 и v1 менять путь не нужно, потому что v0 нестабильна и ничего не обещает, а v1 — обещание совместимости с последней v0, а не объявление ломающего перехода.
Зачем такая строгость? Из-за алмаза зависимостей. Представьте: ваш проект тянет библиотеки A и B, обе зависят от библиотеки C — A от старой версии, B от новой несовместимой. Если бы v2 пути не менял, тулчейн должен был бы выбрать одну версию C для обоих — и сломать либо A, либо B. Суффикс делает v1 и v2 двумя разными модулями с разными путями импорта: они скачаны по отдельности, их типы не взаимозаменяемы, и обе версии живут в одной сборке одновременно. Старый код импортирует example.com/todo/task, новый — example.com/todo/v2/task, и никто не подставляет соседа.
Особое имя в этой системе — gopkg.in: пути, начинающиеся с gopkg.in/, обязаны иметь суффикс мажора даже на v0 и v1, и суффикс пишется через точку, а не слэш — знаменитый gopkg.in/yaml.v2. Таких модулей в реальных проектах полно, и теперь вы знаете, что точка в имени — не украшение, а тот же суффикс мажора в исторической упаковке.
А помните долг из тринадцатого урока? Пакет golang.org/x/exp/constraints отказался импортироваться в браузере. Теперь можно назвать причину точно: это внешний модуль, а не пакет стандартной библиотеки — его путь не резолвится без проекта с go.mod, заявленного require и скачанного кода. В настоящем проекте решается одной командой go mod tidy; в браузерном интерпретаторе у модулей нет ни дома, ни версий — и это честная граница среды, а не дефект языка.
И снова проверьте себя
Модуль example.com/todo выпущен с ломающими изменениями как версия 2.0.0. Как теперь импортируется его пакет task из каталога task/?
Внутренние пакеты: каталог internal#
Заглавная буква прячет имена внутри пакета. Но у настоящего проекта есть и другой секрет: целые пакеты, которые должны служить своему модулю и никому больше. Для этого в Go есть встроенное правило с одним словом в имени — internal. Формулировка из заметок к Go 1.4: когда тулчейн видит в пути импорта элемент internal, он проверяет, что импортирующий код лежит в дереве каталогов, корнем которого служит родительский каталог internal. Пример из той же заметки: пакет …/a/b/c/internal/d/e/f можно импортировать только из кода, который находится в дереве …/a/b/c; код из …/a/b/g и из любого другого репозитория импортировать его не может. Механизм ввели в Go 1.4 для главного репозитория Go, а начиная с 1.5 он действует для любых проектов — правило простое и всеобщее.
Применяем к нашему модулю: каталог example.com/todo/internal/config доступен пакетам main и task, потому что оба живут в дереве example.com/todo — родителе internal. А чужой модуль, даже если он очень попросит, получит от компилятора отказ: use of internal package … not allowed. Обратная сторона та же, что у неэкспортируемых имён: внутри своего дерева вы вправе менять внутренние пакеты как угодно — документация по оформлению модулей прямо советует складывать служебные пакеты в internal, чтобы чужие проекты не могли зависеть от того, что вы не готовы поддерживать, и напоминает: раз другие проекты не могут импортировать ваш internal, вы свободно рефакторите его API, не ломая никого снаружи.
Тонкость, которую стоит проговорить: internal — правило инструментария, а не языка. Компилятор языка про каталоги не знает; проверку делает команда go при резолве импортов. Поэтому ошибка всплывает на этапе сборки, и текст её начинается с use of internal package — если увидите такой, искать нужно не опечатку в имени, а нарушение границы дерева. И вторая: internal работает на любом уровне вложенности — internal может лежать в корне модуля или в subpackage; в обоих случаях правило считает родителя границей.
Дерево модуля с internal
GO// Дерево модуля example.com/todo:
//
// todo/
// go.mod ← module example.com/todo
// main.go ← package main: точка входа
// task/
// task.go ← package task: открытый пакет
// internal/
// config/
// config.go ← package config: только внутри дерева todo
// storage/
// storage.go ← package storage: тоже только внутри
//
// main.go и task/task.go могут писать:
// import "example.com/todo/internal/config"
// Чужой модуль — не может: use of internal package not allowedinternal/config импортируется по полному пути example.com/todo/internal/config. Граница — родительский каталог internal: всё дерево example.com/todo импортирует свободно, весь остальной мир получает отказ сборки. Служебные пакеты storage и config свободно рефакторятся: снаружи на них никто не завязан.
Алиасы импорта: когда два пакета зовутся одинаково#
Имя пакета в коде берётся из его package-строки, а не из пути. Отсюда коллизия: в одном файле понадобились math/rand из стандартной библиотеки и чужой пакет example.com/lib/rand — оба пакета rand. Без выхода файл не соберётся: имя rand конфликтует. Выход существует со времён первой версии языка — алиас импорта. Спецификация знает три формы привязки: import m "lib/math" связывает пакет с локальным именем m (обращение m.Sin), import . "lib/math" выносит экспортируемые имена прямо в область видимости файла (просто Sin), import _ "lib/math" привязывает пустое имя — пакет инициализируется, но в коде не виден; эту форму используют ради побочных эффектов инициализации. Алиас действует только в файле импорта — соседние файлы того же пакета его не видят и пишут импорты по-своему.
Алиас — не украшение, а средство трёх бед. Первая — коллизии имён: math/rand против example.com/lib/rand, json из stdlib против json собственной библиотеки — одной из пар дают короткое имя (stdrand "math/rand"). Вторая — длинные или невнятные имена: чужой пакет мог объявить package v2 или package protobufv3, и имя google_protobuf превращает каждую строку кода в шум — алиас pb "…/protobuf" возвращает читаемость. Третья — суффиксы мажоров из прошлого пункта: пакет todo/v2 внутри может называться по-старому todo, и чтобы код читался честно, пишут todo2 "example.com/todo/v2/task" — версия видна прямо в имени.
Дисциплина при этом проста. Алиас пишут, когда без него нельзя или когда он заметно улучшает чтение; алиас ради «мне так нравится» — лишний слой, который заставляет читателя помнить, что за именем стоит. Точечный импорт import . — самое редкое из трёх: он скрывает происхождение имён и легко превращает файл в загадку, поэтому в прикладном коде встречается почти исключительно в тестах. Пустой импорт import _ честно документирует намерение — комментарии вида import _ "net/http/pprof" вы встретите в продакшене постоянно: пакет нужен ради регистрации своих обработчиков при старте.
Что выведет эта программа?
Прочитайте код и выберите, что он выведет:
package main
import f "fmt"
func main() {
f.Println("готово")
}Соберите go.mod
Впишите: директиву пути модуля, минимальную версию языка и точную семантическую версию зависимости.
Циклические импорты запрещены#
Граф пакетов в Go — направленный и ациклический по закону. Формулировка спецификации беспощадна: импорт объявляет зависимость, и пакету запрещено импортировать себя — прямо или косвенно. Никаких «но у нас архитектурно обосновано»: пакет A импортирует B, B импортирует A — и сборка падает с сообщением import cycle not allowed, которое вы увидите как только тулчейн закроет круг. Косвенные циклы караются так же: A → B → C → A — тот же отказ, найденный на этапе анализа зависимостей, задолго до линковки.
Почему запрет тотальный? Цикл разрушает базовые гарантии языка. Пакеты инициализируются в порядке зависимостей — импортированные раньше импортирующих; в цикле этот порядок не определён физически: кто из A и B «первый», если каждый нуждается в готовности другого? Компиляция как единица тоже стоит на ацикличности: собрать B можно только со ссылкой на готовые типы A — а в цикле готовых нет ни тех, ни других. Поэтому в Go цикл не «плохой стиль», а ошибка уровня «программа не существует».
Что делать, когда цикл напрашивается? Классическая ситуация: пакет task хочет логировать через пакет logger, а logger хочет описывать сущности из task. Разорвать круг можно тремя способами, по возрастанию усилий. Первый — убрать зависимость через интерфейс: logger объявляет свой минимальный контракт (у сущности есть метод String), а task его реализует; импорт идёт только task → logger. Второй — вынести общий код в третий пакет: типы Task и ID переезжают в пакет model, который импортируют оба, и цикл исчезает физически. Третий — пересмотреть слои: если task и logger не могут жить друг без друга, возможно, это один пакет, и деление искусственно. Заметьте: запрет циклов — не зануда-компилятор, а бесплатный архитектор — каждый import cycle not allowed указывает на границу, которую вы нарисовали зря.
package main и точки входа#
Последний штрих — особый статус пакета main. Спецификация описывает программу так: полная программа получается связыванием единственного непимпортируемого пакета main со всеми пакетами, которые он импортирует — прямо и транзитивно; пакет main обязан называться main и объявлять функцию main без аргументов и без результата. Выполнение начинается с инициализации программы и вызова func main в package main; когда main возвращается, программа завершается — и не дожидается остальных горутин. Из «непимпортируемого» следует вторая половина правила: пакет main никто не может импортировать — попытка import «…/cmd/todo» с package main внутри закончится ошибкой импорта. Точка входа ровно одна на программу.
Рядом с main работает и второй служебный механизм — функция init: необязательная, без аргументов и результата, объявлять её можно несколько раз даже в одном файле. Порядок инициализации языка: импортированные пакеты — сначала, потом переменные уровня пакета, потом init-функции, и лишь затем func main. Этим пользуются для регистрации драйверов, разбора флагов, проверок окружения — всей работы, которая обязана случиться до точки входа. В наших листингах init не понадобился, но читать чужой код без знания init нельзя.
Три команды превращают пакет main в запускаемое: go run собирает во временный каталог и сразу запускает (наш тренажёр всех прошлых уроков), go build собирает и оставляет исполняемый файл — в корне модуля он получит имя по последнему сегменту module path, go install кладёт собранное в каталог bin. В настоящих репозиториях точек входа бывает несколько: подкаталог cmd/api и cmd/worker, в каждом свой пакет main со своим main — внутри одного модуля это легально, ведь каждый такой каталог — отдельный пакет, а импортируют они общие пакеты модуля. Так что «точка входа одна» значит «одна на собранную программу», а не «одна на репозиторий».
Соберите main.go с импортом своего пакета
Соберите файл main.go: пакет main, два импорта, точка входа, конструирование задачи из своего пакета и печать экспортируемого поля.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Шпаргалка: пакеты и модули#
| Инструмент | Что делает | Правило или пример | Осторожно |
|---|---|---|---|
| package | имя пакета в начале каждого файла | один каталог — один пакет; файлы видят имена друг друга | разные package-строки в каталоге: found packages … |
| экспорт | делает имя видимым другим пакетам | первая буква в верхнем регистре (категория Lu); поля и методы — тоже | экспорт — обещание API: экспортируйте минимум |
| go.mod | паспорт модуля: module path, go, require | module example.com/todo; go 1.23; require …/uuid v1.6.0 | рядом нужен go.sum: контрольные суммы зависимостей |
| go mod init | создаёт модуль и go.mod | go mod init example.com/todo | go.mod в каталоге уже должен отсутствовать |
| go mod tidy | синхронизирует require с кодом | добавляет недостающее, убирает лишнее, чинит go.sum | импорт без require — ошибка сборки, лечится tidy |
| версии semver | vMAJOR.MINOR.PATCH: что изменилось | v0 нестабильна; v1 — обещание совместимости; пре-релиз раньше релиза | ломающее изменение = новый мажор, а не +1 к patch |
| суффикс /v2 | делает мажор отдельным модулем | import "example.com/todo/v2/task"; gopkg.in/yaml.v2 — через точку | v0 и v1 живут без суффикса |
| internal/ | пакеты только для своего дерева | example.com/todo/internal/config доступен лишь коду дерева example.com/todo | правило тулчейна: use of internal package not allowed |
| алиас импорта | локальное имя пакета в файле | import f "fmt"; stdrand "math/rand"; import _ — ради побочных эффектов | действует только в файле импорта; настоящее имя теряется |
| package main | точка входа программы | func main без аргументов и результата; init() до main | main не импортируется; несколько main — по одному в cmd/* |
Правила гигиены пакетов и модулей#
- Один каталог — одно имя пакета: два имени в каталоге дают found packages, а не два пакета.
- Имя пакета — короткое, строчными буквами, без подчёркиваний: task, config, storage; тот, кто импортирует, будет писать это слово в каждой строке.
- Экспортируйте минимум: заглавная буква — публичное обещание; неэкспортируемое вы вправе менять молча.
- Служебные пакеты прячьте в internal/ — границей служит родительский каталог internal, и чужие модули не завяжутся на вашу механику.
- Не редактируйте require руками, когда есть go mod tidy: он синхронен с кодом и заодно поддерживает go.sum.
- Ломающее изменение — это +1 к major и суффикс в пути (/v2), а не перезапись старой версии: старый код обязан продолжать собираться.
- Алиас импорта — при коллизии имён, длинном или невнятном имени, суффиксе v2; алиас ради вкуса — лишний слой между кодом и истиной.
- Цикл импорта — не проблема компилятора, а диагноз архитектуре: вынесите общий тип в третий пакет или объявите минимальный интерфейс на месте цикла.
Это первый урок курса без раннеров и ранимых листингов: песочница исполняет одиночный файл в памяти браузера — у неё нет ни каталогов, ни файловой системы, ни module cache, поэтому многофайловые пакеты, go.mod и команды go mod в ней не живут. Поэтому интерактивы урока — quiz, predict, fill, order и карточки: они проверяют понимание без компиляции. Всё остальное воспроизводится локально за пять минут: mkdir todo; cd todo; go mod init example.com/todo; mkdir task; соберите два файла из листингов — и весь материал урока пройдёт через ваши руки терминалом: go build, go run ., go mod tidy.
Что дальше: ошибки, которые выносят за границы пакета#
Тема границ закрыта. Пакеты отделили ваше от чужого — заглавной буквой, internal и запретом циклов; модули дали проекту имя, зависимости и версии — манифестом, tidy и semver с суффиксом мажора. Логично, что следующая тема — то, что пересекает эти границы ежедневно: ошибки. Тринадцатый урок закончил виток про типы, и теперь мы перейдём к значениям, которые путешествуют между пакетами вместе с результатами: как оборачивать ошибки своим контекстом (fmt.Errorf с %w), как разворачивать их обратно (errors.Is и errors.As) и куда вырастет знакомая по двенадцатому уроку ok-форма утверждения типа, когда проверять типы ошибок придётся честно. Пятнадцатый урок — «Ошибки со сквозной упаковкой»: err.(*MyErr) станет errors.As, а проверки на == — errors.Is.
До встречи на страницах курса: весь список тем живёт на странице курса, а если хочется повторить, откуда растут ноги у package и import — вернитесь к уроку о структуре программы и посмотрите на него новыми глазами: теперь вы знаете, что стоит за каждой строчкой.
Карточки урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Пакет — все файлы одного каталога с одной package-строкой: компилируются как единое целое и видят имена друг друга; разные имена в каталоге — ошибка found packages.
- Экспорт определяется первой буквой: верхний регистр (категория Lu) плюс объявление в package block или поле/метод. Экспорт — граница между пакетами и обещание API.
- Модуль — дерево с go.mod в корне: module path — каноническое имя и префикс путей импорта, go — минимальная версия языка (с 1.21 обязательна), require — зависимости, go.sum — контрольные суммы.
- go mod init создаёт манифест в пустом каталоге; go mod tidy синхронизирует require с кодом и чинит go.sum; импорт без require — ошибка сборки.
- Версии — semver из трёх чисел: v0 нестабильна, v1 — обещание совместимости, пре-релиз сортируется раньше релиза; псевдоверсии описывают коммиты без тега.
- С major 2+ module path обязан заканчиваться суффиксом мажора (/v2), поэтому v1 и v2 — разные модули и сосуществуют в одной сборке; gopkg.in/yaml.v2 — особый случай с точкой.
- internal: пакет импортируется только из дерева родителя internal-каталога — механика тулчейна с Go 1.5 для любых проектов; внутри дерева — свобода рефакторить.
- Алиасы импорта связывают пакет с локальным именем файла (import f "fmt"), лечат коллизии имён и длинные пути; import _ — ради побочных эффектов.
- Циклические импорты запрещены спецификацией: import cycle not allowed; разрываются выносом общего пакета или минимальным интерфейсом.
- package main — непимпортируемая точка входа: func main без аргументов и результата; init-функции исполняются до main; go run/build/install — три пути к запуску.
Домашнее задание
Часть первая — соберите модуль: mkdir todo; cd todo; go mod init example.com/todo; создайте пакет task с типом Task (ID, Title, неэкспортируемое done), конструктором New и методом Done() bool; соберите main.go, который печатает задачу; убедитесь, что обращение к t.done не компилируется, а вызов t.Done() — работает. Часть вторая — internal: спрячьте чтение конфигурации в internal/config (тип Config и функцию Load), импортируйте её из main; попробуйте импортировать example.com/todo/internal/config из отдельного модуля-соседа и сохраните текст отказа. Часть третья — tidy: добавьте import "github.com/google/uuid", запустите go mod tidy и сравните go.mod и go.sum до и после; затем удалите импорт и запустите tidy ещё раз — require исчезнет. Часть четвёртая — версии: найдите в чужом go.mod (в кэше модулей или в открытом репозитории) модуль с суффиксом /v2 или gopkg.in/yaml.v2 и письменно объясните, почему суффикс нельзя было опустить; попробуйте объявить require с версией v2.0.0 без суффикса пути и прочтите ответ тулчейна. Комментарии в коде — по-русски.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем пакет отличается от модуля?
Пакет — единица компиляции и видимости: каталог файлов с одной package-строкой. Модуль — единица распространения и версий: дерево каталогов с go.mod в корне, внутри которого живут один или много пакетов. Видимость определяет пакет (экспорт, internal), зависимости и версии — модуль (require, semver, суффикс мажора). Импорт пакета внутри модуля — module path плюс подкаталоги.
Почему неэкспортируемое поле не видно в другом пакете, даже если оба пакета в одном проекте?
Потому что граница видимости — пакет, а не проект, каталог или репозиторий. Поле или функция со строчной первой буквой существует только внутри своего пакета: из другого пакета обращение не компилируется (ошибка вида t.done undefined). Разносить пакеты по одному проекту — норма; «один проект» — не паспорт доступа, паспортом служит заглавная буква.
Зачем суффикс /v2 и почему нельзя просто написать require с версией v2.0.0?
Суффикс делает мажор отдельным модулем с отдельным путём импорта: example.com/todo и example.com/todo/v2 сосуществуют в одной сборке, их типы не взаимозаменяемы. Без суффикса алмаз зависимостей (два клиента требуют несовместимые версии одной библиотеки) был бы неразрешим: тулчейн выбирает более высокую версию, и она сломала бы клиента старой. Суффиксы разрешены с major 2; у v0/v1 их нет, а у gopkg.in — обязательная точка (yaml.v2).
Как разрешить ошибку import cycle not allowed?
Цикл — диагноз делению на пакеты. Разрывать можно тремя способами: вынести общий код (типы, интерфейсы) в третий пакет, который импортируют обе стороны; объявить на месте цикла минимальный интерфейс и импортировать только в одну сторону; либо признать, что два «пакета» — на самом деле один, и слить их. Компилятор циклы не разрешит никогда: спецификация запрещает пакету импортировать себя прямо или косвенно.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Fiber: экспресс-подобный веб-фреймворк на Fasthttp
Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.
- Поднимать HTTP-сервер на Fiber за минуты
- Проектировать маршруты, группы и версии API
- Писать middleware: логи, CORS, лимиты, recovery
- Принимать и отдавать JSON с валидацией