ARTICLE / GO BACKEND

Todos los artículos

Evolución de contratos gRPC sin publicar todos los servicios a la vez

Cómo cambiar esquemas protobuf y la semántica de métodos gRPC con releases independientes: campos compatibles, ventana de migración, pruebas de contrato y eliminación segura.

El texto completo está en ruso.

GogRPCArchitectureDistributed SystemsIntegrations

Поле добавили, код сгенерировался, проверка схемы зелёная. Новый клиент отправляет режим расчёта, старый сервер спокойно разбирает запрос, игнорирует незнакомое поле и выполняет прежний алгоритм. Ошибки транспорта нет. Ошибка в том, что клиент и сервер договорились о разных действиях.

Такие изменения опаснее явного отказа. Они проходят через обычную проверку доступности и обнаруживаются уже в заказах, бронированиях или сверке результатов. Независимый релиз начинается не с совместимого .proto, а с ответа: что произойдёт, когда две версии встретятся в production.

Контекст и ограничения

Рассмотрим внутренний gRPC API на Go и proto3. Сервисы выпускаются отдельно, обновление экземпляров не мгновенное, откат остаётся рабочим инструментом. Часть запросов может идти через JSON-шлюз. Это модель задачи, а не описание конкретной системы.

У совместимости несколько уровней. Двоичный формат определяет, можно ли разобрать сообщение. Сгенерированный API — соберётся ли клиент после обновления зависимости. Семантика — останутся ли прежними результат, ошибки и побочные эффекты. ProtoJSON добавляет отдельные правила: безопасность двоичного изменения на него автоматически не переносится.

В период выпуска одновременно важны старый клиент с новым сервером и новый клиент со старым. Последняя пара нужна не только при неудачном порядке развёртывания: она возвращается при откате. Если запросы или ответы сохраняются для отложенной обработки, окно совместимости длиннее жизни процесса.

Поэтому я не считаю версию общего Go-модуля с контрактами версией работающей системы. Она показывает, с чем собран компонент, но не доказывает, какие значения он отправляет и какую семантику уже включил.

Рабочая модель: расширить, перевести, сузить

Добавление нового намерения в запрос проходит несколько выпусков. В схеме ниже C0 и S0 — прежние клиент и сервер; S1 уже понимает новое намерение, но сохраняет старый путь.

flowchart LR
    A["C0 → S0<br/>Прежнее поведение"] --> B["C0 → S1<br/>Старый и новый путь"]
    B --> C["C1 → S1<br/>Новое намерение включено"]
    C --> D["Миграция завершена<br/>Окно отката закрыто"]
    D --> E["S2<br/>Старый путь удалён"]
    B -. "До включения C1" .-> A
    C -. "Откат клиента" .-> B

Сначала определить читателей и писателей

Для нового поля запроса читатель — сервер. Сначала все адресуемые серверы должны научиться его понимать; затем клиенты начинают передавать новое намерение. Для нового состояния в ответе роли меняются: сначала готовят клиентов, затем сервер начинает возвращать это состояние.

Выпуск кода и включение поведения полезно разделять. Новый клиент может уже работать, но ещё отправлять прежнюю форму запроса. Это позволяет проверить инфраструктуру отдельно от смены смысла. Флаг включения не создаёт совместимость сам: нужно знать, какие экземпляры он затронет и до какой версии их разрешено откатывать.

До включения новой записи откат часто прост. После него прежний сервер может не понимать намерение, а прежний клиент — уже сохранённые ответы. Откат кода не отменяет записанные данные и выполненные действия. В показанной миграции нельзя откатывать сервер ниже S1, пока клиенты отправляют новое намерение. Нижнюю допустимую версию отката фиксируют в плане изменения, а не выясняют во время инцидента.

Отсутствие поля должно иметь прежний смысл

Новое поле не становится безопасным от одного слова optional. Если обработчик сразу требует его бизнес-валидацией, все старые клиенты начнут получать отказ. Для совместимого расширения отсутствие должно сохранять прежнее поведение, а новое значение — включать явно описанную ветку.

У обычного скалярного поля proto3 отсутствие неотличимо от нуля, false или пустой строки. Когда эта разница важна, нужен явный учёт присутствия. Например, у нового optional enum pricing_policy отсутствие означает прежний расчёт, а переданное значение обязано быть поддержанным. В условной модели прежний расчёт и STANDARD допускают последнюю сохранённую цену; STRICT требует актуального ответа источника и возвращает отказ при его недоступности. В Go с Open Struct API это можно выразить так:

func resolvePricingPolicy(req *pb.CalculateRequest) (domain.PricingPolicy, error) {
    if req.PricingPolicy == nil {
        return domain.StandardPricingPolicy, nil
    }

    switch req.GetPricingPolicy() {
    case pb.PricingPolicy_PRICING_POLICY_STANDARD:
        return domain.StandardPricingPolicy, nil
    case pb.PricingPolicy_PRICING_POLICY_STRICT:
        return domain.StrictPricingPolicy, nil
    default:
        return 0, status.Error(codes.InvalidArgument, "unsupported pricing_policy")
    }
}

Здесь намеренно различаются отсутствие и явно переданный UNSPECIFIED. Неизвестное числовое значение enum тоже не превращается в старую политику. Но эта проверка защищает только новый сервер. Старый сервер неизвестное поле проигнорирует, поэтому порядок включения остаётся обязательным.

Не менять идентичность поля ради удобства

Номер уже выпущенного поля не меняют и не используют повторно. Новый тип или смысл лучше вводить под новым номером, с правилами приоритета на время сосуществования. После удаления резервируют номер и имя; для enum — число и имя значения. Резервирование предотвращает повторное использование в схеме, но не восстанавливает поддержку удалённого поля у JSON-парсера.

Некоторые смены типов совместимы лишь условно. При двоичном обмене старый читатель int32 сможет разобрать значение, записанное как int64, но выход за прежний диапазон приведёт к усечению. В ProtoJSON значение вне диапазона должно вызвать ошибку разбора. Возможность разобрать байты не оправдывает изменение диапазона. Если команда не контролирует всех читателей и сохранённые сообщения, новое поле обычно дешевле такой скрытой зависимости.

Нельзя также незаметно менять единицы измерения, полноту списка или смысл пустого ответа. Переход от полного результата к первой странице может не изменить ни одного типа, но старый клиент примет неполный ответ за полный. Это уже другой договор, даже если генератор не заметил разницы.

Проверять схему и поведение отдельно

В CI я ожидаю сравнение схемы с поддерживаемыми опубликованными версиями, а не только с соседним коммитом. Проверка вроде buf breaking ловит структурные нарушения. Её настраивают под реальные зависимости потребителей: двоичный формат, JSON и сгенерированный Go API. Если наружу распространяется пакет с типами или работает JSON-шлюз, одной проверки WIRE, защищающей лишь двоичный обмен, недостаточно.

Вторая проверка — контрактные тесты смешанных версий. Старый клиент вызывает новый сервер; новый клиент встречает старый сервер там, где такая пара разрешена. Сохранённые запросы и ответы проверяют отсутствие новых полей, нулевые значения, неизвестные enum и новые варианты oneof. JSON-шлюз включают в путь теста, если он есть в реальном обмене. Успешного разбора мало: тест утверждает бизнес-результат и код ошибки.

Собирать обе стороны из одной новой схемы недостаточно. Такой тест проверяет согласованность текущего изменения с самим собой. Нужны опубликованные клиентские артефакты или отдельные процессы со старыми схемами. Версии генератора и библиотек фиксируют, чтобы случайное обновление инструментария не смешалось с изменением контракта.

Типичные поломки и как их ловят

Новое значение enum стало успешным исходом

В proto3 enum открытые: старый Go-код может получить неизвестное числовое значение. Оно не обязано превратиться в UNSPECIFIED. Если ветка default возвращает успех или выбирает первую бизнес-политику, расширение схемы меняет поведение молча. Тест должен передать неизвестное значение и проверить явный отказ, сохранение неизвестного состояния или другую заранее выбранную реакцию.

Новый вариант oneof выглядит как отсутствие

Старый клиент не знает, что незнакомое поле относится к oneof. Он может увидеть незаданный вариант, хотя новый сервер передал вполне определённый результат. Нельзя автоматически трактовать такое состояние как «объект не найден» или «ограничений нет». Перенос уже независимых полей в существующий oneof ещё опаснее: значения начинают исключать друг друга. Это не косметическая перегруппировка схемы.

Посредник потерял неизвестное поле

Современный proto3 сохраняет неизвестные поля при двоичном разборе и повторной сериализации. Но преобразование в JSON или сборка нового сообщения по известным полям может их потерять. ProtoJSON по умолчанию отклоняет незнакомые поля, а имена полей и enum входят в данные. Проверка только крайних gRPC-сервисов не покрывает шлюз между ними.

Код ошибки изменил повторы

Замена одного кода ошибки другим может включить настроенную политику повторов. UNAVAILABLE не делает повтор операции безопасным, а DEADLINE_EXCEEDED не доказывает отсутствие эффекта: сервер мог уже выполнить действие. Это явно оговорено в описании кодов gRPC. Контракт изменяющего метода должен описывать ключ идемпотентности с правилами обработки повторов либо способ выяснить неизвестный исход перед повтором. Смена этих правил требует той же миграции, что и смена полей.

Старый путь удалили по списку развёрнутых версий

В реестре остались только новые сборки, но редкий клиент ещё использует старый метод, задача повторяет сохранённый запрос, а план отката возвращает старый бинарный файл. Перед удалением проверяют фактическое использование и весь срок допустимого возврата старых данных. Отсутствие вызовов за короткий промежуток не равно завершённой миграции.

Что измерять

У миграции должны быть собственные сигналы, иначе временный совместимый слой становится постоянным. Я отдельно измеряю использование старого и нового пути, долю запросов без нового поля, отказы по неизвестным значениям и вызовы устаревшего метода. Это показывает поведение, а не только версию сборки. Но сервер не видит, какие поля ответа читает клиент. Для их удаления нужны клиентские сигналы или подтверждённый перечень потребителей; отсутствие старых запросов этого не доказывает.

Коды gRPC, хвосты длительности и число повторов сравнивают по методу и поддерживаемой версии контракта. Идентификатор версии лучше ограничить небольшим набором семейств; хеш каждой сборки или идентификатор клиента в метках создаст лишнюю кардинальность. Если версия передаётся метаданными, её нельзя считать доказательством возможности: реальное поведение всё равно проверяется тестами и исходами запросов.

Критерий завершения задают заранее: кто должен перейти, какие старые сообщения могут вернуться, сколько длится окно отката и кто разрешает удалить прежнюю ветку. Универсального числа дней здесь нет. Для внешнего клиента срок задаётся соглашением о поддержке, для внутреннего — реальным циклом выпуска и восстановления.

Когда так делать не стоит

Если меняются атомарность операции, права доступа или сам бизнес-результат, новый RPC часто честнее набора флагов в старом. Новый метод или пакет версии создаёт явную границу, но не мигрирует клиентов автоматически. Старый сервер новый метод не реализует; порядок выпуска и срок совместной поддержки остаются задачей команды.

Не стоит бессрочно поддерживать обе семантики «на всякий случай». Каждая ветка увеличивает число проверяемых сочетаний и мешает дальнейшим изменениям. У временного слоя должны быть владелец и условие удаления. Обратная крайность — сложная система версий для компонентов, которые действительно выпускаются одним атомарным артефактом и не обмениваются сохранёнными сообщениями: там достаточно более простой границы модуля.

Вывод для архитектурного ревью

Совместимый gRPC-контракт — это схема плюс разрешённые сочетания версий и прежний смысл старых запросов. Зелёная проверка protobuf защищает только часть этого договора.

В ревью я уношу три правила. При совместимом расширении отсутствие нового поля сохраняет старое поведение. План выпуска определяет читателей, писателей и нижнюю границу отката. Старый путь удаляется по наблюдаемому завершению миграции; если удаляют поля или значения enum, их номера и имена резервируют. Тогда независимость релизов становится проверяемым свойством, а не надеждой на удачный порядок обновления.