8.4 KiB
Архитектура координатора
Карта кода. Читать сверху вниз: сначала «где что лежит», потом «как проходит запрос», в конце — «куда добавлять новое».
1. Четыре слоя
infra конфиг, пул БД, часы, HTTP-сервер, reaper ← драйверы
transport HTTP-хендлеры ← входящее: кто зовёт нас
storage репозитории на SQL ← исходящее: кого зовём мы
usecase операции + ПОРТЫ (интерфейсы) ← прикладные правила
domain Task, Job и их инварианты ← бизнес-правила
┌── transport ──┐
domain ◄── usecase ◄┤ ├◄── infra
└── storage ────┘
transport и storage — один и тот же слой (в книгах он зовётся «адаптеры»),
просто разделённый по направлению: транспорт принимает запросы снаружи, storage
обращается наружу сам. Так путь к файлу говорит о его роли, а не о категории.
Единственное правило: зависимости идут только внутрь. domain не импортирует
ничего из проекта. usecase видит только domain. transport и storage не
знают друг о друге.
Проверить в любой момент:
go list -f '{{range .Imports}}{{.}}{{"\n"}}{{end}}' ./internal/domain | grep internal
# пусто = правило соблюдено
2. Где что лежит
| Файл | Что внутри | Строк |
|---|---|---|
domain/task.go |
Task и все переходы состояний: аренда, завершение, провал, истечение |
~245 |
domain/job.go |
Job, разбиение на чанки, вывод статуса из счётчиков задач |
~107 |
domain/errors.go |
Нарушения бизнес-правил (ErrLeaseConflict, ErrStaleAttempt, …) |
~18 |
usecase/ports.go |
Порты: TaskRepository, JobRepository, TxManager, Clock |
~79 |
usecase/task.go |
Операции над задачей: claim, renew, complete, fail, expire | ~200 |
usecase/job.go |
Операции над job: create, status, results, stitch | ~180 |
usecase/dto.go |
Входные структуры юзкейсов | ~51 |
transport/http/server.go |
Роутер и сборка middleware | ~60 |
transport/http/handlers.go |
По хендлеру на эндпоинт | ~180 |
transport/http/dto.go |
JSON-форматы запросов и ответов | ~118 |
transport/http/middleware.go |
request-ID, access-лог, bearer-авторизация | ~103 |
transport/http/errors.go |
Маппинг доменных ошибок в HTTP-коды | ~55 |
storage/postgres/task_repo.go |
SQL по задачам, включая атомарный claim | ~109 |
storage/postgres/job_repo.go |
SQL по job'ам | ~39 |
storage/postgres/tx.go |
TxManager: транзакция через контекст |
~65 |
infra/*.go |
Конфиг, пул, часы, сервер, reaper | ~240 |
cmd/coordinator/main.go |
Composition root — единственное место со всеми конкретными типами | ~73 |
3. Трасса запроса: POST /tasks/claim
Как воркер получает задачу. Четыре остановки, по одной на слой:
① transport/http/handlers.go → handleClaim
разбирает JSON, отдаёт usecase.ClaimTaskInput
│
▼
② usecase/task.go → ClaimTask.Execute
сначала подчищает протухшие аренды, потом просит одну задачу
через ПОРТ TaskRepository (реализацию не знает)
│
▼
③ usecase/ports.go → TaskRepository.ClaimNext
контракт: «атомарно выдай одну задачу»
│
▼
④ storage/postgres/task_repo.go → claimNextSQL
SELECT ... FOR UPDATE SKIP LOCKED + UPDATE одним запросом
Обратно поднимается *domain.Task, юзкейс сужает его до domain.ClaimedTask
(воркеру не отдаём version, max_attempts и чужие ошибки), хендлер
превращает в JSON. Пустая очередь — это nil, nil на шаге ② и 204 на ①.
Трасса POST /tasks/{id}/result такая же, но с одним отличием: решение
принимает сущность, а не юзкейс.
handlers.go → CompleteTask.Execute → tx.WithinTx(
GetForUpdate → task.CompleteWith(...) ←── ЗДЕСЬ правила
│ (чужая аренда? устаревший
Update ←─────────────┘ attempt? повтор того же
syncJobStatus манифеста?)
)
4. Куда добавлять новое
| Хочу… | Правлю |
|---|---|
| новое бизнес-правило (когда задачу можно повторить) | domain/task.go + тест рядом |
| новую операцию (отменить job) | usecase/job.go + порт в ports.go, если нужен новый запрос к БД |
| новый HTTP-эндпоинт | transport/http/handlers.go + маршрут в server.go + DTO в dto.go |
| новый SQL-запрос | storage/postgres/*_repo.go |
| новую настройку | infra/config.go + .env.example |
| поменять код ответа на ошибку | transport/http/errors.go |
Правило при сомнении: если код можно описать фразой «когда X, то Y» без
упоминания HTTP, SQL и конфигов — это domain. Если он оркеструет несколько
шагов и транзакцию — usecase. Если знает про JSON — transport, про SQL — storage.
5. Три вещи, которые надо понять один раз
Порты объявляет потребитель. TaskRepository описан в usecase/ports.go, а
реализован в storage/postgres. Поэтому usecase не импортирует storage —
стрелка зависимости смотрит внутрь, хотя вызов на рантайме идёт наружу.
Транзакция едет в контексте. TxManager.WithinTx кладёт pgx.Tx в контекст
по неэкспортируемому ключу; репозитории достают её через conn(ctx, pool).
Благодаря этому юзкейс говорит «сделай это атомарно», ни разу не упомянув pgx.
Атомарный claim нельзя разложить на шаги. ClaimNext — один SQL-запрос,
потому что SELECT + отдельный UPDATE вернул бы гонку, при которой одну
задачу выдают двум воркерам. Поэтому ClaimTask.Execute выглядит тонким: там
нечего оркестровать, вся гарантия — внутри запроса.
6. Что уже работает, а что заглушка
Работает: слои и проводка, роутинг, авторизация, access-лог, маппинг ошибок, транзакции, graceful shutdown, миграции, весь domain с 12 юнит-тестами без БД.
Заглушки (ErrNotImplemented → HTTP 501): методы репозиториев. SQL для двух
главных операций уже написан в task_repo.go — claimNextSQL и
expireLeasesSQL, осталось их подключить.