Здравствуйте. В прошлом уроке вы восстановили идиоматичную обработку ошибок: sentinel errors, errors.Is, оборачивание через %w и гарантированную очистку ресурсов с defer. Теперь соберём эти знания в код, который не превращается в один огромный main.go.
Для junior backend-разработчика это практический навык: сервис состоит из множества файлов и компонентов, но его границы должны оставаться понятными. Сегодня разберём, что в Go называют модулем и пакетом, как работает экспорт по регистру, зачем серверному проекту internal и cmd, а также как не допускать циклических зависимостей.
Модуль, пакет и каталог: три разных понятия
В Go модуль — набор связанных пакетов с единым файлом go.mod. Его строка module задаёт префикс путей импорта внутри проекта.
Например:
module github.com/yourname/task-manager
Если в этом модуле есть каталог internal/task, его путь импорта будет таким:
github.com/yourname/task-manager/internal/task
Пакет — набор Go-файлов в одном каталоге, которые компилируются вместе. Файлы не нужно импортировать друг в друга: они уже находятся в общей области видимости пакета.
internal/task/
task.go
validation.go
task_test.go
Обычные исходные файлы в этом каталоге начинаются одинаково:
package task
Функция normalizeTitle, объявленная в validation.go, доступна напрямую из task.go, даже если её имя начинается со строчной буквы. Область видимости не ограничена одним файлом: она ограничена пакетом.
{"type":"reading","par_intro":"Прочитайте раздел официальной документации Go: он чётко разводит понятия пакета, репозитория, модуля и пути импорта.","par_directions":"В разделе **“Code organization”** прочитайте <span data-type=\"resource_reading_textrange\" data-resource-subitem-id=\"cc0cac03\" data-range-start=\"Go programs are organized into packages. A package is a collection of source files in the same directory that are compiled together.\" data-range-end=\"Packages in the standard library do not have a module path prefix.\">основное объяснение</span> целиком. Обратите внимание на два правила: файлы одного каталога компилируются как пакет, а путь модуля из `go.mod` является префиксом импортов его пакетов.","learning_duration":"8 minutes","url":"https://go.dev/doc/code","title":"How to Write Go Code","isV2":true,"blockId":"70ec8c52-d85b-4a3b-98b7-5215ba1d9c81","lessonId":"91822767-7345-4b65-b629-6d69294be584"}
Важно не смешивать уровни:
| Понятие | Пример | Назначение |
|---|---|---|
| Репозиторий | task-manager/ в GitHub | Хранение исходного кода и сопутствующих файлов |
| Модуль | строка module github.com/yourname/task-manager | Единица версионирования и разрешения зависимостей |
| Пакет | internal/task, package task | Единица компиляции и область видимости кода |
| Файл | internal/task/task.go | Физическое место для части кода пакета |
Обычно у backend-сервиса один репозиторий и один модуль в корне. Не стоит создавать новый go.mod только для того, чтобы «красивее разложить код»: это создаёт отдельный модуль и новую границу зависимостей, а не просто папку.
{"type":"image","url":"https://go.dev/doc/modules/images/source-hierarchy.png","caption":"Диаграмма показывает модуль `example.com/mymodule` внутри одного репозитория: `go.mod` находится в корне, а каждый вложенный каталог `package1` и `package2` содержит отдельный Go-пакет с несколькими исходными файлами.","isV2":true,"blockId":"25f3001d-e4b2-491d-8356-0c908a9e5514","lessonId":"91822767-7345-4b65-b629-6d69294be584"}
Экспорт: заглавная буква определяет API пакета
В Go нет ключевых слов public, private или protected. Видимость идентификатора определяется первой буквой имени:
Task,New,ErrTaskNotFound,Title— экспортируемые идентификаторы;task,newTask,normalizeTitle,title— неэкспортируемые идентификаторы.
Экспорт относится к функциям, типам, константам, переменным, полям структур и методам. Идентификатор с заглавной буквы доступен пакетам, которые импортируют ваш пакет. Идентификатор со строчной буквы виден только внутри его пакета.
Посмотрите это на минимальном пакете для будущего task manager:
// internal/task/task.go
package task
import (
"errors"
"strings"
)
var ErrEmptyTitle = errors.New("task title is required")
type Task struct {
title string
}
func New(title string) (Task, error) {
normalized := normalizeTitle(title)
if normalized == "" {
return Task{}, ErrEmptyTitle
}
return Task{title: normalized}, nil
}
func (t Task) Title() string {
return t.title
}
func normalizeTitle(title string) string {
return strings.TrimSpace(title)
}
Здесь пакет осознанно предоставляет небольшой контракт:
Task— тип задачи;New— способ создать задачу;Title— способ прочитать заголовок;ErrEmptyTitle— условие, которое вызывающий код может различить черезerrors.Is.
При этом детали реализации остаются внутри:
- поле
titleнельзя изменить из другого пакета напрямую; normalizeTitleнельзя вызвать снаружи;- формат хранения заголовка можно изменить, не переписывая всех пользователей пакета.
Команда, находящаяся в другом пакете, использует только экспортируемую часть:
// cmd/task-api/main.go
package main
import (
"errors"
"fmt"
"os"
"github.com/yourname/task-manager/internal/task"
)
func main() {
created, err := task.New(" Prepare API ")
if err != nil {
if errors.Is(err, task.ErrEmptyTitle) {
fmt.Fprintln(os.Stderr, "title is required")
return
}
fmt.Fprintln(os.Stderr, "create task:", err)
return
}
fmt.Println(created.Title())
}
Обратите внимание на две разные формы доступа:
created, err := task.New("Prepare API")
Здесь task.New требуется, потому что New находится в другом пакете.
normalized := normalizeTitle(title)
А здесь квалификатор не нужен: normalizeTitle находится в том же пакете task, хотя объявлена в другом файле.
Экспортированный тип не обязан раскрывать все свои поля. Это полезно для инвариантов: задача не должна создаваться с пустым заголовком. Однако помните нюанс: экспортированное значение task.Task{} всё ещё может существовать как нулевое значение. Конструктор делает правильный путь создания явным, но сам по себе не запрещает все некорректные состояния, которые допускает язык.
{"type":"video","title":"Understanding Packages, Exports, and Imports in Go with Code Examples","learning_duration":304,"video_id":"TPHk7qpBvOs","par_intro":"Посмотрите фрагменты видео “Understanding Packages, Exports, and Imports in Go with Code Examples” от Code & Learn. Оно закрепит механику на коротких примерах с каталогами, объявлениями `package` и импортами.","par_directions":"Начните с <span data-type=\"resource_video_timerange\" data-resource-subitem-id=\"52e73326\" data-range-start=\"38\" data-range-end=\"107\">создания пакета</span>: здесь показано правило одного обычного пакета на каталог. Затем посмотрите <span data-type=\"resource_video_timerange\" data-resource-subitem-id=\"b2e5e02d\" data-range-start=\"213\" data-range-end=\"336\">экспорт и импорт</span>, уделяя внимание связи заглавной буквы с доступностью идентификатора из другого пакета. Завершите <span data-type=\"resource_video_timerange\" data-resource-subitem-id=\"b7fca24f\" data-range-start=\"336\" data-range-end=\"448\">именованием пакетов</span>: особенно полезна рекомендация не создавать универсальные пакеты `util` и `helper`.","video_duration":668,"isV2":true,"blockId":"6f6c9978-acca-4715-9c24-22413cd49e17","lessonId":"91822767-7345-4b65-b629-6d69294be584"}
Импортируют пакеты, а не файлы
Импорт всегда задаётся путём пакета:
import "github.com/yourname/task-manager/internal/task"
Этот путь собирается из:
- пути модуля из
go.mod; - относительного пути к каталогу пакета.
По умолчанию обращаться к сущностям нужно по имени, указанному после package. Поэтому имя каталога и имя пакета почти всегда должны совпадать:
internal/task/ package task
internal/config/ package config
internal/httpapi/ package httpapi
Технически они могут различаться, но это делает код запутанным: в импорте будет один путь, а в вызовах — другое имя.
Выбирайте имя пакета по тому, что он предоставляет. Например, task, auth, config и postgres говорят о назначении. Имена utils, common, helpers не говорят почти ничего и постепенно становятся свалкой несвязанных функций.
Также Go запрещает неиспользуемые импорты. Если файл импортирует пакет, он обязан использовать его в этом файле. Это дисциплинирует зависимости: импорт не должен быть «на всякий случай».
{
"type": "exercise",
"id": "fb1f9750-6b55-43d5-bffb-f2f5f3c4a565"
}
internal и cmd: полезная основа серверного репозитория
Для самостоятельного backend-сервиса обычно не нужно создавать библиотеку, рассчитанную на импорт из других проектов. Внутренний код сервиса лучше расположить в каталоге internal.
Минимальная структура нашего портфолио-проекта может выглядеть так:
task-manager/
├── go.mod
├── cmd/
│ └── task-api/
│ └── main.go
└── internal/
└── task/
├── task.go
├── validation.go
└── task_test.go
Здесь:
cmd/task-api— точка входа бинарного приложения;cmd/task-api/main.goобъявляетpackage mainи содержитfunc main();internal/task— пакет с логикой задач;go.modнаходится в корне и описывает единый модуль.
cmd — распространённая договорённость команды, а не специальное слово Go. Если в будущем в репозитории появятся отдельные команды для миграций или фоновых обработчиков, у каждой будет собственная поддиректория и свой main.go.
internal, напротив, имеет специальное значение для компилятора. Пакет внутри internal разрешено импортировать только коду из дерева каталогов, которому принадлежит этот internal.
В нашем случае это означает, что такой импорт допустим:
import "github.com/yourname/task-manager/internal/task"
для кода внутри task-manager, например для cmd/task-api. Но другой модуль не сможет легально импортировать этот пакет, даже если в нём есть экспортированная функция task.New.
Это два независимых слоя ограничений:
| Ограничение | Что защищает |
|---|---|
| Строчная буква в имени | Код от других пакетов, включая пакеты этого же проекта |
Каталог internal | Пакет от использования вне разрешённого дерева каталогов |
То есть internal не отменяет правила экспорта. Пакет internal/task может экспортировать Task и New, чтобы ими пользовался cmd/task-api; при этом внешний модуль не сможет импортировать весь пакет.
{
"type": "exercise",
"id": "f83ae6c6-e7dd-4b26-9230-413c35f16ce0"
}
Направление зависимостей и запрет циклов
Разделение на пакеты нужно не ради количества папок. У пакета должна быть своя ответственность и понятное направление зависимостей.
В текущем примере направление простое:
cmd/task-apiсобирает приложение и вызывает пакетtask;taskсодержит правила, относящиеся к задачам;taskне должен импортироватьcmd/task-api.
Пакет main — край приложения. Он запускает процесс, считывает конфигурацию, создаёт зависимости и передаёт управление остальному коду. Поэтому прикладной пакет не должен знать о main, аргументах командной строки или конкретном способе запуска.
Go запрещает циклические импорты. Такая пара зависимостей не скомпилируется:
package task импортирует package transport
package transport импортирует package task
Ошибка import cycle not allowed — не помеха, которую надо обходить случайными переносами файлов, а сигнал о неясной границе ответственности.
Если возник цикл, действуйте последовательно:
- Определите, какой пакет действительно владеет правилом или типом.
- Оставьте зависимость у того кода, который использует эту возможность.
- Если оба пакета правда используют общий небольшой тип, вынесите его в отдельный пакет только при наличии устойчивой общей концепции.
- Не создавайте пакет
commonлишь для того, чтобы компилятор перестал ругаться.
Позднее, когда появятся HTTP-обработчики, PostgreSQL и прикладные сценарии, вы начнёте выбирать более строгие архитектурные границы. Пока достаточно закрепить фундамент: точка входа зависит от внутренней логики, а не наоборот.
Пакеты и тесты
Для тестов у Go есть полезное исключение из правила «один пакет на каталог»:
package task
Тесты с таким объявлением находятся внутри пакета и могут проверять неэкспортируемые детали.
package task_test
Тесты с суффиксом _test находятся во внешнем тестовом пакете. Они импортируют task так же, как реальный пользователь, и могут обращаться только к его экспортируемому API.
Для доменных правил обычно удобно начинать с package task: так проще проверять внутренние вспомогательные функции. Для публичного контракта пакета особенно ценны тесты package task_test, потому что они не позволяют тесту случайно опереться на скрытую реализацию.
{
"type": "exercise",
"id": "c26e9dcd-9553-4a53-a3e6-513ad678b8ec"
}
Закрепление в коде
Создайте описанную структуру и перенесите логику задачи из одного main.go в internal/task. После этого выполните команды из корня модуля:
go fmt ./...
go test ./...
go build ./...
go run ./cmd/task-api
go fmt ./... отформатирует все пакеты модуля, go test ./... найдёт и запустит тесты во всех пакетах, а go build ./... проверит, что они компилируются как единое целое. Последняя команда полезна после любого заметного перемещения файлов: она быстро обнаруживает неверный импорт, конфликт имён пакетов и циклическую зависимость.
Итоги
Теперь у вас есть базовая модель организации Go-кода:
- модуль определяется
go.mod; его путь служит префиксом импортов; - пакет — это Go-файлы одного каталога, компилируемые вместе;
- неэкспортируемые имена со строчной буквы видны всему пакету, а не одному файлу;
- имена с заглавной буквы формируют API для других пакетов;
- импортируют пакет по пути модуля и каталога, а не отдельный файл;
- имя пакета обычно совпадает с именем каталога и должно описывать его назначение;
internalзапрещает внешним модулям импортировать внутренний код сервиса;cmdудобно использовать для отдельных исполняемых программ;- зависимости должны быть направленными: Go не допускает циклов импорта.
На этом завершается быстрое восстановление базового Go. Дальше начнётся алгоритмический блок: вы разберёте Big O и научитесь уверенно объяснять временную и пространственную сложность решений на собеседовании.
Can't find a good explanation? Sign up and we'll make it for you