Содержание урока · 11 разделов
- Интерфейс Interceptor: три метода
- Сервер: WithInterceptors у хендлера
- Клиент: тот же интерфейс, та же опция
- Авторизация на сервере: токен и контекст
- Interceptor против middleware net/http
- Что куда класть: middleware против interceptor
- Экосистема и стриминг: слово вперёд
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
У нас есть сервис с честными ошибками и клиент, который умеет их разбирать. Но посмотрите на типичный хендлер из прошлого урока: проверки токена, логирование, метрика длительности — всё это скоро придётся копировать в каждый метод каждого сервиса, и знакомый всем Go-разработчикам принцип DRY начнёт нашёптывать: не повторяйся. Сквозная функциональность — то, что не относится к бизнес-логике, но должно выполняться на каждом вызове, — в Go традиционно живёт в двух местах: middleware на сервере и обёртки-декораторы на функциях. Для RPC нужны оба жанра, и Connect предлагает второй в виде interceptors — перехватчиков. Это connect interceptors в чистом виде: тип, который оборачивает вызов RPC и получает шанс что-то сделать до и после, не трогая сам хендлер. Разберём перехватчики connect от и до: интерфейс Interceptor с тремя методами, хелпер UnaryInterceptorFunc для unary-случая, подключение к серверу и клиенту через connect.WithInterceptors, порядок цепочки, практические примеры — логирование и авторизацию — и честное сравнение с middleware net/http, чтобы вы знали, что куда класть.
Интерфейс Interceptor: три метода#
Интерсептор в Connect — это интерфейс из трёх методов, и он один на все случаи: WrapUnary оборачивает обычный вызов «запрос — ответ», WrapStreamingClient — стриминговые вызовы на клиенте, WrapStreamingHandler — стриминговые вызовы на сервере. Выглядит он так: WrapUnary(UnaryFunc) UnaryFunc, где UnaryFunc — это функция вида func(ctx, AnyRequest) (AnyResponse, error), а AnyRequest и AnyResponse — интерфейсные типы пакета connect, под которыми скрывается любое сообщение. Интерсептор написан один раз для всех методов всех сервисов, а конкретный тип сообщения он достаёт приведением req.Any().(*notesv1.GetNoteRequest), если ему это нужно. Самая ценная деталь — req.Spec(): небольшой объект с полями Procedure (путь процедуры вида /notes.v1.NoteService/GetNote) и StreamType (тип вызова: unary или стрим). Это значит, что перехватчик знает, какой именно метод вызывается, — и может логировать, метрики ставить, авторизацию дифференцировать по процедурам. Хелпер connect.UnaryInterceptorFunc экономит для unary-случая черновую работу: принимает вашу функцию вида func(next UnaryFunc) UnaryFunc и возвращает готовый Interceptor, у которого стриминговые методы — pass-through. Когда дойдёт до стриминга, допишете остальные два метода — обёртки симметричны, и в уроке про стриминг мы это сделаем. Пока же всё, что вы видели в middleware-мире Gin и Echo, переносится сюда с поправкой на типы.
Логирующий интерсептор в один экран
GO// Логируем каждую unary-процедуру: имя, длительность, код ошибки.
func logInterceptor() connect.Interceptor {
return connect.UnaryInterceptorFunc(
func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
start := time.Now()
res, err := next(ctx, req)
code := connect.CodeOf(err)
log.Printf("%s %s: %v, code=%v",
req.Spec().Procedure, req.Peer().Addr,
time.Since(start), code)
return res, err
}
},
)
}Читается отнутри наружу: UnaryInterceptorFunc принимает функцию-обёртку, обёртка получает next — следующий в цепочке вызов, — и возвращает новую UnaryFunc, которая сначала что-то делает (start := time.Now()), потом вызывает next, потом досчитывает (длительность, код ошибки). req.Spec().Procedure даёт путь процедуры — то, ради чего middleware не годится, — а connect.CodeOf(err) превращает ошибку в код для лога. Сигнатуры AnyRequest и AnyResponse — интерфейсные типы из пакета connect: перехватчик не привязан к конкретному message-типу и потому один на все методы. Ошибка возвращается как есть: интерсептор не обязан её менять.
Сервер: WithInterceptors у хендлера#
Подключение на сервере — одна опция в конструкторе, который вы уже знаете по третьему уроку. path, handler := notesv1connect.NewNoteServiceHandler(¬esServer{}, connect.WithInterceptors(logInterceptor())) — и теперь каждая процедура NoteService проходит через лог. Порядок в списке имеет смысл: connect.WithInterceptors(a, b, c) оборачивает вызов так, что a — внешний слой, b — средний, c — внутренний; на пути запроса первым срабатывает a, последним — c, а на пути ответа наоборот. Простое правило для запоминания: перечисление читается как луковица, первый — самый внешний слой. Это тот же порядок, к которому вы привыкли в цепочках grpc.ChainUnaryInterceptor и в middleware-стеках фреймворков. Практические следствия: авторизацию ставьте раньше логирования, если лог должен видеть уже аутентифицированного пользователя, но позже, если важно логировать и отклонённые запросы — порядок выражает намерение, и его стоит комментировать в коде. Интерсепторы применяются ко всем процедурам хендлера сразу — на один вызов NewNoteServiceHandler можно повесить всю цепочку сквозной логики. Заметьте, что интерсепторы видят и запрос, и ответ, включая заголовки: серверный перехватчик может добавить заголовок в ответ через res.Header().Set — это часто нужно для версионирования API или трассировки.
Хендлер собран с connect.WithInterceptors(authInterceptor{}, logInterceptor{}). Что произойдёт при вызове?
Клиент: тот же интерфейс, та же опция#
Первая сюрпризная деталь для тех, кто вырос на gRPC: в gRPC перехватчики для клиента и сервера — разные миры (UnaryClientInterceptor и UnaryServerInterceptor, разные сигнатуры), а в Connect это один и тот же интерфейс Interceptor, который можно подключить и туда, и туда. Клиентское подключение выглядит зеркально: notesv1connect.NewNoteServiceClient(http.DefaultClient, url, connect.WithInterceptors(authInterceptor{})). Зачем это нужно на клиенте? Классика — авторизация: клиентский интерсептор навешивает токен на каждый исходящий запрос в одном месте, и ни один вызов в кодовой базе не забудет Authorization. Вторая классика — трассировка и метрики: проставить trace-заголовки, посчитать исходящие вызовы. Третья — ретраи с backoff: перехватчик, который перехватывает неудачи кода unavailable и повторяет вызов с паузой; библиотека сознательно не делает ретраи по умолчанию, потому что политика повторов — слишком контекстная вещь. На клиенте порядок той же луковицы: первый в списке — внешний, первым видит исходящий запрос. И заметьте элегантность: у нас уже был ответ на вопрос «как передать заголовки в unary-вызов» — в пятом уроке мы обещали, что это сделает перехватчик. Вот он, момент исполнения: клиентский auth interceptor — это ровно тот механизм, которым сгенерированный клиент вкладывает метаданные в каждый вызов.
Клиентский авторизационный интерсептор
GOtype authInterceptor struct {
token string
}
func (i *authInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
// Метаданные — обычные HTTP-заголовки, как в уроке про клиентов.
req.Header().Set("Authorization", "Bearer "+i.token)
return next(ctx, req)
}
}
// Стриминг-методы пока pass-through: реализуем в уроке про стриминг.
func (i *authInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc {
return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn {
return next(ctx, spec)
}
}
func (i *authInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
return func(ctx context.Context, conn connect.StreamingHandlerConn) error {
return next(ctx, conn)
}
}Полноценный Interceptor, написанный руками: WrapUnary добавляет заголовок и вызывает next, стриминговые методы пока честно пробрасывают дальше. Использование: NewNoteServiceClient(http.DefaultClient, url, connect.WithInterceptors(&authInterceptor{token: t})) — и каждый вызов клиента едет с токеном. В боевом коде стриминговые методы дополняют той же вставкой заголовка через stream.RequestHeader() — разберём в уроке про стриминг.
Что напечатает сервер на один вызов GetNote?
Прочитайте код и выберите, что он выведет:
// Оба интерсептора — логирующие, как logInterceptor из урока.
// Каждый печатает "A: до" / "A: после" (или B-варианты) вокруг next.
handler := notesv1connect.NewNoteServiceHandler(
¬esServer{},
connect.WithInterceptors(a, b), // сначала a, потом b
)Авторизация на сервере: токен и контекст#
Теперь серверная сторона авторизации — самый частый запрос к перехватчикам в реальных проектах. Схема двухходовая. Ход первый — проверка: интерсептор достаёт заголовок Authorization через req.Header().Get, валидирует токен (для JWT — подпись и срок, мы строили такую проверку в курсах Gin и Echo), и на провал отвечает connect.NewError(connect.CodeUnauthenticated, ...) — ровно тот код и тот HTTP 401, что мы разобрали в уроке про ошибки. Ход второй — передача личности дальше: кто авторизовался, хендлеру надо знать. Идиома Go — контекст: интерсептор создаёт новый контекст через context.WithValue с закрытым (нестроковым) ключом и кладёт туда claims, а хендлер достаёт их через маленький типизированный хелпер вида UserFromContext(ctx). Строковые ключи в WithValue — чреватые коллизиями грабли, о которых предупреждает сама документация пакета context; закрытый тип key из вашего пакета снимает вопрос. Заметьте, что это тот же паттерн, который вы строили в gRPC-мире — только там проверка жила в grpc.UnaryInterceptor и metadata.FromIncomingContext, здесь — в connect-интерсепторе и req.Header(). Тонкая деталь: интерсептор обязан вернуть новый контекст — next(ctx, req) со старым ctx потеряет claims по дороге. И вторая: если у вас есть процедуры, которым авторизация не нужна (health-чеки), интерсептор может ветвиться по req.Spec().Procedure — whitelist живёт в одном месте, а не размазан по хендлерам.
Серверный auth-интерсептор с контекстом
GOtype ctxKey struct{} // закрытый ключ: без коллизий со строковыми
func authInterceptor(verifier func(string) (Claims, error)) connect.Interceptor {
return connect.UnaryInterceptorFunc(
func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
token := strings.TrimPrefix(
req.Header().Get("Authorization"), "Bearer ")
claims, err := verifier(token)
if err != nil {
return nil, connect.NewError(
connect.CodeUnauthenticated, err)
}
// Кладём личность в контекст — и только потом next.
return next(context.WithValue(ctx, ctxKey{}, claims), req)
}
},
)
}
// Хелпер для хендлеров:
func UserFromContext(ctx context.Context) Claims {
claims, _ := ctx.Value(ctxKey{}).(Claims)
return claims
}Полный цикл авторизации: заголовок → валидация (verifier — ваша функция, например проверка подписи JWT) → 401 на провал → claims в контекст через закрытый ключ → next с новым ctx. Хендлер остаётся чистым: UserFromContext(ctx).UserId — и никакой логики токенов внутри. Роутинг исключений для health-процедур делается веткой по req.Spec().Procedure перед проверкой.
Interceptor против middleware net/http#
Вопрос, который вы обязаны себе задать: зачем ещё один механизм, если в мире net/http есть middleware? Ответ не «либо-либо», а карта территории. Middleware работает на уровне HTTP-запроса: видит http.ResponseWriter и *http.Request, не знает ни процедур, ни сообщений, и живёт только на серверной стороне. Интерсептор работает на уровне RPC: знает процедуру через Spec, видит message-типы, работает и на сервере, и на клиенте, и умеет в стриминг через отдельные методы. Отсюда раскладка. На middleware — то, что про HTTP как таковой: TLS-редиректы, сжатие, CORS, восстановление после паник, общий лимит соединений. На интерсепторы — то, что про вызовы: логирование процедур, метрики по методам, авторизация по токенам, ретраи и трассировка на клиенте. Два инструмента прекрасно живут вместе: handler, который вернул NewNoteServiceHandler, — обычный http.Handler, и его можно обернуть в любую цепочку chi или Echo middleware — в уроке про интеграцию с фреймворками мы это и сделаем. Тонкость про паники: middleware с recover защитит сервер от падения, но интерсептор — правильное место, чтобы превратить панику в код internal на конкретном вызове. И ещё наблюдение из практики: лог-строка интерсептора с процедурой и кодом почти всегда полезнее лог-строки middleware с путём и статусом — потому что путь у RPC один на все методы сервиса.
Что куда класть: middleware против interceptor#
| Задача | Middleware net/http | Interceptor Connect |
|---|---|---|
| TLS-редирект, gzip, CORS | да: это про HTTP | нет: ниже уровня задач |
| Recover от паник | да, на весь сервер | точечно: вернуть CodeInternal на вызов |
| Логирование с именем процедуры | не видит процедур | да: req.Spec().Procedure |
| Авторизация по токену | возможно, но без знания процедур | да: заголовки, 401, claims в контекст |
| Клиентская сторона | нет middleware у исходящих вызовов | да: тот же Interceptor у клиента |
| Стриминговые вызовы | видит один HTTP-запрос | да: WrapStreamingClient/Handler |
Что умеет interceptor, чего не может middleware net/http?
Экосистема и стриминг: слово вперёд#
Две вещи, которые стоит знать про перехватчики, чтобы не изобретать велосипеды. Первая — экосистема: официальные пакеты Connect дают готовые интерсепторы для типовых задач. Самый ходовой — connectrpc.com/otelconnect: интерсептор OpenTelemetry, который расставляет трейсы и метрики на каждый вызов с процедурой в атрибутах, и подключается одной опцией WithInterceptors(otelconnect.NewInterceptor()) — к серверу или клиенту, без разницы. Придёт время мониторинга (семнадцатый урок) — начнёте с него, а не с самописного. Вторая — стриминг. Всё, что мы написали, касалось unary-вызовов; для стриминговых у интерфейса есть методы WrapStreamingClient и WrapStreamingHandler, а вместо request-объекта там streaming-соединение со своими RequestHeader и Spec. Структура та же — до next, next, после next, — но цикл сообщений добавляет нюансы: логирующий интерсептор на стриме считает не один вызов, а поток сообщений, и метрики там размечаются иначе. Это тема восьмого урока, где стриминг разберём целиком: типы Stream, Send/Receive, и вот эти самые перехватчики на живом примере. Пока зафиксируйте правило: если ваш сервис обещает поддерживать стриминг, у каждого Interceptor должны быть реализованы все три метода — хотя бы pass-through, как в примере с авторизацией.
Вы проходили gRPC: там grpc.UnaryServerInterceptor на сервере и grpc.WithUnaryInterceptor на клиенте — два разных типа. Чем отвечает Connect?
Соберите серверную цепочку перехватчиков
Восстановите подключение интерсептора к хендлеру и конструкцию обёртки UnaryFunc.
Путь вызова через цепочку перехватчиков
Расставьте по порядку события одного вызова GetNote при WithInterceptors(auth, log) на сервере.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Как только в WithInterceptors появится третий-четвёртый перехватчик, порядок станет контрактом: кто логирует отклонённые запросы, кто ставит метрики до авторизации. Заведите в коде комментарий-схему «auth → log → recover → handler» и меняйте её осознанно — половина загадочных багов со сквозной логикой лечится чтением одного этого списка.
Код уроков — настоящий Go с protobuf-кодогенерацией: сетевые вызовы браузерная песочница сайта исполнить не может, поэтому runner-блоков в курсе нет — для практики нужен локальный проект из второго урока. Интерактивы работают прямо на странице и дают XP, а код с построчными разборами копируйте в свой сервис и добавляйте перехватчики по одному.
Карточки: перехватчики Connect
Что дальше по курсу#
Сквозная логика заняла своё место: логирование с процедурами, авторизация через контекст, метрики — всё в перехватчиках, подключаемых одной опцией. Следующий урок — стриминг: ServerStreaming, ClientStreaming и Bidi через connect.Stream, перехватчики для потоков и то, как ошибки и таймауты ведут себя, когда ответов много. После стриминга выйдем в браузер — TypeScript-клиенты из девятого урока, — затем в тулчейн buf: линт, breaking changes и кодогенерация из десятого и одиннадцатого. Программа всех двадцати пяти уроков — на странице курса. Для сравнения полезно держать в голове, как перехватчики устроены в gRPC: их разбор с цепочками и контекстом мы делали в уроке про gRPC-interceptors — положите рядом, и вы увидите, что Connect свёл два разных типа в один интерфейс, а всё остальное — порядок, контекст, авторизация — вы уже умеете.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Interceptor — интерфейс из трёх методов (WrapUnary, WrapStreamingClient, WrapStreamingHandler), один и тот же для сервера и клиента.
- Unary-случай записывается хелпером connect.UnaryInterceptorFunc: обёртка вокруг next с действиями до и после вызова.
- Подключение — одна опция: connect.WithInterceptors(...) у NewServiceHandler на сервере и у NewServiceClient на клиенте.
- Порядок — луковица: первый в списке внешний, первым видит запрос и последним ответ; на клиенте правило то же.
- Убийственная фича против middleware: req.Spec().Procedure — интерсептор знает имя процедуры и работает на клиентской стороне тоже.
- Авторизация: серверный перехватчик проверяет токен, отвечает CodeUnauthenticated и кладёт claims в контекст; клиентский ставит заголовок на каждый вызов.
- Для трейсов и метрик есть готовый otelconnect; для стриминга реализуются все три метода интерфейса — даже если пока pass-through.
Домашнее задание
Соберите на notes.v1 полный стек перехватчиков: логирующий (процедура, длительность, код ошибки через CodeOf), серверный auth (заголовок Authorization с валидацией фиксированного токена, claims в контекст через закрытый ключ, 401 через CodeUnauthenticated) и клиентский auth, который ставит токен автоматически. Проверьте три сценария curl-ом или клиентом из пятого урока: без токена (401 с кодом unauthenticated), с токеном (200), с токеном на несуществующий id (404). Затем добавьте второй логирующий интерсептор и проследите порядок строк в логе при WithInterceptors(a, b) и (b, a) — выпишите в комментарий, почему порядок именно такой. Финальный штрих: определите, что из вашего текущего middleware-стека можно перевести в интерсепторы, а что обязано остаться middleware, и запишите критерии решения — этот конспект пригодится в проекте и в уроке про интеграцию с фреймворками.
Личные заметкиПусто
Вопросы и ответы по уроку
Как подключить interceptor к Connect-серверу?
Передайте его опцией в конструктор хендлера: NewNoteServiceHandler(¬esServer{}, connect.WithInterceptors(authInterceptor{}, logInterceptor())). Первый в списке — внешний слой цепочки. Для клиента та же опция работает у NewNoteServiceClient.
Чем интерсепторы Connect отличаются от middleware?
Middleware работает на уровне HTTP-запроса и только на сервере; интерсептор — на уровне RPC: знает процедуру через req.Spec(), работает и на сервере, и на клиенте, поддерживает стриминг. TLS, gzip и CORS — middleware; логирование процедур, авторизация и метрики вызовов — интерсепторы.
Один интерсептор обрабатывает и unary, и стриминг?
Да, но обязан реализовать все три метода интерфейса. UnaryInterceptorFunc заполняет только WrapUnary и пропускает стриминг как есть; для полноценной обработки потоков реализуйте WrapStreamingClient и WrapStreamingHandler — их разберём в уроке про стриминг.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Fyne: десктопные и мобильные GUI на Go
Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.
- Создавать окна и интерфейсы из виджетов
- Управлять компоновкой: Box, Grid, Form
- Рисовать примитивами canvas и строить свои виджеты
- Связывать данные через binding