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

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

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

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

Итоговый проект: микросервис на go-kit

Двадцать четыре урока — и каждый оставил в каноне одну деталь: endpoint из урока 02 превратил метод сервиса в функцию одного вызова, кодеки из урока 05 научили сервис говорить JSON и падать с честным 400, логирование из урока 06 и метрики из урока 07 сделали его наблюдаемым, лимит и предохранитель из уроков 09 и 10 — выносливым, discovery и балансировка из уроков 11 и 12 — горизонтально масштабируемым, gRPC-транспорт из урока 13 — двуязычным, graceful shutdown из урока 19 — вежливым при остановке, события из урока 22 — громкоговорящим. Пора собрать всё в один микросервис go kit и увидеть, как слои ложатся друг на друга без единой заплатки: под возможностями go-kit легко спрятать ПО, вот почему крупные команды до сих пор берут его на проекты, где важен контроль, а не магия. Задача выпускного проекта — сервис Shortener: сокращатель ссылок с парой методов Encode и Decode, полным middleware-стеком, JWT-защитой административного ветки, событиями для аудита, тестами всех слоёв и Docker-образом, который можно запустить одной командой. Всё, что будет ниже, уже было разобрано по кусочкам — сегодня ни одной новой библиотеки, только сборка. Если после этого урока ты сможешь закрыть глаза и нарисовать весь проект от main.go до /metrics — курс сделал своё дело.

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

Проект: сервис Shortener#

Спецификация короткая, чтобы хватило места на сборку, а не на бизнес-логику. Shortener — сокращатель ссылок: метод Encode принимает длинный URL и возвращает короткий код из шести символов, метод Decode принимает код и возвращает исходный URL. Коды живут в памяти под мьютексом — так же, как задачи в каноне уроков 02-04: проект этого урока о архитектуре, а не о хранении, и замена памяти на базу данных должна занимать один файл. Один из методов — административный: Delete запрещает дальнейшее использование кода и доступен только владельцу токена, поэтому проект заодно демонстрирует авторизацию из урока 23. Каждый успешный Encode публикует событие shortener.url.created в шину из урока 22 — аудит желает знать, что появилось новое сокращение. Сервис слушает HTTP на 8080 и gRPC на 8081 — двуязычность из урока 13. Health-check из урока 20 отвечает на /healthz, метрики из урока 07 — на /metrics. Всё остальное — точки, в которых уроки курса встают друг на друга: ниже разберём каждую.

Раскладка каталогов#

Структура проекта — расширенная карта из урока 17, и её стоит рассмотреть до кода, потому что в ней видно всё обучение. В корне — main.go, композиционный корень: единственное место, где создаются конкретные реализации и связываются интерфейсы. В internal/service — бизнес-логика: интерфейс StringService-стиля с методами Encode, Decode, Delete и его memory-реализация под мьютексом; никакой HTTP, никакого JSON — чистый Go. В internal/endpoint — endpoint'ы: по одному на метод, request/response-структуры рядом, плюс auth-middleware из урока 23, который вешается не на все, а на адресный endpoint. В internal/transport — два транспорта: HTTP с декодерами и энкодерами из урока 04 и gRPC-биндинг из урока 13. В internal/middleware — стек endpoint-обёрток: логирование, метрики, лимит, предохранитель — ровно в том порядке, который обосновали в уроке 09. В internal/events — шина и издатель из урока 22. В internal/config — конфигурация из урока 18: адреса, таймауты, секрет JWT. Тесты лежат рядом с кодом — правило из урока 16. Раскладка скучная — и это комплимент: скучная раскладка читается без карты.

Сервис: интерфейс и memory-реализация

GO
// internal/service/service.go
type Service interface {
        Encode(ctx context.Context, longURL string) (code string, err error)
        Decode(ctx context.Context, code string) (longURL string, err error)
        Delete(ctx context.Context, code, owner string) error
}

var ErrNotFound = errors.New("shortener: код не найден")

type memoryService struct {
        mu     sync.RWMutex
        byCode map[string]string // код -> длинный URL
}

func NewMemory() Service { return &memoryService{byCode: map[string]string{}} }

func (s *memoryService) Encode(_ context.Context, longURL string) (string, error) {
        code := randomCode(6) // base62, шесть символов
        s.mu.Lock()
        defer s.mu.Unlock()
        s.byCode[code] = longURL
        return code, nil
}

func (s *memoryService) Decode(_ context.Context, code string) (string, error) {
        s.mu.RLock()
        defer s.mu.RUnlock()
        url, ok := s.byCode[code]
        if !ok { return "", ErrNotFound }
        return url, nil
}

Ничего нового — и это главный урок финала. Интерфейс с контекстом, сентинелы ошибок рядом с объявлением, RWMutex: чтение Decode параллелится, запись Encode эксклюзивна — ровно тот же компромисс, что у шины в уроке 22. Delete не показан целиком, но устроен как Decode плюс удаление и проверка владельца: код хранит владельца — для этого в реальной реализации map значений превращается в map структур. randomCode детерминированно подменяется в тестах — для этого он переменная пакета, а не константа-функция.

Endpoint'ы: три метода, три обёртки#

Каждый метод сервиса превращается в endpoint — функцию из request в response, как в уроке 02. Структуры запросов плоские и без тегов: теги живут в транспорте. EncodeEndpoint достаёт longURL из request, зовёт сервис, упаковывает code. DecodeEndpoint — то же для кода. DeleteEndpoint — самый интересный: перед вызовом сервиса он обязан знать, кто звонит, и имя владельца кладёт в request из контекста — туда его положит auth-middleware из урока 23. Над всеми тремя — стек middleware из уроков 06, 07, 09 и 10, и порядок имеет значение: логирование — снаружи, чтобы видеть результат после всех оборотов; метрики — следующими, чтобы замерять латентность целиком; предохранитель — до лимита, чтобы отказы разомкнутой цепи не съедали токены лимитера; лимит — последним перед endpoint'ом, чтобы отсекать непосредственно перед бизнес-логикой. Этот порядок был обоснован в уроке 09 — в проекте он виден одной строкой цепочки, и читатель проекта понимает его без комментария.

Сборка endpoint'ов со стеком

GO
// internal/endpoint/endpoints.go
func MakeEndpoints(svc service.Service, cfg Config) Endpoints {
        var enc endpoint.Endpoint = func(_ context.Context, r any) (any, error) {
                req := r.(EncodeRequest)
                code, err := svc.Encode(context.Background(), req.LongURL)
                return EncodeResponse{Code: code, Err: err}, nil
        }

        var del endpoint.Endpoint = func(ctx context.Context, r any) (any, error) {
                req := r.(DeleteRequest)
                owner := auth.OwnerFrom(ctx) // положил auth-middleware урока 23
                return DeleteResponse{Err: svc.Delete(ctx, req.Code, owner)}, nil
        }

        return Endpoints{
                Encode: chain(enc, cfg.Logging, cfg.Metrics, cfg.Breaker, cfg.Limiter),
                Decode: chain(enc, cfg.Logging, cfg.Metrics, cfg.Breaker, cfg.Limiter),
                Delete: chain(auth.Wrap(del, cfg.Verifier), cfg.Logging, cfg.Metrics),
        }
}

chain — маленькая локальная функция, применяющая обёртки слева направо: первый аргумент — самый внешний слой. Три детали, на которые смотри в первую очередь. Первая: Delete обёрнут auth.Wrap ДО стека наблюдаемости — тогда лог и метрики видят и неавторизованные попытки, а это половина пользы журнала безопасности. Вторая: OwnerFrom читает контекст, который наполнил JWT-верификатор — контракт урока 23. Третья: context.Background() внутри enc — осознанное упрощение для читабельности фрагмента; в полном файле контекст пробрасывается из параметра, и это проверяется тестом из урока 16.

Транспорты: HTTP и gRPC на одной имплементации#

Транспорт — переводчик: декодер превращает HTTP-тело в request, endpoint выполняется, энкодер превращает response в ответ. Декодер Encode принимает JSON {"url": "..."} и валидирует: пустая строка — badRequestError из урока 05, который энкодер превратит в 400 с телом ошибки, не пачкая лог серверным стеком. Декодер Delete читает код из пути и требует заголовок Authorization — его отсутствие auth-middleware превратит в 401. Энкодеры пишут JSON и ставят статус: 201 для созданного кода, 200 для найденного, 404 на service.ErrNotFound — маппинг сентинелов на коды ответа живёт в одном месте, как учил урок 05. gRPC-транспорт из урока 13 биндит те же endpoint'ы к proto-сервису: ни строки бизнес-логики, только перекладывание между proto-сообщениями и request-структурами. Один сервер имплементации — два транспорта — ноль дублирования логики: это главный дивиденд слоистой архитектуры, и в проекте он проверяется тестом, который гоняет один и тот же сценарий через оба транспорта.

Наблюдаемость: логи, метрики, health#

Логирование из урока 06: JSON-логгер, поля endpoint, method, took, err на каждом запросе; уровни фильтруются env-переменной LOG_LEVEL, чтобы в проде не тащить дебаг. Метрики из урока 07: счётчик запросов с метками endpoint и status, гистограмма латентности с buckets по экспоненте, gauge глубины шины событий — на дашборде сразу видно, копятся ли факты. Health-check из урока 20: /healthz отвечает 200 всегда, когда процесс жив, — это liveness; readiness-файл не нужен сервису без внешних зависимостей, но hook для него оставлен — при замене памяти на базу он заполнится проверкой соединения. События из урока 22: сервисный middleware издателя оборачивает только Encode — успешное сокращение публикует факт shortener.url.created с payload из кода и длинного URL; ошибка шины не роняет запрос, она пишет лог и поднимает счётчик дропов. Каждый из этих слоёв настраивается независимо — и все они вместе добавили меньше ста строк к проекту.

Композиционный корень main.go

GO
// cmd/shortener/main.go — единственное место сборки
func main() {
        cfg := config.FromEnv() // урок 18: адреса, таймауты, секрет

        svc := service.NewMemory()
        logger := kitlog.NewJSONLogger(os.Stdout)
        events := events.NewBus(64) // буфер на подписчика — урок 22
        ep := endpoint.MakeEndpoints(svc, endpoint.Config{
                Logging: loggingmw(logger), Metrics: metricsmw(prom),
                Breaker: breakermw(), Limiter: ratemw(100),
                Verifier: jwt.NewHS256(cfg.JWTSecret),
        })

        mux := http.NewServeMux()
        mux.Handle("/metrics", promhttp.Handler())
        mux.HandleFunc("/healthz", health.Z200)
        mux.Handle("/", httptransport.NewServer(ep.Encode,
                decodeEncode, encodeCreated))
        go func() { _ = http.ListenAndServe(cfg.GRPCAddr, grpcServer(ep)) }()

        srv := &http.Server{Addr: cfg.HTTPAddr, Handler: mux,
                ReadTimeout: cfg.ReadTimeout, WriteTimeout: cfg.WriteTimeout}
        graceful.Run(srv, func() { events.Close() }) // урок 19
}

main.go читается как оглавление курса: конфиг из урока 18, сервис, логгер из 06, шина из 22, endpoint'ы со стеком из 09, метрики из 07, health из 20, HTTP-транспорт из 04, gRPC в горутине из 13, graceful из 19. Ни один слой не знает о соседях — зависимости направлены внутрь, к сервису. Ровно поэтому каждый следующий урок курса добавлял строку здесь, не трогая остальные файлы. Тест всей сборки — один: поднять main в httptest-обёртке, прогнать сценарий, загасить graceful'ом и убедиться в exit 0.

Порядок middleware-стека

В проекте стек собран так: logging → metrics → breaker → limiter → endpoint. Что увидит лог, если лимитёр отклонил запрос?

Что ответит сервис?

Прочитайте код и выберите, что он выведет:

# состояние: код abc123 -> https://go.dev создан владельцем vasya
$ curl -X DELETE http://localhost:8080/urls/abc123
$ echo "---"
$ curl -X DELETE http://localhost:8080/urls/abc123 \
    -H "Authorization: Bearer <токен petia>"
$ echo "---"
$ curl -X DELETE http://localhost:8080/urls/abc123 \
    -H "Authorization: Bearer <токен vasya>"

Восстанови строку композиционного корня

Впиши пропущенные части сборки graceful shutdown из урока 19.

srv := &http.Server{Addr: cfg.HTTPAddr, Handler: mux} graceful.Run(srv, func() { events._() }) // дренаж шины после HTTP

Собери сценарий запуска и остановки

Расставь шаги жизненного цикла микросервиса от старта до выхода процесса.

Строки

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

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

Карта курса: какая строка за что отвечает

Термин

Метод сервиса -> функция из request в response

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

Endpoint — урок 02: ядро go-kit, тестирование без транспорта

1 / 6

Тесты проекта: пирамида из урока 16#

Пирамида тестов проекта собрана из готовых блоков. Основание — юнит-тесты сервиса: memory-реализация проверяется таблицей кейсов на Encode-Decode-Delete, включая коллизию кодов и чужого владельца; мьютекс проверяется гонкой — go test -race с сотней параллельных Encode. Средний уровень — endpoint-тесты: каждый endpoint вызывается как функция без транспорта, auth-middleware тестируется отдельно — валидный, просроченный, чужой токен; тот самый predict-сценарий 401-403-200 живёт здесь как table-driven тест. Вершина — транспортные тесты через httptest из урока 16: полный стек от HTTP-запроса до ответа, включая проверку, что badRequestError приходит 400 с телом, а не роняет сервер. Интеграционный тест собирает весь main.go: поднимает оба транспорта, гоняет сценарий сокращения через HTTP, проверяет событие в шине, гасит graceful'ом и требует exit 0. И финальный аккорд — тест обоих транспортов на одной имплементации: сценарий прогоняется дважды, через HTTP и через gRPC, результаты обязаны совпасть байт в байт.

Docker-образ проекта

GO
# multi-stage из урока 24
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /bin/shortener ./cmd/shortener

FROM gcr.io/distroless/static-debian12
COPY --from=build /bin/shortener /shortener
USER nonroot:nonroot
EXPOSE 8080 8081
ENTRYPOINT ["/shortener"]

Образ — дословно канон из урока 24: two-stage сборка, кэш модулей на отдельном слое, distroless без шелла, nonroot-пользователь, exec-форма ENTRYPOINT для корректных сигналов. Особенность двуязычного сервиса — EXPOSE двух портов: HTTP 8080 наружу, gRPC 8081 для соседей по кластеру. HEALTHCHECK не вписан намеренно — в оркестраторе из урока 24 здоровье проверяется пробами, а не командой в образе; обе точки зрения показаны в уроке, выбирай по инфраструктуре.

Запуск и проверка#

Собранный проект проверяется сценарием из пяти команд, и это одновременно приёмочный тест. Первая — старт: docker run -p 8080:8080 -p 8081:8081 -e JWT_SECRET=dev-secret shortener && curl -s localhost:8080/healthz — жив. Вторая — сокращение: curl -X POST localhost:8080/urls -d '{"url":"https://go.dev"}' — в ответе код из шести символов. Третья — обратный путь: curl localhost:8080/urls/{код} — исходный URL. Четвёртая — наблюдаемость: curl -s localhost:8080/metrics | grep shortener_requests_total — счётчики дышат, гистограмма наполнилась. Пятая — дренаж: docker stop с SIGTERM, в логах — порядок остановки из урока 19 и exit 0. Пять команд покрывают все слои, потому что каждый слой проекта обязан быть видимым снаружи: либо ответом, либо метрикой, либо строкой лога. Невидимый слой — неисправный слой, это правило стоит поместить на первый экран внутренней вики команды.

Что дальше#

Курс собран, проект собран — осталось только твоё. Карта всех двадцати пяти уроков лежит на странице курса; если какой-то слой коротает ночь в голове непонятой, вернитесь к первоисточнику: endpoint'ы — в уроке 02, стек обёрток — в уроках 09 и 10, gRPC — в уроке 13, остановка — в уроке 19, события — в уроке 22. Микросервис go kit из этого урока — не конечная точка, а стартовая площадка: подменяйте реализации, наращивайте стек, выпускайте модули. И помните правило курса: сначала контракт, потом реализация, потом обвязка — в этом порядке код не гниёт.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Проект Shortener собирает весь курс: сервис, endpoint'ы, два транспорта, полный middleware-стек, события, тесты и Docker.
  • Раскладка из урока 17: main.go — композиционный корень, зависимости направлены внутрь, к сервису.
  • Порядок стека logging → metrics → breaker → limiter: наблюдаемость видит весь трафик, включая отказы.
  • Delete — адресный endpoint: auth-middleware кладёт владельца в контекст, сервис принимает owner параметром.
  • 401 против 403: нет токена — не аутентифицирован; чужой токен — не авторизован; проверка в endpoint'е.
  • Один сервис — два транспорта: сценарий гоняется через HTTP и gRPC с совпадением результатов.
  • Пирамида из урока 16: юнит сервиса, endpoint-тесты, транспортные httptest, интеграция всей сборки.
  • Docker из урока 24: multi-stage, distroless, nonroot, exec-форма ENTRYPOINT; проверка — пять команд.

Домашнее задание

Выпускной экзамен: соберите проект по шагам урока и доведите его до пяти приёмочных команд. Шаг первый: раскладка каталогов из урока 17 и сервис с интерфейсом и сентинелами. Шаг второй: три endpoint'а со стеком chain и auth.Wrap на адресной ветке; тест 401-403-200 как table-driven. Шаг третий: HTTP-транспорт с badRequestError на пустом URL и маппингом ErrNotFound на 404; gRPC-биндинг тех же endpoint'ов и общий сценарий через оба транспорта. Шаг четвёртый: логирование, метрики с гистограммой, health-check и шина событий на Encode — все четыре слоя видны в /metrics и в логе. Шаг пятый: graceful-остановка с close шины после дренажа HTTP; интеграционный тест требует exit 0. Шаг шестой: multi-stage Dockerfile, запуск, пять приёмочных команд из урока. Бонус: подмените память на интерфейс-совместимое хранилище и убедитесь, что ни один файл, кроме service, не изменился — это и есть главный экзамен курса.

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

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

Почему сервис на памяти, а не с базой — разве это production-ready?

Проект демонстрирует архитектуру, а не хранение, и делает это осознанно: memory-реализация за интерфейсом Service — это замена одного файла. Вся ценность слоистой структуры в том, что подмена хранения не трогает ни endpoint, ни транспорт, ни middleware. Написать Postgres-реализацию — первый пункт домашки с бонусом; интерфейс к ней уже готов.

Почему два порта — нельзя ли gRPC внутрь HTTP?

Можно, но не нужно смешивать: HTTP-стриминг и gRPC-фрейминг живут по разным правилам, и общий порт создаёт диспетчер по Content-Type — лишний слой с собственными граблями. Два порта дешевле в отладке: 8080 смотрит наружу, 8081 слушает только кластер, и правило firewall-зон читается с одного взгляда на конфиг.

Что вынести в следующим шагом — базу, брокер или второй инстанс?

Базу. Хранение в памяти — единственное упрощение, которое ломает честные сценарии: рестарт теряет данные, второй инстанс не видит чужие коды. NATS и discovery подождут: сначала persistent-слой за тем же интерфейсом, затем проверка миграцией — если подмена заняла один файл и ноль правок в транспорте, архитектуру вы освоили.

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

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

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

gRPC25 уроков~14 чпродвинутый25 уроков уже доступны

gRPC-Go: высокопроизводительный RPC

Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.

  • Писать .proto-схемы и генерировать код
  • Реализовывать unary и streaming сервисы
  • Управлять метаданными, дедлайнами и отменами
  • Строить interceptors для логов и аутентификации