Содержание урока · 15 разделов
- Второй этаж: зачем обёрткам интерфейса своя глава
- Type ServiceMiddleware: контракт обёртки интерфейса
- Своя обёртка №1: кэш над методом домена
- Композиция матрёшкой: порядок — это решение
- Прокси из урока 11 — тот же паттерн
- Инъекция зависимостей: конструкторы вместо глобального состояния
- Что уносим в декораторы, а что оставляем
- Два этажа обёрток рядом
- Ретраи как декоратор: осторожно с идемпотентностью
- Домен остаётся чистым: итоговая карта
- Чек-лист сервисного декоратора
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Второй этаж: зачем обёрткам интерфейса своя глава#
Урок 03 подарил тебе первый декоратор — LoggingMiddleware вокруг StringService — и обещал «разложить по полочкам» в отдельном уроке. Полочки такие. Во-первых, паттерн: type ServiceMiddleware func(StringService) StringService — тип-псевдоним, конструктор с зависимостями, структура со встраиванием интерфейса и переопределением нужных методов; один лекал закрывает кэш, ретраи, бизнес-метрики и прокси. Во-вторых, композиция: декораторы навешиваются матрёшкой в main до создания endpoint'ов, и порядок этой матрёшки — самостоятельное инженерное решение со своими правилами. В-третьих, инъекция зависимостей: logger, хранилище, кэш входят через конструкторы, никакого глобального состояния — урок 03 это заложил, здесь доведём до среза обёрток и функции применения. В-четвёртых, граница: что уносится в декоратор, а что остаётся endpoint-обёртке из урока 14. Прокси из урока 11-lb-proxy — proxymw и proxyingMiddleware — окажется частным случаем того же паттерна, и после этого урока все обёртки курса встанут в единую систему из двух этажей. Ключ к слою один: go kit service middleware живёт над интерфейсом домена и не знает про транспорт ничего.
Type ServiceMiddleware: контракт обёртки интерфейса#
Начни с определения, которое нужно запомнить дословно: type ServiceMiddleware func(StringService) StringService. Декоратор — функция, принимающая сервис и возвращающая сервис; сигнатура не меняется, поэтому main может навесить хоть пять штук подряд, и тип останется StringService. Отсюда первое отличие от endpoint-этажа: там существовал универсальный endpoint.Middleware, пригодный к любому endpoint'у; здесь универсального типа в пакете нет — у каждого сервиса свой псевдоним со своим интерфейсом. Это не неудобство, а честность: декоратор сервиса связан с доменом сильнее, чем endpoint-обёртка с запросом, и компилятор обязан это видеть. Второе отличие — момент исполнения: endpoint-обёртка применялась к готовой функции; service-декоратор применяется к реализации интерфейса ДО построения endpoint'ов — makeUppercaseEndpoint из урока 02 получает уже декорированный svc и про обёртки не знает. Третье отличие — видимость: декоратор видит доменные аргументы и может принимать решения по их содержимому («пустая строка — не кэшировать»), endpoint-обёртка видит только ctx и interface{}. Именно поэтому кэш из этого урока невозможен наверху, а лимитер из урока 09 — невозможен внизу: у каждого этажа свои вопросы.
Лекал декоратора: тип, конструктор, встраивание
GOtype ServiceMiddleware func(StringService) StringService
// Конструктор с зависимостью — по канону урока 03.
func NewLoggingMiddleware(logger log.Logger) ServiceMiddleware {
return func(next StringService) StringService {
return loggingMiddleware{next, logger}
}
}
type loggingMiddleware struct {
StringService // встраивание: Count наследуется, Uppercase переопределён
logger log.Logger
}
func (mw loggingMiddleware) Uppercase(ctx context.Context, s string) (output string, err error) {
defer func(begin time.Time) {
mw.logger.Log("method", "uppercase", "input", s,
"output", output, "err", err, "took", time.Since(begin))
}(time.Now())
output, err = mw.StringService.Uppercase(ctx, s)
return
}Три составные части лекала. Псевдоним типа — контракт обёртки; конструктор принимает зависимость и возвращает декоратор; структура со встраиванием переопределяет только то, что ей нужно. Встраивание StringService даёт бесплатное делегирование: метод Count не переопределён, поэтому вызов уходит прямо в обёрнутый сервис — декоратор «прозрачен» для остальных методов. Named returns output и err видны в defer — тот же приём, что в loggingMiddleware урока 06, только на домене.
Своя обёртка №1: кэш над методом домена#
Пора за рукотворное. Кэш — хрестоматийный resident service-этажа: он знает, что ключ — это строка s из домена, и решает «отвечать или считать», меняя саму суть вызова; endpoint-обёртка на это не способна в принципе, ведь request-структуру ей трогать запрещено правилами урока 14. Реализация — тот же лекал: структура с встроенным StringService, картой и RWMutex, переопределённый Uppercase. Обрати внимание на два решения внутри. Первое: кэшируем только успешные вычисления — ошибка ErrEmpty не попадает в карту, иначе пустой ввод один раз закэшируется и сервис навсегда перестанет его валидировать. Второе: Count не кэшируется вообще — переопределений меньше, поведение прозрачнее; захочешь — допишешь по тому же лекалу. RWMutex вместо Mutex: чтения много, записи редки — read-блокировка параллелит читателей. Кэш здесь in-memory для простоты; продовый вариант прячет хранилище за интерфейсом из урока 03 и подставляет Redis-реализацию тем же конструктором — инъекция зависимостей в действии.
cachingMiddleware: map под RWMutex
GOtype cachingMiddleware struct {
StringService
cache map[string]string
mtx sync.RWMutex
}
func NewCachingMiddleware(cache map[string]string) ServiceMiddleware {
return func(next StringService) StringService {
return cachingMiddleware{next, cache, sync.RWMutex{}}
}
}
func (mw cachingMiddleware) Uppercase(ctx context.Context, s string) (string, error) {
mw.mtx.RLock()
v, ok := mw.cache[s]
mw.mtx.RUnlock()
if ok {
return v, nil // вызов base не случился вовсе
}
v, err := mw.StringService.Uppercase(ctx, s)
if err != nil {
return "", err // ошибки не кэшируются
}
mw.mtx.Lock()
mw.cache[s] = v
mw.mtx.Unlock()
return v, nil
}Сигнатура метода повторяет интерфейс: декоратор go-кода реализует контракт целиком, отличаясь лишь поведением вокруг base. Проверка «ок» под RLock отдаёт значение без вызова обёрнутого сервиса — вот почему декораторы относят к service-этажу: они меняют семантику вызова. Ключ карты — сама строка s, доменный аргумент; никакой сериализации и никакой возни с request-структурами, которых здесь просто нет.
Что увидит второй вызов
svc := NewLoggingMiddleware(logger)(NewCachingMiddleware(m)(stringService{})). Первый вызов Uppercase(ctx, "go") вернул "GO". Что произойдёт при втором Uppercase(ctx, "go")?
Композиция матрёшкой: порядок — это решение#
Декораторы навешиваются последовательно: svc = NewLoggingMiddleware(logger)(svc), затем svc = NewCachingMiddleware(m)(svc), и так далее. Читается сборка изнутри наружу: самый последний надетый декоратор стоит снаружи и встречает вызов первым — та же арифметика, что у ручной обёртки endpoint'ов из урока 02. Каноническое правило уроков 06-10 «кто внутри — тот пишет первым» работает и здесь: defer внутреннего декоратора сработает раньше defer внешнего. Как выбрать порядок? Задай вопрос, что должно быть видимым. Логи снаружи кэша видят и попадания, и промахи — дежурный видит полный трафик; логи внутри видят только реальные вычисления, зато took показывает чистую стоимость базы. Метрики бизнес-событий обычно ставят самым внешним слоем: счётчик должен считать каждый вызов, включая кэш-хиты. Кэш — самым внутренним, перед самой реализацией: его место — стереть вызов, а не пропустить его через все слои. Прокси из урока 11 в stringsvc3 стоит снаружи метрик — запрос сначала решает «я или сосед», и только потом уходит в подсчёты. Каждая перестановка — легальна и осмысленна; документируй выбор комментарием в main, как это делалось с порядком endpoint-цепочки.
Переставь матрёшку
Прочитайте код и выберите, что он выведет:
// Вариант А:
svc := NewLoggingMiddleware(logger)(NewCachingMiddleware(m)(base))
// Вариант Б:
svc := NewCachingMiddleware(m)(NewLoggingMiddleware(logger)(base))
// Второй вызов Uppercase(ctx, "go") — что различится?Прокси из урока 11 — тот же паттерн#
Теперь у тебя есть карта, и proxymw из урока 11-lb-proxy встаёт на место без сюрпризов: proxyingMiddleware — это service-декоратор, структура с встроенным StringService, чей переопределённый Uppercase зовёт makeUppercaseProxy и иногда отвечает чужим ответом. Почему прокси — service-этаж, хотя он ходит в сеть? По двум нашим же проверочным вопросам: он знает домен (перенаправляет конкретный метод с конкретной строкой) и меняет семантику вызова (отвечает вместо локального вычисления) — оба ответа указывают на декоратор. При этом клиентская нога прокси — endpoint из kithttp.NewClient, — обёрнута в свой endpoint-цепочку с breaker'ом и метриками: этажи не смешиваются, каждый работает со своим слоем абстракции. Именно так канонический stringsvc3 собирает main: базовая реализация, поверх — прокси-декоратор, поверх — логи и метрики сервиса, затем endpoint'ы, затем endpoint-middleware из урока 14, затем транспорты из уроков 04 и 13. Двухэтажная система обёрток — главное архитектурное наследие курса; запомни порядок сборки, он понадобится в итоговом проекте урока 25.
Сборка stringsvc: двухэтажная система
GOvar svc StringService
svc = stringService{}
// Этаж service: декораторы домена, снаружи — первый встречает вызов.
svc = NewLoggingMiddleware(logger)(svc) // бизнес-логи — снаружи
svc = NewCachingMiddleware(cache)(svc) // кэш — перед базой
// Этаж endpoint: из декорированного сервиса строятся функции.
uppercase := makeUppercaseEndpoint(svc) // урок 02
uppercase = loggingMiddleware(logger)(uppercase) // этаж 14 и далее...
// Транспорт (урок 04) получает уже всё:
// httptransport.NewServer(uppercase, decode, encode, options...)Две строки декораторов делают то, что в меньших фреймворках размазано по хендлерам: домен остался чистым — stringService{} про кэш и логи не знает. Заметь последовательность: сначала service-матрёшка, потом endpoint'ы, потом endpoint-цепочка. Обратный порядок невозможен: endpoint'ы едят готовый интерфейс, и декоратор, надетый после построения endpoint'ов, до них уже не дотянется — в функции закрыт старый svc.
Инъекция зависимостей: конструкторы вместо глобального состояния#
Урок 03 сформулировал правило: зависимости входят через конструкторы, глобальное состояние запрещено. Service-middleware — главный потребитель этого правила: logger, карта кэша, репозиторий, лимит хранилища — всё приходит параметрами конструктора, и декоратор не имеет права заводить свои пакеты-глобалы. Что это даёт на практике. Тестируемость: в тесте подставляется fake-логгер или пустая карта, и декоратор тестируется изоляционно — это будет кодом урока 16. Конфигурируемость: одна и та же матрёшка собирается с Redis-кэшем или с in-memory по флагу запуска. Явность: по сигнатуре NewCachingMiddleware видно, что обёртке нужен кэш, — ничего не прячется в пакетных переменных. Когда декораторов становится больше пяти, сборка в main превращается в лестницу из одинаковых строк; на этот случай держи функцию применения среза — applyMiddleware из следующего блока. Она — единственное место, где список декораторов и порядок их применения живут вместе, и её же удобно передавать в тесты: тест собирает матрёшку из своих стабов той же функцией, что и прод-код.
applyMiddleware: срез декораторов одним вызовом
GOfunc applyMiddleware(svc StringService, mws ...ServiceMiddleware) StringService {
for i := len(mws) - 1; i >= 0; i-- {
svc = mws[i](svc)
}
return svc
}
// main:
svc := applyMiddleware(
stringService{},
NewLoggingMiddleware(logger), // снаружи
NewCachingMiddleware(cache),
) // svc остаётся StringService
// Тест — та же функция, свои стабы:
fake := applyMiddleware(fakeService{}, NewCachingMiddleware(map[string]string{}))Обход с конца — семантика «первый элемент среза снаружи», знакомая по endpoint.Middlewares из урока 02: два вида обёрток курса ведут себя одинаково, и это не совпадение, а сознательный дизайн. Срез декораторов удобно собирать по конфигу: включён кэш — добавляем декоратор в срез; нет — не добавляем, и ни один вызов в коде не меняется. Функция возвращает тот же интерфейс — сигнатура main не зависит от числа слоёв.
Порядок применения среза
applyMiddleware(base, A, B, C) обходит срез с конца: C(B(A(base))). Кто встретит вызов первым и кто последний?
Что уносим в декораторы, а что оставляем#
Границу урока 14 теперь можно расписать по списку. В service-декораторы уносится всё, что понимает домен: кэш по ключу, ретраи к хранилищу, метрики бизнес-событий («сколько строк посчитано»), логи уровня операций с доменными полями, лимиты на доменные сущности, прокси к другим инстансам. В endpoint-обёртках остаются путь запроса и наблюдаемость: логи method/err/took, request_count, спаны, токен-бакет, предохранитель, дедлайн, recovery. Есть серые зоны — проговори их честно. Ретраи: к внешнему хранилищу — декоратор (он знает операцию и её идемпотентность); к балансированным инстансам — lb.Retry на endpoint-этаже из урока 11, потому что это вопрос пути запроса. Метрики: транспортные (латентность запроса) — endpoint; бизнес-события (посчитано строк) — декоратор. Логи: на каждый вызов с method/err/took — endpoint; с input/output домена — декоратор. Третий критерий для сомнительных случаев: попробуй протестировать обёртку без транспорта и без endpoint'а, только с fake-сервисом; если тест выразился естественно — место декоратора верное, если потребовался ctx с request-структурой — это endpoint-этаж.
Два этажа обёрток рядом#
| Свойство | Endpoint middleware | Service middleware |
|---|---|---|
| Тип | endpoint.Middleware — единый для всех | type ServiceMiddleware — по одному на интерфейс |
| Что видит | ctx и interface{} запроса | доменные аргументы и возвраты |
| Применяется к | готовому endpoint-у в main | реализации интерфейса до endpoint'ов |
| Меняет семантику | доступ к вызову (лимит, breaker) | сам вызов (кэш, прокси, ретраи) |
| Примеры курса | логи 06, метрики 07, breaker 10 | логи 03, прокси 11, кэш 15 |
| Тест | fake-next замыканием | fake-сервис без транспорта |
| Композиция | Chain / Middlewares / ручная | матрёшка / applyMiddleware |
Ретраи как декоратор: осторожно с идемпотентностью#
Второй хрестоматийный декоратор — ретраи — требует одной оговорки, которой не было у кэша. Повтор вызова Uppercase безопасен: операция чистая, строку в uppercase можно переводить сколько угодно раз. Но декоратор, повторяющий произвольные операции, — мина: если метод списывает деньги или публикует событие, повтор после частичного сбоя удвоит эффект. Правило то же, что и в lb.Retry из урока 11: ретраи только идемпотентных операций, и знание об идемпотентности живёт в домене — значит, и ретраи к хранилищу пишутся декоратором, который переопределяет только безопасные методы. Реализация — лекал, знакомый по кэшу: структура со встроенным интерфейсом, переопределённый метод, цикл из трёх попыток с паузой. Дополнение из урока 10: ретраи поверх breaker'а не ставятся никогда — обёртка не знает, где живёт предохранитель, и повтор может добить полуживую цепь; на нижнем этаже лимитер и breaker уже решают, кому идти, а декоратор получает только доброкачественный трафик. Если операция не идемпотентна — добавь ей идемпотентность (idempotency key в аргументах) или оставь единственную попытку.
Место для ретраев
Сервис ходит в Postgres через интерфейс Storage, методы GetProfile (чистое чтение) и AddOrder (пишет строку). Где ретраи уместны?
Домен остаётся чистым: итоговая карта#
Пройди глазами всю систему: stringService{} реализует Uppercase и Count — и не импортирует ничего из go-kit, кроме контекста; декораторы реализуют тот же интерфейс поверх, ничего не добавляя к контракту; endpoint'ы из урока 02 адаптируют методы к универсальной сигнатуре; endpoint-обёртки из уроков 06-14 обвешивают функции; транспорты из уроков 04 и 13 сериализуют. Каждый слой знает только соседей, и контракт между слоями — интерфейс сервиса и тип Endpoint. Именно это имел в виду урок 01-filosofiya, обещая «инструменты, а не каркас»: ни один слой не придётся переписать при смене соседа — смена транспорта не трогает декораторы, добавление кэша не трогает endpoint'ы, новый декоратор не трогает домен. Проверка на чистоту проста и красива: pkg-каталог сервиса собирается без единого импорта go-kit — если grep находит в домене kitlog или kitratelimit, слой протёк. Чек-лист декоратора из следующего блока — финальная полировка перед тестами урока 16, где вся эта матрёшка будет проверена изоляционно.
Чек-лист сервисного декоратора#
- Сигнатура: type-псевдоним func(XService) XService и конструктор с зависимостями в параметрах.
- Встраивание интерфейса: переопределяй только нужные методы, остальные — прозрачно в base.
- Named returns и defer — если декоратор пишет лог или меряет время.
- Состояние — под мьютексом; карта кэша, счётчики — поточно-безопасны.
- Ошибки домена не глотаются: sentinel ErrEmpty и другие доходят до клиента неизменными.
- Порядок матрёшки документирован в main: что видно в логах, что считает метрика.
- Домен не импортирует go-kit: проверка grep'ом на pkg-каталог.
Sentinel сквозь два декоратора
Прочитайте код и выберите, что он выведет:
// base возвращает ErrEmpty на пустой строке
svc := NewLoggingMiddleware(logger)(NewCachingMiddleware(m)(base))
_, err := svc.Uppercase(ctx, "")
_, err2 := svc.Uppercase(ctx, "")Собери декоратор кэша
Восстанови пропуск в cachingMiddleware и в сборке матрёшки.
Путь вызова Uppercase через два этажа
Расставь этапы вызова от HTTP-декодера до реализации.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Термины урока
Как только в проекте появился первый декоратор, туда тянут всё: логи, метрики, валидацию, авторизацию, транзакции. Правило одной ответственности из урока 14 действует и здесь: декоратор делает одно дело, и имя структуры ему подсказывает — cachingMiddleware не пишет логов, loggingMiddleware не кэширует. Шесть декораторов по пятьдесят строк лучше одного на триста: их можно переставлять, тестировать поодиночке и включать по конфигу.
Что дальше#
Система обёрток курса собрана полностью: endpoint-этаж из урока 14 и service-этаж этого урока закрывают все сквозные задачи stringsvc, а домен остался чистым — как обещал урок 03. Осталось научиться всё это проверять. Урок 16-testirovanie — про тестирование go kit: unit-тесты endpoint'ов прямыми вызовами, fake-next для endpoint-обёрток, fake-сервис для декораторов, кодеки через httptest.NewRecorder и интеграционные тесты транспорта. После него карта курса ведёт в сборку большого сервиса — уроки 17-18 про структуру и конфигурацию; путь до туда — на странице курса.
Урок требует модуля go-kit и установленного Go: в песочнице курса сторонние пакеты не исполняются, поэтому интерактивы построены на чтении кода. Локально: собери матрёшку stringService + кэш + логи, позови Uppercase с одной строкой дважды и сравни took в логе; затем переставь декораторы в обратном порядке и убедись, что второй вызов перестал логироваться; напиши applyMiddleware и включай кэш флагом -cache, проверяя, что сигнатура main не изменилась.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Service middleware — type-псевдоним func(StringService) StringService: декоратор принимает и возвращает интерфейс; сигнатура сервиса не меняется ни после одного слоя.
- Лекал декоратора: конструктор с зависимостями, структура со встраиванием интерфейса, переопределение только нужных методов, named returns + defer для логов и замеров.
- Кэш — хрестоматийный декоратор: ключ из домена, RWMutex, ошибки не кэшируются; он меняет семантику вызова, что endpoint-обёртке запрещено.
- Порядок матрёшки — решение о наблюдаемости: логи снаружи видят кэш-хиты, кэш внутри стирает вызовы до остальных слоёв; прокси из урока 11 — тот же паттерн.
- applyMiddleware со срезом декораторов повторяет семантику endpoint.Middlewares: обход с конца, первый элемент снаружи.
- Ретраи пишутся декоратором только для идемпотентных методов; поверх breaker'а ретраи не ставятся.
- Граница этажей: доменные поля и смена семантики — декораторам, путь запроса и наблюдаемость — endpoint-обёрткам; домен не импортирует go-kit.
Домашнее задание
Дострой service-этаж stringsvc руками. Первое: реализуй cachingMiddleware по уроку, собери матрёшку логи-снаружи-кэш-внутри и прогони Uppercase с одной строкой десять раз — в логе должно быть десять записей с копеечным took после первой. Второе: переставь матрёшку кэш-снаружи-логи-внутри и запиши, что изменилось в наблюдаемости; комментарий в main с обоснованием финального порядка обязателен. Третье: напиши applyMiddleware, собери декораторы из среза по флагу -cache и убедись, что тест декоратора использует ту же функцию, что и main. Четвёртое: добавь retry-декоратор для Count с двумя попытками и проверь на fake-сервисе, что первая неудача перезапускается; затем объясни письменно, почему для AddOrder-аналога такой декоратор запрещён. Пятое: прогони grep по сервисному пакету на импорты go-kit — в домене их быть не должно; один найденный импорт контекста stdlib разрешён. Запиши получившуюся карту двух этажей и сверь её с порядком из уроков 14 и 11.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему у service middleware нет общего типа вроде endpoint.Middleware?
Endpoint универсален, потому что сигнатура Endpoint одна на всю систему — func(ctx, interface{}) (interface{}, error). Интерфейсы сервисов у всех разные: StringService несовместим с ProfilesService, и общий тип обёртки потребовал бы пустого interface{} на входе — возврат к тому, от чего go-kit уводит. Псевдоним на каждый сервис — type ServiceMiddleware func(StringService) StringService — сохраняет типобезопасность: декоратор от чужого интерфейса просто не скомпилируется.
Кэш-декоратор или кэш в сервисе — как выбрать?
Кэш в сервисе — когда кэширование часть бизнес-правила: например, лимит пересчёта по доменному регламенту. Во всех остальных случаях — декоратор: сервис остаётся чистым, кэш подменяется на Redis-реализацию конструктором, тестируется с пустой картой, а главное — его можно убрать одной строкой в main, когда профиль нагрузки изменится. Кэш, вросший в реализацию, вынимается только рефакторингом.
Декоратор должен пробрасывать ctx или может его заменить?
Пробрасывать. Контекст принадлежит запросу и всему стеку: в нём лежат request_id из ServerBefore, спан из урока 08, дедлайны из timeout-обёртки. Декоратор, создающий context.Background(), отрежет нижним слоям и трассировку, и отмену — тихая поломка, которая проявится в Jaeger пропавшим деревом и в pprof — горутинами, которые никто не отменяет. Единственный легальный случай — фоновая задача, начатая с нового корневого контекста, и это отдельное осознанное решение.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
gRPC-Go: высокопроизводительный RPC
Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.
- Писать .proto-схемы и генерировать код
- Реализовывать unary и streaming сервисы
- Управлять метаданными, дедлайнами и отменами
- Строить interceptors для логов и аутентификации