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

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

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

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

Interceptors

Перехватчики Connect — единое место для сквозной логики RPC: логирование с процедурой и длительностью, проверка токенов, метрики. Разбираем connect interceptors от интерфейса Interceptor и хелпера UnaryInterceptorFunc до серверных и клиентских цепочек через WithInterceptors: как устроены интерсепторы connect, в каком порядке выполняются и чем они дополняют middleware net/http.

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

У нас есть сервис с честными ошибками и клиент, который умеет их разбирать. Но посмотрите на типичный хендлер из прошлого урока: проверки токена, логирование, метрика длительности — всё это скоро придётся копировать в каждый метод каждого сервиса, и знакомый всем 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(&notesServer{}, 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 — это ровно тот механизм, которым сгенерированный клиент вкладывает метаданные в каждый вызов.

Клиентский авторизационный интерсептор

GO
type 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(
        &notesServer{},
        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-интерсептор с контекстом

GO
type 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/httpInterceptor 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.

path, handler := notesv1connect.NewNoteServiceHandler( &notesServer{}, connect.(connect.UnaryInterceptorFunc(logUnary)), ) func logUnary(next connect.) connect.UnaryFunc { return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { log.Print(req.().Procedure) return next(ctx, req) } }

Путь вызова через цепочку перехватчиков

Расставьте по порядку события одного вызова GetNote при WithInterceptors(auth, log) на сервере.

Строки

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

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

Карточки: перехватчики Connect

Термин

Interceptor

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

Интерфейс из трёх методов: WrapUnary, WrapStreamingClient, WrapStreamingHandler. Один и тот же тип подключается и к серверу, и к клиенту.

1 / 6

Что дальше по курсу#

Сквозная логика заняла своё место: логирование с процедурами, авторизация через контекст, метрики — всё в перехватчиках, подключаемых одной опцией. Следующий урок — стриминг: 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, и запишите критерии решения — этот конспект пригодится в проекте и в уроке про интеграцию с фреймворками.

Личные заметкиПусто
Хранятся только в вашем браузере (Connect · Interceptors)

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

Как подключить interceptor к Connect-серверу?

Передайте его опцией в конструктор хендлера: NewNoteServiceHandler(&notesServer{}, connect.WithInterceptors(authInterceptor{}, logInterceptor())). Первый в списке — внешний слой цепочки. Для клиента та же опция работает у NewNoteServiceClient.

Чем интерсепторы Connect отличаются от middleware?

Middleware работает на уровне HTTP-запроса и только на сервере; интерсептор — на уровне RPC: знает процедуру через req.Spec(), работает и на сервере, и на клиенте, поддерживает стриминг. TLS, gzip и CORS — middleware; логирование процедур, авторизация и метрики вызовов — интерсепторы.

Один интерсептор обрабатывает и unary, и стриминг?

Да, но обязан реализовать все три метода интерфейса. UnaryInterceptorFunc заполняет только WrapUnary и пропускает стриминг как есть; для полноценной обработки потоков реализуйте WrapStreamingClient и WrapStreamingHandler — их разберём в уроке про стриминг.

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

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

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

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

Fyne25 уроков~13 чпрактика25 уроков уже доступны

Fyne: десктопные и мобильные GUI на Go

Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.

  • Создавать окна и интерфейсы из виджетов
  • Управлять компоновкой: Box, Grid, Form
  • Рисовать примитивами canvas и строить свои виджеты
  • Связывать данные через binding