Содержание урока · 12 разделов
- Слои: карта из двадцать третьего урока в деле
- Хендлеры: ошибки и владение
- Авторизация: интерсептор на границе
- Наблюдаемость: счётчики, гистограмма, пробы
- Тесты: три слоя из шестнадцатого урока
- main.go: сервер целиком
- Docker и выкат: последний аккорд деплоя
- Контрольный список финального API
- Финал курса
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Техзадание короткое и честное: REST-подобный CRUD заметок поверх RPC, доступный одновременно Go-клиентам, браузеру и curl. Функционально: получить заметку по id, создать, перечислить свои, обновить, удалить — пять процедур вокруг ресурса Note. Нефункционально: доступ только по Bearer-токену; чужие заметки неотличимы от несуществующих; каждый вызов виден в метриках по процедуре и коду; тело запроса ограничено, крупное сжимается; контракт лоздается buf lint и buf breaking; тесты проверяют коды, а не тексты; сервис запускается одной docker-командой и корректно умирает по SIGTERM. Это не список фич ради списка: каждая строчка — навык из конкретного урока, и финал устроен как сборка конструктора, а не как новая теория. Если что-то из списка вызывает вопрос — ссылка на урок будет рядом, и вернуться к ней дешевле, чем тащить вопрос в прод. Заметь и то, чего в проекте нет: стримов и второй версии схемы. Осознанно — экзамен ниже предложит их как опции, но базовый финал держит юнит-минимум: один сервис, одна версия, пять методов.
Контракт: proto/notes/v1/notes.proto
GOsyntax = "proto3";
package notes.v1;
service NoteService {
// Возвращает заметку владельцу по id; чужая — not_found.
rpc GetNote(GetNoteRequest) returns (Note) {
option idempotency_level = NO_SIDE_EFFECTS;
}
// Перечисляет заметки текущего владельца.
rpc ListNotes(ListNotesRequest) returns (ListNotesResponse) {
option idempotency_level = NO_SIDE_EFFECTS;
}
rpc CreateNote(CreateNoteRequest) returns (Note);
rpc UpdateNote(UpdateNoteRequest) returns (Note);
rpc DeleteNote(DeleteNoteRequest) returns (DeleteNoteResponse);
}
message Note {
string id = 1;
string title = 2;
string body = 3;
string owner_id = 4;
}
message GetNoteRequest { string id = 1; }
message ListNotesRequest {}
message ListNotesResponse { repeated Note notes = 1; }
message CreateNoteRequest { string title = 1; string body = 2; }
message UpdateNoteRequest { string id = 1; string title = 2; string body = 3; }
message DeleteNoteRequest { string id = 1; }
message DeleteNoteResponse {}Пять процедур из канона курса: GetNote и CreateNote жили с первого урока, ListNotes, UpdateNote и DeleteNote — расширение по правилам восемнадцатого: только новые методы, ничего не переименовывается. idempotency_level = NO_SIDE_EFFECTS на чтении включает GET-кэширование Connect-жанра из двадцать первого урока. Комментарии над rpc — будущие доки из пятнадцатого.
Слои: карта из двадцать третьего урока в деле#
Проект разложен по этажам рабочего шаблона. Контракт — в proto/notes/v1, генерация — buf generate в gen/notes/v1 с подпакетом notesv1connect, где ждут handler-интерфейс, заглушка UnimplementedNoteServiceHandler, конструктор NewNoteServiceHandler и клиент NewNoteServiceClient. Service-слой — internal/notes: сервер с приёмником-указателем, интерфейс хранилища Store и sentinel-ошибки; домен не знает о connect и HTTP. Платформа — internal/platform: auth-интерсептор, метрики, логирующий интерсептор, CORS-обёртка — всё то, что не импортирует notesv1. Композиционный корень — cmd/notes/main.go: единственный файл, который видит всех и собирает mux, опции, h2c и graceful shutdown. Закон импортов держит стрелки: platform без домена, gen без internal, cmd без логики. Первый шаг после генерации — положить в internal/notes интерфейс хранилища: пять методов, sentinel-ошибки ErrNotFound и ErrForbidden, in-memory реализация с мьютексом для тестов и локалки. Подмена хранилища на SQL не потребует правки хендлеров — это и есть проверка, что слои не перепутались.
internal/notes: хранилище и sentinel-ошибки
GOtype Note = notesmodel.Note // доменный тип: id, title, body, owner
type Store interface {
Create(ctx context.Context, n Note) (Note, error)
Get(ctx context.Context, id string) (Note, error)
List(ctx context.Context, owner string) ([]Note, error)
Update(ctx context.Context, n Note) (Note, error)
Delete(ctx context.Context, id string) error
}
var ErrNotFound = errors.New("note not found")
var ErrForbidden = errors.New("note belongs to another owner")Хендлер переводит sentinel-ошибки в коды один раз и в одном месте — ни одна ошибка хранилища не утекает наружу текстом. ErrForbidden понадобится на Update и Delete: владение проверяется по owner_id, и чужая попытка — это not_found по политике из блока ниже.
Хендлеры: ошибки и владение#
Каждый метод сервера — короткая последовательность из канона: достать владельца из контекста, вызвать хранилище, перевести sentinel-ошибки в коды через connect.NewError. Валидация входа — invalid_argument: пустой title у CreateNote не должен доходить до хранилища. Отсутствие — not_found. Тонкое место — владение: если чужая заметка вернёт permission_denied, вызывающий узнает, что id существует, а перечисление id станет картой чужих данных. Поэтому политика курса: чужая заметка — not_found, неотличимо от отсутствующей. Это решение уровня дизайна, и тест его фиксирует отдельно, чтобы никто не «улучшил» его на возврате честного кода. Update и Delete делают то же сравнение владельца до изменения, Create подставляет owner из контекста, а не из тела запроса: клиент не выбирает, чья заметка. Коды ошибок — вся таблица из шестого урока в работе: invalid_argument на входе, not_found на поиске, unauthenticated до хендлера, internal на всё, что не предвидел ты.
GetNote: политика владения в шести строках
GOfunc (s *Server) GetNote(ctx context.Context, req *notesv1.GetNoteRequest) (*notesv1.Note, error) {
owner, err := auth.Owner(ctx)
if err != nil {
return nil, err // уже unauthenticated из интерсептора
}
note, err := s.store.Get(ctx, req.Id)
if errors.Is(err, ErrNotFound) || note.Owner != owner {
// существование чужой заметки не раскрываем: not_found
return nil, connect.NewError(connect.CodeNotFound, errNotFound)
}
return toProto(note), nil
}Сигнатуры повторяют handler-интерфейс из третьего урока. Один нюанс честности: если хранилище вернуло ErrNotFound, note нулевой, и сравнение владельца к нему неприменимо — потому порядок условий важен, а политика единая на ветку. Тест из шестнадцатого урока закрепит этот выбор как контракт.
Пользователь запрашивает чужую заметку. Какой код вернёт сервер по политике проекта?
Авторизация: интерсептор на границе#
Проверка токена живёт в platform и подключается опцией WithInterceptors — скелет из седьмого урока, канон тринадцатого. Интерсептор читает Authorization, находит Bearer-токен, проверяет подпись и срок, и кладёт владельца в контекст: хендлеры получают auth.Owner(ctx) и не знают, как именно добывалось имя. Отказ — unauthenticated из connect.NewError, и не в хендлере, а на границе: незаведённый вызов не тратит ни строки доменного кода. Порядок интерсепторов — контракт луковицы из семнадцатого урока: метрики первыми, авторизация внутри, и тогда отказ auth виден в счётчике как code=unauthenticated — это не утечка, а материал для дашборда. Служебные роуты интерсептор не трогает: healthz, readyz и metrics — обычные http.HandlerFunc на mux, до интерсептора RPC-мир не начинается; рефлексия из пятнадцатого урока, если ты её монтируешь, закрывается той же авторизацией или не монтируется на граничном сервере вовсе. Для стендов интерсептор принимает ключи из окружения — конфигурация по 12-factor держит секреты в env, не в бинарнике и не в git.
Auth-интерсептор: до и после next
GOfunc (a *Interceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
owner, err := a.verify(req.Header().Get("Authorization"))
if err != nil {
return nil, connect.NewError(connect.CodeUnauthenticated, errNoToken)
}
return next(auth.WithOwner(ctx, owner), req)
}
}Работает с req.Header() и ничего не знает о заметках — потому и живёт в platform. Стриминговым вызовам нужна симметричная пара WrapStreamingHandler из восьмого урока; в базовом проекте стримов нет, но интерсептор обязан об этом помнить, когда проект вырастет.
Клиент позвал без токена. Что увидят метрики и клиент?
Прочитайте код и выберите, что он выведет:
connect.WithInterceptors(&metrics.Interceptor{}, &auth.Interceptor{})
// вызов ListNotes без заголовка AuthorizationНаблюдаемость: счётчики, гистограмма, пробы#
Метрики из семнадцатого урока в проекте — три экспоната. Счётчик запросов по меткам procedure и code: на нём живёт доля отказов и всплески unauthenticated после выката. Гистограмма длительности с бакетами до пары секунд: p50 и p99 на дашборде, и именно p99 будет тестом на то, что хранилище не начало тормозить. Метрика in-flight покажет очередь в момент деплоя. Старт по умолчанию — otelconnect из седьмого: трейсы на каждый вызов с атрибутами процедуры; самописный слой оставь для счётчиков, если хочешь минимальную зависимость. Экспозиция — promhttp.Handler() на том же mux по пути /metrics: один порт, один скрейп, ничего публиковать наружу не нужно — деплой из двадцать второго урока закрывает служебные роуты ingress-ом. Пробы — из того же урока: /healthz отвечает за живость без походов к зависимостям, /readyz делает лёгкий пинг хранилища и держит под вне ротации, пока хранилище не готово. Логи — интерсептор с процедурой, кодом, длительностью и request id; по request id разговор с клиентом о конкретном вызове становится предметным за одну команду grep.
Тесты: три слоя из шестнадцатого урока#
Пирамида тестов проекта — копия шестнадцатого урока, только на пяти процедурах. Нижний слой: прямые вызовы сервера без сети — логика и маппинг ошибок, миллисекунды на прогон. Средний — главный: полный стек на httptest.NewServer, где настоящий handler с настоящими интерсепторами вызывается настоящим сгенерированным клиентом; здесь проверяются сериализация, HTTP-маппинг кодов и порядок интерсепторов — всё то, что в проде рассыпается. Верхний слой — интеграционный смоук в CI: поднять образ, вызвать grpcurl и curl-режимом, погасить. Матрица кодов — table-driven с t.Run: пустой id — invalid_argument, нет такой — not_found, чужая — not_found по политике, без токена — unauthenticated, с токеном — ок. Имена кейсов — утверждения, читаются как документация. Флаг -race обязателен: in-memory хранилище под конкурентными вызовами — мини-полигон для гонок, и лучше, чтобы его нашёл тест, а не прод. Тест владения отдельным кейсом закрепляет политику not_found на чужой заметке — единственное место, где «улучшение» будет замечено тестом раньше ревью.
Матрица кодов на полном стеке
GOfunc TestGetNoteMatrix(t *testing.T) {
server := httptest.NewServer(newHandler()) // хендлер + интерсепторы
t.Cleanup(server.Close)
client := notesv1connect.NewNoteServiceClient(server.Client(), server.URL)
cases := []struct {
name string
id string
want connect.Code
}{
{"нет такой — not_found", "ghost", connect.CodeNotFound},
{"чужая заметка — not_found", "foreign", connect.CodeNotFound},
{"пустой id — invalid_argument", "", connect.CodeInvalidArgument},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := client.GetNote(ctx(), connect.NewRequest(¬esv1.GetNoteRequest{Id: tc.id}))
if connect.CodeOf(err) != tc.want {
t.Fatalf("want %v, got %v", tc.want, connect.CodeOf(err))
}
})
}
}Скелет из шестнадцатого урока: httptest.NewServer поднимает настоящий handler, server.Client() и server.URL кормят клиента, кейсы сравнивают код, а не текст. newHandler() — фабрика из main, она же используется в проде: тест проверяет тот же код, что едет в деплой.
Что именно проверяет полностековый тест на httptest.NewServer?
main.go: сервер целиком#
Композиционный корень собирает весь курс в один файл, и здесь нет ни одной новой идеи — только сборка. Mux: healthz, readyz, metrics и notes-хендлер, смонтированный путём из конструктора. Опции хендлера: потолок тела WithReadMaxBytes и порог сжатия WithCompressMinBytes из четырнадцатого урока — по метрикам их когда-нибудь выровняешь; порядок интерсепторов из блока выше. CORS-обёртка для браузерных клиентов из девятого урока — вокруг mux, origin из env. h2c.NewHandler из девятнадцатого: gRPC-жанры живы за plaintext-границей, и ingress сможет проксировать HTTP/2 из двадцать второго. Запуск и остановка — тоже девятнадцатый: http.Server с читаемым таймаутом не нужен для RPC-пути, зато нужен graceful shutdown — signal.NotifyContext, srv.Shutdown с дедлайном, который меньше terminationGracePeriodSeconds из деплоя. Флаги и env — из окружения: порт, DSN хранилища, ключи подписи токенов, уровень логов. Правило корня: только сборка, без бизнес-логики; если в main появился if не про конфигурацию — код просится в platform или notes.
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), // потолок тела: 1 МБ
connect.WithCompressMinBytes(1024), // сжимать только крупное
connect.WithInterceptors(&metrics.Interceptor{}, &auth.Interceptor{Keys: keysFromEnv()}),
)
mux.Handle(path, handler)
httpSrv := &http.Server{
Addr: ":8080",
Handler: h2c.NewHandler(cors(mux), &http2.Server{}),
}
go httpSrv.ListenAndServe()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
_ = httpSrv.Shutdown(shutdownCtx)
}Всё, что курс строил по одному: лимиты и сжатие (четырнадцатый), интерсепторы (седьмой, тринадцатый, семнадцатый), пробы и h2c (девятнадцатый), graceful shutdown с дедлайном под grace из деплоя (девятнадцатый, двадцать второй). Ни одной новой абстракции — потому и читается за минуту.
Docker и выкат: последний аккорд деплоя#
Образ — мультистейдж из двадцать второго урока: builder на golang-alpine с вынесенным go mod download, сборка с CGO_ENABLED=0, runtime — distroless/static nonroot, EXPOSE 8080, ENTRYPOINT на бинарник. Генерация — не в образе, а в CI: buf lint, buf breaking против прошлого релиза, buf generate, тесты с -race, и только потом docker build; деплой из двадцать второго подхватывает этот пайплайн без правок. Выкат проверяется смоуком из двадцать четвёртого: grpcurl на gRPC-жанр и curl на Connect-жанр через ingress отвечают на одном хосте — жанры живы, HTTP/2-проксирование на месте. Пробы манифеста смотрят на /healthz и /readyz, terminationGracePeriodSeconds больше Shutdown-дедлайна в двадцать пять секунд — арифметика согласована. На этом проект завершён: контракт, слои, код, наблюдаемость, тесты, образ. Контрольный список ниже — финальная сверка перед экзаменом: если каждый пункт подтвердим, у тебя на руках сервис, который не стыдно показать на собеседовании и не страшно выкатить в прод.
Собери опции хендлера и обёртку сервера
Впиши пропущенные опции и имя h2c-конструктора.
Сборка проекта по шагам
Расставь шаги сборки финального проекта.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Контрольный список финального API#
| Компонент | Урок-канон | Признак готовности |
|---|---|---|
| Контракт notes.v1 | 10, 15 | buf lint зелёный; комментарии над rpc превращаются в доки |
| Генерация gen/ | 11 | buf generate воспроизводим в CI, плагины закреплены версиями |
| Слои и импорты | 23 | platform собирается без notesv1, цикл импортов невозможен |
| Ошибки | 06 | sentinel-ошибки маппятся connect.NewError-ом, матрица кодов в тестах |
| Авторизация | 07, 13 | без токена — unauthenticated на границе, тесты «без токена/с токеном» |
| Владение | 06 | чужая заметка — not_found, кейс закреплён тестом |
| Метрики | 17 | /metrics отдаёт счётчики procedure/code и гистограмму, p99 на дашборде |
| Лимиты и сжатие | 14 | WithReadMaxBytes и WithCompressMinBytes стоят, resource_exhausted честный |
| Тесты | 16 | go test -race ./... зелёный: матрица кодов и владение на полном стеке |
| Сервер и деплой | 19, 22 | h2c, пробы, graceful shutdown; docker run — смоук grpcurl и curl зелёный |
Образ собран, контейнер запущен локально. Что входит в смоук перед выкатом?
Генерация «на памяти» — gen/ собран старой версией плагина и не воспроизводится в CI. Токен в логе — интерсептор логирует заголовок целиком, и секрет уезжает в хранилище логов. buf breaking не в пайплайне — клиенты падают на выкате, который «ничего не ломал». Grace меньше Shutdown-дедлайна — оркестратор прибивает поды посреди доезда unary. Каждый пункт — урок курса; финал отличается тем, что ошибка стоит дороже.
Скелет рассчитан на рост: стримы из восьмого урока лягут отдельным rpc с симметричными обёртками интерсепторов; вторая версия схемы из восемнадцатого поставится рядом на тот же mux; REST-поверхность из двадцать первого добавится аннотациями к тому же контракту; контракты для соседних команд — через BSR из двенадцатого. Ничего из этого не потребует переписать хендлеры — это и есть проверка, что канон сработал.
Финал собирается на своей машине: редактор, терминал, docker. Браузерная песочница сервис с сетью и файловой системой не исполняет, поэтому исполняемых блоков в курсе нет — интерактивы работают на странице и дают XP, а проект проверяют компилятор, go test и настоящий запуск контейнера.
Карточки: итоговый проект
Финал курса#
Двадцать пять уроков назад вопрос был «что такое Connect RPC» — сегодня у тебя сервис, который отвечает трём жанрам, говорит честными кодами ошибок, считает свои вызовы, умирает с достоинством и помещается в образ размером с десятки мегабайт. Дуга курса: знакомство и установка, первый сервис и протоколы, клиенты и ошибки, интерсепторы и стримы, браузерный клиент, buf-конвейер с lint, генерацией и BSR, безопасность, сжатие, документация, тесты, метрики, версии, интеграция с net/http, миграция, REST-поверхность, деплой, структура, отладка — и вот финал. Каждое звено этой цепи живёт в отдельном уроке с интерактивами и карточками, и возвращаться в них дешевле, чем переучиваться с нуля: программа всего курса — на странице курса. Что дальше: переноси скелет на свои домены, добавляй стримы, версии и REST-поверхность — канон выдержит; если захочется классического gRPC-мира с grpc-go и envoy, на сайте есть отдельный курс по gRPC. Поздравляю: курс пройден, и главный проект — тот, который ты соберёшь следующим.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Финал собирает канон курса: Notes API с пятью процедурами notes.v1, хендлерами, авторизацией, метриками, тестами и Docker.
- Контракт: idempotency_level = NO_SIDE_EFFECTS на чтении, комментарии над rpc — источник доков; только новые методы, ничего не переименовывается.
- Слои из двадцать третьего: Store за интерфейсом, sentinel-ошибки → connect.NewError, platform без домена, main — только сборка.
- Владение: чужая заметка — not_found, неотличимо от отсутствующей; owner из контекста, не из тела запроса.
- Авторизация: Bearer-интерсептор на границе, unauthenticated до хендлера; метрики первыми в цепочке — отказ auth виден в счётчике.
- Наблюдаемость: счётчик procedure/code, гистограмма с p99, /metrics на общем mux, healthz и readyz, логи с request id.
- Тесты: три слоя, матрица кодов table-driven на httptest.NewServer, -race обязателен; фабрика хендлера одна для теста и прода.
- Выкат: CI с buf lint, buf breaking, buf generate и тестами; мультистейдж-образ; смоук grpcurl и curl на одном хосте.
Домашнее задание
Выпускной экзамен: собери Notes API целиком и пройди пять ступеней проверки. Ступень первая, контракт: notes.v1 с пятью процедурами из урока, buf lint зелёный, buf generate воспроизводим свежим клоном репозитория одной командой. Ступень вторая, код: хендлеры на интерфейсе Store, политика not_found на чужой заметке, матрица кодов table-driven на httptest.NewServer — go test -race ./... зелёный, и среди кейсов есть «без токена — unauthenticated», «с токеном — ок», «чужая — not_found». Ступень третья, наблюдаемость: подними сервис, дёрни десять вызовов grpcurl и curl-режимом, покажи, что счётчик на /metrics вырос ровно на десять с метками процедуры и кода; затем убери токен и найди unauthenticated в счётчике. Ступень четвёртая, образ: мультистейдж Dockerfile из двадцать второго урока, docker run, смоук из трёх команд — healthz, grpcurl, curl; затем SIGTERM контейнеру и лог graceful shutdown в выводе. Ступень пятая, CI: пайплайн с buf lint, buf breaking, buf generate, тестами и сборкой образа. Бонус на отличную оценку: за вечер перенеси скелет на сервис задач — package tasks.v1 с своим ресурсом и теми же политиками; если хендлеры пришлось переписывать больше, чем имена типов, вернись к двадцать третьему уроку и найди, где слои перепутались.
Личные заметкиПусто
Вопросы и ответы по уроку
Где взять готовый репозиторий итогового проекта?
Нигде: каждый фрагмент уже разобран в уроках — контракт и хендлер в третьем, тесты в шестнадцатом, метрики в семнадцатом, main и graceful shutdown в девятнадцатом, Docker в двадцать втором. Сборка руками — и есть экзамен: она проверяет, что связи между уроками поняты, а не скопированы.
Notes API — это прод-готовый код?
Это прод-готовый каркас: контракт, коды, авторизация, метрики, тесты и деплой на месте. Подменяемые части — хранилище (интерфейс Store ждёт настоящую БД), выдача токенов, политики лимитов под свой трафик. Скелет рассчитан на замену частей без переписывания хендлеров — это и есть главный критерий годности.
Что делать после выпускного экзамена?
Расти по маршруту из tip-блока: стримы из восьмого урока, вторая версия схемы из восемнадцатого, REST-аннотации из двадцать первого, BSR для контрактов соседних команд. Затем — второй сервис по тому же шаблону: скорость второй сборки покажет, насколько канон въелся. Классический стек grpc-go разбирает отдельный курс на сайте.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Fyne: десктопные и мобильные GUI на Go
Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.
- Создавать окна и интерфейсы из виджетов
- Управлять компоновкой: Box, Grid, Form
- Рисовать примитивами canvas и строить свои виджеты
- Связывать данные через binding