Содержание урока · 15 разделов
- Мост из прошлого урока: скучный рефакторинг — комплимент архитектуре
- Диагноз: main.go, который знает всё
- internal: стены ставит компилятор
- Зачем нужна структура проекта fiber: карта каталогов
- Слои: handler → service → repository
- DI-подход: зависимости входят через конструкторы
- Композиционный корень: buildApp из конструкторов
- Роут-регистрация отдельной функцией: fiber.Router
- Конфиг-структура: из двадцатого урока — в internal/config
- Тесты не изменились ни строчкой
- pkg, циклы импорта и другие грабли
- Итоги: перестройка /api/tasks на слои
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Мост из прошлого урока: скучный рефакторинг — комплимент архитектуре#
Прошлый урок закрылся словами, с которых начнём почти дословно: «двадцать второй урок — структура большого проекта: слои, репозитории, DI-подход, вынос хендлеров из main… buildApp станет собирать приложение из нужного среза — страж без логгера, роутер задач без статики, репозиторий над хранением в памяти вместо настоящей базы… сделать рефакторинг скучным — а скучный рефакторинг это комплимент архитектуре». Обещание исполняется тремя шагами. Шаг первый — слои: handler → service → repository, транспорт отдельно от логики, логика отдельно от хранения. Шаг второй — DI-подход: зависимости входят в конструкторы, NewTaskService(repo TaskRepo) и NewTaskHandler(svc), и ни одна глобальная переменная их не подменяет. Шаг третий — география: main худеет до композиционного корня, хендлеры уезжают в internal/handlers, роут-регистрация становится функцией RegisterRoutes на fiber.Router. Инвариант один: unit-тесты прошлого урока — httptest.NewRequest, app.Test, io.ReadAll — не изменятся ни строчкой. Если после рефакторинга тест дрогнул, перестраивали не так: транспортный контракт устоял, значит, дрогнуть было нечему.
Диагноз: main.go, который знает всё#
Канон, который мы несли через курс, в маленьких программах выглядит невинно: type Task рядом с func main, map[int]Task под мьютексом, app.Get с замыканием-хендлером, которое ловит items и nextID из объемлющей области видимости. Программа на сто строк — нормальная идиома, и стыдиться её не надо. Боль начинается с ростом, и симптомы у всех одинаковые. Первый: замыкание прячет зависимости — по сигнатуре func(c fiber.Ctx) error не видно, что хендлер трогает карту, мьютекс и счётчик; зависимость видна компилятору, но не читателю и не тесту. Второй: юнит-границ нет — единственный способ исполнить бизнес-правило в тесте, это поднять весь main целиком. Третий: два человека, два хендлера, одна карта — merge-конфликты в одном файле, где хранение, транспорт и конфиг переплетены узлом. Четвёртый, самый тихий: бизнес-правило — «нельзя создать задачу без названия» — растворяется внутри HTTP-замыкания, и когда придёт второй транспорт, gRPC или CLI, правило придётся выковыривать пинцетом. Запрос «архитектура fiber» — это, по существу, запрос на способ раскладки, при котором каждый следующий хендлер не приближает файл к нечитаемости.
До: канон на пределе вместимости
GOpackage main
import (
"log"
"sync"
"github.com/gofiber/fiber/v3"
)
// Task и хранилище живут прямо в main — пока это терпимо
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
}
func main() {
var mu sync.Mutex
items := map[int]Task{} // хранение спрятано в замыканиях
nextID := 1
app := fiber.New()
app.Get("/api/tasks", func(c fiber.Ctx) error {
mu.Lock()
defer mu.Unlock()
// транспорт = правила = хранение в одном флаконе
return c.JSON(fiber.Map{"items": items})
})
app.Post("/api/tasks", func(c fiber.Ctx) error {
var t Task
if err := c.Bind().Body(&t); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "битый JSON")
}
mu.Lock()
nextID++
t.ID = nextID
items[t.ID] = t
mu.Unlock()
return c.Status(fiber.StatusCreated).JSON(t)
})
// ...ещё двадцать app.* с теми же замыканиями
log.Fatal(app.Listen(":8080"))
}До-состояние: канон курса, доведённый до размера, когда он перестал помещаться в голову. Три этажа — транспорт, правила, хранение — в одном файле. Замыкания ловят items, nextID и mu, и вытащить их наружу, не переписав хендлеры, нельзя: зависимость спрятана в лексической области видимости. Тест из двадцать первого урока такой код переживёт, потому что buildApp там уже вынесена, но собрать «тот же маршрут, другой репозиторий» фабрика не сможет: она не принимает параметров, потому что у кода нет деталей, которые можно подставить.
internal: стены ставит компилятор#
Первое слово, которое появится в путях, — internal. Это не fiber-механика и не договорённость из README: правило зашито в go-команду. Пакеты внутри каталога internal/ может импортировать только код в дереве, растущем из родителя этого каталога. В модуле с именем myapi — строчка module myapi в go.mod — путь myapi/internal/service доступен любому пакету внутри myapi и недоступен чужим модулям: чужой go build упадёт с ошибкой use of internal package myapi/internal/service not allowed ещё до компиляции вашего кода, на шаге разрешения импортов. Дешевле стены не бывает: вы пишете имя каталога, инструмент пишет запрет. Две оговорки, чтобы не переоценить механизм. Первая: внутри одного модуля internal не различает соседей — internal/handlers имеет право импортировать internal/repository напрямую, и компилятор не возразит; направленность слоёв держим мы сами, конструкторами и дисциплиной, а не запретами. Вторая: internal — общий механизм Go, а не fiber-изобретение, поэтому правило действует и на чужие зависимости: у самих исходников fiber есть внутренние пакеты, и лезть туда привычкой быть не должно. Итог: каталог на модуль, стены по периметру — бесплатно; порядок внутри периметра — наша работа, и ею займёмся прямо сейчас.
Зачем нужна структура проекта fiber: карта каталогов#
| Каталог | Что здесь живёт | Чего здесь быть не должно |
|---|---|---|
| cmd/api/ | func main, композиционный корень, разовая сборка слоёв | бизнес-правила, SQL, fiber-хендлеры |
| internal/handlers/ | fiber.Ctx, статусы, JSON-конверты, вызовы сервиса | SQL, бизнес-правила, знание о хранении |
| internal/service/ | модель Task, бизнес-правила, интерфейс TaskRepo | импорты fiber и fasthttp, HTTP-статусы |
| internal/repository/ | реализации TaskRepo: память, SQL | fiber, конверты ответов, бизнес-правила |
| internal/config/ | структура конфига и FromEnv из двадцатого урока | логика домена, хендлеры |
| pkg/ | код, который вы сознательно отдаёте чужим модулям | всё, что можно оставить в internal |
Слои: handler → service → repository#
Три слоя — это три должностные инструкции. Транспортный слой handlers знает всё про HTTP и ничего про правила: распаковать fiber.Ctx, вызвать сервис, свернуть результат в c.JSON или fiber.NewError. Слой service знает всё про правила и ничего про транспорт: принять аргументы, применить бизнес-логику, решить, какую ошибку домена вернуть. Слой repository знает всё про хранение и ничего про оба этажа выше: положить, найти, вернуть модель. Стрелка зависимостей направлена вниз: handlers импортирует service, service держит контракт на хранение, repository реализует контракт; никто не оглядывается вверх. Это ровно та луковица, которую мы разбирали в философии go-kit: транспорт декодирует запрос, service держит чистую бизнес-логику, которой запрещено знать про HTTP-заголовки, и нижний этаж отвечает за данные. По-русски слой хранения зовут репозиторием, по-английски — Repository, и в путях оставим латиницу: internal/repository. В маленьком fiber-проекте три слоя go-kit ужимаются в два этажа — отдельную универсальную абстракцию endpoint мы не заводим, и это честная экономия, а не лень; но правило «сервис не пахнет HTTP» переносится дословно. Проверка на глаз: в файле internal/service/task.go слово fiber не встречается — встретилось, значит, слой перепутал должность.
Канон: internal/service/task.go — правила и шов
GO// internal/service/task.go — бизнес-слой: fiber тут не пахнет
package service
import (
"context"
"errors"
"strings"
)
// Task — модель предметной области; json-теги канона при ней
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
}
// TaskRepo — контракт хранения, объявленный потребителем (шов)
type TaskRepo interface {
List(ctx context.Context) ([]Task, error)
Create(ctx context.Context, t Task) (Task, error)
}
// ErrEmptyTitle — ошибка домена: название обязательно
var ErrEmptyTitle = errors.New("название задачи не может быть пустым")
// TaskService — правила бизнеса над хранением
type TaskService struct {
repo TaskRepo // зависимость: контракт, а не реализация
}
// NewTaskService — DI через конструктор: сервис принимает шов
func NewTaskService(repo TaskRepo) *TaskService {
return &TaskService{repo: repo}
}
// Create — единственное место, где живёт правило «название обязательно»
func (s *TaskService) Create(ctx context.Context, title string) (Task, error) {
title = strings.TrimSpace(title)
if title == "" {
return Task{}, ErrEmptyTitle
}
return s.repo.Create(ctx, Task{Title: title})
}
// List — правила могут стоять и на чтении
func (s *TaskService) List(ctx context.Context) ([]Task, error) {
return s.repo.List(ctx)
}Четыре решения, на которых стоит слой. Первое: модель Task живёт в service — это её предметный дом, json-теги при ней, потому что транспортный формат у нас пока совпадает с доменной формой; когда форм начнёт множиться, модель развесят на доменную и транспортную, но не раньше. Второе: интерфейс TaskRepo объявлен у потребителя — это строгая идиома Go и тот самый шов, который мы строили в финальном проекте курса GORM с интерфейсом PostRepo: сервис описывает, что ему нужно от хранения, а не соглашается на то, что хранение готово дать. Третье: ErrEmptyTitle — ошибка домена, а не HTTP-статус: код ответа выберет транспортный слой, увидев эту ошибку, а сервис о кодах ответов не подозревает. Четвёртое: Go проверяет совместимость структурно — MemoryRepo удовлетворит TaskRepo, не импортируя его; контракт и реализация соединятся в композиционном корне, не познакомившись в исходниках.
Канон: internal/repository/memory.go — хранение
GO// internal/repository/memory.go — память: замена на SQL не тронет слоёв выше
package repository
import (
"context"
"sync"
"myapi/internal/service" // модель и только модель
)
// MemoryRepo — карта под мьютексом: предок настоящей базы
type MemoryRepo struct {
mu sync.Mutex
nextID int
items map[int]service.Task
}
// NewMemoryRepo — конструктор без аргументов: зависимостей у памяти нет
func NewMemoryRepo() *MemoryRepo {
return &MemoryRepo{nextID: 1, items: make(map[int]service.Task)}
}
func (r *MemoryRepo) List(ctx context.Context) ([]service.Task, error) {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]service.Task, 0, len(r.items))
for _, t := range r.items {
out = append(out, t)
}
return out, nil
}
func (r *MemoryRepo) Create(ctx context.Context, t service.Task) (service.Task, error) {
r.mu.Lock()
defer r.mu.Unlock()
t.ID = r.nextID
r.nextID++
r.items[t.ID] = t
return t, nil
}Разбор снизу. Методы объявлены на указателе *MemoryRepo — не из педантизма: мьютекс нельзя копировать, значит, приёмник обязан быть указателем, а это, как увидим в предсказании ниже, имеет прямые последствия для интерфейса. Репозиторий импортирует myapi/internal/service ради модели Task — типы надо откуда-то брать, и это единственная стрелка к сервисному пакету; сам контракт TaskRepo он не импортирует вовсе: совместимость Go сверит по сигнатурам. Контекст ctx проходит транзитом — памяти он не нужен, но сигнатура общая с SQL-версией, где ctx отменяет запрос. Подмена хранения выглядит так: новый файл postgres.go с теми же двумя методами и ноль правок в service и handlers. Ни fiber, ни JSON, ни статусов в файле нет — и это не аккуратность, а условие сборки слоя.
Канон: internal/handlers/task.go — транспорт
GO// internal/handlers/task.go — транспорт: HTTP на входе, вызовы сервиса внутри
package handlers
import (
"errors"
"github.com/gofiber/fiber/v3"
"myapi/internal/service"
)
// TaskHandler — весь fiber.Ctx живёт только здесь
type TaskHandler struct {
svc *service.TaskService
}
// NewTaskHandler — DI: хендлер принимает сервис и не знает, кто под ним
func NewTaskHandler(svc *service.TaskService) *TaskHandler {
return &TaskHandler{svc: svc}
}
// List — GET /api/tasks
func (h *TaskHandler) List(c fiber.Ctx) error {
items, err := h.svc.List(c.Context())
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "хранилище недоступно")
}
return c.JSON(fiber.Map{"items": items}) // конверт канона курса
}
// Create — POST /api/tasks
func (h *TaskHandler) Create(c fiber.Ctx) error {
type Task = service.Task // транспортная форма пока совпадает с доменной
var in Task
if err := c.Bind().Body(&in); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "битый JSON")
}
t, err := h.svc.Create(c.Context(), in.Title)
if errors.Is(err, service.ErrEmptyTitle) {
return fiber.NewError(fiber.StatusBadRequest, err.Error()) // домен → 400
}
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "хранилище недоступно")
}
return c.Status(fiber.StatusCreated).JSON(t)
}Транспортная инструкция. fiber.Ctx появляется в сигнатуре и умирает на выходе: хендлер достал контекст запроса через c.Context() — в fiber v3 он возвращает context.Context, тот самый, который принимает TaskRepo, — и отдал сервису. Конверт items — канон девятого урока; статусы выбирает транспорт: 400 на битый JSON, 400 на ErrEmptyTitle, 201 на создание, 500 на прочее. errors.Is здесь — мостик между слоями: домен вернул ошибку, транспорт перевёл её в статус; сами коды HTTP сервису не известны. Замыканий больше нет: у хендлеров появились имена, приёмник h.svc и адрес в структуре — их можно регистрировать, тестировать и читать по отдельности.
Проверьте себя: направление зависимостей
Проект собран по слоям из урока. Какая строка нарушает правила слоёв?
DI-подход: зависимости входят через конструкторы#
Внедрение зависимостей — длинное слово для короткой вещи: структура получает то, от чего зависит, через параметр конструктора, а не достаёт сама. В Go есть три пути, и два из них — ловушки. Путь первый — глобальная переменная вида var Repo TaskRepo: пишется в один столбец, читается нигде — зависимость не видна в сигнатурах, подменяется из любого места программы, а тесты обязаны чинить чужие следы. Путь второй — пакетные init() и синглтоны: время создания скрыто от читателя, порядок инициализации становится лотереей. Путь третий — конструкторы: NewMemoryRepo(), NewTaskService(repo TaskRepo), NewTaskHandler(svc *service.TaskService) — каждый тип декларирует потребности в сигнатуре, и компилятор превращает «забыли подключить хранилище» из продакшн-инцидента в ошибку сборки. Обратите внимание на форму NewTaskHandler(repo TaskRepo)-стиля: конструктор принимает контракт. Именно это в финале курса GORM называлось шовом — потребители зависят от контракта, а не от структуры. DI-фреймворки здесь не нужны: в Go композиция через конструкторы умещается в три строчки main, и это фича — вся схема сборки видна на одном экране, а не разваливается по аннотациям и контейнерам. Архитектура fiber в этом смысле ничего не добавляет и не отнимает: fiber.Ctx — просто ещё один параметр, живущий на транспортном этаже.
Что выдаст сборка на значении вместо указателя?
Прочитайте код и выберите, что он выведет:
// internal/repository/memory.go — методы на УКАЗАТЕЛЕ:
// мьютекс нельзя копировать, приёмник обязан быть *MemoryRepo
func (r *MemoryRepo) List(ctx context.Context) ([]service.Task, error) {
return nil, nil
}
func (r *MemoryRepo) Create(ctx context.Context, t service.Task) (service.Task, error) {
return t, nil
}
// cmd/api/main.go — композиционный корень:
func main() {
svc := service.NewTaskService(repository.MemoryRepo{})
// MemoryRepo{} — значение, а не &MemoryRepo{}
_ = svc
}Композиционный корень: buildApp из конструкторов#
Композиционный корень — единственное место программы, где знание обо всех слоях собирается вместе: создаются реализации, навешиваются контракты, склеиваются зависимости. Ниже корня — конструкторы, каждый знает только своих соседей; выше корня — ничего. В Go этот термин не требует фреймворка: корень — это func main плюс, ради тестов, фабрика buildApp из двадцать первого урока. Правило одно: слои не тянут знания вверх, корень спускает зависимости вниз, и только корень импортирует handlers, service и repository одновременно. Отдельная красота в том, что корень маленький: сборка слоёв — три конструктора, три строки, читаются и сверху вниз как сценарий, и снизу вверх как стек. Именно корень исполняет обещание прошлого урока: buildApp собирает приложение из срезов — роутер задач без статики, страж без логгера, репозиторий в памяти вместо настоящей базы; какой срез нужен, решает вызывающий. Если корень начал обрастать логикой — проверками, ветками, правилами, — он перестал быть корнем и стал новым main: логике место в service, корню — только сборка.
Канон: cmd/api/main.go — пятнадцать строк сборки
GO// cmd/api/main.go — композиционный корень: слои встречаются здесь и только здесь
package main
import (
"log"
"github.com/gofiber/fiber/v3"
"myapi/internal/config"
"myapi/internal/handlers"
"myapi/internal/repository"
"myapi/internal/service"
)
func main() {
cfg := config.FromEnv() // конфиг двадцатого урока: env читается один раз
// сборка снизу вверх: хранение → логика → транспорт
repo := repository.NewMemoryRepo()
svc := service.NewTaskService(repo)
h := handlers.NewTaskHandler(svc)
app := buildApp(h, cfg)
log.Fatal(app.Listen(":8080"))
}
// buildApp — фабрика из прошлого урока, теперь принимает слои
func buildApp(h *handlers.TaskHandler, cfg config.Config) *fiber.App {
app := fiber.New(fiber.Config{
ReadTimeout: cfg.ReadTimeout,
})
handlers.RegisterRoutes(app, h) // маршруты — отдельной функцией
return app
}Пятнадцать строк, которые раньше были семьюстами. Читается сверху вниз как сценарий сборки: конфиг, хранение, логика, транспорт, приложение, запуск. Каждый импорт myapi/internal/ появляется впервые — и компилятор следит, чтобы эти пакеты не позвали друг друга по кругу. buildApp сохранила контракт двадцать первого урока — возвращает fiber.App без прослушивания порта — но теперь принимает хендлер и конфиг: фабрика перестала быть хозяином деталей и стала сборщиком. fiber.New(fiber.Config{...}) — единственное место, где собственная структура конфига встречается с fiber.Config: перевоз таймаутов из окружения в приложение — забота корня, а не хендлеров.
Роут-регистрация отдельной функцией: fiber.Router#
Второе обещание прошлого урока — «роутер задач без статики» — исполняется маленькой функцией с большим типом параметра. Регистрация маршрутов выносится из сборки в хендлерский пакет: RegisterRoutes(r fiber.Router, h TaskHandler) описывает маршруты задач рядом с их файлом, а корень лишь решает, на какой узел дерева их вешать. Секрет — в fiber.Router, интерфейсе из router.go, о котором мы говорили в пятнадцатом уроке про группы; состав сверен по исходникам fiber v3.5.0: контракт на восемнадцать методов — десять HTTP-глаголов, Use, Add, All, Group, Domain, RouteChain, Route и Name — реализуют и fiber.App, и *fiber.Group. Поэтому RegisterRoutes одинаково честно работает в main — handlers.RegisterRoutes(app, h) — и в тесте, где маршруты нужно повесить на ветку без общего стека: api := app.Group("/api"); handlers.RegisterRoutes(api, h). Страж requireAPIKey из урока про свои middleware ложится сюда же отдельным срезом: в проде — на группу /api, в тесте стража — собственное приложение с тем же стражем, а тесту сериализации страж не нужен вовсе. Слои и ветки размножаются независимо: добавить /api/v2 — значит вызвать RegisterRoutes на второй группе, а не скопировать десяток app.Get.
Проверьте себя: тип параметра RegisterRoutes
Почему параметр RegisterRoutes объявлен как r fiber.Router, а не *fiber.App?
Конфиг-структура: из двадцатого урока — в internal/config#
Структура проекта возвращает долг конфиг-уроку мгновенно. Фабрика configFromEnv переезжает в internal/config под именем FromEnv и возвращает собственную структуру Config с таймаутами и лимитами — поля те же, что мы перечисляли в двадцатом уроке, только без привязки к fiber.Config. Дальше — правило направления: конфиг читается один раз, в корне, и раздаётся вниз параметрами. fiber.Config при fiber.New — забота buildApp; таймауты хранилища, когда появятся, — параметр конструктора репозитория; лимит списка для сервиса — параметр NewTaskService. Ни один слой не вызывает os.Getenv сам: во-первых, это скрытая зависимость — сигнатура врёт о том, что функция трогает окружение; во-вторых, тестам пришлось бы подделывать переменные в каждом слое. t.Setenv-канон прошлого урока остаётся в силе ровно потому, что env читает один FromEnv: тест конфига не разъехался по проекту, а остался в internal/config. Поведение становится проверяемым по слоям: FromEnv тестируется отдельно, сборка приложения под конкретным Config — отдельно, и оба теста живут в своих пакетах.
Тесты не изменились ни строчкой#
Теперь инвариант, обещанный в начале. Юнит-тест TestListTasks из прошлого урока вызывает buildApp, шлёт httptest.NewRequest("GET", "/api/tasks", nil) с заголовком X-API-Key, прогоняет app.Test и читает конверт items. Ни одна из этих строк не знает, что под хендлером появилась структура, под структурой — сервис, под сервисом — контракт и карта под мьютексом. Транспортный контракт не изменился, поэтому тест не изменился; изменились детали — и именно поэтому он не изменился. Если нужен другой сценарий, DI даёт его в одну строку: собрать то же приложение на фейковом репозитории — второй срез, «репозиторий в памяти вместо настоящей базы», в исполнении теста. Более того, сервис тестируется вовсе без fiber: fakeRepo в NewTaskService, вызов svc.Create(" "), проверка ErrEmptyTitle — миллисекунды и ни одного HTTP-поворота. Это и есть дивиденд слоёв: транспортный контракт проверяется как раньше, бизнес-правила — дешевле, хранение — отдельно от всех.
Канон: fakeRepo и второй срез сборки
GO// handlers_test.go — фейк под шов, объявленный в service
type fakeRepo struct {
items []service.Task
}
func (f fakeRepo) List(ctx context.Context) ([]service.Task, error) {
return f.items, nil
}
func (f fakeRepo) Create(ctx context.Context, t service.Task) (service.Task, error) {
return t, nil
}
// buildAppFake — тот же корень, другой нижний этаж
func buildAppFake(items []service.Task) *fiber.App {
svc := service.NewTaskService(fakeRepo{items: items})
h := handlers.NewTaskHandler(svc)
app := fiber.New()
handlers.RegisterRoutes(app, h) // срез без стража: тест сериализации
return app
}Две вещи, ради которых всё затевалось. Первое: fakeRepo объявлен на приёмниках-значениях — и потому удовлетворяет TaskRepo без амперсанда; полная противоположность предсказанию выше, и это не каприз, а техника: у фейка нет мьютекса, копировать нечего. Второе: buildAppFake — тот же композиционный корень, только нижний этаж другой: никакой базы, никакого окружения, ноль сети. Страж не подключён — срез «роутер задач без стража»; в реальном проекте рядом живёт тестовый app под requireAPIKey из седьмого урока, собранный теми же конструкторами. Юнит-тест поверх этого скелета — httptest.NewRequest, app.Test, проверка StatusCode и items — тот же скелет из двадцать первого урока, который мы обещали не трогать.
Что вернёт тест поверх фейкового репозитория?
Прочитайте код и выберите, что он выведет:
func TestListTasksFake(t *testing.T) {
app := buildAppFake([]service.Task{{ID: 7, Title: "задача из фейка"}})
req := httptest.NewRequest("GET", "/api/tasks", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
body, _ := io.ReadAll(resp.Body)
var got struct {
Items []service.Task `json:"items"`
}
_ = json.Unmarshal(body, &got)
if resp.StatusCode != 200 || len(got.Items) != 1 {
t.Fatalf("статус %d, items: %+v", resp.StatusCode, got.Items)
}
t.Log(got.Items[0].Title)
}Соберите композиционный корень
Вставьте пропуски так, чтобы корень собрал слои снизу вверх и запустил сервер.
pkg, циклы импорта и другие грабли#
Четыре вопроса, которые всплывают на любой перестройке. Первый: нужен ли pkg/? Каталог pkg/ — конвенция из популярного, но неофициального репозитория golang-standards/project-layout; go-команда не даёт ему ни одной привилегии — это обычный каталог с красивым именем. Правило честное: всё, что обязано остаться внутри проекта, живёт в internal; pkg/ заводят, когда код реально созрел для чужого использования, и чаще всего правильный ответ — «не заводить». Второй грабель: циклический импорт. handlers нужны типы сервиса, service вдруг захотел константы транспорта — компилятор обрежет это import cycle not allowed. Лечение: вынести общий тип — модель, интерфейс ошибки — в нижний пакет или объявить интерфейс у потребителя; Go-структурная типизация позволяет реализацию без импорта контракта, чем мы и воспользовались с TaskRepo. Третий грабель — god-пакет: internal/app, в который свалены все хендлеры, все сервисы и половина репозиториев «чтобы не плодить папки». Пакет — единица компиляции и граница тестов; когда файлов больше десятка и они импортируют всё отовсюду, структура потеряна, как бы ни назывались папки. Четвёртый, тихий: прыжок через слой — хендлер зовёт repository мимо сервиса, потому что «там просто чтение». Один такой прыжок стоит правила «название обязательно»: оно вдруг не исполняется для части запросов. Прыжки запрещены, исключения живут в service.
Соберите порядок перестройки на слои
Расставьте шаги рефакторинга канона в правильном порядке: от прикрытия тестом до зелёного прогона.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Итоги: перестройка /api/tasks на слои#
Соберём маршрут, который таскали через весь курс, в новой географии: GET /api/tasks приходит в cmd/api/main.go, buildApp собирает fiber.New с конфигом из internal/config, RegisterRoutes вешает хендлеры из internal/handlers, TaskHandler зовёт TaskService, а тот — репозиторий над памятью — и всё это снаружи по-прежнему выглядит как канон на :8080. Что изменилось: у каждой строки появился адрес, у каждой зависимости — конструктор, у каждого слоя — своя причина для изменения. Что не изменилось: unit-тесты прошлого урока, конверт items, формат ошибок четырнадцатого урока — транспортный контракт устоял, хотя под ним пересобрали всё. Честное слово про инструменты: песочница сайта Go-код не исполняет — runner-ов в уроках Fiber нет с первого урока, а интерактивы здесь quiz, predict, fill, order и флешкарты; перестройка проекта тем более требует локального тулчейна: соберите каталог, перенесите канон по шагам из собирательного блока выше и гоняйте go test ./... у себя. Полный план — на странице курса Fiber.
Следующий, двадцать третий урок — развертывание Fiber-приложения: сборка бинарника, Docker, nginx и переменные окружения. Структура, построенная сегодня, деплоится как есть: корень собирает слои, слои не знают про окружение, а весь хаос env читается в одном FromEnv — двадцатый урок подготовил это заранее.
Карточки: структура проекта fiber
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Слои handler → service → repository отделяют транспорт от логики и хранения: fiber.Ctx живёт только в handlers, правила — только в service, карта под мьютексом — только в repository.
- internal/ защищает пакеты силами go-команды: чужой модуль получает use of internal package not allowed ещё на разрешении импортов.
- DI через конструкторы: NewMemoryRepo → NewTaskService(repo TaskRepo) → NewTaskHandler(svc) — подмена реализации занимает одну строку композиционного корня.
- buildApp осталась фабрикой прошлого урока и стала сборщиком срезов, а RegisterRoutes на fiber.Router вешает маршруты и на приложение, и на группу.
- Юнит-тесты не изменились ни строчкой — скучный рефакторинг и есть комплимент архитектуре.
Домашнее задание
Перестройте канон курса по шагам из урока: каталоги cmd/api, internal/handlers, internal/service, internal/repository, internal/config; перенесите Task, соберите TaskRepo и MemoryRepo, вынесите хендлеры, выделите RegisterRoutes на fiber.Router и соберите композиционный корень. Добавьте в service метод Done(ctx, id) с правилом «нельзя завершить несуществующую задачу» и ошибкой ErrNotFound, а в хендлере переведите её в fiber.NewError(fiber.StatusNotFound, ...). Затем соберите fakeRepo, чей Create возвращает ошибку, и напишите тест: хендлер отдаёт 500, а сервис не изменился. Финал: попробуйте второй бинарник cmd/cli, импортирующий тот же internal/service, — убедитесь, что стены internal работают, а логика переиспользуется.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужен ли service-слой, если бизнес-логики почти нет?
Можно начать с пары handler → repository: конструкторы NewTaskHandler(repo TaskRepo)-стиля работают и без среднего этажа. Но service окупается при первых правилах — валидация, права, сортировка; добавить слой позже дешевле, чем выковыривать правила из хендлеров.
Где объявлять интерфейс TaskRepo — в service или в repository?
Строгая идиома Go — у потребителя: service объявляет контракт, repository удовлетворяет его структурно, не импортируя. Практичный компромисс, как в курсе GORM, — интерфейс рядом с реализацией. Выбирайте одно на проект: смешение двух стилей хуже любого из них.
Не замедлит ли структура зависимостей приложение?
Нет. Слои — это вызовы функций и один косвенный вызов через интерфейс на запрос; fasthttp и роутер работают как работали. Плата за DI — наносекунды, экономия — часы при каждой смене хранилища.
Обязательно ли разбивать проект на cmd и internal?
Для одного бинарника достаточно internal в корне модуля. cmd/ нужен, когда бинарников несколько — api и cli, — а internal защищает код с первого дня: обе механики бесплатные, заводить их при росте легко, а переезжать задним числом дорого.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Gin: самый популярный веб-фреймворк Go
Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.
- Уверенно владеть gin.Engine: маршруты, группы, версии
- Привязывать запросы к структурам (ShouldBindJSON и др.)
- Писать middleware и использовать встроенные
- Строить аутентификацию на JWT