Содержание урока · 15 разделов
- grpc client streaming: много сообщений, один ответ
- Серверная сторона: Recv, SendAndClose и легальный io.EOF
- Агрегация: почему не слайс
- Клиентская сторона: Send, CloseAndRecv и никаких сюрпризов
- Дедлайн накрывает весь вызов
- Ошибки Recv: что приходит до half-close
- Кардинальность ответа: ровно одно сообщение
- Server streaming и client streaming: семь различий
- Когда client streaming — правильный выбор
- Send под капотом: flow control и кто здесь источник истины
- Чек-лист client streaming
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Седьмой урок закончился тем, что WatchOrders перестал быть заглушкой: сервер научился слать поток событий, а клиент — читать его циклом Recv до io.EOF. Сегодня разворачиваем трубы в обратную сторону. Ситуация житейская: касса на складе работает офлайн, копит события заказов — создан, оплачен, отгружен — и к вечеру на накопителе лежат сотни записей. Отправить их одним unary-вызовом из урока про unary можно ровно до тех пор, пока батч влезает в одно сообщение; упрётесь в потолок размера — и ResourceExhausted. Открывать по вызову на каждое событие — жечь заголовки HTTP/2 впустую. Выход — третий из четырёх типов вызовов: клиент шлёт поток сообщений, сервер читает их по одному и в самом конце отвечает один раз — сводкой. В таблице типов из шестого урока эта строка стояла с пометкой «нет в схеме» и гипотетическим примером UploadReceipt — сегодня строка перестанет быть гипотетической: добавим в OrderService метод UploadOrderEvents и напишем обе его стороны. По дороге разберём то, на чём спотыкаются почти все: что на самом деле вернёт Send, если сервер отказал, и что увидит клиент, если хендлер забудет про SendAndClose.
grpc client streaming: много сообщений, один ответ#
Client streaming — вторая стриминговая модель курса и точное зеркало server streaming: клиент отправляет последовательность сообщений — ноль, одно или сколько накопил, — а сервер в конце отвечает ровно одним сообщением. В .proto это слово stream слева от returns: rpc UploadOrderEvents (stream OrderEvent) returns (UploadSummary) читается как «поток OrderEvent на входе, один UploadSummary на выходе». Русскоязычный запрос «стрим клиент go» описывает ровно эту конструкцию: поток ведёт клиент, сервер слушает и отвечает финалом. Заметьте асимметрию кардинальности: со стороны запросов ограничений нет — пустой поток легален, тысяча сообщений легальна; со стороны ответа правило unary из шестого урока действует в полную силу — ровно одно сообщение, и кардинальность здесь проверяется жёстко. Модель прирождена для всех «выгрузок»: батчи метрик, пачки логов, загрузка файла чанками, массовое создание записей — в других кодовых базах тот же метод зовут BatchCreate или UploadMetrics, суть одна: много мелких сообщений, одна сводка. Схема обмена у обоих стриминговых уроков одна и та же — меняются только направления: там сервер говорил, здесь говорит клиент. Контекст вызова и статусные ошибки при этом не изменились вовсе: grpc-timeout едет в заголовках, итоговый статус — в trailers, и это не совпадение, а одна и та же wire-механика из шестого урока, повернутая другой стороной.
Контракт UploadOrderEvents: proto и сгенерированные подписи
GO// api/shop/v1/order.proto — расширяем контракт: четвёртый метод сервиса.
service OrderService {
// stream СЛЕВА от returns — клиентский стрим:
// на входе поток OrderEvent, на выходе одна сводка UploadSummary.
rpc UploadOrderEvents (stream OrderEvent) returns (UploadSummary);
}
message UploadSummary {
int64 accepted = 1; // сколько событий принято сервером
int64 orders_seen = 2; // сколько уникальных заказов затронули события
int64 items_total = 3; // суммарное количество позиций в принятых событиях
}
// Сгенерированный order_grpc.pb.go — обе стороны одного метода.
// Клиент: параметра-запроса НЕТ — вместо него поток; контекст остался.
UploadOrderEvents(ctx context.Context, opts ...grpc.CallOption) (
grpc.ClientStreamingClient[OrderEvent, UploadSummary], error)
// Сервер: ни запроса, ни ctx — стрим забрал всё.
UploadOrderEvents(grpc.ClientStreamingServer[OrderEvent, UploadSummary]) errorПервое, что бросается в глаза клиенту: метод не принимает сообщение-запрос — оно заменено потоком, контекст по-прежнему первым аргументом. Дженерик стал вдвое шире: ClientStreamingClient[OrderEvent, UploadSummary] параметризован и типом потока, и типом ответа — потому что здесь есть и то, и другое. У серверного хендлера параметров вообще два меньше, чем было в unary: ни req, ни ctx — всё живёт внутри стрима. В дескрипторе метода это строка ClientStreams: true без ServerStreams — та самая пара флагов, которая в шестом уроке объясняла кардинальность unary, а теперь объясняет и эту. Псевдоним OrderService_UploadOrderEventsServer = grpc.ClientStreamingServer[OrderEvent, UploadSummary] генератор оставляет ради обратной совместимости, как и в седьмом уроке.
Серверная сторона: Recv, SendAndClose и легальный io.EOF#
Серверный интерфейс grpc.ClientStreamingServer[OrderEvent, UploadSummary] из stream_interfaces.go — с grpc-go 1.64 он дженериковый, под старым именем OrderService_UploadOrderEventsServer теперь прячется псевдоним — несёт два собственных метода плюс встроенный ServerStream с привычными Context, SetHeader, SendHeader и SetTrailer. Recv() (OrderEvent, error) блокируется до очередного сообщения; документация описывает исходы так: «If io.EOF is returned, it indicates the client called CloseAndRecv on its ClientStreamingClient. Any other error indicates the stream was terminated unexpectedly, and the handler method should return, as the stream is no longer usable». Вот он, главный переворот седьмого урока: io.EOF на сервере здесь не антипаттерн, а штатный сигнал — клиент закрыл свою сторону, время отвечать. SendAndClose(UploadSummary) error отправляет единственное сообщение-ответ; доке дословно: «This method must be called once and only once after all request messages have been processed. Recv should not be called after calling SendAndClose». И общий контракт типа: «To terminate the RPC, call SendAndClose and return nil from the method handler or do not call SendAndClose and return an error from the status package». Переводя: успех — SendAndClose плюс возврат nil, отказ — возврат статусной ошибки, хоть до первого Recv, хоть посреди чтения.
Хендлер UploadOrderEvents: агрегация на лету
GO// internal/server/order.go — приём выгрузки накопленных событий.
func (s *OrderServiceServer) UploadOrderEvents(
stream pb.OrderService_UploadOrderEventsServer) error {
// Сводка копится по мере прихода: батч целиком в памяти не нужен.
summary := &pb.UploadSummary{}
seen := make(map[int64]struct{}) // заказы, которые уже видели
for {
ev, err := stream.Recv()
switch {
case err == io.EOF:
// Клиент вызвал CloseAndRecv: его сторона закрыта.
// Отвечаем одним сообщением — и только здесь.
return stream.SendAndClose(summary)
case err != nil:
// Стрим сломан (клиент ушёл, сеть оборвалась):
// статус уже записан библиотекой, возвращаем как есть.
return err
}
// Агрегация на лету: счётчики, уникальные заказы, сумма позиций.
summary.Accepted++
if _, ok := seen[ev.GetOrder().GetId()]; !ok {
seen[ev.GetOrder().GetId()] = struct{}{}
summary.OrdersSeen++
}
for _, it := range ev.GetOrder().GetItems() {
summary.ItemsTotal += int64(it.GetQuantity())
}
}
}Это точный родственник хендлера RecordRoute из официального примера route_guide — там точно так же крутятся счётчики точек, найденных feature и суммарной дистанции, а на io.EOF следует SendAndClose(&pb.RouteSummary{...}). Каркас: бесконечный for, внутри Recv, io.EOF проверяется первым — он не ошибка, а приглашение к финалу; прочая ошибка означает, что стрим непригоден, и хендлер просто возвращается. Геттеры GetOrder и GetQuantity — nil-безопасные помощники protobuf из урока про синтаксис: клиент мог прислать событие без вложенного заказа, и хендлер обязан пережить это без паники.
Агрегация: почему не слайс#
Учебная альтернатива — складывать все события в []*pb.OrderEvent, а после io.EOF обработать разом — выглядит невинно и ломается ровно там, ради чего client streaming придуман. Во-первых, память: хендлер держит весь батч целиком, а клиенты бывают быстрые — гигабайт чанков выест лимит задолго до конца выгрузки; агрегация на лету держит лишь сводку и карту виденных заказов, то есть O(уникальным заказам), а не O(событиям). Во-вторых, честность: если вызов умрёт на середине, слайс-версия потеряет всё — потоковая версия хотя бы уже записала в базу то, что успела, если вы пишете события по мере прихода. В-третьих, потолок из шестого урока касается каждого сообщения стрима отдельно, а не вызова: миллион сообщений по килобайту проходит спокойно там, где одно сообщение на миллион килобайт упрётся в maxReceiveMessageSize и умрёт с ResourceExhausted. Канон агрегации задаёт RouteSummary из route_guide: точка за точкой, счётчики растут, итог собирается из накопленного. Единственный честный повод копить слайс — обработке нужен весь батч целиком: сортировка, группировка, дедупликация с перекрёстными ссылками; тогда держите в голове, что размер батча — теперь ваш риск, а не ограничение транспорта.
Сервер отказал не дослушав. Что вернёт Send?
Прочитайте код и выберите, что он выведет:
// Сервер: хендлер отказал, не дочитав поток.
func (s *OrderServiceServer) UploadOrderEvents(
stream pb.OrderService_UploadOrderEventsServer) error {
if !s.quotaAllows(stream) {
return status.Error(codes.PermissionDenied, "квота выгрузки исчерпана")
}
// ...
}
// Клиент: в цикле шлёт события; транспорт уже видит закрытый стрим.
for _, ev := range events {
err := stream.Send(ev)
fmt.Println(err) // что напечатает?
}Клиентская сторона: Send, CloseAndRecv и никаких сюрпризов#
Клиентский интерфейс grpc.ClientStreamingClient[OrderEvent, UploadSummary] — два метода плюс унаследованные Header, Trailer и Context. Send(OrderEvent) error шлёт очередное сообщение; клиент может звать её сколько нужно, но из одной горутины — конкурентная отправка это территория девятого урока. CloseAndRecv() (UploadSummary, error) закрывает отправляющую сторону и дожидается ответа; под капотом GenericClientStream.CloseAndRecv — ровно два шага: CloseSend(), затем RecvMsg — никакой магии, просто удобная склейка. Документация требует: «This method must be called once and only once after sending all request messages» — один и только один раз, после всех сообщений. И заметьте финальную фразу: «Any error returned is implemented by the status package» — из CloseAndRecv io.EOF не выходит никогда, в отличие от Recv серверного стрима из седьмого урока: здесь статус вызова всегда упакован в статусную ошибку, и status.Code работает без оговорок. Типовой цикл выгрузки читается за секунды: открыть стрим вызовом с ctx, прогнать батч через Send, закрыть CloseAndRecv — и получить сводку либо статусную ошибку. Официальный пример runRecordRoute из route_guide живёт ровно по этому сценарию: context.WithTimeout на десять секунд, цикл Send по точкам, CloseAndRecv в конце.
Сквозной клиент: выгрузка батча с дедлайном
GO// cmd/client/main.go — выгрузка накопленных событий заказов.
func main() {
// Канал без сетевых операций — как в клиентском уроке.
conn, err := grpc.NewClient("localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("канал: %v", err)
}
defer conn.Close()
client := pb.NewOrderServiceClient(conn)
// Дедлайн накрывает ВЕСЬ вызов: каждую Send и ожидание сводки.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Параметра-запроса нет: контекст — единственный аргумент.
stream, err := client.UploadOrderEvents(ctx)
if err != nil {
log.Fatalf("UploadOrderEvents: %v", err)
}
for _, ev := range readLocalBatch() { // события, накопленные офлайн
// io.EOF из Send — стрим закрыт не нами: выходим из цикла,
// настоящую причину узнаем на закрытии.
if err := stream.Send(ev); err != nil {
break
}
}
// Один и только один раз: закрыть свою сторону и дождаться сводки.
summary, err := stream.CloseAndRecv()
if err != nil {
// Здесь всегда статусная ошибка: отказ сервера, дедлайн, сеть.
log.Fatalf("выгрузка: код %v: %v", status.Code(err), err)
}
fmt.Printf("принято %d событий, заказов: %d, позиций: %d\n",
summary.GetAccepted(), summary.GetOrdersSeen(), summary.GetItemsTotal())
}Симметрия с седьмым уроком на лицо: те же grpc.NewClient с insecure.NewCredentials, тот же context.WithTimeout с defer cancel(), та же дисциплина ошибок через status.Code. Две тонкости. Первая: break вместо log.Fatalf в цикле Send — сервер мог отказать, и правильный ход не падать с io.EOF, а дойти до CloseAndRecv и забрать настоящий статус. Вторая: CloseAndRecv вызывается ровно один раз и вне зависимости от того, дошли ли все Send — даже после пустого цикла он закроет стрим и вернёт сводку или статус.
Дедлайн накрывает весь вызов#
В unary дедлайн — это время одного обмена. В client streaming под одним ctx лежит всё: каждая Send, каждый Recv на сервере, обработка и финальное ожидание сводки в CloseAndRecv. Заголовок grpc-timeout из шестого урока едет при открытии вызова, и сервер видит его в stream.Context() — того же самого, что был в седьмом: с дедлайном, отменой и метаданными. Истёк срок посреди выгрузки — и обе стороны узнают об этом статусной ошибкой DeadlineExceeded: у клиента её вернёт Send или CloseAndRecv, у сервера — очередной Recv; ветка stream.Context().Done() в хендлере, привычная по подписке WatchOrders, сработает как прежде. Отсюда простое правило проектирования: длительность дедлайна выбирается по размеру батча и скорости сети, а не по времени одного сообщения — выгрузка тысячи событий не должна делить секундный таймаут с unary-запросом. Для выгрузок, которые могут тянуться непредсказуемо, берут context.WithCancel и отменяют по пользовательскому решению, как для вечной подписки из седьмого урока. И предохранитель оттуда же остаётся в силе: вызов обязан уметь кончаться, иначе GracefulStop из серверного урока так и не вернётся — висящий клиентский стрим держит хендлер.
Ошибки Recv: что приходит до half-close#
Пока клиент шлёт сообщения, серверный Recv возвращает их без сюрпризов; интерес начинается, когда стрим умирает не по протоколу. Клиент отменил контекст или истёк его дедлайн — очередной Recv вернёт статусную ошибку Canceled или DeadlineExceeded; сеть оборвалась — Unavailable. Отдельный случай — io.ErrUnexpectedEOF: клиент оборвался посреди передачи сообщения, и библиотека честно превращает его в codes.Internal. Заметьте, чего среди этих ошибок нет: io.EOF зарезервирован за half-close — только явное CloseAndRecv клиента даёт его серверу, смешивать «клиент закончил» и «клиент умер» библиотека не станет. Есть и автоматика на стороне сервера: в serverStream.RecvMsg отложенный код при любой не-EOF ошибке сам пишет статус в транспорт — WriteStatus вызывается до того, как хендлер успеет вернуться, так что клиент в большинстве случаев узнает причину даже при молчаливом хендлере. Практический вывод для хендлера совпадает с седьмым уроком: ошибку Recv не глотаем и не пытаемся читать дальше — после сбоя стрим непригоден, документация прямо говорит «the handler method should return». Единственное различие с подпиской: здесь конец потока означается io.EOF из Recv, а не отменой хендлера.
Проверка: io.EOF на сервере
Хендлер client-streaming метода вызвал stream.Recv() и получил io.EOF. Что это значит?
Проверка: клиент умер посреди выгрузки
Клиент отменил контекст после пятисоти событий. Что вернёт следующий stream.Recv() на сервере?
Кардинальность ответа: ровно одно сообщение#
Со стороны запросов клиентский стрим свободен, но сторона ответа — под железным правилом unary: ServerStreams: false в дескрипторе метода означает ровно один ответ, и проверка из шестого урока теперь сторожит именно вас. Хендлер прочитал весь поток и вернул nil, забыв про SendAndClose? На клиенте RecvMsg получает конец данных при статусе OK, а затем та самая проверка срабатывает: код Internal с сообщением «cardinality violation: received no response message from non-server-streaming RPC» — клиент честно сообщает, что не-серверный стрим не прислал ни одного ответа. Второй случай зеркальный: посчитать SendAndClose дважды — второй вызов упрётся в уже отправленный half-close и вернёт хендлеру Internal; дважды одного ответа не бывает. Контраст со стороной запросов подчёркивает замысел: ClientStreams: true не включает никаких проверок — клиент, открывший стрим и немедленно закрывший его без единого сообщения, получит легальную пустую сводку, как получал пустой поток в седьмом уроке. То есть пустота слева — бизнес-решение, пустота справа — ошибка программиста. Запомните это разделение: оно же станет подсказкой при диагностике — Internal про кардинальность всегда указывает на хендлер, а не на сеть.
Забытый SendAndClose. Что получит клиент?
Прочитайте код и выберите, что он выведет:
// Хендлер агрегировал все события и вышел — без SendAndClose.
func (s *OrderServiceServer) UploadOrderEvents(
stream pb.OrderService_UploadOrderEventsServer) error {
for {
ev, err := stream.Recv()
if err == io.EOF {
return nil // упс: сводку так и не отправили
}
if err != nil {
return err
}
// ... агрегация ...
}
}
// Клиент: прогнал батч и ждёт ответа.
summary, err := stream.CloseAndRecv()CloseAndRecv — «once and only once», как требует документация. Send после него — codes.Internal с текстом «SendMsg called after CloseSend»: библиотека запомнила, что ваша сторона закрыта, и второй половины разговора не даст. На сервере то же зеркало: SendAndClose — один раз после всех Recv, Recv после SendAndClose запрещён документацией. Если нужен второй заход — это новый вызов, а не продолжение старого: итог вызова фиксируется один раз, статусом и одним сообщением.
Ранняя валидация и пустой батч
GO// Хендлер может отказать, не дочитав поток, — и обязан пережить пустой.
func (s *OrderServiceServer) UploadOrderEvents(
stream pb.OrderService_UploadOrderEventsServer) error {
// Первое сообщение решает судьбу батча: проверим его суть.
first, err := stream.Recv()
if err == io.EOF {
// Клиент сразу закрыл сторону: пустой батч легален,
// отвечаем нулевой сводкой.
return stream.SendAndClose(&pb.UploadSummary{})
}
if err != nil {
return err
}
if first.GetOrder().GetId() <= 0 {
// Отказ до чтения остальных: клиентский Send получит io.EOF,
// а правду он узнает в CloseAndRecv.
return status.Error(codes.InvalidArgument,
"событие без идентификатора заказа")
}
// ... дальше обычный цикл агрегации, начиная с first ...
return nil
}Две сценарные развилки в одном хендлере. Пустой батч: ClientStreams: true не проверяет, было ли хоть что-то принято, — Recv сразу вернёт io.EOF, и SendAndClose нулевой сводки корректен; решать, легален ли пустой батч для бизнеса, — вам, транспорт его пропустит. Ранний отказ: статусную ошибку можно вернуть в любой момент, хоть до первого Recv, хоть на десятом сообщении — недочитанный хвост клиента при этом никому не мешает: его Send вернёт io.EOF, CloseAndRecv — InvalidArgument. Именно так реализуются квоты и валидация первой записи.
Проверка: пустой батч
Клиент открыл стрим и немедленно вызвал CloseAndRecv, не послав ни одного события. Что произойдёт?
Server streaming и client streaming: семь различий#
| Вопрос | Server streaming (седьмой урок) | Client streaming (этот урок) |
|---|---|---|
| Схема | stream справа: returns (stream OrderEvent) | stream слева: (stream OrderEvent) returns (UploadSummary) |
| Направление | клиент → один запрос, сервер → поток | клиент → поток, сервер → один ответ |
| Хендлер | WatchOrders(req, stream) — есть req, нет ctx | UploadOrderEvents(stream) — нет ни req, ни ctx |
| Отправка | stream.Send(ev) на каждое событие | stream.Send(ev) на клиенте, SendAndClose(summary) финалом |
| io.EOF | клиентский сигнал конца; из хендлера — антипаттерн | штатный сигнал на сервере: клиент вызвал CloseAndRecv |
| Кардинальность | пустой поток ответов легален | ответ ровно один; пустой батч запросов легален |
| Ошибки | Send после отсоединения → codes.Canceled | Send при отказе сервера → io.EOF; правду ищи в CloseAndRecv |
Когда client streaming — правильный выбор#
Критерий тот же, что в шестом уроке: модель должна выражать то, что не выражается запросом-ответом. Client streaming берут, когда у клиента много данных и они появляются по мере работы, а серверу нужен один осмысленный итог: батчи метрик и логов, выгрузка файла чанками, массовая вставка записей, потоковые апдейты состояния. Не берут его там, где хватит unary: если событий три и они уже в памяти, заверните их в repeated-поле и не плодите стрим. Отдельно проговорите то, что часто недооценивают: между открытием вызова и сводкой промежуточных ответов не существует вообще — сервер молчит до самого конца. Если задача «посылать и слышать ответ по ходу» — это не client streaming, а территория девятого урока, где обе стороны говорят одновременно. По той же причине клиент не видит прогресса: пятьдесят процентов выгрузки неотличимы от нуля — если прогресс нужен, заводите отдельный unary-вызов или WatchOrders из седьмого урока рядом. И последнее: client streaming не транзакция. Отмена на середине — не rollback, а просто конец: сервер сохранил то, что успел принять, и сводки не будет; идемпотентность и повторная выгрузка — ваша забота на уровне домена.
Send под капотом: flow control и кто здесь источник истины#
Клиентский Send — тот же SendMsg из седьмого урока, но с другой стороны стекла, и его документация читается теми же тремя предложениями. Send блокируется, пока не появится достаточный flow control — место в HTTP/2-окне; быстрый клиент против медленного сервера упрётся в окно и притормозит сам, без всяких искусственных задержек. Send не ждёт приёма: «ушло в транспорт» не значит «сервер прочитал». Сообщение больше maxSendMessageSize вернёт codes.ResourceExhausted с текстом «trying to send message larger than max» — причём это клиентская ошибка, и она приезжает из Send напрямую статусом, без посредничества io.EOF: её «сгенерировал клиент». Из этих трёх предложений следует архитектурная точка урока: единственный источник истины о принятом — сводка сервера. Клиент посчитал тысячу Send, вернувших nil, — но nil лишь про транспорт; сколько событий реально попало в агрегацию, знает только UploadSummary. Поэтому сводка и существует: не для удобства, а потому что подтверждения доставки в стриме нет. Ну и про дисциплину: Send из двух горутин на одном стриме запрещён так же, как на сервере в седьмом уроке, — очередь отправки одна, и право голоса в ней принадлежит одной горутине.
Чек-лист client streaming#
- Схема: stream слева от returns — поток запросов, один ответ; UploadOrderEvents (stream OrderEvent) returns (UploadSummary).
- Сервер: Recv в цикле до io.EOF — он означает, что клиент вызвал CloseAndRecv; агрегация на лету, SendAndClose ровно один раз, затем return nil.
- Отказ сервера — статусная ошибка в любой момент, хоть до первого Recv; клиент узнает её в CloseAndRecv, а его Send получит io.EOF.
- Клиент: Send по одному сообщению, CloseAndRecv один и только один раз; после неё Send запрещён — codes.Internal «SendMsg called after CloseSend».
- io.EOF из CloseAndRecv не бывает: любая его ошибка статусная — ветвьтесь по status.Code.
- Дедлайн накрывает весь вызов: каждая Send, серверный Recv и ожидание сводки; выбирается по размеру батча.
- Забытый SendAndClose → клиент получит codes.Internal cardinality violation; пустой батч легален — сверьте с бизнес-требованиями.
- Источник истины — сводка сервера: Send не гарантирует доставки, отмена теряет хвост без отката.
Соберите клиентскую выгрузку
Восстановите клиентскую половину UploadOrderEvents: дедлайн, открытие стрима, цикл Send и получение сводки.
Жизненный цикл client-streaming вызова
Расставьте события выгрузки UploadOrderEvents — от открытия стрима до сводки у клиента.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Что дальше по курсу#
Одно честное замечание про формат: у самоучителя есть песочница с интерпретатором Go, но сеть в браузере недоступна — открыть gRPC-вызов оттуда невозможно, поэтому runner-ов в этом уроке и во всём gRPC-курсе нет. Всё разобранное проверяется локально за минуты: сервер из урока 04, клиентский каркас из пятого урока — и UploadOrderEvents оживёт: срежьте дедлайн до миллисекунды и посмотрите, что вернёт CloseAndRecv, а затем уберите SendAndClose и встретьте cardinality violation лично. Механику этого урока стоит сверить с серверным стримом седьмого урока: те же сообщения, встречные направления, тот же io.EOF — но на другой стороне. Дальше по маршруту: девятый урок — bidi, где обе стороны говорят одновременно и одна труба перестаёт быть монологом; он уже мелькал в седьмом уроке и ждёт своего часа. За ним — metadata и токены в уроке про метаданные, глубокие дедлайны — в уроке про дедлайны, а базовая wire-механика unary — grpc-timeout, trailers, статусные ошибки — остаётся фундаментом всех четырёх моделей. Полный маршрут курса — на странице gRPC-Go.
Карточки: client streaming под микроскопом
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Client streaming — поток запросов, один ответ: stream слева от returns; в схеме заказов появился UploadOrderEvents (stream OrderEvent) returns (UploadSummary).
- Сервер: Recv в цикле, io.EOF — клиент вызвал CloseAndRecv (в этом уроке io.EOF на сервере легален), агрегация на лету, SendAndClose ровно один раз и return nil.
- Клиент: Send по одному сообщению, CloseAndRecv — один раз; Send при отказе сервера вернёт io.EOF, настоящий статус живёт в CloseAndRecv.
- Ответ ровно один: хендлер без SendAndClose отдаёт клиенту codes.Internal «cardinality violation: received no response message from non-server-streaming RPC».
- Пустой батч легален: ClientStreams: true не проверяет количество сообщений; решение о пустой сводке — за бизнес-логикой.
- Дедлайн накрывает весь вызов: grpc-timeout из unary, stream.Context() на сервере; отмена клиента видна серверу статусной ошибкой Recv, а не io.EOF.
- Send блокируется на flow control и не гарантирует доставки: источник истины — сводка сервера; конкурентный Send запрещён — это тема девятого урока.
Домашнее задание
Добавьте UploadOrderEvents в свою копию сервиса заказов по раскладке урока. Первый шаг: соберите хендлер с агрегацией на лету — счётчик принятых, карта уникальных заказов, сумма позиций — и прогоните батч из трёх событий заказа 42 (created, paid, shipped): клиент должен получить сводку с Accepted = 3. Второй: уберите SendAndClose и верните nil — убедитесь, что CloseAndRecv отдаёт codes.Internal с текстом про cardinality violation; верните SendAndClose, но добавьте второй — прочитайте на сервере Internal «SendMsg called after CloseSend». Третий: откажите в хендлере status.Error(codes.InvalidArgument) на первом сообщении и проверьте, что Send вернёт io.EOF, а CloseAndRecv — InvalidArgument; затем откройте стрим и сразу вызовите CloseAndRecv без единого Send — сервер должен увидеть io.EOF и вернуть нулевую сводку. Наконец, поставьте клиенту таймаут в одну секунду, а агрегации — искусственную паузу: обе стороны обязаны увидеть DeadlineExceeded, а хендлер — выйти по ошибке Recv, не по io.EOF.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем client streaming лучше unary с большим repeated-полем?
Unary упаковывает весь батч в одно сообщение и упирается в потолок размера (по умолчанию 4 МиБ) целиком. Стрим шлёт событие за событием: каждое сообщение маленькое, сервер начинает агрегировать сразу, память держит сводку, а не батч. Для трёх записей unary проще; для тысяч — стрим.
Зачем CloseAndRecv, если есть CloseSend и Recv по отдельности?
Это удобная склейка: GenericClientStream.CloseAndRecv вызывает CloseSend, затем RecvMsg — ровно те же два шага. Разницы в проводах нет, но пара отдельных вызовов провоцирует ошибки порядка, а после CloseSend Send уже запрещён. Пользуйтесь CloseAndRecv как единой точкой закрытия.
Серверу нужен весь батч целиком. Стрим ещё актуален?
Да, но решайте по памяти. Агрегация и потоковая запись в базу не требуют всего батча — держите только сводку. Если обработке нужен полный набор, копите слайс и помните: его размер — ваш риск; альтернатива — писать события по мере прихода и собирать итог из базы.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Connect: современный RPC от Buf
Курс Connect (Buf): протоколы, клиенты, ошибки, interceptors, браузерные клиенты, buf CLI и BSR. 25 уроков и итоговый проект API.
- Строить сервисы на connectrpc.com/connect
- Понимать три протокола и выбирать правильный
- Настраивать buf: lint, breaking changes, generate
- Писать interceptors и обрабатывать ошибки