Введение
С настроенным процессом сборки (статья 322) разберём более подробно сам синтаксис .proto-файлов — доступные типы данных, вложенные структуры, перечисления, и опциональные/повторяющиеся поля, что необходимо для проектирования реалистичного, не игрушечного контракта API.
Концепция
Protobuf поддерживает набор скалярных типов (int32, int64, double, float, bool, string, bytes), вложенные сообщения (одно сообщение может содержать поле типа другого сообщения), enum для перечислений, и модификатор repeated для полей-массивов (аналог QVector/QList на стороне C++). Начиная с proto3 (синтаксис, используемый по умолчанию в современных проектах) все поля по умолчанию необязательны, и для явного указания «поле было установлено» (в отличие от «поле имеет значение по умолчанию своего типа») нужен либо модификатор optional, либо обёртка в отдельное вложенное сообщение.
Пример кода: Описание .proto файлов
// device_service.proto — расширенная версия с вложенными типами, enum и repeated полями
syntax = "proto3";
package telemetry;
enum DeviceStatus {
DEVICE_STATUS_UNKNOWN = 0; // proto3 требует, чтобы первое значение enum было равно 0
DEVICE_STATUS_ONLINE = 1;
DEVICE_STATUS_OFFLINE = 2;
DEVICE_STATUS_ERROR = 3;
}
message Location {
double latitude = 1;
double longitude = 2;
}
message SensorReading {
string device_id = 1;
double temperature = 2;
double humidity = 3;
int64 timestamp_unix = 4;
DeviceStatus status = 5;
Location location = 6; // вложенное сообщение
repeated string tags = 7; // повторяющееся поле — аналог списка строк
optional string firmware_version = 8; // явно опциональное поле, отличимое от пустой строки
}
message ReadingRequest {
string device_id = 1;
}
message DeviceListRequest {
repeated string device_ids = 1; // запрос сразу для нескольких устройств
}
message DeviceListResponse {
repeated SensorReading readings = 1;
}
service TelemetryService {
rpc GetLatestReading(ReadingRequest) returns (SensorReading);
rpc GetMultipleReadings(DeviceListRequest) returns (DeviceListResponse);
}
// Использование сгенерированных классов в C++ — типобезопасная работа со всеми описанными типами
#include "device_service.pb.h"
void demonstrateGeneratedTypes()
{
telemetry::SensorReading reading;
reading.set_device_id("esp32_livingroom_01");
reading.set_temperature(23.5);
reading.set_status(telemetry::DEVICE_STATUS_ONLINE);
// Вложенное сообщение — доступ через mutable_-метод для изменения
reading.mutable_location()->set_latitude(55.7558);
reading.mutable_location()->set_longitude(37.6173);
// Повторяющееся поле — методы add_ для добавления элементов
reading.add_tags("livingroom");
reading.add_tags("dht11");
// Опциональное поле — явная проверка наличия через has_
if (reading.has_firmware_version()) {
qDebug() << "Версия прошивки:" << QString::fromStdString(reading.firmware_version());
} else {
qDebug() << "Версия прошивки не указана явно";
}
reading.set_firmware_version("1.2.0");
}
Пояснения к коду
Перечисление DeviceStatus демонстрирует обязательное для proto3 правило — первое значение enum должно быть равно 0 и традиционно используется как значение «неизвестно/не задано» (DEVICE_STATUS_UNKNOWN), поскольку именно это значение принимает поле enum, если оно не было явно установлено отправителем сообщения — в отличие от C++ enum, где такого жёсткого требования нет. Вложенное сообщение Location показывает композицию — SensorReading содержит поле типа Location, и в сгенерированном C++ коде доступ к вложенному сообщению происходит через метод mutable_location(), возвращающий указатель на внутренний объект для изменения (или location() для одного только чтения, без возможности изменения).
Поле optional string firmware_version демонстрирует разницу между «поле не установлено» и «поле установлено в пустую строку» — без модификатора optional оба этих случая в proto3 были бы неотличимы (reading.firmware_version() вернула бы пустую строку в обоих случаях), но с optional появляется дополнительный сгенерированный метод has_firmware_version(), позволяющий явно проверить, было ли поле действительно установлено отправителем, что важно для логики, различающей «значение неизвестно» от «значение явно пустое».
Подводные камни
- Изменение значений (номеров) уже существующих элементов
enumв продакшен-контракте — аналогично номерам полей сообщений (статья 321, статья 330), номера значений enum также являются частью бинарного протокола, и их изменение или удаление в уже выпущенной версии контракта нарушает совместимость со старыми клиентами/серверами, продолжающими использовать предыдущую версию.proto-файла. - Путаница между скалярными числовыми типами разной размерности (
int32противint64,floatпротивdouble) — выбор недостаточно широкого типа для значения, которое в будущем может превысить его диапазон (например,int32для значения, способного со временем превысить ~2.1 миллиарда), требует последующего, потенциально несовместимого изменения типа поля — для значений, чей рост в будущем нельзя полностью исключить, стоит сразу рассмотреть более широкий тип, даже если текущие значения малы. - Излишнее использование
optionalдля каждого поля «на всякий случай», даже когда различие между «не задано» и «значение по умолчанию» реально не имеет смысла для конкретного поля (например,optional bool is_activeредко осмысленно отличимо от обычногоboolбез модификатора, если приложение и так трактует «не задано» как «false») —optionalсоздаёт дополнительные накладные расходы (отдельное битовое поле «присутствия» для каждого такого поля) и усложняет код проверкамиhas_*(), оправданными только когда разница между «неизвестно» и «явно установленное значение по умолчанию» действительно важна для бизнес-логики. - Отсутствие явного, продуманного именования и нумерации полей с учётом будущего расширения контракта — поскольку проще зарезервировать диапазон номеров полей заранее (через
reserved, разбираемое в статье 330) или хотя бы продумать логичный порядок добавления новых полей, чем позже разбираться с уже выпущенными, несовместимыми версиями контракта — продумывание контракта API на ранней стадии существенно облегчает последующую эволюцию системы.