10. Пакеты: у каждого правила свой адрес
К концу главы программа по-прежнему читает название и публикует книгу. Меняется устройство исходников: правила каталога можно использовать из терминала, а затем из HTTP-обработчика. Читатель умеет найти определение по импорту, запустить все тесты и объяснить, почему модуль не равен репозиторию.
Перед началом без подсказки восстановите цепочку главы 9: открыть файл → ограничить чтение → проверить название → закрыть файл → вернуть причину ошибки. Если непонятно, почему закрытие должно произойти и при ошибке чтения, сначала повторите три теста defer из предыдущей главы.
Контрольная точка и перенос
Откройте examples/10-packages из комплекта кода. Здесь уже есть go.mod; выполнять go mod init повторно не нужно. Каждая папка главы — самостоятельная версия магазина, а не подкаталог одного огромного модуля. Терминал остаётся в корне контрольной точки: здесь лежат go.mod и title.txt, а точка входа теперь находится в cmd/shop/main.go.
10-packages/
go.mod
cmd/shop/main.go
title.txt
stdout.txt
internal/
catalog/
book.go book_test.go
pricing.go pricing_test.go
title.go title_test.go
titlefile/
file.go file_test.go
Если собираете переход вручную, скопируйте папку главы 9 в отдельную папку. Перенесите файлы book, pricing, title вместе с их тестами в internal/catalog, замените первую строку на package catalog. Переименуйте normalizeTitle в NormalizeTitle во всех этих файлах. Затем перенесите file.go и file_test.go в internal/titlefile, замените пакет на titlefile; loadTitle станет Load. В file.go добавьте импорт каталога и вызов catalog.NormalizeTitle. Оставшийся учебный defer_test.go полезен в главе 9; он не часть рабочего пакета каталога. Создайте cmd/shop и перенесите туда main.go, сохранив package main. Старые копии перенесённых файлов в корне новой папки не оставляйте.
Путь модуля в готовом go.mod:
module github.com/dake-edu/gopher-shop/book/examples/10-packages
go 1.27.1
Это адрес пространства имён, а не команда скачивания проекта. Go найдёт собственные пакеты внутри текущего модуля. Учебные пути сохранены для воспроизводимости примеров; при создании своего отдельного продукта выберите свой путь и согласованно замените его в импортах.
Пакет, модуль и репозиторий
Пакет объединяет файлы Go в одной папке. Их объявления доступны друг другу без импортов. Тесты могут находиться в том же пакете; отдельный внешний тестовый пакет с суффиксом _test разберём позже. В обычной папке нельзя смешать package main и package catalog.
Модуль — дерево пакетов с go.mod в корне, собственной версией языка и зависимостями. Репозиторий хранит историю файлов. Один репозиторий может содержать несколько модулей: именно так устроены наши независимые контрольные точки. Запуск go test ./... из папки главы не перескакивает в соседние модули.
Опорный образ: пакет — отдел, модуль — организация, репозиторий — архив изменений. Граница образа: каталог на диске не всегда пакет Go, а Git не участвует в исполнении программы.
Папка internal имеет специальное правило импорта. Пакет внутри неё доступен коду из дерева родительской папки internal. В нашем случае все пакеты магазина могут импортировать internal/catalog, а чужой модуль вне этого дерева — нет. Это ограничение компилятора для архитектуры, не защита секретов или файлов от чтения.
Два пакета до переноса магазина
Проверим импорт на программе без файлов, цен и ошибок. В готовой контрольной точке есть labs/01-package/main.go и labs/01-package/greeting/message.go. Общий go.mod остаётся в корне главы. Папка greeting — отдельный пакет; main.go её родительской папки принадлежит запускаемому пакету main.
package greeting
func Message(title string) string {
return "Книга: " + title
}
package main
import (
"fmt"
"github.com/dake-edu/gopher-shop/book/examples/10-packages/labs/01-package/greeting"
)
func main() {
fmt.Println(greeting.Message("Go"))
}
go run ./labs/01-package
Книга: Go
Путь импорта складывается из строки module и относительного пути папки labs/01-package/greeting. Это свой пакет текущего модуля: команда не должна скачивать его с GitHub. В собственном модуле замените начало импорта на свой путь из go.mod, сохранив путь папки. Имя greeting в вызове берётся из package greeting; имя файла message.go в вызове не участвует.
Опыт с ошибкой: в копии опыта назовите функцию message со строчной буквы, сохранив вызов из другого пакета. Компилятор откажет в доступе к нужному экспортированному имени. Затем верните Message. Второй опыт: удалите импорт, оставив вызов; ошибка теперь относится к неизвестному имени пакета. Это разные причины, исправлять их нужно в разных местах.
Не переносите этот учебный пакет в internal/catalog: он показывает механизм импорта, а не правило магазина. Наличие labs не меняет точку входа приложения. Теперь путь main → titlefile → catalog ниже можно читать как вызовы между такими же пакетами.
Экспорт и направление зависимостей
Имя, начинающееся с заглавной буквы, экспортируется: Book, Publish, NormalizeTitle, Load. Строчная буква делает объявление доступным только внутри своего пакета. Не нужно делать каждую вспомогательную функцию публичной. Например, readTitleAndClose остаётся деталью чтения файла.
Направление зависимостей сейчас такое:
main → titlefile → catalog
main → catalog
Каталог ничего не знает о файлах. Поэтому HTTP-слой позже сможет использовать те же правила. Цикл catalog → titlefile → catalog запрещён: Go сообщит import cycle not allowed. Лечить его случайным переносом импортов нельзя; нужно пересмотреть, кому принадлежит правило.
Полная точка входа:
package main
import (
"fmt"
"os"
"github.com/dake-edu/gopher-shop/book/examples/10-packages/internal/catalog"
"github.com/dake-edu/gopher-shop/book/examples/10-packages/internal/titlefile"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
title, err := titlefile.Load("title.txt")
if err != nil {
return err
}
book := catalog.Book{ID: "go-shop", Title: title, PriceMinor: 249900, Currency: "KZT"}
if !book.Publish() {
return fmt.Errorf("карточка книги не прошла проверку")
}
fmt.Println(book.Title)
fmt.Println("Опубликована:", book.Published)
return nil
}
В catalog.Book точка означает обращение к экспортированному имени пакета. В book.Title та же точка выбирает поле значения. По левой части выражения можно отличить одно от другого. Импорт перечисляет путь, а имя catalog берётся из объявления пакета. Неиспользуемый импорт — ошибка компиляции.
Разделите стандартные импорты и импорты проекта пустой строкой. gofmt выравнивает оформление, но не принимает архитектурных решений. Комментарий к экспортированной функции начинайте с её имени и объясняйте обещание вызывающему: какие данные допустимы и как представлен отказ.
Проверка без догадок
Исполняемый пакет переехал из корня в cmd/shop, поэтому вместо go run . теперь используем go run ./cmd/shop. Терминал остаётся рядом с go.mod и title.txt: путь после run выбирает пакет, но не меняет рабочую папку программы.
gofmt -w . форматирует Go-файлы в текущей папке и её подкаталогах; в главе 2 мы передавали имя одного файла. go vet ./... ищет подозрительные конструкции без запуска магазина, например несовпадение формата Printf и типа аргумента. Успех обычно не печатает ничего и не заменяет тесты. go doc ./internal/catalog Book показывает объявление и комментарий типа Book из указанного пакета. Эти команды выполняйте из папки 10-packages:
gofmt -w .
go run ./cmd/shop
go test ./...
go vet ./...
go doc ./internal/catalog Book
Вывод программы совпадает с главой 9:
Go: от первой строки до интернет-магазина
Опубликована: true
./... означает текущий пакет и вложенные пакеты текущего модуля. Строка [no test files] для cmd/shop нормальна: бизнес-тесты переехали вместе с правилами. Но отсутствие тестов во всех пакетах — повод проверить, не потерялись ли файлы при переносе. go doc показывает документацию объявления; справка доступна локально без браузера.
Предскажите результат опыта: переименуйте Load в load, но оставьте titlefile.Load в main.go. Компиляция завершится ошибкой: требуемого экспортированного имени нет. Верните имя. Второй опыт: запустите go run . из корня этой контрольной точки. Go сообщит, что в корне нет файлов Go: запускаемый пакет теперь лежит в cmd/shop. Верная команда — go run ./cmd/shop; она включает все файлы пакета. Не переходите в cmd/shop: относительный путь title.txt считается от текущей папки терминала.
История изменений
Git необязателен для запуска примеров. Если он установлен и вы ведёте собственный учебный репозиторий, после успешных тестов посмотрите git status и git diff. Добавляйте осознанно выбранные файлы, затем делайте коммит с понятной причиной изменения. Коммит сохраняется локально; git push отправляет его на удалённый сервер. Это разные действия.
Не добавляйте пароли, ключи, пользовательские базы и платные материалы без разрешения. В рабочем проекте этой книги полная рукопись хранится отдельно от открытого учебного репозитория. Учебные импорты не означают, что ваши файлы автоматически публикуются по этому адресу.
Самостоятельно: найдите хозяина правила
Нужно запретить пустое название и после чтения файла, и при создании карточки в будущей форме. Где должна находиться проверка? Запишите ответ до просмотра решения.
Подсказка: файл и HTTP — два способа получить данные. Запрет должен работать независимо от способа.
Решение: правило остаётся в catalog.NormalizeTitle, а оба входа используют его. Сделайте в копии проекта проверку строже и убедитесь, что тесты чтения файла замечают изменение. Не дублируйте отдельную версию проверки в каждом входе. Вернитесь к исходному правилу и повторите go test ./....
Если появляется no required module provides package, проверьте соответствие пути в go.mod и начале импортов. Если found packages, проверьте первые строки файлов одной папки. Если cannot find main module, перейдите в папку с go.mod; не создавайте новый модуль там, где случайно оказался терминал.
Готовность к следующей главе: вы находите пакет по импорту, объясняете экспорт и internal, запускаете все тесты и можете нарисовать зависимости без цикла.
Почему cmd, internal и web, а не обязательный набор папок
cmd/shop — исполняемая программа: флаги, соединение зависимостей, запуск и завершение. internal/catalog содержит правила товара, internal/titlefile — чтение заголовка. При появлении HTTP добавится internal/web с обработчиками, а в главе 12 — верхнеуровневый web с HTML и CSS. Одинаковое слово не означает одинаковую ответственность: один пакет обрабатывает запросы, другой хранит оформление.
Go ограничивает импорт пакетов внутри internal деревом родительской директории. Это полезно для логики приложения, внешний API которой мы не обещаем поддерживать. Каталоги cmd и web — соглашения проекта. pkg не является обязательным стандартом языка; пустую папку ради схемы не создаём. Если появится самостоятельная библиотека для других приложений, отдельно определим её API и границы модуля.
Такое решение соответствует разделу Server project официального руководства: https://go.dev/doc/modules/layout. Первые маленькие программы оставались в корне, потому что делить их на слои ещё не требовалось. Теперь объяснить путь запроса и найти правило важнее, чем сократить число папок.