Ознакомительный фрагмент 0.21.0-sample · вступление и главы 1–12. Бесплатный фрагмент: главы 1–12.

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. Первые маленькие программы оставались в корне, потому что делить их на слои ещё не требовалось. Теперь объяснить путь запроса и найти правило важнее, чем сократить число папок.