Введение
Основной цикл статей про сеть строился вокруг JSON как формата обмена данными — гибкого, человекочитаемого, но не дающего никаких гарантий на уровне компилятора о структуре передаваемых данных: опечатка в имени поля или несовпадение типа обнаруживается только во время выполнения, а часто и вовсе остаётся незамеченной. gRPC и Protocol Buffers (Protobuf) — экосистема Google, решающая эту проблему через строгую типизацию API, описываемого один раз в отдельном файле и порождающего согласованный код и для клиента, и для сервера. В статье разберём, что это такое и какие конкретные проблемы решает такой подход.
Концепция
Protocol Buffers — бинарный формат сериализации данных, структура которых описывается в .proto-файле (статья 323) — компилятор protoc генерирует из этого описания готовый код на целевом языке (C++ в нашем случае), включающий классы сообщений с типобезопасными getter/setter методами. gRPC — фреймворк удалённого вызова процедур (RPC), построенный поверх HTTP/2 и использующий Protobuf для описания не только структур данных, но и самих сервисов — методов, которые можно вызвать удалённо, с указанием типов запроса и ответа. В отличие от REST/JSON, где контракт API обычно описывается отдельно (документация, OpenAPI-спецификация) и должен поддерживаться синхронно с реальным кодом вручную, в gRPC контракт (.proto-файл) — это и есть единственный источник истины, из которого механически порождается код обеих сторон.
Пример кода: Обзор gRPC и Protocol Buffers
// device_service.proto — пример контракта, к которому будем обращаться в последующих статьях
syntax = "proto3";
package telemetry;
message SensorReading {
string device_id = 1;
double temperature = 2;
double humidity = 3;
int64 timestamp_unix = 4;
}
message ReadingRequest {
string device_id = 1;
}
service TelemetryService {
rpc GetLatestReading(ReadingRequest) returns (SensorReading);
}
// Сравнение объёма кода для эквивалентной задачи без gRPC (вручную через JSON, как в основном цикле)
QJsonObject buildReadingJson(const QString &deviceId, double temperature, double humidity, qint64 timestamp)
{
QJsonObject obj;
obj["device_id"] = deviceId; // опечатка в имени поля не обнаружится компилятором
obj["temperature"] = temperature;
obj["humidity"] = humidity;
obj["timestamp_unix"] = timestamp;
return obj;
}
// С Protobuf эквивалентный код генерируется автоматически из .proto файла —
// разработчику не нужно писать ни эту функцию, ни код её разбора на другой стороне:
//
// telemetry::SensorReading reading;
// reading.set_device_id(deviceId.toStdString());
// reading.set_temperature(temperature);
// reading.set_humidity(humidity);
// reading.set_timestamp_unix(timestamp);
Пояснения к коду
device_service.proto декларативно описывает и структуру данных (SensorReading, ReadingRequest), и сам RPC-метод (GetLatestReading) — обе стороны (Qt-клиент и сервер, которые могут быть написаны на совершенно разных языках) компилируют один и тот же файл через protoc и получают согласованные, типобезопасные классы, без риска рассинхронизации, неизбежного при ручном поддержании JSON-структур с обеих сторон, как показано в сравнительном примере с QJsonObject.
Числа после имени каждого поля (= 1, = 2) — не значения по умолчанию, а уникальные номера полей, используемые в бинарном формате Protobuf для компактного кодирования без передачи самих имён полей по сети (в отличие от JSON, где имя ключа передаётся буквально при каждой сериализации) — это даёт Protobuf заметное преимущество по компактности передаваемых данных по сравнению с эквивалентным JSON, особенно для часто повторяющихся, небольших сообщений.
Подводные камни
- Восприятие gRPC/Protobuf как безусловно «лучшего» решения, которое должно полностью заменить REST/JSON во всех случаях — для публичных API, ориентированных на широкий круг разных клиентов (включая простые веб-страницы, обращающиеся через JavaScript
fetch()), JSON через REST остаётся значительно более универсальным выбором, поскольку gRPC изначально не предназначен для прямого вызова из браузера без дополнительного прокси-слоя (gRPC-Web) — сравнение подходов и осознанный выбор разбираются подробнее в статье 331. - Изменение номеров полей в уже выпущенном, используемом в продакшене
.proto-файле — поскольку именно номер поля (а не его имя) используется в бинарном представлении, изменение номера существующего поля делает старые сериализованные данные нечитаемыми новой версией кода (значение окажется привязано к другому, переименованному полю) — правила безопасного изменения.proto-контрактов с сохранением совместимости разбираются в статье 330. - Недооценка трудозатрат на настройку инструментов сборки (
protoc, gRPC-плагины, интеграция с CMake) по сравнению с использованием встроенного в QtQJsonDocument, не требующего вообще никакой дополнительной настройки — для небольшого проекта или быстрого прототипа эти первоначальные трудозатраты могут не окупиться, и переход на gRPC оправдан прежде всего для более крупных, развивающихся систем с несколькими сервисами и клиентами, как разбирается в статье 331. - gRPC построен на HTTP/2, и в средах, где HTTP/2 ограничен или плохо поддерживается (некоторые корпоративные прокси-серверы, устаревшая сетевая инфраструктура, ограничения некоторых мобильных операторов), соединение может работать менее надёжно, чем привычный HTTP/1.1 для REST — перед принятием решения о переходе на gRPC для проекта с заранее известными, специфичными сетевыми ограничениями стоит явно проверить совместимость с реальной сетевой инфраструктурой, в которой будет работать приложение.