У нас была система обработки фискальных транзакций. Работала годами. Клиенты — устройства по всей стране — использовали бинарный протокол на protobuf. Всё было хорошо, пока протокол не начал развиваться.

Первая версия была простой. Вторая добавила новые поля. Третья переименовала часть полей. Четвёртая расширила типы. Пятая превратила примитивы во вложенные message.

К моменту, когда я присоединился к проекту, код выглядел так:

public void processRequest(byte[] data, String version) {
    if (version.equals("v1")) {
        RequestV1 req = RequestV1.parseFrom(data);
        // 50 строк обработки
    } else if (version.equals("v2")) {
        RequestV2 req = RequestV2.parseFrom(data);
        // 60 строк обработки (часть скопирована из v1)
    } else if (version.equals("v3")) {
        RequestV3 req = RequestV3.parseFrom(data);
        // 70 строк обработки (часть скопирована из v2)
    } else if (version.equals("v4")) {
        RequestV4 req = RequestV4.parseFrom(data);
        // 80 строк обработки
    } else if (version.equals("v5")) {
        RequestV5 req = RequestV5.parseFrom(data);
        // 90 строк обработки
    }
}

Пять веток. Пять наборов почти одинакового кода с мелкими различиями. Каждое изменение бизнес-логики — это правки в пяти местах. Каждый баг потенциально существует в пяти вариантах.

И это только один метод. В проекте их были сотни.

Знакомо? Если вы работаете с protobuf в системе, которая живёт дольше пары лет — скорее всего, да. Особенно если клиенты не могут обновляться синхронно с сервером. Особенно если часть клиентов — это железо, которое обновить физически невозможно.

В этой статье я расскажу, как мы решили эту проблему — и почему в итоге написали свой Maven/Gradle плагин для генерации версионно-агностичного API.


Проблема глубже, чем кажется

Дело не в том, что кто-то плохо написал код. Дело в том, что protobuf не предлагает решения для версионирования API на уровне кода.

Давайте разберёмся, что protobuf делает хорошо и где заканчиваются его возможности.

Что protobuf делает отлично: wire compatibility

Protobuf блестяще решает проблему backward compatibility на уровне сериализации. Это его главная сила.

Добавляете новое поле в схему — старые клиенты его просто игнорируют. Удаляете поле — данные парсятся, просто без этого поля. Wire format остаётся совместимым.

Это работает потому, что protobuf использует числовые теги (field numbers) вместо имён полей. Парсер видит field #5 и либо знает, что с ним делать, либо пропускает. Имена полей — это только для людей и кодогенератора.

message Order {
    string order_id = 1;     // field #1
    int32 total_cents = 2;   // field #2
    string notes = 3;        // field #3 — добавили в v2
}

Если v1 клиент получит v2 данные с полем notes — он его проигнорирует. Если v2 клиент получит v1 данные — notes будет пустым. Никаких ошибок парсинга. Wire format совместим.

Где protobuf заканчивается: код

Но код, который работает с этими данными — совершенно не совместим.

Когда вы запускаете protoc, он генерирует Java-класс для каждой версии схемы. И эти классы — разные типы. OrderV1 и OrderV2 не связаны наследованием. У них нет общего интерфейса. Вы не можете написать:

public void processOrder(Order order) {  // Какой Order? OrderV1? OrderV2?
    // ...
}

Вам нужно писать:

public void processOrder(OrderV1 order) { ... }
public void processOrder(OrderV2 order) { ... }
// И так для каждой версии

Или сваливать всё в один метод с if-else по версии. Что мы и имели.

Типы меняются — и это больно

Самая болезненная проблема — изменение типов полей. Рассмотрим типичную эволюцию:

// v1: MVP, делаем просто
message Order {
    int32 total_cents = 1;  // Цена в центах, int32 хватит
}

// v2: оказалось, не хватает для крупных B2B заказов
message Order {
    int64 total_cents = 1;  // Расширили до int64
}

// v3: добавили мультивалютность
message Order {
    Money total = 1;  // Теперь это отдельный message
}

message Money {
    int64 amount_cents = 1;
    string currency = 2;
}

Каждый переход — это новый тип в Java:

  • OrderV1.getTotalCents() возвращает int

  • OrderV2.getTotalCents() возвращает long

  • OrderV3.getTotal() возвращает Money

Три разных сигнатуры. Три разных return type. Нельзя написать один generic метод, который работает со всеми — у них просто разный интерфейс.

И это не ошибка дизайна. Это естественная эволюция системы. Сначала не знаешь всех требований. Потом они появляются. Потом меняются.

Поля появляются и исчезают

Другая типичная ситуация — новые поля:

// v1
message Order {
    string order_id = 1;
    int32 status = 2;  // 0=PENDING, 1=COMPLETED, 2=CANCELLED
}

// v2: решили сделать status типизированным + добавили комментарии
message Order {
    string order_id = 1;
    OrderStatus status = 2;  // Теперь enum
    string notes = 3;        // Новое поле для комментариев
}

enum OrderStatus {
    ORDER_STATUS_PENDING = 0;
    ORDER_STATUS_COMPLETED = 1;
    ORDER_STATUS_CANCELLED = 2;
}

Поле notes существует только в v2. Enum OrderStatus тоже. Если ваш код использует эти конструкции, для v1 они должны как-то обрабатываться.

Возвращать null? Тогда весь код в проверках if (notes != null).

Возвращать дефолт? Тогда нельзя отличить "не указано" от "указана пустая строка".

Бросать исключение? Тогда нужна защита на уровне вызывающего кода.

И ни один из вариантов не решается на уровне типов. Компилятор не поможет.

Номера полей меняются (да, это бывает)

Это особенно неприятно. Google настоятельно рекомендует никогда не менять номера полей. Это правило номер один в protobuf best practices.

Но в реальных системах это случается. Особенно в legacy-системах, которые развивались без строгих guidelines.

// v4
message TicketRequest {
    ShiftInfo shift_info = 15;        // Информация о смене
    ParentTicket parent_ticket = 17;  // Родительский чек
}

// v5: shift_info больше не нужен, parent_ticket переехал
message TicketRequest {
    ParentTicket parent_ticket = 15;  // Был 17, стал 15
}

Почему так произошло? Кто-то решил "оптимизировать" схему, убрав неиспользуемое поле и сдвинув номера. Или это было сделано по ошибке. Или по каким-то легаси-причинам, которые уже никто не помнит.

Факт в том, что protobuf не знает, что parent_ticket#17 в v4 и parent_ticket#15 в v5 — это одно и то же поле. Для protobuf это два разных поля с одинаковым именем.

При парсинге v5 данных через v4 схему:

  • parent_ticket#15 будет проигнорирован (v4 не знает field #15)

  • parent_ticket#17 будет пустым (v5 не отправляет field #17)

Данные потеряны. Молча. Без ошибок.


Существующие решения (и почему они не работают)

Мы не первые, кто столкнулся с этой проблемой. Существует несколько стандартных подходов. Мы попробовали все.

Подход 1: Конвертация "всё в последнюю версию"

Идея простая и красивая: при получении данных конвертируем их в последнюю версию и работаем только с ней.

public void processRequest(byte[] data, String version) {
    LatestRequest request = RequestConverter.toLatest(data, version);
    // Дальше работаем только с LatestRequest
    processRequest(request);
}

Один тип. Один код. Проблема решена?

Не совсем.

Проблема 1: Потеря информации о версии.

Иногда важно знать, какая версия пришла изначально. Для логирования, для диагностики, для отправки ответа в том же формате. После конвертации эта информация теряется (или нужно таскать её отдельно).

Проблема 2: Каскадные конвертеры.

При добавлении новой версии нужно написать конвертер v_new → v_latest. Но что если v_latest изменился? Нужно обновить все конвертеры, которые в него конвертируют.

А что если конвертация нетривиальная? Если в v3 поле total стало message Money, а в v1 это был int32 — как конвертировать? Какую валюту подставить? USD по умолчанию? А если система работает с евро?

Проблема 3: Nullable hell.

Если поле существует только в новых версиях, в конвертере его нужно как-то заполнить. Обычно — null. А потом весь код проверяет на null:

if (request.getNotes() != null) {
    // Работаем с notes
}

Это не type-safe. Компилятор не заставит вас проверить null. Рано или поздно кто-то забудет.

Подход 2: Общий интерфейс вручную

Идея: написать интерфейс для каждого message-типа и адаптеры для каждой версии.

public interface Order {
    String getOrderId();
    long getTotalCents();
    OrderStatus getStatus();
}

public class OrderV1Adapter implements Order {
    private final OrderV1Proto proto;

    public OrderV1Adapter(OrderV1Proto proto) {
        this.proto = proto;
    }

    @Override
    public String getOrderId() {
        return proto.getOrderId();
    }

    @Override
    public long getTotalCents() {
        return proto.getTotalCents();  // int → long автоматически
    }

    @Override
    public OrderStatus getStatus() {
        return OrderStatus.forNumber(proto.getStatus());  // int → enum
    }
}

public class OrderV2Adapter implements Order {
    private final OrderV2Proto proto;
    // ... аналогично
}

Это работает. Мы даже начали так делать. Написали ~50 адаптеров за неделю.

А потом посчитали.

Проблема 1: Тонны boilerplate.

На каждое поле — геттер. На каждый message — интерфейс и N адаптеров (по числу версий). На каждый вложенный message — рекурсивно то же самое.

У нас было 150+ message-типов и 5 версий протокола. Это 750+ классов-адаптеров. Плюс интерфейсы. Плюс enum'ы.

Проблема 2: Синхронизация.

Каждое изменение в proto требует ручного обновления интерфейсов и адаптеров. Добавили поле — обновить интерфейс, обновить все адаптеры. Изменили тип — то же самое.

Это ошибкоопасно. Забыли обновить один адаптер — получите runtime ошибку в production. Компилятор не поможет, потому что старый адаптер компилируется успешно (он просто не реализует новые методы интерфейса, но если интерфейс не менялся...).

Проблема 3: Конфликты типов не решаются.

Когда в v1 поле int32, а в v2 — enum, какой тип у интерфейса?

public interface Order {
    // Что здесь? int? OrderStatus? Оба?
    ??? getStatus();
}

Если int — теряем type safety для v2.
Если OrderStatus — нужна конвертация для v1, а что если значение не входит в enum?
Если оба метода — интерфейс раздувается, и всё равно нужно решать, какой метод вызывать.

А builder как должен выглядеть? Тоже два метода? Тоже конфликты?

Подход 3: Reflection и dynamic messages

Protobuf поддерживает dynamic messages — можно работать с данными без сгенерированных классов, через reflection-подобный API.

DynamicMessage message = DynamicMessage.parseFrom(descriptor, data);
Object value = message.getField(descriptor.findFieldByName("total_cents"));

Проблема: теряется type safety.

IDE не поможет найти ошибки. Опечатки в именах полей — runtime exception. Рефакторинг становится опасным.

Мы пишем на Java не просто так. Статическая типизация — это защита от целого класса ошибок. Reflection эту защиту снимает.

Почему все подходы провалились

Все три подхода имеют общую проблему: они решают симптомы, а не причину.

Причина в том, что protobuf генерирует несвязанные типы для разных версий. Все подходы пытаются обойти это ограничение вручную — конвертерами, адаптерами, reflection.

А что если не обходить, а решить? Что если сгенерировать правильный код сразу?


Proto Wrapper: генерируем версионно-агностичный код

После пары месяцев борьбы с ручными адаптерами родилась идея: а что если сгенерировать весь этот boilerplate автоматически?

Не просто адаптеры. Полноценный API с:

  • Интерфейсами для всех message-типов

  • Реализациями для каждой версии

  • Автоматическим разрешением конфликтов типов

  • Builder'ами для создания и модификации

  • Сериализацией обратно в protobuf

Идея не нова. Кодогенерация — стандартный подход в Java-мире. JPA генерирует entity из таблиц. OpenAPI генерирует клиенты из спецификации. Даже сам protobuf — это кодогенератор.

Мы просто добавили ещё один слой: генерируем wrapper'ы поверх protobuf-generated классов.

Так появился Proto Wrapper Plugin.

Как это работает

Плагин работает в два этапа:

Этап 1: Анализ схем

Плагин читает все версии proto-файлов и строит объединённую модель:

  • Какие message существуют в каждой версии

  • Какие поля в каждом message

  • Где есть конфликты типов

  • Какие поля version-specific

proto/v1/*.proto  ─┐
proto/v2/*.proto  ─┼──▶ [Analyzer] ──▶ MergedSchema
proto/v3/*.proto  ─┘

Этап 2: Генерация кода

На основе MergedSchema генерируется Java-код:

  • Интерфейс для каждого message (в api пакете)

  • Abstract class с общей логикой (в api.impl пакете)

  • Реализация для каждой версии (в v1, v2, ... пакетах)

  • VersionContext — фабрика для создания wrapper'ов

MergedSchema ──▶ [Generator] ──▶ Java Source Files

На выходе получается такая структура:

com.example.model/
├── api/
│   ├── Order.java              # Интерфейс — работает с любой версией
│   ├── OrderStatusEnum.java    # Унифицированный enum
│   ├── VersionContext.java     # Фабрика для создания wrapper'ов
│   ├── ProtocolVersions.java   # Константы версий (опционально)
│   └── impl/
│       └── AbstractOrder.java  # Базовый класс с Template Method
├── v1/
│   ├── OrderV1.java            # Реализация для v1
│   └── VersionContextV1.java   # Фабрика для v1
├── v2/
│   ├── OrderV2.java            # Реализация для v2
│   └── VersionContextV2.java
└── v3/
    ├── OrderV3.java
    └── VersionContextV3.java

Единый API в действии

Теперь бизнес-логика выглядит так:

public void processOrder(byte[] data, String versionId) {
    // Получаем контекст для нужной версии
    VersionContext ctx = VersionContext.forVersionId(versionId);

    // Парсим данные — получаем wrapper над protobuf-объектом
    Order order = Order.parseFromBytes(ctx, data);

    // Единый API для всех версий!
    String orderId = order.getOrderId();
    long total = order.getTotalCents();  // long для всех версий (автоматический widening)
    OrderStatusEnum status = order.getStatusEnum();  // enum (автоконверсия из int)

    // Version-specific поля с проверкой поддержки
    if (order.supportsNotes()) {
        String notes = order.getNotes();
        // Работаем с notes
    }

    // Можем сериализовать обратно
    byte[] response = createResponse(order).toBytes();
}

Один метод. Один код. Все версии.

Компилятор проверяет типы. IDE подсказывает методы. Рефакторинг работает.

VersionContext: фабрика для всего

VersionContext — это точка входа в версионно-специфичный код. Через него создаются все wrapper'ы:

// Получаем контекст для версии
VersionContext ctx = VersionContext.forVersionId("v2");

// Оборачиваем существующий protobuf-объект
Order order = ctx.wrapOrder(protoOrder);

// Парсим из байтов
Order parsed = ctx.parseOrderFromBytes(bytes);

// Создаём через builder
Order newOrder = ctx.newOrderBuilder()
    .setOrderId("ORD-001")
    .setTotalCents(15000L)
    .build();

// Или статически через интерфейс (рекомендуется)
Order order = Order.newBuilder(ctx)
    .setOrderId("ORD-001")
    .build();

VersionContext знает, какую версию wrapper'а создавать. Вы работаете с интерфейсом Order, а под капотом это wrapper OrderV2, который оборачивает protobuf-generated класс.

Статические методы на интерфейсах

Для удобства интерфейсы имеют статические методы:

// Вместо ctx.parseOrderFromBytes(bytes)
Order order = Order.parseFromBytes(ctx, bytes);

// Вместо ctx.newOrderBuilder()
Order.Builder builder = Order.newBuilder(ctx);

// Проверка поддержки версии
if (VersionContext.isSupported("v2")) {
    // ...
}

// Все поддерживаемые версии
List<String> versions = VersionContext.supportedVersions();  // ["v1", "v2", "v3"]

Это ближе к стандартному protobuf API (MyProto.parseFrom(bytes)), привычнее для разработчиков.


Разрешение конфликтов типов: магия под капотом

Самая интересная часть плагина — как он справляется с ситуациями, когда типы полей различаются между версиями.

Это не просто копирование значений. Это интеллектуальное разрешение конфликтов с генерацией правильного кода для каждого случая.

WIDENING: расширение типа (int32 → int64)

Ситуация: поле было int32, стало int64. Или float стало double.

Это самый простой конфликт. Значения младшего типа всегда можно безопасно представить в старшем типе.

// v1
message Order {
    int32 total_cents = 1;  // Максимум ~2 млрд центов (~20 млн долларов)
}

// v2
message Order {
    int64 total_cents = 1;  // Теперь можно больше
}

Что генерирует плагин:

Интерфейс использует расширенный тип:

public interface Order {
    long getTotalCents();  // long, даже для v1
}

Реализация для v1 выполняет widening автоматически:

class OrderV1 extends AbstractOrder {
    @Override
    protected long extractTotalCents() {
        return (long) proto.getTotalCents();  // int → long
    }
}

При чтении это полностью прозрачно. int всегда вмещается в long.

А при записи?

Вот тут интересно. Если вы записываете через Builder, плагин проверяет диапазон:

Order order = Order.newBuilder(v1Ctx)
    .setTotalCents(3_000_000_000L)  // 3 млрд — не влезает в int32
    .build();
// java.lang.IllegalArgumentException: Value 3000000000 exceeds int32 range for v1

Это защита от data corruption. Лучше получить понятное исключение на этапе сборки объекта, чем молча потерять данные при сериализации.

Для v2 контекста та же операция пройдёт успешно — int64 вмещает это значение.

INT_ENUM: когда int стал enum'ом

Ситуация: начинали с магических чисел, потом решили сделать нормальный enum.

Это классика легаси-систем. В первой версии было быстрее просто использовать числа. Потом стало понятно, что нужен enum для type safety и читаемости.

// v1: MVP, некогда думать об enum'ах
message Order {
    int32 status = 1;  // 0=PENDING, 1=COMPLETED, 2=CANCELLED
}

// v2: рефакторинг, добавляем типизацию
message Order {
    OrderStatus status = 1;
}

enum OrderStatus {
    ORDER_STATUS_PENDING = 0;
    ORDER_STATUS_COMPLETED = 1;
    ORDER_STATUS_CANCELLED = 2;
    ORDER_STATUS_REFUNDED = 3;  // Новый статус в v2
}

Проблема: в Java это два разных типа — int и OrderStatus. Какой тип использовать в интерфейсе?

Решение плагина: оба!

public interface Order {
    // Числовое значение — работает для всех версий
    int getStatus();

    // Enum — автоматическая конверсия
    OrderStatusEnum getStatusEnum();
}

Плагин генерирует унифицированный enum OrderStatusEnum, который содержит все значения из всех версий:

public enum OrderStatusEnum {
    ORDER_STATUS_PENDING(0),
    ORDER_STATUS_COMPLETED(1),
    ORDER_STATUS_CANCELLED(2),
    ORDER_STATUS_REFUNDED(3);  // Из v2

    private final int protoValue;

    public int getProtoValue() { return protoValue; }

    public static OrderStatusEnum fromProtoValue(int value) {
        // Маппинг числа в enum
    }
}

Как работает конверсия:

// Чтение v1 данных
Order v1Order = ctx.wrapOrder(proto);
int statusInt = v1Order.getStatus();             // 1
OrderStatusEnum status = v1Order.getStatusEnum(); // ORDER_STATUS_COMPLETED

// Чтение v2 данных — аналогично
Order v2Order = ctx.wrapOrder(proto);
int statusInt = v2Order.getStatus();             // 1 (из enum'а берётся число)
OrderStatusEnum status = v2Order.getStatusEnum(); // ORDER_STATUS_COMPLETED

Builder тоже поддерживает оба варианта:

Order order = Order.newBuilder(ctx)
    .setStatus(OrderStatusEnum.COMPLETED)  // Можно enum
    .build();

Order order = Order.newBuilder(ctx)
    .setStatus(1)  // Можно число
    .build();

А если приходит неизвестное значение?

Например, v2 клиент отправил REFUNDED(3), а мы парсим через v1 контекст. В v1 схеме такого значения не было.

При вызове getStatus() вернётся 3 — просто число.

При вызове getStatusEnum() плагин попытается сконвертировать. Если значение есть в enum — вернёт его. Если нет — вернёт null (или бросит исключение, в зависимости от настройки).

// Защитная версия с понятной ошибкой
OrderStatusEnum status = OrderStatusEnum.fromProtoValueOrThrow(value, versionId);
// IllegalArgumentException: Invalid value 999 for OrderStatus in v1.
// Valid values: [PENDING(0), COMPLETED(1), CANCELLED(2)]

PRIMITIVE_MESSAGE: когда примитив стал объектом

Ситуация: поле было простым числом, стало вложенным message.

Это самый сложный случай. Типы фундаментально несовместимы.

// v1: просто сумма
message Order {
    int64 total_cents = 1;
}

// v2: добавили мультивалютность
message Order {
    Money total = 1;
}

message Money {
    int64 amount_cents = 1;
    string currency = 2;
}

Проблема: int64 и Money — нельзя привести друг к другу. Нет "правильного" unified типа.

Решение плагина: параллельные геттеры.

public interface Order {
    // Примитивный геттер — работает для v1
    long getTotalCents();

    // Message геттер — работает для v2
    Money getTotalMessage();

    // Проверки поддержки
    boolean supportsTotalCents();    // true для v1
    boolean supportsTotalMessage();  // true для v2
}

Реализации возвращают осмысленные значения:

// OrderV1
protected long extractTotalCents() {
    return proto.getTotalCents();  // Прямое чтение
}
protected Money extractTotalMessage() {
    return null;  // v1 не поддерживает Money
}
protected boolean supportsTotalCents() { return true; }
protected boolean supportsTotalMessage() { return false; }

// OrderV2
protected long extractTotalCents() {
    return 0L;  // v2 не имеет примитивного поля
}
protected Money extractTotalMessage() {
    return new MoneyV2(proto.getTotal());  // Оборачиваем
}
protected boolean supportsTotalCents() { return false; }
protected boolean supportsTotalMessage() { return true; }

Использование:

Order order = ctx.wrapOrder(proto);

if (order.supportsTotalMessage()) {
    // v2+: работаем с Money
    Money total = order.getTotalMessage();
    long cents = total.getAmountCents();
    String currency = total.getCurrency();
} else {
    // v1: работаем с числом
    long cents = order.getTotalCents();
    String currency = "USD";  // Дефолтная валюта для legacy
}

Builder с runtime-валидацией:

// v1 — используем примитивный сеттер
Order v1Order = Order.newBuilder(v1Ctx)
    .setTotalCents(15000L)  // OK
    .build();

// v2 — используем message сеттер
Money money = Money.newBuilder(v2Ctx)
    .setAmountCents(15000L)
    .setCurrency("USD")
    .build();
Order v2Order = Order.newBuilder(v2Ctx)
    .setTotalMessage(money)  // OK
    .build();

// Ошибка: вызов неподдерживаемого сеттера
Order.newBuilder(v1Ctx)
    .setTotalMessage(money);  // UnsupportedOperationException!
// "v1 does not support message type for field 'total'. Use setTotalCents(long) instead."

Ошибка ловится сразу, с понятным сообщением. Не при сериализации, не в production — прямо здесь.

STRING_BYTES: когда текст стал бинарным

Ситуация: поле было string, стало bytes (или наоборот).

// v1: хранили текст
message Document {
    string content = 1;
}

// v2: решили хранить бинарные данные
message Document {
    bytes content = 1;
}

Решение плагина: оба представления.

public interface Document {
    String getContent();        // Текст (UTF-8 декодирование для bytes)
    byte[] getContentBytes();   // Байты (UTF-8 кодирование для string)
}

Конверсия автоматическая:

  • v1: getContent() возвращает string напрямую, getContentBytes() кодирует в UTF-8

  • v2: getContentBytes() возвращает bytes напрямую, getContent() декодирует из UTF-8

Это работает, пока данные действительно текстовые. Если в v2 записать бинарные данные, которые не являются валидным UTF-8, getContent() вернёт мусор или бросит исключение.

Repeated поля с конфликтами

Всё вышеперечисленное работает и для repeated полей:

// v1
message Data {
    repeated int32 values = 1;
}

// v2
message Data {
    repeated int64 values = 1;
}

Генерируется List<Long> с автоматическим расширением каждого элемента:

public interface Data {
    List<Long> getValues();  // List<Long> для обеих версий
}

Builder поддерживает все операции:

Data data = Data.newBuilder(ctx)
    .addValues(100L)
    .addValues(200L)
    .addAllValues(List.of(300L, 400L))
    .clearValues()
    .build();

При записи в v1 каждый элемент проверяется на диапазон:

Data data = Data.newBuilder(v1Ctx)
    .addValues(100L)              // OK
    .addValues(Long.MAX_VALUE)    // IllegalArgumentException!
    .build();
// "Value 9223372036854775807 at index 1 exceeds int32 range for v1"

Google Well-Known Types: когда protobuf встречает Java

Protobuf включает набор "well-known types" — стандартных message для распространённых случаев. Это Timestamp, Duration, StringValue и другие.

Проблема в том, что работать с ними неудобно:

// Создание Timestamp вручную
Timestamp timestamp = Timestamp.newBuilder()
    .setSeconds(instant.getEpochSecond())
    .setNanos(instant.getNano())
    .build();

// Чтение Timestamp
Instant instant = Instant.ofEpochSecond(
    timestamp.getSeconds(),
    timestamp.getNanos()
);

Это много boilerplate для такой простой операции, как "записать текущее время".

Proto Wrapper автоматически конвертирует Well-Known Types в идиоматичные Java-типы.

Timestamp и Duration

message Event {
    google.protobuf.Timestamp created_at = 1;
    google.protobuf.Duration timeout = 2;
}

Генерируется:

public interface Event {
    Instant getCreatedAt();     // java.time.Instant
    Duration getTimeout();       // java.time.Duration
}

Использование — как с обычными Java-объектами:

Event event = Event.newBuilder(ctx)
    .setCreatedAt(Instant.now())
    .setTimeout(Duration.ofMinutes(30))
    .build();

// Чтение
Instant created = event.getCreatedAt();
Duration timeout = event.getTimeout();

// Стандартные операции java.time
LocalDateTime local = LocalDateTime.ofInstant(created, ZoneId.systemDefault());
long minutes = timeout.toMinutes();

Никаких setSeconds().setNanos(). Работаем с нормальными типами.

Wrapper Types: nullable примитивы

В protobuf3 скалярные поля не могут быть null — у них всегда есть дефолтное значение (0, "", false). Это проблема, когда нужно отличить "не указано" от "указан ноль".

Wrapper types решают эту проблему:

message User {
    google.protobuf.StringValue nickname = 1;   // nullable string
    google.protobuf.Int32Value age = 2;         // nullable int
    google.protobuf.BoolValue verified = 3;     // nullable boolean
}

Генерируется:

public interface User {
    String getNickname();    // null если не установлено
    Integer getAge();        // null если не установлено
    Boolean getVerified();   // null если не установлено
}

Теперь можно различать состояния:

User user = ctx.wrapUser(proto);

if (user.getAge() == null) {
    System.out.println("Возраст не указан");
} else if (user.getAge() == 0) {
    System.out.println("Возраст указан как 0 (новорождённый?)");
} else {
    System.out.println("Возраст: " + user.getAge());
}

Struct: JSON в protobuf

Иногда нужно хранить произвольные структурированные данные — метаданные, конфигурации, динамические атрибуты.

google.protobuf.Struct — это по сути JSON, закодированный в protobuf:

message Response {
    google.protobuf.Struct metadata = 1;
}

Генерируется:

public interface Response {
    Map<String, Object> getMetadata();
}

Использование:

Response response = ctx.wrapResponse(proto);
Map<String, Object> meta = response.getMetadata();

// Значения могут быть разных типов
String type = (String) meta.get("type");
Double count = (Double) meta.get("count");      // Числа всегда Double
Boolean active = (Boolean) meta.get("active");
List<?> tags = (List<?>) meta.get("tags");
Map<?, ?> nested = (Map<?, ?>) meta.get("nested");

Плагин генерирует вспомогательный класс StructConverter для конвертации:

// Java Map → Protobuf Struct
Map<String, Object> data = Map.of(
    "status", "ok",
    "count", 42.0,
    "tags", List.of("a", "b", "c"),
    "nested", Map.of("key", "value")
);
Struct struct = StructConverter.toStruct(data);

// Protobuf Struct → Java Map
Map<String, Object> back = StructConverter.toMap(struct);

Полный список поддерживаемых типов

Protobuf Type

Java Type

google.protobuf.Timestamp

java.time.Instant

google.protobuf.Duration

java.time.Duration

google.protobuf.StringValue

String (nullable)

google.protobuf.Int32Value

Integer (nullable)

google.protobuf.Int64Value

Long (nullable)

google.protobuf.UInt32Value

Long (nullable, unsigned)

google.protobuf.UInt64Value

Long (nullable, unsigned)

google.protobuf.BoolValue

Boolean (nullable)

google.protobuf.FloatValue

Float (nullable)

google.protobuf.DoubleValue

Double (nullable)

google.protobuf.BytesValue

byte[] (nullable)

google.protobuf.FieldMask

List<String>

google.protobuf.Struct

Map<String, Object>

google.protobuf.Value

Object

google.protobuf.ListValue

List<Object>

Конверсия включена по умолчанию. Можно отключить через convertWellKnownTypes=false.


Oneof: union types в protobuf

Protobuf oneof — это способ сказать "только одно из этих полей может быть установлено".

Типичный пример — способ оплаты:

message Payment {
    string id = 1;
    int64 amount = 2;

    oneof method {
        CreditCard credit_card = 10;
        BankTransfer bank_transfer = 11;
        Crypto crypto = 12;
    }
}

message CreditCard {
    string card_number = 1;
    string expiry = 2;
    string holder_name = 3;
}

message BankTransfer {
    string account_number = 1;
    string bank_code = 2;
}

message Crypto {
    string wallet_address = 1;
    string currency = 2;  // BTC, ETH, etc.
}

Если установлен credit_card, то bank_transfer и crypto автоматически пусты. И наоборот.

Что генерирует плагин

public interface Payment {
    String getId();
    long getAmount();

    // Discriminator — какое поле установлено
    MethodCase getMethodCase();

    // Геттеры для каждого варианта
    CreditCard getCreditCard();
    BankTransfer getBankTransfer();
    Crypto getCrypto();

    // Проверки
    boolean hasCreditCard();
    boolean hasBankTransfer();
    boolean hasCrypto();
}

public enum MethodCase {
    CREDIT_CARD(10),
    BANK_TRANSFER(11),
    CRYPTO(12),
    METHOD_NOT_SET(0);

    private final int number;
    // ...
}

Использование с pattern matching

Payment payment = ctx.wrapPayment(proto);

switch (payment.getMethodCase()) {
    case CREDIT_CARD -> {
        CreditCard card = payment.getCreditCard();
        processCardPayment(card.getCardNumber(), card.getExpiry());
    }
    case BANK_TRANSFER -> {
        BankTransfer transfer = payment.getBankTransfer();
        processBankPayment(transfer.getAccountNumber(), transfer.getBankCode());
    }
    case CRYPTO -> {
        Crypto crypto = payment.getCrypto();
        processCryptoPayment(crypto.getWalletAddress(), crypto.getCurrency());
    }
    case METHOD_NOT_SET -> {
        throw new IllegalStateException("Payment method not specified");
    }
}

Builder автоматически очищает другие варианты

Payment.Builder builder = Payment.newBuilder(ctx)
    .setId("PAY-001")
    .setAmount(10000L)
    .setCreditCard(card);  // Устанавливаем credit_card

// Если теперь установить bank_transfer — credit_card автоматически очистится
builder.setBankTransfer(transfer);

Payment payment = builder.build();
assert payment.getMethodCase() == MethodCase.BANK_TRANSFER;
assert !payment.hasCreditCard();  // credit_card теперь пуст

Это стандартное поведение protobuf oneof. Плагин его сохраняет.

Oneof с различиями между версиями

Когда oneof группа эволюционирует — например, в v2 добавляется новый вариант crypto — плагин корректно это обрабатывает.

// v1 payment с credit_card
Payment v1Payment = v1Ctx.wrapPayment(proto);
assert v1Payment.getMethodCase() == MethodCase.CREDIT_CARD;

// Конвертируем в v2 — credit_card сохраняется
Payment v2Payment = v1Payment.asVersion(PaymentV2.class);
assert v2Payment.getMethodCase() == MethodCase.CREDIT_CARD;  // OK

// А теперь наоборот: v2 с crypto
Payment v2CryptoPayment = v2Ctx.wrapPayment(cryptoProto);
assert v2CryptoPayment.getMethodCase() == MethodCase.CRYPTO;

// Конвертируем в v1 — crypto не поддерживается в v1
Payment v1FromCrypto = v2CryptoPayment.asVersion(PaymentV1.class);
assert v1FromCrypto.getMethodCase() == MethodCase.METHOD_NOT_SET;  // Пусто!

Данные не теряются молча. Если поле не поддерживается в целевой версии — oneof становится пустым. Это можно проверить и обработать.

Обнаружение конфликтов

Плагин автоматически обнаруживает и логирует потенциальные проблемы с oneof:

[WARN] Oneof PARTIAL_EXISTENCE: 'Payment.method' exists only in versions: [v2, v3]
[WARN] Oneof FIELD_SET_DIFFERENCE: 'Payment.method' has different fields across versions:
       v1: [credit_card, bank_transfer]
       v2: [credit_card, bank_transfer, crypto]
[WARN] Oneof RENAMED: 'Payment.method' has different names:
       v1: payment_method
       v2: method

Эти предупреждения помогают понять, как oneof эволюционирует, и принять осознанное решение об обработке.


Schema Diff Tool: обнаружение breaking changes

Одна из частых проблем при работе со схемами — случайное внесение breaking changes.

Разработчик добавил поле, изменил тип, удалил что-то "ненужное" — и production сломался.

Proto Wrapper включает инструмент для сравнения версий схем и обнаружения изменений:

mvn proto-wrapper:diff -Dv1=proto/v1 -Dv2=proto/v2

Что выводит diff tool

=== Schema Diff: v1 → v2 ===

Messages:
  Order:
    ~ total_cents: int32 → int64 [WIDENING]
        Plugin: Generates long getter, validates range on write
    ~ status: int32 → OrderStatus [INT_ENUM]
        Plugin: Generates getStatus() and getStatusEnum()
    + notes: string (added)
        Plugin: Returns null for v1, generates supportsNotes()

Enums:
  + OrderStatus (added)
      Values: PENDING(0), COMPLETED(1), CANCELLED(2)

Summary:
  Messages: 0 added, 0 removed, 1 modified
  Fields: 1 added, 0 removed, 2 type changes
  Breaking changes: 0 errors, 0 warnings, 3 plugin-handled

Классификация изменений

Плагин классифицирует каждое изменение по серьёзности:

Severity

Meaning

Примеры

ERROR

Breaking change, плагин не обрабатывает

Удаление message, смена номера поля, несовместимые типы

WARNING

Потенциально breaking

Добавление required поля, изменение cardinality

INFO

Обрабатывается плагином

WIDENING, INT_ENUM, version-specific fields

CI/CD интеграция

Добавьте проверку в CI pipeline:

mvn proto-wrapper:diff \
    -Dv1=proto/production \
    -Dv2=proto/develop \
    -DfailOnBreaking=true

Exit code 1 при наличии breaking changes. Теперь случайные несовместимые изменения не попадут в production.

Обнаружение renumbered полей

Особая фича: плагин умеет обнаруживать поля, у которых изменился номер.

Помните проблему с parent_ticket#17parent_ticket#15? Diff tool найдёт это:

SUSPECTED RENUMBERED FIELDS
----------------------------
  TicketRequest.parent_ticket: #17 → #15 (HIGH confidence)
    Same name, same type, compatible semantics

    Suggested mapping:
      <fieldMapping>
          <message>TicketRequest</message>
          <fieldName>parent_ticket</fieldName>
          <versionNumbers>
              <v4>17</v4>
              <v5>15</v5>
          </versionNumbers>
      </fieldMapping>

Плагин даже генерирует конфигурацию, которую можно скопировать в pom.xml.

Механизм обнаружения:

  1. Strategy 1: Находит пары REMOVED + ADDED с одинаковым именем и совместимым типом

  2. Strategy 2 (displaced fields): Обнаруживает, когда поле переехало на место удалённого

После добавления маппинга запустите diff ещё раз — renumbered поле будет показано как [MAPPED] с severity INFO.


Incremental Build: экономим время

На больших проектах с сотнями proto-файлов и множеством версий полная генерация может занимать минуты.

Proto Wrapper поддерживает инкрементальную сборку — перегенерирует только то, что изменилось.

Как работает

<configuration>
    <incremental>true</incremental>  <!-- Включено по умолчанию -->
</configuration>

При первой сборке:

  1. Генерируются все файлы

  2. Сохраняются хеши всех proto-файлов

  3. Сохраняются зависимости (какие proto импортируют какие)

При повторной сборке:

  1. Сравниваются хеши proto-файлов с кешированными

  2. Если proto-файл изменился — перегенерируются wrapper'ы для него и всех зависящих

  3. Если ничего не изменилось — сборка пропускается

Результат: >50% экономии времени на типичных rebuild.

Что вызывает полную перегенерацию

Условие

Действие

Изменился proto-файл

Перегенерировать его и зависимые

Добавился proto-файл

Перегенерировать зависимые

Удалился proto-файл

Полная перегенерация

Изменилась версия плагина

Полная перегенерация

Изменилась конфигурация

Полная перегенерация

Cache повреждён

Полная перегенерация (автовосстановление)

Форсированная полная генерация

# Maven
mvn compile -Dproto-wrapper.force=true

# Gradle
./gradlew generateProtoWrapper -Pproto-wrapper.force=true

Thread-safe

Кеш защищён file lock'ами. Параллельные Maven/Gradle процессы (например, в multi-module проекте) не испортят состояние.


Spring Boot Starter: интеграция из коробки

Для Spring Boot приложений есть готовый starter с автоконфигурацией.

Подключение

<dependency>
    <groupId>io.alnovis</groupId>
    <artifactId>proto-wrapper-spring-boot-starter</artifactId>
    <version>2.3.1</version>
</dependency>

Конфигурация

# application.yml
proto-wrapper:
  default-version: v2                    # Версия по умолчанию
  version-header: X-Protocol-Version     # HTTP-заголовок с версией

Что даёт starter

  1. VersionContextProvider — бин для получения контекста

  2. VersionContextRequestFilter — автоматически извлекает версию из HTTP-заголовка

  3. RequestScopedVersionContext — request-scoped контекст (каждый запрос — своя версия)

  4. ProtoWrapperExceptionHandler — обработка ошибок (UnsupportedOperationException и т.д.)

Использование в контроллере

@RestController
public class OrderController {

    private final VersionContextProvider versionProvider;
    private final OrderService orderService;

    @PostMapping("/orders")
    public byte[] createOrder(@RequestBody byte[] data) {
        // Версия автоматически извлечена из X-Protocol-Version header
        VersionContext ctx = versionProvider.getVersionContext();

        // Парсим запрос
        Order order = Order.parseFromBytes(ctx, data);

        // Бизнес-логика
        Order processed = orderService.process(order);

        // Ответ в той же версии
        return processed.toBytes();
    }
}

Клиент отправляет X-Protocol-Version: v2 — и весь код автоматически работает с v2 контекстом.


Validation Annotations: интеграция с Bean Validation

Часто нужно валидировать входящие данные. Proto Wrapper может генерировать Bean Validation (JSR-380) аннотации автоматически.

Включение

<configuration>
    <generateValidationAnnotations>true</generateValidationAnnotations>
    <validationAnnotationStyle>jakarta</validationAnnotationStyle>  <!-- или javax для Java EE 8 -->
</configuration>

Что генерируется

public interface Order {
    @NotNull
    String getOrderId();  // Обязательное поле

    @NotNull
    @Valid
    List<OrderItem> getItems();  // Список не null + каскадная валидация элементов

    @Valid
    Customer getCustomer();  // Может быть null, но если есть — валидировать
}

Правила генерации @NotNull:

  • Repeated и map поля (всегда не null, пустой список/map вместо null)

  • Required поля во всех версиях (proto2)

  • Message-поля без optional, присутствующие во всех версиях

Правила генерации @Valid:

  • Message-type поля (каскадная валидация вложенных объектов)

  • Repeated message поля

  • Map с message values

НЕ генерируется для:

  • Примитивных типов (int, long, boolean — они не могут быть null в Java)

  • Полей с конфликтами типов

  • Version-specific полей (не во всех версиях)

  • Oneof полей

Использование

@Validated
@Service
public class OrderService {

    public void process(@Valid Order order) {
        // order уже провалидирован
        // Если orderId == null → ConstraintViolationException
    }
}

Стандартный Bean Validation работает из коробки.


Schema Metadata: интроспекция в runtime

Иногда нужен доступ к метаданным схемы во время выполнения:

  • Динамические UI (показать все возможные статусы заказа)

  • Отладка (какие поля изменились между версиями)

  • Миграция данных (как преобразовать v1 → v2)

Включение

<configuration>
    <generateSchemaMetadata>true</generateSchemaMetadata>
</configuration>

Что генерируется

  • SchemaInfoV1, SchemaInfoV2, ... — метаданные каждой версии

  • SchemaDiffV1ToV2, ... — различия между версиями

Использование

VersionContext ctx = VersionContext.forVersionId("v2");
SchemaInfo schema = ctx.getSchemaInfo();

// Информация об enum
schema.getEnum("OrderStatusEnum").ifPresent(enumInfo -> {
    System.out.println("Enum: " + enumInfo.fullName());
    for (SchemaInfo.EnumValue value : enumInfo.getValues()) {
        System.out.println("  " + value.name() + " = " + value.number());
    }
});
// Вывод:
// Enum: com.example.model.api.OrderStatusEnum
//   ORDER_STATUS_PENDING = 0
//   ORDER_STATUS_COMPLETED = 1
//   ORDER_STATUS_CANCELLED = 2

// Информация о message
schema.getMessage("Order").ifPresent(msgInfo -> {
    System.out.println("Fields:");
    for (SchemaInfo.FieldInfo field : msgInfo.getFields()) {
        System.out.println("  " + field.name() + ": " + field.type());
    }
});

// Diff между версиями
ctx.getDiffFrom("v1").ifPresent(diff -> {
    System.out.println("Changes from v1:");
    for (VersionSchemaDiff.FieldChange change : diff.getFieldChanges()) {
        System.out.println("  " + change.messageName() + "." + change.fieldName());
        System.out.println("    Type: " + change.changeType());
        System.out.println("    Hint: " + change.migrationHint());
    }
});

Это полезно для инструментов миграции, admin-панелей, отладочных интерфейсов.


Реальный кейс: система фискальных транзакций

Вернёмся к истории с начала статьи. После внедрения Proto Wrapper:

Было:

  • 750+ ручных adapter-классов

  • 5 веток в каждом методе обработки

  • Баги синхронизации между версиями

  • Страх трогать старый код — "работает — не трогай"

  • Новый разработчик разбирается неделю

Стало:

  • Автогенерируемый код (удалили все ручные адаптеры)

  • Единая бизнес-логика (один метод для всех версий)

  • Типобезопасность на этапе компиляции

  • Документированные конфликты типов (видно в generated code)

  • Новый разработчик продуктивен через день

Конкретный пример: поле parent_ticket

Помните проблему с parent_ticket#17parent_ticket#15?

Без плагина пришлось бы писать специальную логику:

ParentTicket getParentTicket(Request request, String version) {
    if (version.equals("v5")) {
        return request.getParentTicket();  // field #15
    } else {
        // Нужно как-то достать field #17 из v4...
        // Reflection? Dynamic message? Отдельный класс?
    }
}

С плагином — добавляем маппинг в конфигурацию:

<fieldMappings>
    <fieldMapping>
        <message>TicketRequest</message>
        <fieldName>parent_ticket</fieldName>
        <versionNumbers>
            <v4>17</v4>
            <v5>15</v5>
        </versionNumbers>
    </fieldMapping>
</fieldMappings>

И код работает единообразно:

public void processTicket(TicketRequest request) {
    // Плагин знает, откуда читать в каждой версии
    if (request.hasParentTicket()) {
        ParentTicket parent = request.getParentTicket();
        validateParentTicket(parent);
    }
}

Один метод. Версия не важна. Плагин делает правильную вещь.

Конфликт PRIMITIVE_MESSAGE в production

Поле shift_document_number в v4 было uint32, в v5 стало вложенным message ParentTicket.

Раньше это было источником багов — забыли проверить версию, вызвали не тот метод, получили null или 0.

Теперь:

TicketRequest request = ctx.wrapTicketRequest(proto);

if (request.supportsShiftDocumentNumberMessage()) {
    // v5: работаем с message
    ParentTicket parent = request.getShiftDocumentNumberMessage();
    processParentTicket(parent);
} else {
    // v4: работаем с числом
    long docNum = request.getShiftDocumentNumber();
    processLegacyDocumentNumber(docNum);
}

Компилятор видит оба метода. IDE подсказывает. Невозможно случайно вызвать не тот.


Быстрый старт

Maven

<plugin>
    <groupId>io.alnovis</groupId>
    <artifactId>proto-wrapper-maven-plugin</artifactId>
    <version>2.3.1</version>
    <configuration>
        <basePackage>com.example.model</basePackage>
        <protoRoot>${basedir}/proto</protoRoot>
        <generateBuilders>true</generateBuilders>
        <versions>
            <version><protoDir>v1</protoDir></version>
            <version><protoDir>v2</protoDir></version>
        </versions>
    </configuration>
    <executions>
        <execution><goals><goal>generate</goal></goals></execution>
    </executions>
</plugin>

Gradle

plugins {
    id("io.alnovis.proto-wrapper") version "2.3.1"
}

protoWrapper {
    basePackage.set("com.example.model")
    protoRoot.set(file("proto"))
    generateBuilders.set(true)
    versions {
        version("v1")
        version("v2")
    }
}

Генерация

mvn generate-sources
# или
./gradlew generateProtoWrapper

Protoc не нужно устанавливать вручную — плагин скачает его автоматически из Maven Central.


Когда Proto Wrapper НЕ нужен

Плагин решает конкретную проблему: работа с множественными версиями protobuf-схем в одном приложении.

Он избыточен, если:

  • У вас одна версия схемы

  • Все клиенты обновляются синхронно

  • Изменения backward-compatible без конфликтов типов

  • Не нужно поддерживать legacy-клиентов

В этих случаях стандартный protobuf справится. Не добавляйте сложность без необходимости.

Плагин полезен, когда:

  • Клиенты на разных версиях протокола (мобильные приложения, IoT-устройства, партнёрские интеграции)

  • Legacy-системы, которые нельзя или дорого обновить

  • Схемы эволюционируют с изменениями типов

  • Нужна единая бизнес-логика без copy-paste между версиями

  • Важна типобезопасность и поддержка IDE


Заключение

Proto Wrapper Plugin родился из реальной боли. Сотни часов, потраченных на ручную синхронизацию адаптеров. Баги, которые проявлялись только для конкретных версий клиентов. Страх менять код, потому что нужно менять в пяти местах и молиться, что ничего не забыл.

Сейчас плагин обрабатывает:

  • Конфликты типов: WIDENING, INT_ENUM, PRIMITIVE_MESSAGE, STRING_BYTES — автоматически

  • Builder pattern: создание и модификация объектов с валидацией

  • Google Well-Known Types: Timestamp → Instant, Duration → Duration, и т.д.

  • Oneof: полная поддержка с обнаружением конфликтов между версиями

  • Incremental build: >50% экономии времени на повторных сборках

  • Schema Diff: обнаружение breaking changes для CI/CD

  • Spring Boot Starter: автоконфигурация для веб-приложений

  • Validation Annotations: @NotNull, @Valid из коробки

  • Schema Metadata: интроспекция в runtime

Проект open source под Apache 2.0. Используется в production.


Ссылки:


Если работаете с эволюционирующими protobuf-схемами — попробуйте. Вопросы, баги, feature requests — issues на GitHub открыты. Pull requests приветствуются.