Содержание урока · 11 разделов
- Схема: ключевое слово stream в rpc
- Сервер: connect.ServerStream и цикл Send
- Клиент: ServerStreamForClient и io.EOF
- Bidi: connect.BidiStream и два полудуплекса
- Перехватчики стримов: долг седьмого урока
- Ошибки и таймауты в середине потока
- Четыре жанра вызовов Connect
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
В седьмом уроке мы обещали: у каждого полноценного 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
GOservice 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: шлём поток фраз
GOfunc (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
GOstream, 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-хендлер: эхо-диалог
GOfunc (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-перехватчик для стримов
GOfunc (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, сигнал конца потока.
Путь одного server-streaming вызова
Расставьте события вызова Introduce от вызова метода клиента до закрытия потока.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Прежде чем проектировать стриминговый метод, ответьте на два вопроса: как клиент поймёт «данных больше нет» (штатный nil у сервера и io.EOF у клиента — или проверочное сообщение в самом протоколе) и что клиент должен делать с частичными данными при ошибке. Стрим, у которого нет ответа на эти вопросы в документации схемы, превращается в источник спорных состояний — решайте семантику до кода, а не после первого инцидента.
Код уроков — настоящий Go с protobuf-кодогенерацией: сетевые вызовы браузерная песочница сайта исполнить не может, поэтому runner-блоков в курсе нет — для практики нужен локальный проект из второго урока. Интерактивы работают прямо на странице и дают XP, а циклы Receive/Send копируйте в свой сервис и пробуйте на живом сервере.
Карточки: стриминг Connect
Что дальше по курсу#
Стриминг встал в ряд: 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 отличается от 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 для всех вызовов.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Fyne: десктопные и мобильные GUI на Go
Курс Fyne: окна, виджеты, контейнеры, canvas, темы, binding, сборка под мобильные и десктоп. 25 уроков и итоговый менеджер задач.
- Создавать окна и интерфейсы из виджетов
- Управлять компоновкой: Box, Grid, Form
- Рисовать примитивами canvas и строить свои виджеты
- Связывать данные через binding