Работа с сетью в Qt

Версионирование .proto API и обратная совместимость

Введение

Сервис, развивающийся во времени, неизбежно требует изменения контракта — добавление новых полей, методов, иногда удаление устаревших. В статье разберём правила, позволяющие изменять .proto-файлы без нарушения совместимости со старыми, уже развёрнутыми клиентами и серверами, продолжающими использовать предыдущую версию контракта — что особенно важно в распределённых системах (как итоговый IoT-проект ESP32/MQTT, статья 320), где обновление всех узлов одновременно часто физически невозможно.

Концепция

Protobuf спроектирован с учётом эволюции схемы — безопасные изменения включают добавление новых полей (старый код просто их игнорирует, не зная об их существовании) и добавление новых RPC-методов (не затрагивает существующие). Опасные изменения включают изменение номера или типа существующего поля, и удаление поля без его явного резервирования через reserved (что предотвращает случайное повторное использование того же номера для поля с другим смыслом в будущем, способное привести к неверной интерпретации данных, сериализованных старой версией схемы).

Пример кода: Версионирование .proto API

// Версия 1 контракта — изначальная схема
message SensorReading {
    string device_id = 1;
    double temperature = 2;
    double humidity = 3;
}
// Версия 2 — БЕЗОПАСНОЕ добавление нового поля с новым номером
message SensorReading {
    string device_id = 1;
    double temperature = 2;
    double humidity = 3;
    int64 timestamp_unix = 4; // новое поле — старые клиенты его просто не увидят и не сломаются
}
// Версия 3 — БЕЗОПАСНОЕ удаление поля с явным резервированием номера и имени
message SensorReading {
    string device_id = 1;
    double temperature = 2;
    // humidity больше не нужен — но номер 3 и имя резервируются,
    // чтобы НИКТО в будущем случайно не использовал их повторно для другого смысла
    reserved 3;
    reserved "humidity";
    int64 timestamp_unix = 4;
}
// ОПАСНОЕ изменение — НЕ делайте так в уже выпущенном контракте:
// смена типа существующего поля без изменения номера
message SensorReading {
    string device_id = 1;
    string temperature = 2; // было double — старые клиенты, ожидающие double,
                              // получат данные, которые не смогут корректно интерпретировать
    reserved 3;
    reserved "humidity";
    int64 timestamp_unix = 4;
}
// Практика версионирования через отдельные пакеты для несовместимых, "ломающих" изменений
syntax = "proto3";
package telemetry.v2; // явное версионирование пространства имён для крупных, несовместимых изменений

message SensorReading {
    string device_id = 1;
    double temperature_celsius = 2; // переименовано и явно уточнена единица измерения
    double humidity_percent = 3;
    int64 timestamp_unix = 4;
    string firmware_version = 5;
}

service TelemetryServiceV2 {
    rpc GetLatestReading(ReadingRequest) returns (SensorReading);
}

Пояснения к коду

Переход от версии 1 к версии 2 показывает безопасное добавление поля timestamp_unix с новым, ранее не использованным номером 4 — старые клиенты, скомпилированные против версии 1 схемы, продолжают работать корректно, просто не зная о существовании нового поля (оно будет проигнорировано при разборе сообщения этими старыми клиентами, без ошибки). Переход к версии 3 показывает корректное удаление поля — вместо того чтобы просто убрать строку double humidity = 3 (что освободило бы номер 3 для потенциального переиспользования в будущем с другим типом, способного привести к неверной интерпретации старых, ещё не обновлённых данных), номер и имя явно резервируются через reserved, что сам компилятор protoc проверяет, выдавая ошибку при попытке повторно использовать зарезервированный номер или имя где-либо в будущем.

Явно отмеченный как опасный пример (смена типа поля temperature с double на string без изменения номера) показывает критичную ошибку — несмотря на то, что номер поля (2) остался прежним, изменение его типа делает старые и новые версии схемы несовместимыми на уровне бинарного представления: старый клиент, ожидающий получить число, получит данные, интерпретируемые им как число, хотя на самом деле сервер отправил совершенно другую, текстовую структуру данных. Последний пример показывает более радикальный подход к версионированию через отдельный пакет (telemetry.v2) — оправданный, когда изменения настолько существенны (переименование полей, изменение семантики), что попытка сохранить совместимость с предыдущей версией в рамках одной и той же схемы была бы практически невозможна или создавала бы слишком неудобный, нагруженный историческими компромиссами контракт.

Подводные камни

  • Отсутствие процесса code review, специально проверяющего изменения .proto-файлов на совместимость, прежде чем они попадают в продакшен — поскольку нарушение совместимости часто не вызывает немедленной, явной ошибки компиляции (сам код скомпилируется без проблем), а проявляется только во время выполнения как неверно интерпретированные данные, команды, работающие с эволюционирующим gRPC API, часто вводят отдельный, формализованный процесс проверки именно совместимости изменений схемы перед слиянием изменений, а не полагаются только на обычное код-ревью, нацеленное прежде всего на логику кода.
  • Удаление RPC-метода из сервиса (а не только поля сообщения) без аналогичного резервирования или явного, заранее анонсированного периода устаревания (deprecation) — старые клиенты, продолжающие вызывать удалённый метод, получат ошибку (метод просто не будет существовать на сервере), и для управляемого, не катастрофического перехода стоит сначала помечать методы как устаревшие (через комментарии и документацию, явно сообщая клиентам о планируемом удалении), и только после достаточного периода реально удалять их из контракта.
  • Слишком частая, недостаточно продуманная мажорная версионная миграция (создание telemetry.v3, telemetry.v4 при каждом сколько-нибудь значимом изменении) — каждая новая версия пакета требует поддержки соответствующего серверного кода, реализующего сразу несколько версий сервиса одновременно (пока все клиенты не перейдут на новую версию), что увеличивает сложность поддержки сервера пропорционально количеству одновременно поддерживаемых версий — мажорное версионирование должно быть зарезервировано действительно для существенных, не совместимых иначе изменений, а не использоваться при каждом небольшом расширении контракта, для которого достаточно безопасного добавления полей в рамках существующей версии.
  • Развёртывание новой версии серверного кода раньше, чем все клиенты обновлены до версии, способной работать с новым контрактом, в распределённой системе с множеством независимо обновляемых узлов (аналогично проблеме рассинхронизации версий протокола из статьи 282 Arduino-цикла) — для систем со множеством независимых клиентов (особенно физических устройств, которые невозможно обновить мгновенно и одновременно, как ESP32-узлы из статьи 320) сервер обычно должен поддерживать обратную совместимость с предыдущей версией контракта в течение длительного переходного периода, пока все клиенты постепенно не обновятся, а не предполагать, что обновление сервера и всех клиентов происходит мгновенно и одновременно.