Содержание урока · 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.
Собери сценарий запуска и остановки
Расставь шаги жизненного цикла микросервиса от старта до выхода процесса.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карта курса: какая строка за что отвечает
Тесты проекта: пирамида из урока 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: соседние команды получат клиент без вашего кода. Архитектура держит все четыре шага без переписывания — вот главный экзамен, который проект сдаёт за вас.
Что дальше#
Курс собран, проект собран — осталось только твоё. Карта всех двадцати пяти уроков лежит на странице курса; если какой-то слой коротает ночь в голове непонятой, вернитесь к первоисточнику: 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, не изменился — это и есть главный экзамен курса.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему сервис на памяти, а не с базой — разве это production-ready?
Проект демонстрирует архитектуру, а не хранение, и делает это осознанно: memory-реализация за интерфейсом Service — это замена одного файла. Вся ценность слоистой структуры в том, что подмена хранения не трогает ни endpoint, ни транспорт, ни middleware. Написать Postgres-реализацию — первый пункт домашки с бонусом; интерфейс к ней уже готов.
Почему два порта — нельзя ли gRPC внутрь HTTP?
Можно, но не нужно смешивать: HTTP-стриминг и gRPC-фрейминг живут по разным правилам, и общий порт создаёт диспетчер по Content-Type — лишний слой с собственными граблями. Два порта дешевле в отладке: 8080 смотрит наружу, 8081 слушает только кластер, и правило firewall-зон читается с одного взгляда на конфиг.
Что вынести в следующим шагом — базу, брокер или второй инстанс?
Базу. Хранение в памяти — единственное упрощение, которое ломает честные сценарии: рестарт теряет данные, второй инстанс не видит чужие коды. NATS и discovery подождут: сначала persistent-слой за тем же интерфейсом, затем проверка миграцией — если подмена заняла один файл и ноль правок в транспорте, архитектуру вы освоили.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
gRPC-Go: высокопроизводительный RPC
Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.
- Писать .proto-схемы и генерировать код
- Реализовывать unary и streaming сервисы
- Управлять метаданными, дедлайнами и отменами
- Строить interceptors для логов и аутентификации