Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 25 из 25 ~30 мин +50 XP

Итоговый проект: API на Connect

проект connect — финальный: Notes API целиком, с proto, хендлерами, авторизацией, метриками, тестами и Docker — всё, что курс наращивал двадцать четыре урока, собирается в один сервис. Если собрать частые запросы — connect проект, grpc api проект, — получится ровно он: небольшая, но взрослая система с контрактом notes.v1, матрицей кодов ошибок, Bearer-авторизацией интерсептором, счётчиками и гистограммами, полностековыми тестами и мультистейдж-образом. По плану курса итоговый проект назван сервисом задач — считай это проверкой на переносимость: скелет из этого урока собирает любую доменную схему, и в экзамене будет бонус про задачник. Канон же курса — заметки, и финал мы соберём на них. План: техзадание, контракт, слои, хендлеры и владение, авторизация, наблюдаемость, тесты, сервер, Docker — и контрольный список готовности в финале.

Содержание урока · 12 разделов

Техзадание короткое и честное: REST-подобный CRUD заметок поверх RPC, доступный одновременно Go-клиентам, браузеру и curl. Функционально: получить заметку по id, создать, перечислить свои, обновить, удалить — пять процедур вокруг ресурса Note. Нефункционально: доступ только по Bearer-токену; чужие заметки неотличимы от несуществующих; каждый вызов виден в метриках по процедуре и коду; тело запроса ограничено, крупное сжимается; контракт лоздается buf lint и buf breaking; тесты проверяют коды, а не тексты; сервис запускается одной docker-командой и корректно умирает по SIGTERM. Это не список фич ради списка: каждая строчка — навык из конкретного урока, и финал устроен как сборка конструктора, а не как новая теория. Если что-то из списка вызывает вопрос — ссылка на урок будет рядом, и вернуться к ней дешевле, чем тащить вопрос в прод. Заметь и то, чего в проекте нет: стримов и второй версии схемы. Осознанно — экзамен ниже предложит их как опции, но базовый финал держит юнит-минимум: один сервис, одна версия, пять методов.

Контракт: proto/notes/v1/notes.proto

GO
syntax = "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-ошибки

GO
type 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: политика владения в шести строках

GO
func (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

GO
func (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 на чужой заметке — единственное место, где «улучшение» будет замечено тестом раньше ревью.

Матрица кодов на полном стеке

GO
func 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(&notesv1.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

GO
func main() {
    store := notesmemory.New()
    srv := &notesserver.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-конструктора.

path, handler := notesv1connect.NewNoteServiceHandler( srv, connect.(1<<20), connect.(1024), connect.(&metrics.Interceptor{}, &auth.Interceptor{}), ) mux.Handle(path, handler) httpSrv := &http.Server{ Addr: ":8080", Handler: h2c.(mux, &http2.Server{}), }

Сборка проекта по шагам

Расставь шаги сборки финального проекта.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Контрольный список финального API#

КомпонентУрок-канонПризнак готовности
Контракт notes.v110, 15buf lint зелёный; комментарии над rpc превращаются в доки
Генерация gen/11buf generate воспроизводим в CI, плагины закреплены версиями
Слои и импорты23platform собирается без notesv1, цикл импортов невозможен
Ошибки06sentinel-ошибки маппятся connect.NewError-ом, матрица кодов в тестах
Авторизация07, 13без токена — unauthenticated на границе, тесты «без токена/с токеном»
Владение06чужая заметка — not_found, кейс закреплён тестом
Метрики17/metrics отдаёт счётчики procedure/code и гистограмму, p99 на дашборде
Лимиты и сжатие14WithReadMaxBytes и WithCompressMinBytes стоят, resource_exhausted честный
Тесты16go test -race ./... зелёный: матрица кодов и владение на полном стеке
Сервер и деплой19, 22h2c, пробы, graceful shutdown; docker run — смоук grpcurl и curl зелёный

Образ собран, контейнер запущен локально. Что входит в смоук перед выкатом?

Карточки: итоговый проект

Термин

Контракт финала

нажмите, чтобы перевернуть
Определение

package notes.v1, пять процедур: GetNote, ListNotes, CreateNote, UpdateNote, DeleteNote; idempotency_level = NO_SIDE_EFFECTS на чтении — GET-кэш Connect-жанра; комментарии над rpc — источник доков.

1 / 6

Финал курса#

Двадцать пять уроков назад вопрос был «что такое 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 с своим ресурсом и теми же политиками; если хендлеры пришлось переписывать больше, чем имена типов, вернись к двадцать третьему уроку и найди, где слои перепутались.

Личные заметкиПусто
Хранятся только в вашем браузере (Connect · Итоговый проект: API на Connect)

Вопросы и ответы по уроку

Где взять готовый репозиторий итогового проекта?

Нигде: каждый фрагмент уже разобран в уроках — контракт и хендлер в третьем, тесты в шестнадцатом, метрики в семнадцатом, main и graceful shutdown в девятнадцатом, Docker в двадцать втором. Сборка руками — и есть экзамен: она проверяет, что связи между уроками поняты, а не скопированы.

Notes API — это прод-готовый код?

Это прод-готовый каркас: контракт, коды, авторизация, метрики, тесты и деплой на месте. Подменяемые части — хранилище (интерфейс Store ждёт настоящую БД), выдача токенов, политики лимитов под свой трафик. Скелет рассчитан на замену частей без переписывания хендлеров — это и есть главный критерий годности.

Что делать после выпускного экзамена?

Расти по маршруту из tip-блока: стримы из восьмого урока, вторая версия схемы из восемнадцатого, REST-аннотации из двадцать первого, BSR для контрактов соседних команд. Затем — второй сервис по тому же шаблону: скорость второй сборки покажет, насколько канон въелся. Классический стек grpc-go разбирает отдельный курс на сайте.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Fyne25 уроков~13 чпрактика25 уроков уже доступны

Fyne: десктопные и мобильные GUI на Go

Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.

  • Создавать окна и интерфейсы из виджетов
  • Управлять компоновкой: Box, Grid, Form
  • Рисовать примитивами canvas и строить свои виджеты
  • Связывать данные через binding