---
title: "Итоговый проект: микросервис на go-kit"
description: "Итоговый проект go-kit: полный микросервис на endpoint и transport, middleware-стек логирования и метрик, JWT-защита, тесты и Docker-образ от начала до конца."
course: "go-kit: микросервисный тулкит"
url: "https://golangmaster.ru/courses/go-kit/lessons/25-proekt/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 30
---

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

*Курс «go-kit: микросервисный тулкит», урок 25 из 25. ~30 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-kit/lessons/25-proekt/*

Двадцать четыре урока — и каждый оставил в каноне одну деталь: 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 — курс сделал своё дело.

### Проект: сервис 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. Что увидит лог, если лимитёр отклонил запрос?

- Ничего: лимитёр стоит внутри logging, и отклонённые запросы логгер не видит
- ✅ Запись с err про превышение лимита: логгер — внешний слой и видит всё
- Только метрику: лимитёр отклоняет без логирования

Логирование — самый внешний слой цепочки, поэтому оно видит результат всего, что внутри, включая отклонения лимитёра и размыкания предохранителя. Именно поэтому порядок logging → metrics → breaker → limiter выбран в уроке 09: наблюдаемость видит весь трафик, включая отказы. Поменяй местами — и отклонённые запросы станут невидимыми для журнала, а это худший вид тихих отказов.

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

```go
# состояние: код 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>"
```

**Что выведет программа?**

- ✅ 401, 403, 200 — без токена, чужой владелец, законный владелец
- 401, 200, 200 — Delete не проверяет владельца, только сам факт токена
- 400, 400, 200 — первые два вызова падают на валидации токена

Первый вызов не несёт токен — auth-middleware из урока 23 отвечает 401 до входа в endpoint. Второй несёт валидный токен, но подписан petia, а код создан vasya — аутентификация пройдена, авторизация нет: 403, ровно та граница 401 против 403, которую рисовали в уроке 23. Третий вызов — владелец совпал: Delete выполняется, 200. Проверка владельца живёт в endpoint'е через OwnerFrom — сервис принимает owner параметром и остаётся глупым и тестируемым.

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

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

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

Подсказка: Метод шины из урока 22, который закрывает каналы подписчиков и даёт им дочитать буферы.

Правильные ответы в пропусках: `Close`

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

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

Правильный порядок строк:

1. `Прочитать конфигурацию из окружения (урок 18)`
2. `Создать сервис, логгер, шину событий и метрики`
3. `Собрать endpoint'ы с middleware-стеком`
4. `Поднять HTTP- и gRPC-транспорты`
5. `Принимать трафик и публиковать события`
6. `Получить сигнал SIGTERM (урок 19)`
7. `Остановить приём соединений и дренаж HTTP`
8. `Закрыть шину событий, дочитать буферы`
9. `Выйти с кодом 0`

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

- **Метод сервиса -> функция из request в response** — Endpoint — урок 02: ядро go-kit, тестирование без транспорта
- **JSON-логи на каждом запросе** — logging-middleware — урок 06: named returns и kitlog
- **Счётчик запросов и гистограмма латентности** — instrumenting-middleware — урок 07: прометей-метрики
- **Отказ разомкнутой цепи вместо очереди из ожидающих** — circuit breaker — урок 10: gobreaker на endpoint
- **Один сервер — два протокола 8080 и 8081** — HTTP + gRPC транспорта — уроки 04 и 13
- **SIGTERM: дренаж, затем закрытие зависимостей** — graceful shutdown — урок 19: секвенция остановки

### Тесты проекта: пирамида из урока 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. Пять команд покрывают все слои, потому что каждый слой проекта обязан быть видимым снаружи: либо ответом, либо метрикой, либо строкой лога. Невидимый слой — неисправный слой, это правило стоит поместить на первый экран внутренней вики команды.

> **Куда двигаться после курса.** Проект намеренно хранит данные в памяти — первый шаг роста: подмените memoryService на Postgres за тем же интерфейсом и увидите, что ни один другой файл не изменился. Второй шаг — замените in-memory шину на NATS за интерфейсом Publisher из урока 22. Третий — добавьте второй инстанс сервиса и discovery из урока 12: балансировщик из урока 11 заработает сам. Четвёртый — выпустите proto-модуль в BSR по урокам курса connect: соседние команды получат клиент без вашего кода. Архитектура держит все четыре шага без переписывания — вот главный экзамен, который проект сдаёт за вас.

### Что дальше

Курс собран, проект собран — осталось только твоё. Карта всех двадцати пяти уроков лежит на [странице курса](/courses/go-kit/); если какой-то слой коротает ночь в голове непонятой, вернитесь к первоисточнику: endpoint'ы — в [уроке 02](/courses/go-kit/lessons/02-endpoint/), стек обёрток — в [уроках 09 и 10](/courses/go-kit/lessons/09-rate-limit/), gRPC — в [уроке 13](/courses/go-kit/lessons/13-grpc-transport/), остановка — в [уроке 19](/courses/go-kit/lessons/19-graceful/), события — в [уроке 22](/courses/go-kit/lessons/22-sobytiya/). Микросервис go kit из этого урока — не конечная точка, а стартовая площадка: подменяйте реализации, наращивайте стек, выпускайте модули. И помните правило курса: сначала контракт, потом реализация, потом обвязка — в этом порядке код не гниёт.

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

- Проект 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, не изменился — это и есть главный экзамен курса.

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

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

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

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

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

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

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