Содержание урока · 11 разделов
- proto/: контракт как первый гражданин
- gen/: генерат и правила сосуществования
- internal/notes: service-слой
- internal/platform: сквозное, не знающее домена
- Закон импортов: направление, которое нельзя развернуть
- Карта раскладки
- Рост: второй сервис и вторая версия
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Во втором уроке каноническая минимальная структура держалась на трёх ролях: схемы редактируешь руками, генерат не трогаешь никогда, свой код пишешь всегда. Тот скелет — proto/, gen/ и cmd/server — честно работает, пока проект учебный. Но рабочий сервис из девятнадцатого урока уже тянет за собой healthz, readyz и metrics на общем mux, интерсепторы из седьмого и тринадцатого уроков, хранилище, конфигурацию из окружения, а деплой из двадцать второго добавляет Dockerfile и CI. Всё это нельзя сваливать в один каталог: main.go разрастается до тысячи строк, и правка интерсептора начинается с получаса поиска нужной функции. Взрослый шаблон добавляет к трём ролям два этажа — service-слой со своей логикой и платформу со сквозным, — и главное, фиксирует направление зависимостей между ними. План урока: дерево проекта целиком, каждый этаж по отдельности, закон импортов, таблица раскладки, путь фичи от схемы до PR и то, как шаблон растёт вместе с командой. Второй урок обещал эту тему ближе к концу курса — обещание исполняется здесь и сейчас.
Дерево рабочего шаблона
GOnotes-api/
├── buf.yaml # паспорт модуля: path proto, lint, breaking
├── buf.gen.yaml # рецепт генерации: плагины, out: gen
├── go.mod # module example.com/notes-api
├── proto/ # КОНТРАКТ — редактируешь только здесь
│ └── notes/v1/notes.proto
├── gen/ # ГЕНЕРАТ — пишет только buf generate
│ └── notes/v1/
│ ├── notes.pb.go
│ └── notesv1connect/notes.connect.go
├── internal/
│ ├── notes/ # SERVICE-слой: хендлеры, домен, хранилище
│ │ ├── server.go # notesServer: реализация handler-интерфейса
│ │ ├── store.go # интерфейс хранилища + sentinel-ошибки
│ │ └── memory.go # in-memory реализация для тестов и локалки
│ └── platform/ # СКВОЗНОЕ: auth, метрики, логи, рефлексия
│ ├── auth.go
│ └── metrics.go
└── cmd/notes/main.go # КОМПОЗИЦИОННЫЙ КОРЕНЬ: сборка всегоПять этажей и два конфига в корне. Комментарии — это контракты каталогов: любой новый файл обязан знать своё место до того, как написан. internal/ — встроенная защита Go: пакеты из него не импортирует чужой модуль, поэтому домен не «утекает» в соседние репозитории.
proto/: контракт как первый гражданин#
Каталог proto/ — единственное место, где ты редактируешь контракты, и buf.yaml в корне делает этот каталог модулем: секция modules с path: proto отсчитывает все пути от него, поэтому файл proto/notes/v1/notes.proto для buf — это notes/v1/notes.proto. Каталог внутри proto/ повторяет имя пакета: notes.v1 живёт в notes/v1/, и версия — часть пути, а не комментарий. buf.yaml же держит политику качества: lint с набором STANDARD ловит стиль схем до генерации, секция breaking задаёт правила обратной совместимости для CI. Комментарии над service и rpc — не украшение: из пятнадцатого урока ты знаешь, что из них собираются доки, OpenAPI и ответы рефлексии, поэтому контракт редактируется вместе с документацией, в одном коммите. Правило гигиены: в proto/ нет ничего, кроме схем, — ни моков, ни утилит, ни Go-файлов. Если в схеме появилась языковая опция, подумай дважды: managed mode из одиннадцатого урока умеет проставлять go_package_prefix за все языки сразу, оставляя схемы чистыми. Публикация контракта наружу — buf push с меткой из двенадцатого урока, когда потребители хотят версию без клона твоего репозитория.
buf.yaml и buf.gen.yaml в корне
GO# buf.yaml — паспорт модуля
version: v2
modules:
- path: proto
lint:
use: [STANDARD]
breaking:
use: [FILE]
# buf.gen.yaml — рецепт генерации
version: v2
plugins:
- local: protoc-gen-go
out: gen
opt: paths=source_relative
- local: protoc-gen-connect-go
out: gen
opt: paths=source_relativeОба конфига живут в корне рядом с go.mod: buf generate без аргументов читает их сам. out: gen и paths=source_relative дают путь gen/notes/v1/notes.pb.go — ровно тот, что зашит в go_package схемы. Взрослый вариант заменяет local на remote-плагины с закреплённой версией — тогда CI не ставит тулчейн вообще.
gen/: генерат и правила сосуществования#
Каталог gen/ — территория buf generate: сюда падают notes.pb.go с message-типами и подпакет notesv1connect с handler-интерфейсом, заглушкой UnimplementedNoteServiceHandler, конструктором NewNoteServiceHandler и типизированным клиентом. Первое правило ты знаешь со второго урока: править файлы в gen/ нельзя категорически — любая перегенерация затрёт правки, а источник правды всегда один, и это схема. Второе правило — коммитить или нет — вечный спор двух лагерей, и для учебного проекта второй урок уже дал вердикт: коммить, меньше движущихся частей. Взрослая политика выглядит иначе: gen/ в .gitignore, buf generate в CI с remote-плагинами закреплённой версии, и тогда репозиторий читается без шума в диффах, а воспроизводимость гарантирует конфиг, а не память коллеги. В честном buf проект структура gen/ — артефакт сборки, а не исходник; какую из двух политик ни выбрал бы, выбери одну на всю команду и запиши её в contributing. Третье правило — границы: gen/ импортирует только runtime connectrpc.com/connect и protobuf-пакеты; ничего из internal/ сгенерированный код не видит и видеть не должен, иначе шов между контрактом и кодом перестаёт быть швом.
Коллега поправил сообщение в gen/notes/v1/notes.pb.go, чтобы «не ждать генерацию». Что произойдёт?
internal/notes: service-слой#
Здесь живёт твой код, который делает сервис сервисом. Реализация notesServer — тот тип с приёмником-указателем из третьего урока, что реализует handler-интерфейс: по методу на каждый rpc, сигнатуры со сгенерированными message-типами, состояние в полях структуры. Рядом — интерфейс хранилища Store с методами Create, Get, List, Update, Delete и sentinel-ошибки ErrNotFound и ErrForbidden: хендлер переводит их в коды через connect.NewError из шестого урока, и ни одна ошибка хранилища не утекает наружу сырым текстом. Хранилище может быть in-memory из этого же каталога, SQL-драйвером или прокси на соседний сервис — notesServer не знает и не должен знать. Домен держи в чистых Go-типах: структура Note с полями, валидации, правила владения; конвертация домен-контракт занимает одну строку на поле, и эта плата за чистоту окупается, когда появится вторая версия схемы из восемнадцатого урока и общий домен станет общим честно. Тесты слоя лежат рядом: service_test.go с table-driven матрицей кодов — канон шестнадцатого урока.
internal/platform: сквозное, не знающее домена#
Платформа собирает то, что должно работать на каждом вызове, не зная ни одного message-типа. Auth-интерсептор из тринадцатого урока: читает Authorization, проверяет токен, кладёт владельца в контекст, отказывает unauthenticated — его интересуют заголовки и процедуры, а не заметки. Метрики из семнадцатого: счётчик по меткам procedure и code, гистограмма длительности — интерсептор работает с req.Spec().Procedure и connect.CodeOf(err), домен ему не нужен. Логирующий интерсептор с request id, otelconnect для трейсов, монтаж gRPC-рефлексии из пятнадцатого урока — всё это пакеты platform, которые можно взять и переиспользовать в следующем сервисе проекта. CORS для браузерных клиентов из девятого урока — тоже platform: middleware на mux, белый список origin из конфига. Ключевое отличие платформы от service-слоя: она обязана собираться без импорта notes-пакета. Если платформенному файлу понадобился notesv1 — ты пишешь не платформу, а бизнес-логику не в том месте, и это ранний сигнал переставить код. Именно так интерфейсные типы AnyRequest и AnyResponse из седьмого урока окупаются архитектурой: один интерсептор на все сервисы и версии.
cmd/notes/main.go: композиционный корень
GOfunc main() {
store := notesmemory.New()
srv := ¬esserver.Server{Store: store}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", health)
mux.HandleFunc("GET /readyz", ready(store))
mux.Handle("GET /metrics", promhttp.Handler())
path, handler := notesv1connect.NewNoteServiceHandler(
srv,
connect.WithReadMaxBytes(1<<20),
connect.WithCompressMinBytes(1024),
connect.WithInterceptors(&metrics.Interceptor{}, &auth.Interceptor{}),
)
mux.Handle(path, handler)
httpSrv := &http.Server{
Addr: ":8080",
Handler: h2c.NewHandler(mux, &http2.Server{}),
}
// запуск, сигнал SIGTERM и srv.Shutdown — как в девятнадцатом уроке
_ = httpSrv
}main — единственное место, где видны все пакеты сразу: хранилище, сервер, платформа, генерат. Опции лимитов и сжатия из четырнадцатого урока, h2c из девятнадцатого, пробные роуты из того же урока — композиционный корень собирает канон курса в один файл, и других мест с импортом всего и вся в проекте нет.
Куда положить интерсептор, который проверяет Bearer-токен и кладёт владельца в контекст?
Закон импортов: направление, которое нельзя развернуть#
Структура без правил зависимостей — это просто папки. Закон этого шаблона читается по стрелкам: cmd импортирует всех, internal/notes импортирует gen и стандартную библиотеку, internal/platform импортирует только connect и общие пакеты, gen не импортирует ничего из internal. Обратное направление запрещено: платформа не знает домена, домен не знает платформы, и если оба правила держать, import cycle невозможен физически — компилятор будет ругаться раньше, чем ревьюер. gen — граница: только cmd и internal/notes имеют право импортировать notesv1 и notesv1connect; появились такие импорты в platform — код лежит не в своём этаже. Проверка закона автоматизируется: goimports держит порядок импортов, а правила типа «никаких notesv1 вне notes и cmd» ловит lint-скрипт в CI за десять строк. Отдельный запрет — каталоги utils и helpers: имя, под которым нельзя угадать содержимое, — имя, в которое складывают всё. Закон импортов — тот же принцип, что кнопки лимитов из четырнадцатого урока: каждый барьер существует, чтобы изменения текли в одну сторону, и его нарушение видно сразу.
Карта раскладки#
| Каталог | Что живёт | Правило |
|---|---|---|
| proto/ | Схемы notes/v1, комментарии контракта | Единственное место ручных правок контракта; buf lint и breaking смотрят сюда |
| gen/ | notes.pb.go и notesv1connect | Только buf generate; правки затираются, политика коммита одна на команду |
| internal/notes | notesServer, Store, sentinel-ошибки, домен | Импортирует gen; тесты table-driven рядом |
| internal/platform | Auth, метрики, логи, CORS, рефлексия | Не знает домена: работает с Any-типами и процедурами |
| cmd/notes | main.go, композиционный корень | Собирает всех; единственный файл, знающий все пакеты |
| buf.yaml | Модуль path proto, lint STANDARD, breaking | Меняется при смене политики, не при каждой фиче |
| buf.gen.yaml | Плагины и out: gen | Remote-плагины с закреплённой версией — воспроизводимость в CI |
| go.mod | Модуль и зависимости | connectrpc.com/connect без grpc-go; internal закрыт от соседей |
| Makefile и CI | Цели lint, generate, breaking, test, build | Генерация и breaking в пайплайне, не на памяти коллег |
Собери монтаж сервиса в main
Впиши пропущенные вызовы: конструктор handler-а, опция интерсепторов и два способа монтирования на mux.
Путь фичи от схемы до PR
Расставь шаги новой фичи в рабочем шаблоне.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Рост: второй сервис и вторая версия#
Хороший шаблон узнаётся по тому, как он переживает рост. Второй сервис: добавляешь proto/tasks/v1/tasks.proto, buf generate сам раскладывает генерат по путям, в internal появляется tasks/, а в main — вторая пара строк конструктор плюс Handle на том же mux и том же порту: третий урок показал это на eliza и notes рядом, и ничего не изменилось. Схемы разных сервисов могут жить в одном модуле, а могут разъехаться по репозиториям — тогда контракт доставляется зависимостью из BSR, и двенадцатый урок уже дал тебе механику. Вторая версия: notes.v2 живёт рядом с v1 на том же mux, маршрутизация по путям процедур разделяет их автоматически, конвертация v1-v2 занимает место в общем домене — восемнадцатый урок расписал жизненный цикл. Несколько модулей Go в одном репозитории требуют go.work — не включай его без нужды: пока пакетов меньше десятка, один go.mod проще. И главный индикатор здоровья: добавление фичи не должно требовать правок в platform. Если платформа начала меняться под каждую фичу — сквозное и доменное перепутались, и стоит перечитать закон импортов.
В команде появился второй сервис tasks.v1. Что происходит с mux и портом?
Правки в gen/ «пока не готова генерация» — затрутся и разъедутся по команде; правь схему. Buf-тулчейн в runtime-образе — генерация живёт в CI, деплой из двадцать второго урока везёт только бинарник. Импорт notesv1 в internal/platform — начало конца: платформа обрастает доменом, импорт-цикл становится делом времени, и вычленить сквозное из доменного потом не сможет ни один рефакторинг-инструмент.
Четыре цели — make lint, make generate, make test, make run — рассказывают новому человеку весь жизненный цикл без чтения документации. Внутри — buf lint, buf generate, go test -race ./... и go run ./cmd/notes. CI вызывает те же цели: одна правда о сборке и локально, и в пайплайне, а docker build из двадцать второго урока просто наступает на этот же Makefile.
Код урока — настоящий шаблон: перенеси его в редактор, разложи notes-сервис из третьего урока по этажам и добейся зелёного go build. Браузерная песочница файловую систему не исполняет, поэтому исполняемых блоков в курсе нет — интерактивы работают на странице и дают XP, а раскладку проверяет компилятор: неверный импорт или незакрытый метод он не простит.
Карточки: структура проекта
Что дальше по курсу#
Раскладка готова, и шаблон уже ждёт первую поломку: в двадцать четвёртом уроке — типичные ошибки и отладка curl-режимом, grpcurl и buf curl, карта симптомов от 404 до resource_exhausted и трассировка проблемного вызова по меткам и спанам. Финал курса — двадцать пятый урок: итоговый проект, который собирает всё в один Notes API. Для повторения: скелет и первые конфиги — во втором уроке, два сервиса на одном mux — в третьем, lint и breaking — в десятом, генерация и remote-плагины — в одиннадцатом, модули BSR — в двенадцатом, рефлексия и дескрипторы — в пятнадцатом, версии схем — в восемнадцатом, общий mux с пробами и h2c — в девятнадцатом. Программа всех двадцати пяти уроков — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Рабочая структура: proto/ контракт, gen/ генерат, internal/notes — service-слой, internal/platform — сквозное, cmd/notes — композиционный корень.
- buf.yaml делает proto/ модулем: путь и версия пакета — часть каталога; lint STANDARD и breaking FILE — политика CI.
- gen/ — артефакт: правки затираются; коммить или генерируй в CI — одно правило на команду, remote-плагины дают воспроизводимость.
- Service-слой: notesServer реализует handler-интерфейс, хранилище за интерфейсом Store, sentinel-ошибки маппятся в коды на границе.
- Platform не знает домена: auth, метрики, логи, CORS и рефлексия работают с Any-типами и req.Spec().Procedure.
- Закон импортов: gen импортируют только cmd и notes; platform без notesv1; цикл невозможен, пока стрелки не развернуты.
- Рост без боли: второй сервис — конструктор и Handle на том же mux; v2 рядом с v1; контракт наружу — BSR.
- Makefile с целями lint, generate, test, run — одна правда о сборке локально и в CI; тулчейн не попадает в runtime-образ.
Домашнее задание
Переложи notes-сервис из третьего урока в рабочий шаблон. Шаг первый: создай каталоги internal/notes и internal/platform, вынеси notesServer в internal/notes/server.go, добавь интерфейс Store с sentinel-ошибками и in-memory реализацией. Шаг второй: перенеси auth-интерсептор из тринадцатого урока в internal/platform/auth.go и добейся, чтобы пакет platform собирался без импорта notesv1. Шаг третий: собери cmd/notes/main.go с healthz, readyz, metrics, опциями лимитов из четырнадцатого урока и h2c — сервис должен запускаться одной командой go run ./cmd/notes. Шаг четвёртый: добавь Makefile с целями lint, generate, test, run и прогони их по очереди. Шаг пятый, исследовательский: попробуй импортировать notesv1 из platform и прочитай ошибку компилятора; затем попробуй вызвать platform-код из notes — убедись, что закон импортов держит тебя сильнее, чем код-ревью. Зафиксируй выбранную политику по gen/ в README: коммит или генерация в CI.
Личные заметкиПусто
Вопросы и ответы по уроку
Коммитить gen/ или генерировать в CI?
Обе политики рабочие, выбирается одна на команду. Учебный проект — коммить: клон и сборка без тулчейна. Взрослая — gen в .gitignore, buf generate в CI, remote-плагины с закреплённой версией: чистые диффы и воспроизводимость. Опасно только гибрид, когда часть файлов коммитится, а часть генерируется.
Один репозиторий на сервис или монорепозиторий?
Начинай с одного репозитория и одного go.mod: пока сервисов меньше пяти, цена монорепо — издержки, а польза — атомарные PR с контрактом и кодом. Когда сервисы разъезжаются, контракты выносятся в BSR, и потребители берут схему зависимостью из двенадцатого урока. go.work нужен, только если модулей Go стало несколько.
Где должны лежать тесты?
Юнит и table-driven матрицы кодов — рядом с пакетом: internal/notes/server_test.go собирается без сети и прогоняется с -race. Полностековые тесты на httptest.NewServer — там же или в cmd. Интеграционные с реальными зависимостями — в отдельном каталоге с меткой сборки, чтобы go test ./... оставался быстрым.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Fyne: десктопные и мобильные GUI на Go
Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.
- Создавать окна и интерфейсы из виджетов
- Управлять компоновкой: Box, Grid, Form
- Рисовать примитивами canvas и строить свои виджеты
- Связывать данные через binding