C#8, .NET 5, DevSecOps, Azure Functions та мікросервіси на конференції .NET fwdays | Online

Protobuf — не только сериализация. Генерация кода и другие прикладные аспекты

В этой статье рассмотрим возможности Protobuf, которые могут быть полезны для широкого круга проектов. Проанализируем, как эта технология поможет разработчикам, практикующим Domain-Driven Design (DDD). Также расскажем о типе Any, рефлексии, опциях, сервисах и других возможностях Protobuf, которые делают работу продуктивнее и приятнее.

Я и соавтор статьи Дмитрий Дашенков работаем в компании TeamDev. Мы оба программируем, занимая разные должности. Я — технический директор, пишу код за деньги с 1992 года. Дмитрий — инженер-программист, практикующий с 2016 года. Как же получилось, что столь непохожие господа взялись за совместное написание статьи? Мы вместе работаем на семействе проектов, которые активно используют технологию Google Protocol Buffers, также известную под коротким именем Protobuf. Наше знакомство с этой технологией, как и для большинства наших коллег, началось из-за необходимости передавать данные между разными клиентами и языками. По мере освоения Protobuf открывал значительно более широкие возможности, чем просто сериализация данных. О том, как мы используем эту технологию в разных проектах, и хотим рассказать.

Мы полагаемся на то, что читатели знакомы с синтаксисом Protobuf версии 3. Если нет, то, поскольку язык несложен, а документация хороша, первоначальное знакомство не займет много времени.

Начало истории

Трудности передачи типизированных сообщений между разнородными узлами

Наша история с Protobuf началась на одном SaaS-проекте. Система была построена на основе событийного, или, как в последнее время про это говорят, реактивного Domain-Driven Design. В браузерное приложение на JavaScript-события и данные для отображения передавались как JSON-объекты. Потом клиент принял решение добавить клиенты под Android и iOS. На момент начала работы над клиентскими приложениями событийная модель уже была сформирована в виде иерархии Java-классов. И этих классов было много.

Перед нами встали следующие вопросы:

  1. Как добиться согласованности типов данных в разных языках, если нужно что-то поменять или добавить? Когда классов событий десятки, руками делать не хочется и вероятность ошибки высока. Хочется какой-то инструментальной поддержки.
  2. Можно ли избежать конвертации в JSON и обратно? Нагрузка у нас была небольшой, поэтому речи о повышении производительности на этом этапе не было. Но интуитивно понятно, что если можно избежать конвертации в текст и обратно, которая происходит только ради передачи на другой узел, то хуже точно не будет.

Отказаться от типов данных и все везде делать в JSON — это не выход. Работа с доменной моделью, «сердце и мозг» бизнеса, превратится в работу со строками и примитивными типами! Мы пустились в поиски.

Поиски решения

Хвала Интернету, варианты начали появляться очень быстро. Одним из первых стала библиотека Wire компании Square. На тот момент она была в версиях 1.x, работала с Protobuf v2, а Protobuf v3 был версии alpha-3. Библиотека Wire не решала всех наших проблем с поддержкой платформ, поскольку предназначена только для Android и Java, но познакомила нас с технологией Protobuf и прикладной кодогенерацией. Protobuf в сравнении с другими выглядел наиболее привлекательно. Но возникла еще одна проблема.

Иерархия Java-классов для событий и других типов данных, которые нужно передавать туда-сюда, плохо ложится на реализацию типов на другом языке. Гибкость на одной стороне (Java) превращается в сложности обеспечения передачи данных. Надо адаптировать наследование в композицию. Когда таких классов много, то трудозатрат на поддержку тоже много. Часть проблем (на тот момент для нас лишь теоретически) Protobuf решал, но не автомагически: наша модель на Java не вписывалась в структуры, которые мы могли построить без наследования.

Мы пришли к следующим решениям:

  1. Описывать команды, события и все другие типы данных в Protobuf и генерировать код для всех языков проекта.
  2. Поскольку Protobuf описывает только данные, сущности предметной области мы будем реализовывать как Java-классы, которые агрегируют классы данных, сгенерированные из proto-типов.

Реализация этих решений вылилась в: 1) создание фреймворка для проектов, разрабатываемых на основе методологии Domain-Driven Design, где Protobuf существенно облегчает работу с данными; 2) сокращение рукописного кода на интеграционных продуктах, где код на С++ сопрягается с Java и C#. Об опыте применения в этих двух направлениях мы расскажем подробнее ниже, а сейчас рассмотрим свойства Protobuf, которые будут полезны для многих проектов вне зависимости от их природы.

Полезные свойства Protobuf

Поддержка многих языков

Последние версии Protobuf поддерживают C++, C#, Dart, Go, Java, JavaScript, Objective-C, Python, PHP, Ruby. Реализация для Swift есть прямо от Apple. Если же вам нужно на Clojure, Erlang или Haskell, то список сторонних библиотек для разных языков обширен.

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

Бинарная сериализация

Предположим, у нас есть тип данных Task, определенный следующим образом:

message Task {
    string name = 1;
    string description = 2;
}

Тогда в Java в бинарной форме такой объект можно получить так:

byte[] bytes = task.toByteArray();

Или так:

ByteString bytes = task.toByteString();

Класс ByteString, предоставляемый библиотекой, помогает в преобразовании в Java String, а также в работе с ByteBuffer, InputStream и OutputStream.

В JavaScript объект типа Task можно превратить в байты с помощью метода serializeBinary():

const bytes = task.serializeBinary();

В результате получим массив 8-битных чисел. Для обратного превращения генерируется функция deserializeBinary(), стиль вызова которой напоминает статические методы в Java:

const task = myprotos.Task.deserializeBinary(bytes);

Для Dart ситуация похожая. Метод writeToBuffer() на общем предке сгенерированных классов возвращает список беззнаковых 8-битных целых чисел.

var bytes = task.writeToBuffer();

А конструктор fromBuffer() осуществляет обратное превращение.

var task = Task.fromBuffer(bytes);

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

Устойчивость к изменению формата

Protobuf достаточно гибок при необходимости изменить тип данных. Мы не будем описывать все возможности, а остановимся лишь на самых интересных.

Добавление полей

Тут все совсем просто. Добавляете поле, и обновленный тип будет бинарно совместим со старыми значениями. Для предыдущей версии кода наличие нового поля будет прозрачным, но оно будет учитываться при сериализации.

Удаление полей

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

Может статься, что при следующей модификации типа кто-то добавит поле с таким же индексом, как недавно удаленное. Это поломает бинарную совместимость. Если же в будущем кто-то добавит поле с таким же именем, но с другим типом, то это поломает совместимость с вызывающим кодом.

Авторы Protobuf рекомендуют такие поля переименовывать, прибавляя префикс OBSOLETE_, или удалять, помечая индекс этого поля с помощью инструкции reserved.

Во избежание неприятных сюрпризов мы рекомендуем следующий цикл обработки удаления:

  1. Пометить поле опцией deprecated:
    message MyMessage {
       // ...
       int32 old_field = 6 [deprecated = true];
    }
    
  2. Сгенерировать код для обновленного типа.
  3. Обновить вызывающий код, избавившись от вызовов @Deprecated-методов.
  4. Удалить поле, пометив его индекс и имя как reserved:
    message MyMessage {
       // ...
       reserved 6;
       reserved "old_field";
    }
    

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

Переименование полей

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

Стоит отметить, что подобное переименование лучше проводить «задом наперед». Сначала следует переименовать методы, которые работают с этим полем, в сгенерированном коде. Это, в свою очередь, обновит ссылки на них из кода проекта. И только после этого надо переименовать поле в proto-типе.

Например, у нас есть поле name у типа Task, а мы хотим вместо name иметь title. Чтобы потом не править все вызовы соответствующих методов руками, надо выполнить следующую последовательность:

  1. Переименовать метод класса Task в Java из getName() в getTitle().
  2. Переименовать методы у билдера Task.Builder на getName() и setName() соответственно.
  3. Выполнить аналогичную процедуру для других языков вашего проекта. И только после этого...
  4. Переименовать само поле в proto-типе Task.

Понятное дело, что по сравнению с обычным переименованием в среде это неудобно. Но не стоит забывать, что: 1) на генерацию кода мы усилий практически не тратим; 2) переименований может быть немало, но не очень-очень много. При должном первоначальном внимании к языку предметной области количество этих проблем можно уменьшить.

Также стоит отметить, что если вы используете сериализацию в JSON, то переименованием злоупотреблять не стоит, поскольку обновление клиентов на новую версию потребует дополнительных усилий.

Вывод в JSON

В Java для работы с JSON используется утилитный класс JsonFormat, который входит в библиотеку protobuf-java-util. За вывод отвечает класс JsonFormat.Printer. Простой случай выглядит так:

Printer printer = JsonFormat.printer();
try {
    String json = printer.print(myMessage);
} catch (InvalidProtocolBufferException e) {
    // Unknown type was encountered in an `Any` field.
}

Как видно из примера, метод Printer.print() может выбросить InvalidProtocolBufferException. Это происходит, когда выводимое сообщение содержит поле типа Any, в которое упакован неизвестный для принтера тип. Если вы не используете Any в сообщениях, которые собираетесь конвертировать в JSON, то ничего делать не надо. А если используете, то Printer надо оснастить объектом типа TypeRegistry, предварительно добавив в него типы, ожидаемые внутри Any:

TypeRegistry registry = TypeRegistry.newBuilder()
    .add(MyMessage.getDescriptor())
    .add(AnotherType.getDescriptor())
    .add(YetAnotherType.getDescriptor())
    .build();
Printer printer = JsonFormat.printer().usingTypeRegistry(registry);

По умолчанию Printer выводит сообщение в удобочитаемом формате. Но можно создать и версию, которая будет выдавать компактный формат:

Printer compactPrinter = printer.omittingInsignificantWhitespace();

Для обратного преобразования служит JsonFormat.Parser, который также нуждается в TypeRegistry для понимания содержания полей типа Any:

Parser parser = JsonFormat.parser().usingTypeRegistry(typeRegistry);
MyMessage.Builder builder = MyMessage.newBuilder();
parser.merge(json, builder);
MyMessage message = builder.build();

Для JavaScript ситуация менее радужная. Реализация для этого языка «из коробки» не позволяет осуществлять сериализацию в JSON и из него. Поддержка конвертации из «простых» объектов и в них доступна с помощью метода toObject(). Но в отдельных случаях, например для google.protobuf.Timestamp, google.protobuf.Any и других встроенных типов, выхлоп toObject() не совпадает с тем, что выведет Java-библиотека. Пользователю Protobuf для JavaScript остается лишь расширить сгенерированный API, что мы и сделали, когда сами наткнулись на этот недостаток.

Альтернатива работы с JavaScript — Dart. Для Dart конвертация в JSON полностью соответствует той, которая используется в Java.

Сериализация:

var jsonString = task.toProto3Json();

Десериализация:

var task = Task();
task.mergeFromProto3Json(jsonString);

У обоих методов есть необязательный параметр типа TypeRegistry, необходимый для обработки google.protobuf.Any. Создаем TypeRegistry, передав ему пустые экземпляры сообщений:

var registry = TypeRegistry([Task(), TaskLabel(), User()]);

Добавляем параметр в метод:

task.mergeFromProto3Json(jsonString, typeRegistry: registry);

Immutability

Преимущества Immutable-типов для улучшения качества жизни программиста — тема отдельной статьи. В контексте этой же скажем лишь, что объекты Protobuf в Java немодифицируемые. И это удобно. А создать новый объект на основе предыдущего тоже удобно:

Task updated = task.toBuilder().setDescription("...").build();

Теперь давайте рассмотрим, как эти свойства помогают при работе с бизнес-логикой.

Использование Protobuf в проектах на основе Domain-Driven Design

Один из столпов методологии Domain-Driven Design — это Ubiquitous Language, вездесущий язык, который используется всеми участниками проекта — как доменными экспертами, так и разработчиками. Технология Protobuf помогает в таком проекте быстро создавать новые типы данных и потом относительно несложно поддерживать их по мере развития доменной модели проекта.

Value Objects

Главная цель использования Value Objects состоит в том, чтобы ввести язык предметной области в систему типов приложения. Эти объекты представляют собой неизменяемые значения и, как правило, невелики, например: EmailAddress, PhoneNumber, LocalDateTime, Money и т. д. Кроме инкапсуляции самих данных, такие типы могут нести в себе логику валидации, строковое представление, взаимодействие с другими типами и т. д.

Преимущество Protobuf для написания Value Objects заключается в том, что:

  1. создание доменных типов происходит быстро и для всех языков системы;
  2. объекты, представляющие Protobuf-сообщения, сравниваются по значению в глубину во всех платформах. Таким образом, главное свойство Value Objects — равность по значению (нет внешнего идентификатора) реализована «из коробки».

Рассмотрим пример реализации Value Objects на Protobuf.

message EmailAddress {
    string value = 1 [(required) = true, (pattern).regex = ".+@.+\..+"];
}

У типа есть одно строковое поле с наивной валидацией через регулярное выражение. Также поле value должно быть заполненным. Более подробно валидацию рассмотрим в следующих разделах этой статьи.

Protobuf compiler сгенерирует из этой декларации Java-класс с реализацией метода equals(), которая сравнивает значения поля value.

Важная часть Value Object — его поведение. В Java Protobuf генерирует нерасширяемые классы, что усложняет добавление к ним поведения. Мы решили эту проблему, определив опцию (is) для добавления интерфейсов, в том числе с default-методами, к сгенерированным классам. Подробности — в разделе о mixin-интерфейсах.

Частный случай Value Object — идентификатор.

Типизированные идентификаторы

Специалисты по Domain-Driven Design рекомендуют для каждого типа сущностей иметь свой тип ID. Это особенно актуально в современной реактивной редакции DDD, где сущности обрабатывают сообщения (команды или события). Использование примитивных типов или строк для идентификаторов может привести к путанице с параметрами, если их типы одинаковы. Например, если у нас есть customerId, orderId, userId и все они Long или String, то машинально перепутать порядок несложно. Кроме того, код работы с типизированными идентификаторами компактнее. Его легче читать и понимать. Сравним, например, такой вызов:

completeOrder(String userId, String customerId, String orderId);

...и его аналог с типизированными ID-шниками:

completeOrder(UserId user, CustomerId customer, OrderId order);

Сказав один раз Id в типе, нам больше не надо повторяться. И можно написать даже так:

completeOrder(UserId u, CustomerId c, OrderId o);

В простом случае типа идентификатора для сущности типа, например, Task выглядит следующим образом:

message TaskId {
   string uuid = 1;
}

Вдобавок, спрятав тип реализации идентификатора, мы получаем возможность расширения, если оно потребуется: например, для интеграции данных разных вендоров. В этом нам поможет конструкция oneof:

message CustomerId {
  oneof kind {
    uint64 code = 1;
    EmailAddress email = 2;
    string phone = 3;
  }
}

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

Типы сообщений

Вот так выглядит команда создать новую задачу:

// A command to create a new task.
message CreateTask {
    TaskId id = 1;
    string name = 2 [(required) = true];
    string description = 3;
}

Опция (required) — это не стандартная фича Protobuf, а свойство нашей библиотеки валидации, о которой мы поговорим чуть ниже. Эта опция говорит, что поле name обязательно к заполнению, в отличие от поля description, которое можно опустить. ID сущности, к которой относится команда, обязательно по умолчанию. Мы пришли к этому соглашению, чтобы упростить себе жизнь.

Событие декларируется аналогично:

// A new task has been created.
message TaskCreated {
    TaskId id = 1;
    string name = 2 [(required) = true];
    string description = 3;
}

Сообщения атомарны и не зависят друг от друга. События, как правило, являются следствиями команд, но не связаны с ними на уровне данных. Легко заметить, что ссылка на сущность осуществляется с помощью типизированного ID. Поразмыслив над тем, как мы работаем, мы решились на следующий шаг.

Отказ от ORM

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

message Task {
    TaskId id = 1;
    string name = 2 [(required) = true];
    string description = 3;
    repeated SubTaskId subtask = 4;
}

А если этих связей много, то их в любом случае нужно возвращать запросом к базе. При этом ссылка хранится на стороне объектов, которых много, и выставляется для поиска как колонка:

message User {
    UserId id = 1;
    // ...
    CountryCode primary_citizenship = 42 [(column) = true];
}

При событийном подходе недостающую информацию можно получать путем накопления из необходимых событий, а не прохождения графа объектов, когда понадобилось. И если очень надо, то сущность мы можем получить у репозитория через его ID. Мы подошли к решению проблемы The Object-Relational Impedance Mismatch, постановив:

  1. хранить данные сущности бинарно или в JSON (в зависимости от типа базы и характера задач);
  2. в других колонках хранить только свойства, используемые для поиска, если подобные данные невозможно или неэффективно получать с помощью накапливания информации через события.

Дальше в зависимости от требований предметной области можно добавлять колонки:

message User {
    UserId id = 1;
    // ...
    LocalDate birthday = 41 [(column) = true];
    CountryCode primary_citizenship = 42 [(column) = true];
    bool subcribed_to_emails = 43 [(column) = true];
}

Впоследствии можно составить запрос на всех людей, у которых день рождения приходится на какой-нибудь национальный праздник, и поздравлять их с этим маловероятным совпадением. Конечно, только если они не отписались от email-рассылки.

Аннигиляция DTO

Поскольку данные в Protobuf легко передаются на клиент и храним мы их целиком, то надобность в DTO-классах отпадает сама собой. Вместо того чтобы создавать классы DTO для передачи данных, давайте иметь эту возможность всегда и использовать при необходимости. Этого несложно достичь, если классы сущностей будут принимать тип своих данных как generic parameter.

Если же данные какой-то сущности надо скрыть от возможности чтения с клиентов, будем это делать proto-опциями для соответствующего типа:

message SecretService {
   option (entity) = { kind: PROCESS_MANAGER visibility: NONE };

   long id = 1;
   bytes data = 2;
}

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

Работа с сущностями

Рассмотрим пример декларации сущности класса TaskItemProjection, который наполняет данные для одной строки в списке задач типа TaskItem:

final class TaskItemProjection
    extends Projection<TaskId, TaskItem, TaskItem.Builder> {

    @Subscribe
    void on(TaskCreated e) {
        builder().setId(e.getId())
                 .setName(e.getName())
    }
}

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

  1. тип идентификаторов, TaskId;
  2. тип состояния сущности, TaskItem;
  3. тип билдера состояния, TaskItem.Builder.

Чтобы иметь контроль над модификацией состояния сущности, мы договорились о следующем:

  1. Переход между состояниями сущности происходит посредством обработки сообщения (команды или события). В примере выше это событие TaskCreated.
  2. Состояние можно получить из метода state() всегда. Этот метод доступен из базовых классов сущностей.
  3. Модификация состояния осуществляется через метод builder(), также предоставляемый базовыми классами. Метод возвращает конкретный тип, наследуемый от Message.Builder (см. третий generic parameter). По сути, билдер получается в результате вызова state().toBuilder().
  4. Метод builder() доступен только внутри методов, которые обрабатывают сообщения. Попытка получить билдер вне контекста обработки сообщения повлечет за собой runtime-исключение.

Приведенный нами пример показывает переход состояния Projection в ответ на событие. Для классов, наследуемых от Aggregate или ProcessManager, которые могут еще и обрабатывать команды, продуцируя в ответ события, это работает аналогично.

Использование mixin-интерфейсов

Достаточно долгое время в работе с кодом, генерируемым Protobuf Compiler, нас почти все устраивало. Да, код для Java, который он генерирует, прямо скажем, «страшноват». Он явно не для приятного вечернего чтения с размышлениями на тему «А что бы на это сказал Гослинг?». Но, поскольку API удобен и для нас все хорошо работало, мы не стали идти по пути Square Wire, создавая свой компилятор proto-файлов. Но со временем возникли задачи, которые нам пришлось решать, вклиниваясь в кодогенерацию.

Одна из таких задач — объединение генерируемых классов под дополнительным интерфейсом. В Protobuf на Java все генерируемые классы реализуют интерфейс com.google.protobuf.Message, объявленный в коде библиотеки. Модифицировать этот код мы, понятное дело, не можем. А проблемы, которые надо решать, есть.

Например, как уберечь программиста от ошибки случайно поставить тип команды в метод, который призван получать событие? Ведь все эти классы у нас наследуют Message и неразличимы как группы на уровне типов. А нам надо бы иметь CommandMessage и EventMessage и объединять генерируемые типы под этими интерфейсами. Так можно было бы создать API, принимающий, к примеру, только команды.

Еще одна проблема — добавление необходимого поведения в типы данных. ООП все-таки сильно меняет сознание программиста, и необходимость заводить утилитные классы и методы на каждую мелочь одновременно и напрягает, и затрудняет написание вместе с пониманием кода. То, что доменные типы теперь появляются быстро — это хорошо, но еще хочется, чтобы ими было удобно «разговаривать» в коде. Хочется вместо query.getTarget().getFilters() иметь возможность написать query.filters().

В свете этих проблем мы пришли к необходимости влиять на Protobuf Compiler так, чтобы генерируемые Java-классы реализовывали дополнительные интерфейсы. Для этого в proto-типах мы ввели нашу, кастомную опцию (is). Пример:

message Query {
   option (is).java_type = "QueryMixin";
   QueryId id = 1 [(required) = true];
   // The type of entity of interest and a set of fetch criteria.
   Target target = 2 [(required) = true];
   ...
}

А QueryMixin определяем так:

public interface QueryMixin extends QueryOrBuilder {
    /** Obtains target filters specified in the query. */
    default TargetFilters filters() {
        return getTarget().getFilters();
    }
    ...
}

Наш плагин к Protobuf Compiler, видя опцию (is).java_type, добавляет указанный интерфейс в список реализуемых интерфейсов генерируемого класса. Также есть возможность автоматически генерировать пустые интерфейсы и тоже добавлять их к реализуемым, указав (is) = { java_type: "ClientRequest" generate: true}. Второй случай покрывает необходимость маркерных интерфейсов, которые нужны, например, для ограничения типа.

Стоит отметить, что в приведенном примере кода mixin-интерфейс расширяет интерфейс QueryOrBuilder. Подобные интерфейсы Protobuf генерирует самостоятельно для объединения доступа на чтение как к полям типа сообщения, так и его билдера. Таким образом, «трюк» с миксинами состоит из следующих шагов:

  1. Создаем тип MyMessage.
  2. Генерируем для него код.
  3. Работаем. Обнаруживаем, что стандартных методов не хватает.
  4. Создаем MyMessageMixin, наследуемый от MyMessageOrBuilder. Добавляем в него необходимое поведение как default-методы, опираясь на существующие из наследуемого интерфейса.
  5. Добавляем опцию (is).java_type = "MyMessageMixin" в MyMessage.
  6. Перегенерируем код.

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

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

Как работать с Any

В ранее приведенных примерах на Protobuf мы видели, что одни типы Message могут агрегировать другие. Но как сослаться на тип сообщения, который заранее не известен?

Что делать, если тип заранее не известен

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

message Command {

    CommandId id = 1;
    // An instruction to do something.
    bytes message = 2;
    // Common things like UserId, the time of creation, etc.
    CommandContext context = 3;
}

Но как на принимающей стороне понять, что лежит внутри bytes? Для этого мы могли бы сначала записать имя типа, а потом сериализованное представление объекта. Но тогда придется следить за соблюдением этого неявного протокола. Можно было бы тип сообщения определять отдельным полем, тогда протокол становится явным:

message Command {
    CommandId id = 1;
    string type = 2;
    bytes message = 3;
    CommandContext context = 4;
}

Но теперь у нас есть пара полей, которые концептуально объединены. И нужно следить за тем, чтобы подобные пары обрабатывались на запись и чтение согласованно. Скорее всего, эту пару надо объединить в новый тип. Именно таким типом и является google.protobuf.Any.

Any thing goes

Опуская коментарии, тип Any определен в Protobuf так:

message Any {
    string type_url = 1;
    bytes value = 2;
}

Поле type_url служит для определения типа и должно состоять из двух частей, разделенных символом "/": пути и полного имени proto-типа. С полным именем типа все понятно: оно состоит из имени пакета, где определен тип, и его имени, например google.protobuf.Timestamp. А вот часть, относящаяся к пути, окутана туманом. Документация к Any регламентирует, что полное значение type_url должно выглядеть как-то так: path/mypackage.MyType. Но что должно быть в path? Тут есть несколько проблем.

Во-первых, та же документация говорит, что если указана схема http или https (значение по умолчанию), то разработчики могут выставить сервер, который по этому адресу будет возвращать описание в виде двоичного представления значения типа google.protobuf.Type. Не обязаны, но могут. Это похоже на service discovery и потенциально может быть интересно, но описание типа Any говорит, что функциональности, которая работала бы с этим соглашением в официальном релизе Protobuf, пока нет. Гид по языку Protobuf также говорит, что библиотека для работы с Any находится в разработке.

Не так давно в версии 3.10 Java-реализации библиотеки появился класс com.google.protobuf.TypeRegistry, который перекочевал из утилитной библиотеки. Раньше он был внутренним классом в com.google.protobuf.util.JsonFormat, о котором мы говорили выше. Это изменение в библиотеке дает некоторую надежду на то, что работа с Any будет улучшена. Но других подвижек пока нет.

Во-вторых, Java-реализация типа Any предоставляет метод pack(Message), который использует для пути префикс type.googleapis.com. Новичков это, прямо скажем, сбивает с толку. Когда в логах или данных видишь этот префикс для своих типов, это обескураживает. Да, существует другой метод Any, который принимает два параметра, один из которых — строка с кастомным префиксом, pack(Message, String), но вызвать первый метод по ошибке проще, и есть следующая проблема.

В-третьих, все еще непонятно, что использовать в качестве префикса пути типов нашего проекта. Использовать там значение "type.googleapis.com", которое библиотека ставит по умолчанию, глупо: типы — наши, не Google. Сервер с типами мы вряд ли будем выставлять, особенно в начале проекта. Если нужно будет зачем-то выставлять описание типов, то сделать это несложно. Но не очень понятно зачем: в узлах, которые будут общаться с API нашего проекта на Protobuf, скорее всего, должна быть какая-то реализация. Вряд ли они будут динамически генерировать код на основании описания типов. Такая автомагия возможна, но она сложнее в реализации и сопровождении. Проще и понятнее просто сгенерировать код для этих типов и задеплоить новую версию.

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

Type URL Prefix

Type URL prefix и path, упомянутые выше, — это одно и то же строковое значение, то, что идет перед символом "/" в URL типа. В своих проектах мы добавили дополнительную опцию к proto-файлам, которая называется (type_url_prefix) и определяется один раз на proto-файл, например так:

option (type_url_prefix) = "type.myproject.org";

Скобки вокруг имени опции говорят компилятору, что эта опция определена нами как расширение стандартного набора опций.

Механизм опций позволяет описывать метаданные Protobuf-определений подобно аннотациям в Java. Во время исполнения значения этих опций можно получить через дескрипторы — механизм рефлексии в Protobuf. Мы написали небольшую обертку для упаковки сообщений в Any, которая автоматически формирует URL типа с указанным в определении префиксом. Для наших типов этот URL выглядит так: type.myproject.org/myproject.core.Command.

Упомянутую опцию приходится не забывать вставлять в каждый proto-файл. И это напрягает. Во-первых, потому что она, скорее всего, должна быть одна на весь проект. Во-вторых, потому что это пока никто никак не будет использовать. Но пока это единственный способ, который мы придумали для реализации, оставаясь в рамках фич Protobuf.

Неудобство с префиксами типов — не единственная проблема. Еще нужно как-то понять, чему же соответствует proto-тип на стороне реализации.

Собрание типов

Обратное превращение из URL типа в Java-класс — тоже непростая задача. У нас есть строка с полным именем proto-типа, а однозначного преобразования из этого имени в имя Java-типа нет. Как ответить на вопрос, какой Java-класс вот эти байты может понять? Для этого приходится создавать реестр, который бы знал обо всех Protobuf-типах в проекте и умел находить Java-класс по URL соответствующего proto-типа. Чтобы наполнять этот реестр, мы:

  1. настраиваем Protobuf-компилятор так, чтобы он генерировал файл со всеми дескрипторами;
  2. включаем этот файл в classpath проекта;
  3. во время исполнения находим все такие файлы в classpath и вычитываем их содержимое;
  4. на основе информации из дескрипторов типов строим соответствие URL типов к Java-классам.

Благодаря получившемуся реестру мы можем восстановить сообщение из Any, не зная его типа на момент компиляции.

Прото-рефлексия

Настало время поговорить подробнее о рефлексии. В Protobuf определен ряд типов данных, которые описывают декларации этого языка на метауровне. Эти типы называются дескрипторами и доступны в момент компиляции proto-исходников в код на целевых языках. Также на многих платформах дескрипторы можно получить во время исполнения непосредственно из экземпляров сообщений. Например, в Java это выглядит следующим образом:

Descriptor type = myMessage.getDescriptorForType();

Или же через статический метод:

Descriptor type = MyType.getDescriptor();

Надо отметить, что в Java существуют две параллельные иерархии дескрипторов:

  • обычные Protobuf-сообщения, определенные в google/protobuf/descriptor.proto;
  • обертки, написанные на Java.

Обертки отличаются от Protobuf-сообщений тем, что они уже слинкованы, а следовательно, типы зависимостей можно получить напрямую из объекта дескриптора, в то время как из Protobuf-объекта можно получить только названия типов зависимостей, а не сами типы. Дескрипторы-сообщения можно получить из оберток вызовом метода toProto(). Чтобы получить обертки из дескрипторов-сообщений, нужно слинковать их между собой. Делать это не рекомендуется, так как библиотека Protobuf рассчитывает, что дескриптор-обертка одного типа может быть представлен только одним Java-объектом. Проще использовать уже готовые дескрипторы-обертки, полученные через методы getDescriptorForType() и getDescriptor(). После получения дескриптора можно проанализировать то, из каких полей состоит данный тип, и получить информацию об опциях, заданных для всего типа или его полей.

Опции как основа для валидации данных

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

При проектировании механизма валидации мы ставили перед собой следующие цели:

  1. Валидационные правила для типов данных должны быть определены где-то рядом с объявлением структуры или прямо в ней, а не во внешних источниках: не хочется складывать в уме proto и, например, Java-код. Кроме того, крайне желательно, чтобы эти правила сразу работали для всех языков, а не только для одного. Не хочется писать валидацию руками по несколько раз.
  2. Механизм валидации должен быть легко расширяем и для нашего фреймворка, и для его клиентов.

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

Второе требование заставляет нас поддерживать «окно», через которое во время выполнения пользовательский код может добавлять проверки в соответствии со своими бизнес-правилами.

Исходя из описанных требований, мы построили инфраструктуру для валидации Protobuf-сообщений. Правила валидации определяются с помощью кастомных опций для полей и сообщений.

Наши опции, такие как (required), (pattern), (min), (max) и т. д., превращаются в исполняемый код во время компиляции Protobuf. Этот валидационный код встраивается прямо в обычный сгенерированный код.

Кроме «стандартных» опций, пользователи нашей валидации могут определять и использовать собственные. Этот функционал поддерживается только в Java. Для этого пользователь реализует интерфейс ValidatingOptionFactory, переопределяет в нем методы, возвращая только новые опции и только для нужных типов полей. Например, вот так выглядит одна из фабрик, поставляемых как часть фреймворка:

public final class WhenFactory implements ValidatingOptionFactory {
    @Override
    public Set<FieldValidatingOption<?>> forMessage() {
        return ImmutableSet.of(When.create());
    }
}

When — название опции, определяющей дополнительную валидацию для полей, хранящих время.

Далее, используя механизм ServiceLoader в Java, пользователь подкладывает свою реализацию фабрики, а наш код во время исполнения находит ее и, когда нужно, вызывает дополнительную валидацию.

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

message Project {
    ProjectId id = 1 [(required) = true];
    string name = 2 [(required) = true];
    repeated Task task = 3 [(required) = true,
                            (distinct) = true,
                            (validate) = true];
    OffsetDateTime when_created = 4 [(when).in = PAST,
                                     (required) = true];
    Status status = 5;
}

Читатель, знакомый с изменениями Protobuf при переходе от версии 2 к версии 3, мог заметить, что в примере выше упоминается слово required, от которого отказались в новой версии. Зачем же мы «вернули» его? Тут мы должны прояснить, что и почему мы делаем с этой опцией.

Требуемые пояснения на тему required и Protobuf 3

В Protobuf версии 2 любое поле можно было пометить как required или optional. Позже создатели библиотеки посчитали, что этот подход несет больше вреда, чем пользы. В Protobuf версии 3, как и в аналогах Protobuf, появившихся примерно в то же время, все поля всегда считаются необязательными. Причина тому — бинарная совместимость. required-поле в Protobuf 2 нельзя сделать optional, не нарушив совместимости с предыдущими версиями сообщения, и наоборот. Для инструмента сериализации и передачи данных такая оплошность недопустима.

Однако в нашей работе с Protobuf мы решили частично «оживить» монстра required. Это вызвано необходимостью описывать требования для значений данных в доменной модели. Некоторые поля обязательны по своей сути. Это, конечно же, может измениться при развитии модели. Но, в отличие от встроенного в Protobuf версии 2 ключевого слова required, наша опция (required) работает на уровне валидации. Это значит, что невалидные сообщения все еще можно передавать и сериализовать/десериализовать. Использовать ли или игнорировать валидацию — решение программиста в каждом отдельном случае. В коде своих проектов мы отслеживаем создание невалидированных сообщений на этапе статического анализа.

Аналог из Google API

Для работы со своим API Google разработали похожую систему аннотации полей сообщений. Их механизм позволяет определить для поля некоторые аспекты его поведения вроде REQUIRED и IMMUTABLE. Как и в случае с нашей валидацией, пользователь этих флагов волен сам определять, когда ему нужно им следовать, а когда их можно и проигнорировать. За счет этого добавление новых правил не мешает обратной совместимости данных.

Как реализовать с нуля?

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

Итак, чтобы реализовать валидацию на Protobuf-опциях, необходимо:

  1. описать несколько (до десяти, как показывает опыт) «стандартных» валидационных правил — они покроют бо́льшую часть необходимых сценариев;
  2. написать на вашем языке программирования валидатор, который анализирует опции в run-time и проверяет сообщения на соответствие валидационным правилам;
  3. если производительность run-time-валидатора не вполне вас устраивает, вы всегда можете написать плагин для Protobuf-компилятора, который будет генерировать валидационный код, разбирая опции во время сборки проекта.

Первое время мы работали, остановившись на шаге 2. Это позволяло тратить на код проверки корректности данных сравнительно немного времени. Но со временем необходимость в ускорении кода становилась все более очевидной. Потратив несколько человеко-недель, мы написали кодогенерацию для наших стандартных опций, реализовав шаг 3. Результатом стал существенный рост производительности: обработка 1 тыс. сообщений (1170, если быть точными), в процессе которой валидация происходит в среднем по 3-4 раза на каждое сообщение в одном серверном потоке, ранее занимавшая ~2,5 с, ускорилась до ~1,2 с — более чем в 2 раза.

Взаимодействие процессов на основе сервисов

Наши опыты с Protobuf не ограничились проектами бизнес-систем на основе DDD. В нашей компании коллеги из другой рабочей группы работают над семейством продуктов, которые позволяют встраивать Chromium в десктоп-системы, написанные на Java или под .NET. Продукты — назовем их условно Jessica и Dorothy — работают в таких окружениях:

  • Jessica: Windows 32/64 bit, macOS, Linux 64 bit (Intel и ARM), Swing, JavaFX;
  • Dorothy: Windows, .NET, Framework 4.0+, 32/64 bit, WinForms, WPF.

Технологическая основа для продуктов — код Chromium, написанный на C++. Этого кода очень много. Взаимодействие с этим кодом построено на Inter-Process Communications (IPC), как показано на диаграмме внизу:

Как видно из диаграммы, взаимодействия для кода на Java и C# носят один и тот же характер: надо отправлять запросы, получать данные и уведомления от процессов на стороне Chromium. Стрелок с пометкой IPC Channel на диаграмме немного, но они символизируют API, состоящий из 53 сервисов, в которых суммарно 380 методов и 516 типов данных. До недавнего времени реализация была основана на нашем самописном IPC-протоколе. Проблема в том, что Chromium обновляется регулярно, кода много и от релиза к релизу он может меняться достаточно сильно, добавляя новую функциональность, модифицируя существующую, а иногда и удаляя ее. За всем этим надо поспевать, а трудоемкость наших методов работы этому препятствовала. Поддержка сотен типов данных и десятков интерфейсов в трех языках была и трудозатратной, и достаточно чувствительной к изменениям. Нередко что-то ломалось.

Прослышав о трудностях, с которыми героически борются наши коллеги из группы интеграции, мы, воодушевленные успехами на ниве Protobuf, пришли к ним с предложением: «Парни, а не заюзать ли вам Protobuf, да еще и с gRPC в придачу? Ведь у gRPC есть локальный режим!» Преодолев начальные «технические» возражения типа «Да достали вы всех со своим Protobuf!», мы начали вместе разбираться в деталях.

Интуитивно понятно, что раз функциональность, с которой надо интегрироваться, сложна и код написан на разных языках, то стандартизировать работу и генерировать код вместо того, чтобы писать это все вручную, — идея здравая. Механизм декларации сервисов в языке Protobuf решает задачу стандартизации API с точки зрения вызовов, а данные, которые эти сервисы принимают, тоже будут совместимы во всех языках и на всех платформах. Но оказалось, что ни стандартные стабы, которые генерирует Protobuf Compiler для сервисов, ни gRPC нашим коллегам не подошли.

gRPC даже в локальном режиме все равно обслуживает транспортный уровень. Это влечет накладные расходы, которые при работе с UI существенны, а в случае межпроцессного взаимодействия просто никогда не оправданны. Стандартные (не gRPC) стабы, генерируемые Protobuf Compiler, не поддерживают инструкцию stream, которая позволяет организовать асинхронное взаимодействие. Например, нам надо организовать подписку на событие FocusLost:

service Browser {
    ...
    rpc WhenFocusLost(stream EventSubscription) 
             returns (stream FocusLost);
    ...
}

Ключевое слово stream в этом случае позволяет организовать взаимодействие процессов таким образом, что подписаться/отписаться и получать уведомления можно в любой момент. Если в gRPC такая конструкция работает, то стандартный Protobuf Compiler слово stream просто игнорирует. Вероятно, наличие gRPC сделало стандартную поддержку асинхронности ненужной. Но не в нашем случае.

В результате наши коллеги из группы интеграции написали кодогенерацию сервисов для нужд своих проектов, используя gRPC «вприглядку». Конечно же, значительные улучшения в существующем коде не даются даром, и пришлось повозиться. Но, по мнению руководителя группы Владимира Икрянова, это быстро окупилось: «Теперь никто из ребят не боится брать новые фичи или менять формат общения между процессами. Мы стали работать значительно быстрее и увереннее». Авторы статьи с радостью отмечают, что в следующих обновлениях Jessica и Dorothy выйдут, не только похорошев снаружи, но и постройнев и помолодев внутри.

Выводы

Protobuf — это игра с высокими ставками. В статье мы описали результаты нашего пятилетнего опыта работы с этой технологией. В целом преимущества, полученные от использования Protobuf, оправдывают усилия, потраченные на изучение, кастомизацию и настройку. Однако нельзя сказать, что таковых усилий не было вообще. Как и в любой технологии, в Protobuf вы не найдете silver bullet. Применимость его к вашей задаче оценивать лишь вам.

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

LinkedIn

23 комментария

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Столкнулся с проблемой преобразования proto фала в json, при наличии в нем импортов — эту проблему как-то получилось решить? Использую protobufjs

Похоже, вы имеете ввиду JSON descriptor-ы (www.npmjs.com/...​js#using-json-descriptors). Мы используем для генерации стандартные плагины `protoc` для JS, Java и остальных платформ. По поводу библиотеки `protobufjs` сказать ничего не могу. Вы пробовали стандартный плагин (github.com/...​/protobuf/tree/master/js

Я не корректно выразился :) Не получается исходя из Json Descriptor’a запустить соответствующий grpc сервер, не подгружаются импорты (я делаю мок сервис на который ты отправляешь json descriptor и стабы и он запускает соответствующий grpc сервер). Собственно все работает кроме импортов

В Protobuf есть тип `FileDescriptorSet`. Это набор всех дескрипторов, которые передаются компилятору на обработку (в том числе импорты из Ваших зависимостей). Если запустить компилятор с правильными флагами, он автомагически запишет бинарный файлик с `FileDescriptorSet`-ом. Во время исполнения, вы этот файлик можете прочитать и сериализовать/отправить куда угодно. Главное, если у Вас несколько модулей с Protobuf-ом, убедиться в том что все-все типы попали в файлик.

Как дополнительный бонус, Вы сможете иметь список всех известных Protobuf типов во время исполнения и на них строить логику.

Последняя ссылка обрезалась плохо. Вот правильный вариант.
bit.ly/pb-js

У меня были проблемы с разной глубиной семантики между protobuf и java. Например, oneof не ложится на java хорошо. За статью спасибо, познавательно. Отдельное спасибо за хороший русский!

Паша, да oneof может приводить в switch-ам и if-ам так, что боги ООП по линии Страуструпа начинают бурчать и отворачиваться. А те, кто по линии Алана Кэя, те только хмурятся немного.

Джавистам советую обратить внимание на конвертер proto<->DTO:
BAData/protobuf-converter

Спасибо, интересный опыт. С одной стороны, вроде круто, а с другой — количество допиливания напильником смущает).

Ситуация почему-то напомнила генерацию клиентского кода со связкой OpenAPI + github.com/...​agger-api/swagger-codegen. Тогда нашему CTO идею продали в виде серебряной пули «запустил генератор и получил клиентский код/SDK», и в таком виде подали всем к исполнению, а по факту для нормальной работы в конкретной системе темплейты основательно допиливались для каждой платформы в зависимости от детских болячек генератора, ещё и самодельные пост-процессоры пришлось писать в некоторых случаях, потому что коробочный генератор того же пыха выдавал (подозреваю, выдаёт до сих пор) весьма далёкий от SOLID’а код. Опыт изменения самого генератора был очень быстро выброшен, т.к. сильно обожглись на обновлениях и синхронизации с исходниками.

В итоге, вместо серебряной пули и «нажал — получил готовый проект» вышло:
1. Сгенерили код «из коробки».
2. Не работает, + некоторые темплейты нет смысла генерить каждый раз. Добавили в исключения и допилили проект руками.
3. Допилили темплейты.
4. В некоторых случаях ещё и написали пост-процессоры.
Таким образом, большая часть кода обновлялась автоматом при прогоне кодогенератора на апишку, но о полной автоматизации речи не шло). Например, чтобы разобраться с этим зоопарком и выкатить первую автообновляемую SDK в удобоваримом виде, у меня ушло почти два месяца, и по месяцу на каждую следующую (правда, при этом на тесты уходило процентов 60 времени — заготовки на них тоже есть в генераторе, но они просто бесполезны).

Описанный опыт можно наблюдать в github.com/...​ose-imaging-cloud-codegen

Да, кодогенерация — штука непростая. Но в нашем случае мы делаем не один API для нескольких языков, а инструмент для генерациии кода в каждом проекте, где будут сотни сообщений и десятки сущностей. Другими словами мы делаем инструмент, который бы позволял коллегам не мучиться так, как Вы описываете в своём опыте. Но область применения этого инструмента ограничена проектами на реактивном DDD.

Да, само собой — это два не особо пересекающихся кейса, просто описание работы напильником вызвало дежавю через какое-то время).

Только с проэкта построенного вокруг OpenAPI. Подтверждаю — адовый ад.

А почему люди держатся за REST до сих пор? Ведь есть же и gRPC, и GraphQL. С последним я не работал, но после введения типов в язык описания тоже выглядит решением (developer.github.com/v4). Можно же сделать API на gRPC и выставить его через REST для тех, кому очень надо. Я догадываюсь, что причины могут быть: «У нас тут много легаси», «Люди на этом умеют работать.» А что ещё?

Это чуть ли не единственное с чем сочитается Front/JS

gRPC = Protobuf over HTTP/2 если на пальцах, в смысле выставить через REST?

Есть вот такая штука: github.com/...​pc-ecosystem/grpc-gateway.
Вот тут можно почитать про опыт использования: habr.com/ru/post/337716

А еще с protobuf вам прийдется писать очень много nullsafe кода, плюс дополнительная привязка к типу передачи данных, что не сильно вяжется к подходу микросервисов. Вообщем, как и любую технологию, protobuf нужно использовать к месту и не рассматривать как серебрянную пулю.

В Protobuf нет `null`. Есть пустые сообщения, которые получаются через `Message.getDefaultInstanceForType()`. Это немного безопаснее, но не спасает от того, что про модель домена надо думать и заботиться о целостности данных. Именно поэтому мы и сделали валидацию данных (см. опцию `(required)` в нашей статье).

По поводу обработки `null` на передающей стороне, то мы стараемся избегать `null`, с помощью тех же аннотаций из ErrorProne, например. А для micro-services Protobuf вполне применим. На нём gRPC строится, например.

Отличная статья, спасибо !

Я коли видаляю поля то просто їх коментую, а тут дізнався про ключове слово reserved

Дякую за статтю!

Подписаться на комментарии