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

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

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

Урок 8 из 25 ~22 мин +50 XP

Streaming в Connect

Стриминг в Connect — вызовы, где сообщений много: сервер шлёт поток ответов, клиент шлёт поток запросов или обе стороны говорят одновременно. Разбираем connect streaming от схемы до клиента: rpc со stream-типами, хендлеры с connect.ServerStream и connect.BidiStream, циклы Receive и Send с сигналом io.EOF, стриминговые перехватчики и то, как стримы connect живут на HTTP/1.1 и HTTP/2. Это второй жанр RPC после unary — и ключ к теме grpc streaming go, который вы уже знаете по курсу gRPC.

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

В седьмом уроке мы обещали: у каждого полноценного Interceptor есть методы WrapStreamingClient и WrapStreamingHandler, а до поры их можно оставлять pass-through. Пора платить по счетам — переходим к стримингу, второму жанру вызовов. Unary, которым мы занимались шесть уроков подряд, — это вопрос и ответ: один запрос, один ответ. Но половина реальных задач не укладывается в эту форму: серверу надо отдать ленту событий по мере поступления, клиенту — залить файл кусочками, обеим сторонам — вести диалог в реальном времени. Для этого в protobuf есть ключевое слово stream, а в Connect — свои типы для работы с потоками. Если вы прошли курс gRPC, вам знакома терминология: server streaming, client streaming, bidirectional streaming — connect streaming воспроизводит все три жанра, и тема grpc streaming go переносится сюда почти один в один, только API компактнее. В этом уроке мы расширим знакомый сервис Eliza из третьего урока двумя стриминговыми методами, разберём циклы Receive и Send, сигнал io.EOF, ошибку в середине потока и перехватчики для стримов. Вперёд.

Схема: ключевое слово stream в rpc#

Всё начинается в схеме — proto-first никто не отменял. В protobuf потоковые вызовы описываются тем же rpc, но перед типом появляется слово stream, и позиция этого слова определяет жанр: stream перед типом ответа — сервер шлёт поток (server streaming), перед типом запроса — клиент шлёт поток (client streaming), перед обоими — двунаправленный (bidi). Дополним eliza.proto из третьего урока:rpc Introduce — сервер-консультант представляется и шлёт несколько фраз-знакомств по одной;rpc Converse — диалог, где клиент и сервер обмениваются репликами в произвольном порядке. Генерация ничего нового не требует: buf generate из урока про установку соберёт те же два плагина, protoc-gen-connect-go просто добавит в интерфейс ElizaServiceHandler новые методы — со своими, стриминговыми сигнатурами. Заглушка UnimplementedElizaServiceHandler, о которой мы говорили в третьем уроке, ответит unimplemented на незанятые методы, пока вы не допишете реализацию, — код останется зелёным в любой момент эволюции схемы.

Два новых rpc в eliza.proto

GO
service ElizaService {
        // Unary: один запрос — один ответ (как раньше).
        rpc Say(SayRequest) returns (SayResponse);

        // Server streaming: клиент один запрос — сервер шлёт поток фраз.
        rpc Introduce(IntroduceRequest) returns (stream IntroduceResponse);

        // Bidi: обе стороны шлют потоки независимо друг от друга.
        rpc Converse(stream ConverseRequest) returns (stream ConverseResponse);
}

Три жанра в одном сервисе. Позиция слова stream — единственное отличие синтаксиса: returns (stream X) — серверный поток, (stream X) returns (Y) — клиентский, stream с обеих сторон — дуплекс. Сообщения IntroduceRequest/IntroduceResponse и ConverseRequest/ConverseResponse — обычные message-типы схемы: никаких спецтипов для стримов в protobuf нет, поток — это последовательность обычных сообщений. Тот же синтаксис вы видели в gRPC-мире — схема полностью переносима между экосистемами.

Сервер: connect.ServerStream и цикл Send#

Сгенерированный интерфейс выглядит так: для server streaming хендлер получает контекст, обычный конверт connect.Request с запросом и новый для вас тип — connect.ServerStream[Res], поток, в который хендлер отправляет ответы. Сигнатура: func Introduce(ctx context.Context, req connect.Request[elizav1.IntroduceRequest], stream connect.ServerStream[elizav1.IntroduceResponse]) error. Обратите внимание на возврат — просто error, без объекта ответа: ответов много, и каждый уходит в поток через метод Send, который принимает указатель на message-тип. Цикл сервера предельно честный: шлём сообщения, пока есть что шлёшь, проверяя по дороге контекст — если клиент отключился или истёк дедлайн, ctx.Err() вернёт отмену, и хендлер должен остановиться. Дедлайны здесь работают так же, как в unary: клиент задаёт их через контекст, о чём мы говорили в уроке про клиентов, — разница в том, что у стрима больше точек, где отмена перехватывается. Ошибка, возвращённая из хендлера в любой момент, закрывает поток: сообщения, ушедшие до неё, клиент получит, а код ошибки придёт после них — частичные результаты в стриминге законны и полезны, это одно из главных отличий жанра от unary.

Хендлер Introduce: шлём поток фраз

GO
func (s *elizaServer) Introduce(
        ctx context.Context,
        req *connect.Request[elizav1.IntroduceRequest],
        stream *connect.ServerStream[elizav1.IntroduceResponse],
) error {
        for _, sentence := range s.introSentences {
                // Клиент ушёл или дедлайн истёк — останавливаемся.
                if err := ctx.Err(); err != nil {
                        return err
                }
                res := &elizav1.IntroduceResponse{Sentence: sentence}
                if err := stream.Send(res); err != nil {
                        return err // поток закрыт — дальше слать нечем
                }
        }
        return nil // нормальное закрытие потока
}

Три точки выхода из цикла: ctx.Err() — клиент отключился или истёк дедлайн; stream.Send вернул ошибку — приёмник закрылся, дальше писать некуда; конец списка — return nil, штатное закрытие потока. Каждый Send уходит по сети отдельным сообщением внутри фрейминга, о котором мы говорили в четвёртом уроке, — клиент может начать читать первые ответы, пока сервер ещё шлёт последние: это и есть смысл жанра. Ни Sleep, ни имитации — в живом коде между Send будут запросы к базе или чтение из очереди.

Клиент: ServerStreamForClient и io.EOF#

Клиентская сторона зеркальна. Сгенерированный метод Introduce принимает конверт запроса и возвращает пару (connect.ServerStreamForClient[elizav1.IntroduceResponse], error) — та же идея типов-двойников, что и с UnaryFunc у перехватчиков: серверная ServerStream для записи, клиентская ServerStreamForClient для чтения. Ошибка из первого return — это ошибка старта вызова: маршрут не найден, авторизация не прошла, сервер отклонил запрос до начала потока. Дальше — цикл чтения, самая важная идиома этого урока: stream.Receive() возвращает следующее сообщение или ошибку, и конец потока выражается значением io.EOF. Это та же семантика, что у io.Reader из стандартной библиотеки: EOF — не провал, а нормальный сигнал «данные кончились». Ошибка реальная — это connect.Error, и здесь работает весь арсенал из урока про ошибки: connect.CodeOf, errors.As, разбор кодов. Итого цикл чтения всегда выглядит одинаково: Receive, проверка io.EOF — выход, проверка ошибки — обработка, иначе — работа с сообщением. Заучите эту форму: она встретится в каждом стриминговом клиенте вашего кода.

Клиентский цикл Receive

GO
stream, err := client.Introduce(ctx,
        connect.NewRequest(&elizav1.IntroduceRequest{}))
if err != nil { // ошибка старта вызова: до начала потока
        return err
}
for {
        res, err := stream.Receive()
        if errors.Is(err, io.EOF) {
                break // сервер закрыл поток: все сообщения прочитаны
        }
        if err != nil {
                return err // *connect.Error: код берём через connect.CodeOf
        }
        fmt.Println(res.Sentence)
}

Форма-эталон: Receive — io.EOF — ошибка — сообщение. errors.Is, а не ==, потому что ошибки могут оборачиваться. res — уже разыменованный message-тип IntroduceResponse: клиентская обёртка разворачивает его за вас. Пока цикл крутится, сервер может ещё работать — чтение и отправка идут независимо, и между Receive вы вправе делать что угодно: обновлять UI, копить статистику, пересылать сообщение дальше. После break все метаданные уже получены: заголовки доступны у потока до первого Receive, трейлеры — после конца.

Серверный хендлер Introduce отработал и вернул nil, отправив 4 сообщения. Что вернёт пятый по счёту stream.Receive() на клиенте?

Что напечатает клиентский цикл?

Прочитайте код и выберите, что он выведет:

// introSentences := []string{"Привет", "Я Элиза", "Как дела?"}
// Хендлер Introduce шлёт все три фразы и возвращает nil.
for {
        res, err := stream.Receive()
        if errors.Is(err, io.EOF) {
                break
        }
        if err != nil {
                log.Fatal(err)
        }
        fmt.Println(res.Sentence)
}
fmt.Println("done")

Bidi: connect.BidiStream и два полудуплекса#

Двунаправленный стриминг — жанр, где обе стороны шлют сообщения независимо: диалог, чат, живая синхронизация. Здесь у Connect один тип на сервере — connect.BidiStream[Req, Res] и один у клиента — connect.BidiStreamForClient[Req, Res]; хендлер получает только контекст и поток, без конверта запроса, потому что запросов много, и все они приходят через stream.Receive(). Как и в gRPC, bidi на уровне API — это два независимых полудуплекса: вы шлёте через Send и читаете через Receive, библиотека разруливает мультиплексирование. А если нужен жанр client streaming — клиент шлёт поток, сервер отвечает один раз? Отдельного типа в Connect нет, и это осознанное упрощение: client streaming выражается через тот же BidiStream, просто хендлер читает циклом, а отвечает одним Send. Клиенту в этом жанре доступен метод CloseRequest — закрыть свою половину, сообщив серверу «я закончил слать», — а CloseResponse закрывает приёмную. Двунаправленные потоки требуют HTTP/2: ограничение протокола, о котором мы говорили ещё в первом уроке; server streaming живёт и на HTTP/1.1. Внутри горутин следите за завершением: одна сторона читает, другая шлёт, и обе обязаны увидеть закрытие потока — иначе утечка горутин, тема, которую мы разбирали в gRPC-курсе на чате.

Bidi-хендлер: эхо-диалог

GO
func (s *elizaServer) Converse(
        ctx context.Context,
        stream *connect.BidiStream[elizav1.ConverseRequest, elizav1.ConverseResponse],
) error {
        for {
                req, err := stream.Receive() // читаем реплики клиента
                if errors.Is(err, io.EOF) {
                        return nil // клиент закрыл свою половину
                }
                if err != nil {
                        return err
                }
                answer := s.reply(req.Sentence)
                if err := stream.Send(&elizav1.ConverseResponse{Sentence: answer}); err != nil {
                        return err
                }
        }
}

Простейшая форма bidi — синхронное эхо: читаем реплику, отвечаем, повторяем. Классическим для жанра считается асинхронный вариант: одна горутина крутит цикл Receive, другая шлёт Send по мере готовности, обе выходят по закрытию потока. Клиентская сторона симметрична: stream := client.Converse(ctx) — без error, старт вызова ленивый и происходит при первой отправке; дальше stream.Send запросов, stream.Receive ответов и CloseRequest, когда реплики кончились.

Какой вызов требует HTTP/2, а на HTTP/1.1 не работает?

Перехватчики стримов: долг седьмого урока#

Теперь закрываем обещание из урока про перехватчики: как выглядит сквозная логика на стримах. Методы WrapStreamingClient и WrapStreamingHandler работают не с request-объектом, а с соединением: next получает StreamingClientFunc вида func(ctx, Spec) StreamingClientConn — и ваш перехватчик возвращает собственную обёртку над conn. У соединения есть всё то же, что было у AnyRequest: conn.Spec() с Procedure и StreamType, conn.RequestHeader() для чтения и записи заголовков, conn.Send и conn.Receive с обобщёнными данными сообщения, плюс CloseRequest и CloseResponse у клиента. Классический серверный сценарий — авторизация стрима: перехватчик читает Authorization из conn.RequestHeader() до запуска next и на провал возвращает connect.NewError с CodeUnauthenticated — как в unary. Клиентский — навешивание токена: обёртка выставляет заголовок на соединении один раз, и все сообщения стрима едут с ним. Заметьте одну тонкость: интерсептор оборачивает соединение целиком, а не каждое сообщение — «до/после» сработает один раз на вызов, а не на каждое Receive. Если нужна статистика по сообщениям, обёртка декорирует методы conn.Send и conn.Receive — счётчик сообщений пишется именно там.

Клиентский auth-перехватчик для стримов

GO
func (i *authInterceptor) WrapStreamingClient(
        next connect.StreamingClientFunc,
) connect.StreamingClientFunc {
        return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn {
                conn := next(ctx, spec)
                // Заголовок соединения — все сообщения стрима с ним.
                conn.RequestHeader().Set("Authorization", "Bearer "+i.token)
                return conn
        }
}

func (i *authInterceptor) WrapStreamingHandler(
        next connect.StreamingHandlerFunc,
) connect.StreamingHandlerFunc {
        return func(ctx context.Context, conn connect.StreamingHandlerConn) error {
                if conn.RequestHeader().Get("Authorization") == "" {
                        return connect.NewError(connect.CodeUnauthenticated,
                                errors.New("нет токена"))
                }
                return next(ctx, conn)
        }
}

Тот же authInterceptor из седьмого урока — теперь полноценный: три метода вместо одного pass-through. Клиентская обёртка ставит заголовок на соединение до первой отправки; серверная проверяет его до запуска next. Форма луковицы та же, что и в unary: до next — проверки, next — сам поток, после next — итоговая логика. connect.UnaryInterceptorFunc здесь не подходит — он заполняет только WrapUnary; для стримов пишем методы руками.

Один вызов, пять сообщений — сколько строк «до»/«после»?

Прочитайте код и выберите, что он выведет:

// Логирующий перехватчик печатает "до" до next и "после" после next.
// Обёрнут стриминговый вызов Introduce: сервер шлёт 5 сообщений.
// Сколько раз сработают строки лога за один вызов?

Ошибки и таймауты в середине потока#

Отдельный разговор — что происходит, когда всё пошло не так посреди потока. Сервер возвращает connect.NewError в любой момент: сообщения, ушедшие до ошибки, клиент уже получил, код придёт следом — и клиентский Receive вернёт *connect.Error с этим кодом вместо io.EOF. Это принципиально отличается от unary, где ошибка обнуляет весь вызов: в стриминге частичный успех — легитимный исход, и API следует проектировать так, чтобы клиент мог понять, сколько данных он получил. Коды работают те же, что и в unary: resource_exhausted на превышение лимитов, deadline_exceeded при истёкшем дедлайне, canceled при отмене контекста клиентом — отмена распространяется вниз по стеку, и сервер обязан это заметить через ctx.Err(). Таймаут ставится клиентом на весь вызов, как мы разбирали в пятом уроке: стрим длиной в час с дедлайном в минуту завершится deadline_exceeded, даже если сообщения шли исправно. И ещё одна дисциплина: проверяйте ошибку каждого Send — после первого сбоя поток мёртв, дальнейшие отправки бесполезны. Хендлер, игнорирующий ошибку Send и продолжающий цикл, напрасно тратит CPU и маскирует реальную проблему.

Четыре жанра вызовов Connect#

ЖанрКто что шлётСигнатура хендлераОграничение
Unaryодин запрос — один ответ(ctx, req) (Response, error)нет
Server streamingклиент один, сервер поток(ctx, req, stream *ServerStream[Res]) errorнет: работает и на HTTP/1.1
Client streamingклиент поток, сервер один ответ(ctx, stream *BidiStream[Req, Res]) errorотдельного типа нет: читает циклом
Bidiоба шлют потоки(ctx, stream *BidiStream[Req, Res]) errorтолько HTTP/2

Клиенту нужно залить большой файл на сервер кусочками и получить в конце один итоговый ответ. Какой жанр и тип?

Соберите клиентский цикл чтения стрима

Восстановите идиому чтения server streaming: старт вызова, цикл Receive, сигнал конца потока.

stream, err := client.__(ctx, connect.NewRequest(&elizav1.IntroduceRequest{})) if err != nil { return err } for { res, err := stream.__() if errors.Is(err, __.EOF) { break } if err != nil { return err } fmt.Println(res.Sentence) }

Путь одного server-streaming вызова

Расставьте события вызова Introduce от вызова метода клиента до закрытия потока.

Строки

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

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

Карточки: стриминг Connect

Термин

connect.ServerStream[T]

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

Серверный тип для server streaming: хендлер шлёт ответы через Send(*T) и завершает поток возвратом nil или ошибкой. Появляется третьим аргументом хендлера после connect.Request.

1 / 6

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

Стриминг встал в ряд: schema-side stream-типы, серверные Send и клиентские Receive, io.EOF, ошибки в середине потока, перехватчики соединений. Восьмой урок закрывает второй жанр RPC — и готовит сцену для следующего шага: браузер. Клиенты, которые мы писали до сих пор, живут в Go-процессах, а в девятом уроке выйдет в браузер TypeScript-клиент: connect-web, fetch-транспорт, CORS и асинхронные итераторы вместо циклов Receive — всё то, ради чего Connect и создавался. Для сравнения полезно держать под рукой, как те же жанры устроены в gRPC: server streaming мы разбирали в gRPC-уроке про серверные потоки, а дуплексный чат — в уроке про bidi — API другое, а дисциплина горутин и завершения потоков та же. Программа всех двадцати пяти уроков — на странице курса.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Стриминг описывается в схеме словом stream: перед типом ответа — server streaming, перед запросом — client streaming, с обеих сторон — bidi.
  • Server streaming: хендлер получает connect.ServerStream[Res] третьим аргументом и шлёт ответы циклом Send; штатное закрытие — return nil.
  • Клиент читает stream.Receive() в цикле: io.EOF — нормальный конец потока, *connect.Error — ошибка с кодом, иначе — сообщение.
  • Bidi — connect.BidiStream на сервере и BidiStreamForClient на клиенте: Send и Receive независимы, половины закрываются CloseRequest и CloseResponse.
  • Client streaming в Connect отдельного типа не имеет: используется BidiStream, где сервер отвечает одним Send.
  • Перехватчики стримов оборачивают соединение: conn.RequestHeader(), conn.Spec(); счётчик сообщений — декорированием conn.Send/conn.Receive.
  • Частичные результаты и ошибки в середине потока — норма жанра; bidi требует HTTP/2, остальные жанры живут и на HTTP/1.1.

Домашнее задание

Дополните eliza.proto из третьего урока двумя rpc: Introduce с server streaming и Converse с bidi, прогоните buf generate и убедитесь, что интерфейс ElizaServiceHandler получил новые методы, а код с заглушкой продолжает собираться. Реализуйте Introduce: хендлер шлёт пять фраз с проверкой ctx.Err() между отправками; напишите клиентский цикл Receive с errors.Is(err, io.EOF) и обработкой *connect.Error через connect.CodeOf. Затем соберите асинхронный Converse: горутина-читатель крутит Receive, горутина-писатель шлёт три реплики и зовёт CloseRequest, обе завершаются по закрытию потока — проверьте, что программа не зависает и не течёт горутинами (запустите под go test с -race и логом числа горутин). Уроните поток нарочно: верните из хендлера connect.NewError(CodeResourceExhausted, ...) после двух сообщений и убедитесь, что клиент получил оба сообщения и затем код resource_exhausted, а не io.EOF. Запишите в заметки, в какой момент клиент узнал об ошибке — это ваш конспект по частичным результатам стрима.

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

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

Чем стриминг в Connect отличается от gRPC streaming?

Жанры те же — server, client, bidirectional streaming, синтаксис схемы идентичен. Отличия в API: у Connect типизированные обёртки ServerStream и BidiStream с дженериками, клиентский вызов server streaming возвращает поток-объект, а конец потока сигнализируется io.EOF. Client streaming не имеет отдельного типа и выражается через BidiStream.

Как обработать ошибку в середине потока?

Сервер возвращает connect.NewError с кодом в любой момент: уже отправленные сообщения клиент получит, затем его Receive вернёт *connect.Error вместо io.EOF. На клиенте различайте io.EOF (штатный конец) и реальную ошибку через errors.Is; код доставайте connect.CodeOf.

Работает ли двунаправленный стриминг на HTTP/1.1?

Нет: bidi требует HTTP/2 — это ограничение протокола, а не библиотеки. Unary и server streaming в собственном протоколе Connect работают на HTTP/1.1; классический протокол gRPC требует HTTP/2 для всех вызовов.

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

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

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

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

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

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

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

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

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