7. Backward compatibility

В мире распределенных систем существует негласный закон, сформулированный инженером Google Хайрамом Райтом (Hyrum's Law):

«При достаточном количестве пользователей API не имеет значения, что вы обещали в контракте: любое наблюдаемое поведение вашей системы станет зависимостью для кого-то».

Когда сотни микросервисов разрабатываются десятками независимых команд и выкатываются в продакшен десятки раз в день, обратная совместимость (Backward Compatibility) перестает быть просто признаком хорошего тона — она становится главным механизмом выживания архитектуры. Вы никогда не сможете в один момент времени переключить рубильник и обновить сервер вместе со всеми его клиентами.

Обратная совместимость гарантирует, что новый сервер безупречно понимает и обрабатывает запросы от старых клиентов. Если после релиза версии v2 сервиса мобильное приложение пользователя, не обновлявшееся в App Store три месяца, внезапно начинает получать HTTP 400 или падать с кодом 500 — вы нарушили контракт и сломали продакшен.

flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph Clients["Клиенты разных поколений"]
        OldClient["Старый клиент v1<br/>(Шлет исходный формат)"]
        NewClient["Новый клиент v2<br/>(Шлет расширенный формат)"]
    end

    subgraph Server["Сервер v2 (Backward Compatible)"]
        Handler["Транспортный уровень"]:::process
        ZeroHandler["Обработка Zero Values / Pointers"]:::process
        Logic["Бизнес-логика"]:::process
    end

    OldClient -->|"Запрос v1"| Handler
    NewClient -->|"Запрос v2"| Handler
    Handler --> ZeroHandler
    ZeroHandler --> Logic
  

1. Золотые правила Protobuf

Поскольку в современной микросервисной архитектуре на Go протокол gRPC служит стандартом де-факто для внутреннего общения, обеспечение обратной совместимости тесно связано с механикой кодирования Protocol Buffers.

Поля и Теги

В бинарном потоке Protobuf wire format никогда не передает строковые имена полей (такие как user_id или description). Вместо этого каждое поле упаковывается в виде комбинации числового тега и типа кодирования (Wire Type): (field_number << 3) | wire_type.

  • Правило 1: Никогда не изменяйте числовые номера тегов у существующих полей в .proto файлах. Перенос тега на другое поле приведет к фатальной порче данных.
  • Правило 2: Если вы выводите поле из эксплуатации, явно зарезервируйте его номер и строковое имя через ключевое слово reserved. Это гарантирует, что будущий разработчик случайно не переиспользует освободившийся тег для новых данных:

    PROTOBUF
    message UserProfile {
      reserved 4, 12 to 15;
      reserved "legacy_status", "old_address";
      string user_id = 1;
    }
    

Новые поля

Добавление нового поля в Protobuf — это абсолютно безопасная и обратно совместимая операция.

Старые клиенты, получая ответ от обновленного сервера с новыми полями, просто проигнорируют неизвестный тег при чтении бинарного потока. Новый же сервер, получая запрос от старого клиента, не обнаружит тега в потоке байтов и автоматически инициализирует поле каноническим Zero Value (по умолчанию 0 для int, "" для строк, false для булевых типов) или предоставит явную проверку наличия через предикат has (если поле помечено модификатором optional).


2. Ловушки JSON в Go

В отличие от строго типизированного Protobuf, работа с неструктурированным JSON в Go таит множество коварных ловушек.

Стандартный пакет encoding/json по умолчанию игнорирует неизвестные поля при анмаршалинге (json.Unmarshal) в структуру Go, что создает иллюзию автоматической совместимости. Однако настоящая опасность кроется в механике нулевых значений языка.


3. Паттерн "Expand and Contract" (Расширение и Сужение)

Если архитектурная задача требует провести ломающее изменение схемы данных (например, разделить поле ФИО на first_name и last_name или изменить тип данных), это категорически запрещено делать одномоментно. Применяется классический четырехэтапный танец: Expand and Contract (также известный как Parallel Run):

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    Phase1["1. Expand (Расширение)<br/>Добавить новые поля в схему.<br/>Сервер пишет в оба поля, читает из старого."]
    Phase2["2. Migrate (Миграция)<br/>Обновить всех клиентов.<br/>Клиенты переходят на новые поля."]
    Phase3["3. Sync (Синхронизация)<br/>Сервер переключается на чтение новых полей.<br/>Старое поле мониторится (трафик -> 0)."]
    Phase4["4. Contract (Сужение)<br/>Удалить старое поле из кода и контракта.<br/>Зарезервировать тег через reserved."]

    Phase1 --> Phase2
    Phase2 --> Phase3
    Phase3 --> Phase4
  
  1. Expand (Расширение): В контракт добавляются новые поля, сохраняя старые. Сервер обновляется так, чтобы при записи заполнять оба поля, поддерживая консистентность.
  2. Migrate (Миграция): Команды-потребители независимо обновляют свои сервисы и мобильные клиенты, переводя их на чтение и запись новых полей.
  3. Sync (Синхронизация): Сервер переключает основное чтение на новое поле. Метрики в Prometheus подтверждают, что старое поле больше никем не читается.
  4. Contract (Сужение): Устаревшие поля и адаптеры безопасно удаляются из кодовой базы, а номера тегов в схеме Protobuf блокируются ключевым словом reserved.

4. Mechanical Sympathy: Unknown Fields в Go

Когда Go-микросервис получает бинарное сообщение Protobuf, содержащее поля, о существовании которых он еще не знает (потому что сообщение пришло от более нового сервиса или клиента), эти байты не отбрасываются в мусорную корзину.

flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    Sender["Новый сервис (v2)<br/>Шлет поля [1, 2, 3]"]:::network -->|"Сеть"| Proxy["Промежуточный сервис (v1)<br/>Знает только поля [1, 2]"]
    Proxy -->|"Пересылка"| Receiver["Сервис назначения (v2)<br/>Знает поля [1, 2, 3]"]

    subgraph Unknown["Сохранение Unknown Fields"]
        Proxy -.->|"Поле 3 сохранено<br/>в скрытом буфере"| Proxy
    end
  

5. Валидация и обратная совместимость

Самый распространенный и незаметный способ случайно обрушить прод — это ужесточение валидации входных данных:

  • Версия v1: Поле description в запросе являлось необязательным. Тысячи клиентских устройств отправляли запросы без этого поля.
  • Версия v2: Новый разработчик добавил валидацию:
    if req.Description == "" { return status.Error(codes.InvalidArgument, "description required") }
  • Результат: Сервер начинает отвечать ошибками HTTP 400 / InvalidArgument, и бизнес-процессы пользователей моментально парализуются.

Инженерный совет: Любое новое правило валидации обязано проходить фазу мягкого запуска (soft-launch). Сервер не прерывает запрос ошибкой, а лишь инкрементирует счетчик в мониторинге и логирует предупреждение. Только когда графики покажут 0 срабатываний, проверку можно переводить в блокирующий режим.



Итог

  1. Теги в Protobuf неприкосновенны. Никогда не меняйте числовые идентификаторы полей.
  2. JSON коварен в рантайме Go. Используйте указатели на типы (*bool, *int), чтобы надежно различать дефолтный ноль и намеренное отсутствие поля.
  3. Ломайте систему плавно. Всегда применяйте паттерн «Expand and Contract».
  4. Соблюдайте закон Постела. Будьте снисходительны к входящим сообщениям и игнорируйте неизвестные атрибуты.

Мы подробно разобрали, как гарантировать стабильность протоколов при непрерывных обновлениях кода. Но в динамическом облачном кластере возникает другая фундаментальная сложность: сервисы постоянно перезапускаются, масштабируются, меняют IP-адреса и порты. Как клиентскому приложению мгновенно найти живой экземпляр нужного сервиса? Об этом пойдет речь в следующей статье: 8. Service discovery.