Введение
Помимо потока от сервера к клиенту (статья 325), gRPC поддерживает обратный случай — client streaming (клиент отправляет серверу множество сообщений, получая один финальный ответ) и bidirectional streaming (оба участника отправляют сообщения друг другу независимо и одновременно). В статье разберём оба этих вида RPC на примере отправки данных от устройства и полнодуплексного канала команд.
Концепция
Client streaming в .proto-файле обозначается stream перед типом запроса — сервер получает сообщения через ServerReader, читая их в цикле, и отправляет единственный финальный ответ после того, как клиент завершит отправку (вызовом WritesDone() на стороне клиента). Bidirectional streaming, обозначаемый stream и для запроса, и для ответа одновременно, даёт обоим участникам полную свободу — отправлять и принимать сообщения в любом порядке и в любой момент, независимо друг от друга, что требует более тщательного проектирования протокола взаимодействия по сравнению с более простыми, однонаправленными видами RPC.
Пример кода: Client streaming и
// Дополнение к device_service.proto — client streaming и bidirectional streaming
message UploadSummary {
int32 readings_received = 1;
double average_temperature = 2;
}
message ControlMessage {
string command = 1;
}
message ControlAck {
string command = 1;
bool success = 2;
}
service TelemetryService {
// ... предыдущие методы ...
// Client streaming: клиент отправляет много показаний, получает одну итоговую сводку
rpc UploadReadings(stream SensorReading) returns (UploadSummary);
// Bidirectional streaming: команды и подтверждения идут в обе стороны независимо
rpc ControlChannel(stream ControlMessage) returns (stream ControlAck);
}
// server/telemetry_server.cpp — client streaming: приём множества показаний, одна итоговая сводка
grpc::Status UploadReadings(grpc::ServerContext *context,
grpc::ServerReader<telemetry::SensorReading> *reader,
telemetry::UploadSummary *summary) override
{
telemetry::SensorReading reading;
int count = 0;
double temperatureSum = 0.0;
// Read() в цикле — аналогично клиентскому чтению потока из статьи 325,
// но здесь именно СЕРВЕР читает поток, присылаемый клиентом
while (reader->Read(&reading)) {
count++;
temperatureSum += reading.temperature();
}
summary->set_readings_received(count);
summary->set_average_temperature(count > 0 ? temperatureSum / count : 0.0);
return grpc::Status::OK;
}
// server/telemetry_server.cpp — bidirectional streaming: обработка команд с подтверждениями
grpc::Status ControlChannel(grpc::ServerContext *context,
grpc::ServerReaderWriter<telemetry::ControlAck,
telemetry::ControlMessage> *stream) override
{
telemetry::ControlMessage message;
// Сервер читает входящие команды и сразу же, не дожидаясь завершения всего потока,
// отправляет подтверждение каждой — это и есть ключевое отличие от client streaming
while (stream->Read(&message)) {
telemetry::ControlAck ack;
ack.set_command(message.command());
ack.set_success(!message.command().empty());
stream->Write(ack);
}
return grpc::Status::OK;
}
// client/telemetryclient.h — client streaming: отправка нескольких показаний с клиента
telemetry::UploadSummary uploadReadingsBatch(const QVector<SensorReadingData> &readings)
{
telemetry::UploadSummary summary;
grpc::ClientContext context;
std::unique_ptr<grpc::ClientWriter<telemetry::SensorReading>> writer(
m_stub->UploadReadings(&context, &summary));
for (const auto &readingData : readings) {
telemetry::SensorReading reading;
reading.set_device_id(readingData.deviceId.toStdString());
reading.set_temperature(readingData.temperature);
if (!writer->Write(reading)) {
break; // сервер закрыл поток раньше — дальнейшая отправка бессмысленна
}
}
writer->WritesDone(); // явный сигнал серверу, что отправка завершена — без этого сервер
// продолжит ждать ещё сообщений, не переходя к формированию ответа
grpc::Status status = writer->Finish();
return summary;
}
Пояснения к коду
UploadReadings на сервере принимает ServerReader, а не ServerWriter (в отличие от статьи 325) — роли обратные: теперь сервер читает входящий поток от клиента, накапливая статистику (count, temperatureSum), и только после того, как reader->Read() вернёт false (клиент завершил отправку), заполняет единственный итоговый объект summary и возвращает управление. На клиентской стороне writer->WritesDone() — обязательный, легко забываемый вызов, явно сообщающий серверу, что больше сообщений не будет, и без него reader->Read() на стороне сервера продолжал бы блокироваться, ожидая гипотетических дальнейших сообщений, даже если клиент уже логически закончил отправку всех данных.
ControlChannel показывает наиболее общий, наиболее гибкий вид RPC — ServerReaderWriter, объединяющий возможности и чтения, и записи в одном объекте, что позволяет серверу немедленно отвечать на каждое полученное сообщение (отправляя ControlAck сразу после каждого ControlMessage), не дожидаясь, пока клиент закончит отправку всех своих сообщений — именно это полнодуплексное, не привязанное к строгому порядку «сначала все запросы, потом весь ответ» взаимодействие отличает bidirectional streaming от двух предыдущих, более ограниченных видов RPC.
Подводные камни
- Забытый вызов
writer->WritesDone()на клиентской стороне client streaming — без него серверныйreader->Read()будет неопределённо долго ждать продолжения потока, который клиент логически уже закончил отправлять, что выглядит как «зависшее» взаимодействие без явной ошибки с обеих сторон, пока не сработает тайм-аут (если он явно настроен, статья 328) или соединение не будет разорвано каким-либо внешним образом. - Проектирование bidirectional streaming без явного протокола соответствия запросов и ответов, если порядок сообщений важен, а сама природа bidirectional streaming не гарантирует, что i-й ответ соответствует именно i-му запросу (в общем случае сервер может обрабатывать и отвечать в любом порядке, особенно при параллельной, многопоточной обработке на стороне сервера) — для случаев, где важно точное сопоставление, протокол должен явно включать идентификаторы запроса в каждом сообщении (аналогично подводному камню о гонке heartbeat-сообщений в статье 289), позволяющие клиенту правильно сопоставить полученный ответ с исходным запросом.
- Использование client streaming или bidirectional streaming там, где было бы достаточно простого unary-вызова с массивом (например,
repeated SensorReadingкак одно сообщение в обычном unary RPC вместо потока отдельных сообщений) — потоковые RPC добавляют сложность реализации (явное управление чтением/записью, ошибками на каждом шаге) и оправданы прежде всего когда данных действительно много, они поступают постепенно во времени, или общий объём неизвестен заранее — для небольшого, заранее известного по объёму набора данных простой unary-вызов с повторяющимся полем часто проще в реализации и достаточен. - Отсутствие обработки частичных сбоев в середине длительного потокового взаимодействия (например, разрыв соединения после отправки половины показаний в
uploadReadingsBatch) — клиентский код должен явно решить, что делать в этом случае: повторить отправку всех данных с начала, попытаться продолжить с места разрыва (что требует дополнительного протокола отслеживания прогресса), или просто сообщить пользователю о неполной отправке — без явного решения этого вопроса частичный сбой может остаться незамеченным или обработанным непредсказуемым образом.