Что такое gRPC и Protobuf?
![]()
gRPC — это мощная платформа для работы с удаленными вызовами процедур (Remote Procedure Calls). RPC позволят писать код так, как будто он будет выполняться на локальном компьютере, даже если он может выполняться на другом компьютере.
Что такое RPC?
RPC — это форма взаимодействия клиент-сервер, в которой используется вызов функции, а не обычный вызов HTTP. Идея в том, что мы можем вызвать и выполнить функцию где-то на удаленной системе, как если бы это была локальная функция.
Он использует IDL (Interface Definition Language — язык описания интерфейса) как форму контракта на вызываемые функции и тип данных.
RPC — это протокол «запрос-ответ», т.е. он следует модели «клиент-сервер»:
Клиент делает запрос на выполнение процедуры на удаленном сервере. Как и при синхронном локальном вызове, клиент приостанавливается до тех пор, пока не будут возвращены результаты процедуры. Параметры процедуры передаются по сети на сторону сервера. Процедура выполняется на сервере и, наконец, результаты передаются обратно клиенту.
gRPC воспроизводит этот архитектурный стиль взаимодействия клиент-сервер через вызовы функций.
Таким образом, gRPC технически не является новой концепцией. Скорее, он был заимствован из этой старой техники и улучшен, что сделало ее очень популярной.
Что такое gRPC?
В 2015 году Google открыл исходный код своего проекта, который в конечном итоге получил название gRPC.
Но что на самом деле означает буква «g» в gRPC? Многие люди могут предположить, что это для Google, потому что Google это сделал, но это не так. Google меняет значение «g» для каждой версии до такой степени, что они даже сделали README, чтобы перечислить все значения.
С момента появления gRPC он приобрел довольно большую популярность, и многие компании используют его.
Есть много причин, по которым gRPC так популярен: простая абстракция, он поддерживается во многих языках и он очень эффективный.
И помимо всех вышеперечисленных причин, gRPC популярен потому, что очень популярны микросервисы и имеется большое количество взаимодействий между ними. Именно здесь gRPC помогает больше всего, предоставляя поддержку и возможности для решения типичных проблем, возникающих в таких ситуациях.
А поскольку разные сервисы могут быть написаны на разных языках, gRPC поставляется с несколькими библиотеками для их поддержки.
Архитектура gRPC

Мы сказали что производительность gRPC очень высока, но что делает ее такой хорошей? Что делает gRPC намного лучше, чем RPC, если их дизайн очень похож? Вот несколько ключевых отличий, которые делают gRPC столь эффективным.
HTTP/2
HTTP был с нами очень долго. Сейчас почти все серверные службы используют этот протокол.
HTTP/1.1 долгое время оставался актуальным, затем в 2015 году, появился HTTP/2, который фактически заменил HTTP/1.1 как самый популярный транспортный протокол в Интернете.
Если вы помните, что 2015 год был также годом выхода gRPC, и это было вовсе не совпадение. HTTP/2 также был создан Google для использования gRPC в его архитектуре.
HTTP/2 — одна из важных причин, почему gRPC может работать так хорошо. И в следующем разделе вы поймете, почему.
Мультиплексирование запроса/ответа
В традиционном протоколе HTTP невозможно отправить несколько запросов или получить несколько ответов вместе в одном соединении. Для каждого из них необходимо создать новое соединение.
Такой вид мультиплексирования запроса/ответа стал возможен в HTTP/2 благодаря введению нового уровня HTTP/2, называемого binary framing.
Этот двоичный уровень инкапсулирует и кодирует данные. На этом уровне HTTP-запрос/ответ разбивается на кадры (они же фреймы).
Фрейм заголовков (HEADERS frame) содержит типичную информацию заголовков HTTP, а фрейм данных (DATA frame) содержит полезные данные. Используя этот механизм, можно получить данные из нескольких запросов в одном соединении.
Это позволяет получать полезные данные из нескольких запросов с одним и тем же заголовком, тем самым идентифицируя их как один запрос.
Сжатие заголовка
Вы могли столкнуться со многими случаями, когда заголовки HTTP даже больше, чем полезная нагрузка. И HTTP/2 имеет очень интересную стратегию под названием HPack для решения этой проблемы.
Во-первых, все в HTTP/2 кодируется перед отправкой, включая заголовки. Это помогает повысить производительность, но это не самое важное в сжатии заголовков.
HTTP/2 сопоставляет заголовок как на стороне клиента, так и на стороне сервера. Из этого HTTP/2 может узнать, содержит ли заголовок одно и то же значение, и отправляет значение заголовка только в том случае, если оно отличается от предыдущего заголовка.
Как видно на картинке выше, запрос № 2 отправит только новый путь, так как другие значения точно такие же как и были. И да, это значительно сокращает размер полезной нагрузки и, в свою очередь, еще больше повышает производительность HTTP/2.
Что такое Protocol Buffer (Protobuf)?
Protobuf — это наиболее часто используемый IDL для gRPC. Здесь вы храните свои данные и функциональные контракты в виде так называемого прото-файла. По сути это протокол сериализации данных, такой как JSON или XML.
Выглядит это так:
message Person
Так мы определили сообщение Person с полями name , id и email
Поскольку это форма контракта то и клиент, и сервер должны иметь один и тот же прото-файл. Файл proto действует как промежуточный контракт для клиента, чтобы вызвать любые доступные функции с сервера.
Protobuf также имеет собственные механизмы, в отличие от обычного REST API, который просто отправляет строки JSON в виде байтов. Эти механизмы позволяют значительно уменьшить полезную нагрузку и повысить производительность.
Что еще может предложить gRPC?
Метаданные
Вместо обычного заголовка HTTP-запроса в gRPC есть то, что называется метаданными (Metadata). Метаданные — это тип данных «ключ-значение», которые можно установить как на стороне клиента, так и на стороне сервера.
Заголовок может быть назначен со стороны клиента, в то время как серверы могут назначать заголовок и трейлеры, если они оба представлены в виде метаданных.
Потоковая передача
Потоковая передача (Streaming) — это одна из основных концепций gRPC, когда в одном запросе может выполняться несколько действий. Это стало возможным благодаря упомянутой ранее возможности мультиплексирования HTTP/2.
Существует несколько видов потоковой передачи:
- Server Streaming RPC: когда клиент отправляет один запрос, а сервер может отправить несколько ответов. Например, когда клиент отправляет запрос на домашнюю страницу со списком из нескольких элементов, сервер может отправлять ответы по отдельности, позволяя клиенту использовать отложенную загрузку.
- Client Streaming RPC: когда клиент отправляет несколько запросов, а сервер отправляет обратно только один ответ. Например, zip/chunk, загруженный клиентом.
- Bidirectional Streaming RPC: клиент и сервер одновременно отправляют сообщения друг другу, не дожидаясь ответа.
Перехватчики
gRPC поддерживает использование перехватчиков для своего запроса/ответа. Они перехватывают сообщения и позволяют вам изменять их.
Это звучит знакомо? Если вы работали с HTTP-процессами в REST API, перехватчики очень похожи на middleware (оно же промежуточное ПО).
Библиотеки gRPC обычно поддерживают перехватчики и обеспечивают простую реализацию. Перехватчики обычно используются для:
- Изменения запроса/ответа перед передачей. Это можно использовать для предоставления обязательной информации перед отправкой на клиент/сервер.
- Позволяет вам манипулировать каждым вызовом функции, например, добавлять дополнительные логи для отслеживания времени отклика.
Балансировки нагрузки
Если вы еще не знакомы с балансировкой нагрузки, это механизм, который позволяет распределять клиентские запросы по нескольким серверам.
Но балансировка нагрузки обычно делается на уровне прокси (например, nginx). Так причем это здесь?
Дело в том, что gRPC поддерживает метод балансировки нагрузки клиентом. Он уже реализован в библиотеке Golang и может быть легко использован.
Хотя это может показаться какой-то магией, это не так. Там есть что-то типа преобразователя DNS для получения списка IP-адресов и алгоритм балансировки нагрузки под капотом.
Отмена вызова
Клиенты gRPC могут отменить вызов gRPC, когда им больше не нужен ответ. Однако откат на стороне сервера невозможен.
Эта функция особенно полезна для потоковой передачи на стороне сервера, когда может поступать несколько запросов к серверу. Библиотека gRPC оснащена шаблоном метода наблюдателя, чтобы узнать, отменен ли запрос, и позволить ей отменить несколько соответствующих запросов одновременно.
Protocol Buffers
Protocol Buffers — язык описания данных, предложенный Google [1] , как альтернатива XML. Предполагается, что Protocol Buffers проще и легче, чем XML.
Общие сведения
По замыслу разработчиков сначала должна быть описана структура данных, которая затем компилируется в классы, представляющие эти структуры. Вместе с классами идет код их сериализации в компактный формат представления. В дальнейшем используя высокоуровневые языки программирования такие как Java, C++ или Python осуществляется чтение и запись данных.
Недавно бэкенд Twitter перешёл на Protocol Buffers. По заявлению разработчиков Twitter, база в триллион твитов на XML занимала десять петабайт вместо одного. [2]
По заявлениям Google, Protocol Buffers по сравнению с XML:
- Проще
- От 3 до 10 раз меньше
- От 20 до 100 раз быстрее
- Более однозначный
- Позволяет создавать классы, которые в дальнейшем легче использовать программно
Protocol Buffers не предназначен для чтения пользователем. Для десериализации данных, необходим отдельный .proto-файл, в котором определяется формат сообщения.
Примеры использования
Для того, чтобы определить структуру сериализуемых данных необходимо создать .proto-файл с исходным кодом этой структуры. Ниже приведен пример .proto-файла для описания информации о машине: марка, тип кузова, цвет, год выпуска и информация о предыдущих владельцах.
message Car required string model = 1; enum BodyType sedan = 0; hatchback = 1; SUV = 2; > required BodyType type = 2 [default = sedan]; optional string color = 3; required int32 year = 4; message Owner required string name = 1; required string lastName = 2; required int64 driverLicense = 3; > repeated Owner previousOwner = 5; >
После того, как файл с нужной структурой данных создан, необходимо скомпилировать его компилятором для вашего языка программирования, чтобы сгенерировать класс доступа к этим данным. Этот класс будет содержать простейшие методы доступа ко всем полям типа get/set, а также методы для сериализации и десериализации вашей структуры данных в/из массива байтов.
Примечательно, что можно добавлять к уже созданной структуре данных новые поля не боясь утратить совместимость с предыдущей версией: при парсинге старых записей новые поля просто будут игнорироваться.
Реализация
На данный момент компанией Google созданы компиляторы для языков программирования C++, Java и Python [3] . Но существует ряд проектов сторонних разработчиков, которые создали компиляторы для следующих языков программирования: Action Script, C, C#, Clojure, Common Lisp, D, Erlang, Go, Haskell, JavaScript, Lua, Matlab, Mercury, Objective C, OCaml, Perl, PHP, Python, Ruby, Scala, Visual Basic, Delphi [4] .
Примечания
- ↑Developer Guide — Protocol Buffers — Google Code
- ↑Twitter solves its data formatting challenge — Computerworld
- ↑Developer Guide — Protocol Buffers — Google Code
- ↑Third-Party Add-ons for Protocol Buffers
Ссылки
- Использование Protocol Buffers на платформе .Net
- Google опубликовал протокол обмена данными «Protocol Buffers»
- How to use Protocol Buffers
- Программное обеспечение по алфавиту
- Форматы данных
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-классов. И этих классов было много.
Перед нами встали следующие вопросы:
- Как добиться согласованности типов данных в разных языках, если нужно что-то поменять или добавить? Когда классов событий десятки, руками делать не хочется и вероятность ошибки высока. Хочется какой-то инструментальной поддержки.
- Можно ли избежать конвертации в JSON и обратно? Нагрузка у нас была небольшой, поэтому речи о повышении производительности на этом этапе не было. Но интуитивно понятно, что если можно избежать конвертации в текст и обратно, которая происходит только ради передачи на другой узел, то хуже точно не будет.
Отказаться от типов данных и все везде делать в JSON — это не выход. Работа с доменной моделью, «сердце и мозг» бизнеса, превратится в работу со строками и примитивными типами! Мы пустились в поиски.
Поиски решения
Хвала Интернету, варианты начали появляться очень быстро. Одним из первых стала библиотека Wire компании Square. На тот момент она была в версиях 1.x, работала с Protobuf v2, а Protobuf v3 был версии alpha-3. Библиотека Wire не решала всех наших проблем с поддержкой платформ, поскольку предназначена только для Android и Java, но познакомила нас с технологией Protobuf и прикладной кодогенерацией. Protobuf в сравнении с другими выглядел наиболее привлекательно. Но возникла еще одна проблема.
Иерархия Java-классов для событий и других типов данных, которые нужно передавать туда-сюда, плохо ложится на реализацию типов на другом языке. Гибкость на одной стороне (Java) превращается в сложности обеспечения передачи данных. Надо адаптировать наследование в композицию. Когда таких классов много, то трудозатрат на поддержку тоже много. Часть проблем (на тот момент для нас лишь теоретически) Protobuf решал, но не автомагически: наша модель на Java не вписывалась в структуры, которые мы могли построить без наследования.
Мы пришли к следующим решениям:
- Описывать команды, события и все другие типы данных в Protobuf и генерировать код для всех языков проекта.
- Поскольку 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
Тогда в Java в бинарной форме такой объект можно получить так:
byte[] bytes = task.toByteArray();
ByteString bytes = task.toByteString();
Класс ByteString , предоставляемый библиотекой, помогает в преобразовании в Java String, а также в работе с ByteBuffer , InputStream и OutputStream .
В JavaScript объект типа Task можно превратить в байты с помощью метода serializeBinary() :
const bytes = task.serializeBinary();
В результате получим массив чисел. Для обратного превращения генерируется функция deserializeBinary() , стиль вызова которой напоминает статические методы в Java:
const task = myprotos.Task.deserializeBinary(bytes);
Для Dart ситуация похожая. Метод writeToBuffer() на общем предке сгенерированных классов возвращает список беззнаковых целых чисел.
var bytes = task.writeToBuffer();
А конструктор fromBuffer() осуществляет обратное превращение.
var task = Task.fromBuffer(bytes);
Этот механизм позволяет эффективно передавать данные между узлами, записывать и считывать их из разных языков, не требуя дополнительных трудозатрат.
Устойчивость к изменению формата
Protobuf достаточно гибок при необходимости изменить тип данных. Мы не будем описывать все возможности, а остановимся лишь на самых интересных.
Добавление полей
Тут все совсем просто. Добавляете поле, и обновленный тип будет бинарно совместим со старыми значениями. Для предыдущей версии кода наличие нового поля будет прозрачным, но оно будет учитываться при сериализации.
Удаление полей
С удалением полей надо обходиться деликатнее. Можно просто удалить поле, и, скорее всего, все будет работать. Но в таком кавалеристском наскоке есть риски.
Может статься, что при следующей модификации типа кто-то добавит поле с таким же индексом, как недавно удаленное. Это поломает бинарную совместимость. Если же в будущем кто-то добавит поле с таким же именем, но с другим типом, то это поломает совместимость с вызывающим кодом.
Авторы Protobuf рекомендуют такие поля переименовывать, прибавляя префикс OBSOLETE_ , или удалять, помечая индекс этого поля с помощью инструкции reserved.
Во избежание неприятных сюрпризов мы рекомендуем следующий цикл обработки удаления:
-
Пометить поле опцией deprecated :
message MyMessage < // . int32 old_field = 6 [deprecated = true]; >
message MyMessage < // . reserved 6; reserved "old_field"; >
Таким образом, мы страхуемся от случайного использования старого индекса или имени и не тянем за собой устаревший код. Переименование полей выполняется ненамного сложнее.
Переименование полей
Поскольку сериализация основывается на индексах полей, а не на их именах, то, если что-то было названо неудачно, переименование не будет влечь за собой конвертацию данных или необходимость обновлять ПО на всех узлах. Если переименовать поле, то обновленный тип будет бинарно совместим.
Стоит отметить, что подобное переименование лучше проводить «задом наперед». Сначала следует переименовать методы, которые работают с этим полем, в сгенерированном коде. Это, в свою очередь, обновит ссылки на них из кода проекта. И только после этого надо переименовать поле в proto-типе.
Например, у нас есть поле name у типа Task , а мы хотим вместо name иметь title . Чтобы потом не править все вызовы соответствующих методов руками, надо выполнить следующую последовательность:
- Переименовать метод класса Task в Java из getName() в getTitle() .
- Переименовать методы у билдера Task.Builder на getName() и setName() соответственно.
- Выполнить аналогичную процедуру для других языков вашего проекта. И только после этого.
- Переименовать само поле в 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);
У обоих методов есть необязательный параметр типа TypeRegistr y, необходимый для обработки 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 заключается в том, что:
- создание доменных типов происходит быстро и для всех языков системы;
- объекты, представляющие Protobuf-сообщения, сравниваются по значению в глубину во всех платформах. Таким образом, главное свойство Value Objects — равность по значению (нет внешнего идентификатора) реализована «из коробки».
Рассмотрим пример реализации Value Objects на Protobuf.
message EmailAddress
У типа есть одно строковое поле с наивной валидацией через регулярное выражение. Также поле 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);
. и его аналог с типизированными
completeOrder(UserId user, CustomerId customer, OrderId order);
Сказав один раз Id в типе, нам больше не надо повторяться. И можно написать даже так:
completeOrder(UserId u, CustomerId c, OrderId o);
В простом случае типа идентификатора для сущности типа, например, Task выглядит следующим образом:
message TaskId
Вдобавок, спрятав тип реализации идентификатора, мы получаем возможность расширения, если оно потребуется: например, для интеграции данных разных вендоров. В этом нам поможет конструкция 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
Опция (required) — это не стандартная фича Protobuf, а свойство нашей библиотеки валидации, о которой мы поговорим чуть ниже. Эта опция говорит, что поле name обязательно к заполнению, в отличие от поля description , которое можно опустить. ID сущности, к которой относится команда, обязательно по умолчанию. Мы пришли к этому соглашению, чтобы упростить себе жизнь.
Событие декларируется аналогично:
// A new task has been created. message TaskCreated
Сообщения атомарны и не зависят друг от друга. События, как правило, являются следствиями команд, но не связаны с ними на уровне данных. Легко заметить, что ссылка на сущность осуществляется с помощью типизированного ID. Поразмыслив над тем, как мы работаем, мы решились на следующий шаг.
Отказ от ORM
Поскольку в командах и событиях мы ссылаемся на сущности с помощью типизированных ID, то ссылки между сущностями, скорее всего, тоже должны быть на основе этих ID. Если связей, по сути, мало, то их можно хранить прямо в типе данных сущности. Например, если у нас в задаче есть подзадачи, то мы можем сформулировать это так:
message Task
А если этих связей много, то их в любом случае нужно возвращать запросом к базе. При этом ссылка хранится на стороне объектов, которых много, и выставляется для поиска как колонка:
message User < UserId // . CountryCode primary_citizenship = 42 [(column) = true]; >
При событийном подходе недостающую информацию можно получать путем накопления из необходимых событий, а не прохождения графа объектов, когда понадобилось. И если очень надо, то сущность мы можем получить у репозитория через его ID. Мы подошли к решению проблемы The Object-Relational Impedance Mismatch, постановив:
- хранить данные сущности бинарно или в JSON (в зависимости от типа базы и характера задач);
- в других колонках хранить только свойства, используемые для поиска, если подобные данные невозможно или неэффективно получать с помощью накапливания информации через события.
Дальше в зависимости от требований предметной области можно добавлять колонки:
message User < UserId // . 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 bytes data = 2; >
Как же выглядят сущности при таком подходе?
Работа с сущностями
Рассмотрим пример декларации сущности класса TaskItemProjection , который наполняет данные для одной строки в списке задач типа TaskItem :
final class TaskItemProjection extends Projection < @Subscribe void on(TaskCreated e) < builder().setId(e.getId()) .setName(e.getName()) >>
Класс наследуется от базового класса Projection , посылая три generic-аргумента:
- тип идентификаторов, TaskId ;
- тип состояния сущности, TaskItem ;
- тип билдера состояния, TaskItem.Builder .
Чтобы иметь контроль над модификацией состояния сущности, мы договорились о следующем:
- Переход между состояниями сущности происходит посредством обработки сообщения (команды или события). В примере выше это событие TaskCreated .
- Состояние можно получить из метода state() всегда. Этот метод доступен из базовых классов сущностей.
- Модификация состояния осуществляется через метод builder() , также предоставляемый базовыми классами. Метод возвращает конкретный тип, наследуемый от Message.Builder (см. третий generic parameter). По сути, билдер получается в результате вызова state().toBuilder() .
- Метод 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 [(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 генерирует самостоятельно для объединения доступа на чтение как к полям типа сообщения, так и его билдера. Таким образом, «трюк» с миксинами состоит из следующих шагов:
- Создаем тип MyMessage .
- Генерируем для него код.
- Работаем. Обнаруживаем, что стандартных методов не хватает.
- Создаем MyMessageMixin , наследуемый от MyMessageOrBuilder . Добавляем в него необходимое поведение как default-методы, опираясь на существующие из наследуемого интерфейса.
- Добавляем опцию (is).java_type = «MyMessageMixin» в MyMessage .
- Перегенерируем код.
В MyMessageMixin можно и не наследоваться от MyMessageOrBuilder , а просто вписать необходимые методы вручную. Тогда процедура будет короче, но кода получится больше. Судя по нашему опыту, необходимость «доточить» возникает не сразу, и интерфейс, . OrBuilder , на который можно опереться, уже есть.
Мы пришли к такому подходу не сразу, а через работу с утилитными классами. Процедура может показаться громоздкой. Но, во-первых, она выполняется нечасто, а во-вторых, мы смело можем сказать, что такой подход делает вызывающий код компактнее и понятнее. В общем, усилия того стоят.
Как работать с Any
В ранее приведенных примерах на Protobuf мы видели, что одни типы Message могут агрегировать другие. Но как сослаться на тип сообщения, который заранее не известен?
Что делать, если тип заранее не известен
Положим, мы хотим определить команду, посылаемую с клиента на сервер. Все общие атрибуты мы можем складывать в тип CommandContext , а суть запроса хранить отдельным полем. Но какой тип прописать? Ведь он заранее не известен. Поскольку в Protobuf все несложно сериализуется, можно было бы воспользоваться типом bytеs и получить следующую конструкцию:
message Command < CommandId // An instruction to do something. bytes message = 2; // Common things like UserId, the time of creation, etc. CommandContext context = 3; >
Но как на принимающей стороне понять, что лежит внутри bytes ? Для этого мы могли бы сначала записать имя типа, а потом сериализованное представление объекта. Но тогда придется следить за соблюдением этого неявного протокола. Можно было бы тип сообщения определять отдельным полем, тогда протокол становится явным:
message Command
Но теперь у нас есть пара полей, которые концептуально объединены. И нужно следить за тем, чтобы подобные пары обрабатывались на запись и чтение согласованно. Скорее всего, эту пару надо объединить в новый тип. Именно таким типом и является google.protobuf.Any .
Any thing goes
Опуская коментарии, тип Any определен в Protobuf так:
message Any
Поле 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-типа. Чтобы наполнять этот реестр, мы:
- настраиваем Protobuf-компилятор так, чтобы он генерировал файл со всеми дескрипторами;
- включаем этот файл в classpath проекта;
- во время исполнения находим все такие файлы в classpath и вычитываем их содержимое;
- на основе информации из дескрипторов типов строим соответствие 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 выходит за рамки формата сериализации. Некоторые случаи использования, такие как определение доменной модели, требуют не просто описания типов данных, но и метаинформации для этих типов. Один из примеров такой метаинформации — валидационные правила.
При проектировании механизма валидации мы ставили перед собой следующие цели:
- Валидационные правила для типов данных должны быть определены где-то рядом с объявлением структуры или прямо в ней, а не во внешних источниках: не хочется складывать в уме proto и, например, Java-код. Кроме того, крайне желательно, чтобы эти правила сразу работали для всех языков, а не только для одного. Не хочется писать валидацию руками по несколько раз.
- Механизм валидации должен быть легко расширяем и для нашего фреймворка, и для его клиентов.
Первое требование сразу исключает использование сторонних валидационных библиотек, предлагающих определять «валидаторы» как отдельные объекты, которые проходятся по полям нашей сущности и проверяют их значения на соответствие правилам.
Второе требование заставляет нас поддерживать «окно», через которое во время выполнения пользовательский код может добавлять проверки в соответствии со своими бизнес-правилами.
Исходя из описанных требований, мы построили инфраструктуру для валидации Protobuf-сообщений. Правила валидации определяются с помощью кастомных опций для полей и сообщений.
Наши опции, такие как (required) , (pattern) , (min) , (max) и т. д., превращаются в исполняемый код во время компиляции Protobuf. Этот валидационный код встраивается прямо в обычный сгенерированный код.
Кроме «стандартных» опций, пользователи нашей валидации могут определять и использовать собственные. Этот функционал поддерживается только в Java. Для этого пользователь реализует интерфейс ValidatingOptionFactory , переопределяет в нем методы, возвращая только новые опции и только для нужных типов полей. Например, вот так выглядит одна из фабрик, поставляемых как часть фреймворка:
public final class WhenFactory implements ValidatingOptionFactory < @Override public Set> forMessage() < return ImmutableSet.of(When.create()); >>
When — название опции, определяющей дополнительную валидацию для полей, хранящих время.
Далее, используя механизм ServiceLoader в Java, пользователь подкладывает свою реализацию фабрики, а наш код во время исполнения находит ее и, когда нужно, вызывает дополнительную валидацию.
В результате использование опций выглядит вот так:
message Project
Читатель, знакомый с изменениями 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-опциях, необходимо:
- описать несколько (до десяти, как показывает опыт) «стандартных» валидационных правил — они покроют бо́льшую часть необходимых сценариев;
- написать на вашем языке программирования валидатор, который анализирует опции в run-time и проверяет сообщения на соответствие валидационным правилам;
- если производительность run-time-валидатора не вполне вас устраивает, вы всегда можете написать плагин для Protobuf-компилятора, который будет генерировать валидационный код, разбирая опции во время сборки проекта.
Первое время мы работали, остановившись на шаге 2. Это позволяло тратить на код проверки корректности данных сравнительно немного времени. Но со временем необходимость в ускорении кода становилась все более очевидной. Потратив несколько человеко-недель, мы написали кодогенерацию для наших стандартных опций, реализовав шаг 3. Результатом стал существенный рост производительности: обработка 1 тыс. сообщений (1170, если быть точными), в процессе которой валидация происходит в среднем по раза на каждое сообщение в одном серверном потоке, ранее занимавшая ~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
Ключевое слово stream в этом случае позволяет организовать взаимодействие процессов таким образом, что подписаться/отписаться и получать уведомления можно в любой момент. Если в gRPC такая конструкция работает, то стандартный Protobuf Compiler слово stream просто игнорирует. Вероятно, наличие gRPC сделало стандартную поддержку асинхронности ненужной. Но не в нашем случае.
В результате наши коллеги из группы интеграции написали кодогенерацию сервисов для нужд своих проектов, используя gRPC «вприглядку». Конечно же, значительные улучшения в существующем коде не даются даром, и пришлось повозиться. Но, по мнению руководителя группы Владимира Икрянова, это быстро окупилось: «Теперь никто из ребят не боится брать новые фичи или менять формат общения между процессами. Мы стали работать значительно быстрее и увереннее». Авторы статьи с радостью отмечают, что в следующих обновлениях Jessica и Dorothy выйдут, не только похорошев снаружи, но и постройнев и помолодев внутри.
Выводы
Protobuf — это игра с высокими ставками. В статье мы описали результаты нашего пятилетнего опыта работы с этой технологией. В целом преимущества, полученные от использования Protobuf, оправдывают усилия, потраченные на изучение, кастомизацию и настройку. Однако нельзя сказать, что таковых усилий не было вообще. Как и в любой технологии, в Protobuf вы не найдете silver bullet. Применимость его к вашей задаче оценивать лишь вам.
Темная сторона protobuf
В среде разработчиков часто бытует мнение, что протокол сериализации protobuf и его реализация — это особая, выдающаяся технология, способная решить все реальные и потенциальные проблемы с производительность одним фактом своего применения в проекте. Возможно на такое восприятие влияет простота применения этой технологии и авторитет самой компании Google.
К сожалению, на одном из проектов мне пришлось вплотную столкнуться с некоторыми особенностями, которые никак не упоминаются в рекламной документации, однако сильно влияют на технические характеристики проекта.
Все последующее изложение касается только реализации protobuf на платформе Java. Также в основном описана версия 2.6.1, хотя в уже выпущенной версии 3.0.0 принципиальных изменений я также не увидел.
Также обращаю факт, что статья не претендует на полноту обзора. Про хорошие стороны технологии (например, это мультиязычность и отличная документация) можно почитать на официальном сайте. Эта статья рассказывает только про проблемы и, возможно, позволит принять более взвешенное решение. Одна часть проблем относится к самому формату, другая часть проблем относится к реализации. Также нужно уточнить, что большинство упомянутых тут проблем проявляются при определённых условиях.
maven-проект с уже подключенными зависимостями для самостоятельного исследования можно взять на github.
0. Необходимость препроцессинга
Это наименьшая проблема, даже не хотел включать ее в перечень, но для полноты пусть будет упомянута. Для того чтобы получить java-код необходимо запустить компилятор protoc. Некоторая проблема есть в том, что этот компилятор представляет собой нативное приложение и на каждой из платформ исполняемый файл будет своим, поэтому обойтись простым подключением maven-плагина не получится. Как минимум нужна переменная окружения на машинах разработчиков и на CI-сервере, которая будет указывать на исполняемый файл, и после этого его уже можно запускать из maven/ant сценария.
Как вариант, можно сделать maven-pluging, который держит в ресурсах все бинарники и распаковывает из себя нужный под текущую платформу в временную папку, откуда и запускает его. Не знаю, может такой кто-то уже и сделал.
В общем, невелик грех, поэтому простим.
1. Непрактичный код
К сожалению, для платформы Java генератор protoc производит очень непрактичный код. Вместо того, чтобы сгенерировать чистенькие anemic-контейнеры и отдельно сериализаторы к ним, генератор упихивает все в один большой класс с подклассами. Генерируемые бины нельзя ни внедрить в свою иерархию, ни даже банально заимплементировать интерфейс java.util.Serializable для спихивания бинов на куда-нибудь сторону. В общем они годятся только в качестве узкоспециализированных DTO. Если вас это устраивает — то это и не проблема вовсе, только не заглядывайте внутрь.
2. Излишнее копирование — низкая производительность
Собственно вот тут у меня начались уже совершенно объективные проблемы. Генерируемый код для каждой описываемой сущности (назовем ее «Bean») создает два класса (и один интерфейс, но он не важен в данном контексте). Первый класс — это immutable Bean который представляет собой read-only слепок данных, второй класс — это mutable Bean.Builder, который уже можно править и устанавливать значения.
Зачем так сделано, осталось непонятным. Кто-то говорит, что авторы входят в секту адептов ФП; кто-то утверждает что так они пытались избавится от циклических зависимостей при сериализации (как это им помогло?); кто-то говорит, что protobuf первой версии работал только с mutable-классами, а глупые люди стреляли при этом себе в ноги.
Можно было бы сказать, что на вкус и цвет архитектуры разные, но при таком дизайне для того чтобы получить байтовое представление вам нужно создать Bean.Builder, заполнить его, затем вызвать метод build(). Для того чтобы изменить бин, нужно создать его билдер через метод toBuilder(), изменить значение и затем вызвать build().
И все ничего, только при каждом вызове build() и toBuilder() происходит копирование всех полей из экземпляра одного класса в экземпляр другого класса. Если все что вам нужно — это получить байтовый массив для сериализации или изменить пару полей, то это копирование сильно мешает. Кроме того, в этом методе похоже (я сейчас выясняю) присутствует многолетняя проблема, которая приводит к тому, что копируются даже те поля, значения которых даже не были установлены в билдере.
Вы вряд ли заметите это, если у вас мелкие бины с небольшим количеством полей. Однако мне в наследство досталась целая библиотека, количество полей в отдельных бинах которой достигало трех сотен. Вызов метода build() для такого бина занимает около 50мкс в моем случае, что позволяет обработать не более 20000 бинов в секунду.
Ирония в том, что в моем случае другие тесты показывают, что сохранение подобного бина через Jackson/JSON в два-три раза быстрее (в случае если проинициализированы не все поля и большую часть полей можно не сериализовать).
3. Потеря ссылочности
Если у вас есть графоподобная структура, в которой бины ссылаются друг на друга, то у меня для вас плохая новость — protobuf не подходит для сериализации таких структур. Он сохраняет бины по-значению, не отслеживая факт того, что этот бин уже был сериализован.
Другими словами если у вас есть bean1 и bean2, которые ссылаются друг на друга, то при сериализации-десериализации вы получите bean1, который ссылается на бин bean3; а также bean2, который ссылается на бин bean4.
Уверен, что в подавляющем большинстве случаев такая функциональность не нужна и даже противопоказана в простых DTO. Однако эта проблема проявляется и в более естественных случаях. Например, если вы добавите один и тот же бин в коллекцию 100 раз, он будет сохранен все 100 раз, а не одиножды. Или вы сериализуете список лотов (товаров). Каждый из лотов представляет собой мелкий бин с описанием (количество, цена, дата), а также со ссылкой на развесистое описание продукта. Если сохранять в лоб, то описание продукта будет сериализовано столько раз, сколько существует лотов, даже если все лоты указывают на один и тот же продукт. Решением этой проблемы будет отдельное сохранение продуктов в виде словаря, но это уже дополнительные действия — и при сериализации, и при десереализации.
Описанное поведение является абсолютно ожидаемым и естественным для текстовых форматов типа JSON/XML. Но вот от бинарного формата ожидаешь несколько другого, тем более, что штатная сериализация Java в этом плане работает ровно так, как и ожидается.
4. Компактность под вопросом
Бытует мнение, что protobuf является суперкомпактным форматом. На самом деле компактность сериализации обеспечивается всего несколькими факторами:
-
Реализованы и используются по-умолчанию типы var-int и var-long — как знаковые, так и для беззнаковые. Поля таких типов позволяют сэкономить место, в случае если реальные значения в этих полях невелики. Иными словами, если распределение по всему диапазону значений неравномерно и основная масса значений сконцентрирована около нуля. Например, при сохранении значения 23L оно займет всего лишь один байт вместо восьми. Но с другой стороны, если вы сохраните Long.MAX_VALUE, то такое значение займет уже все десять байт.
Предположим, приложение генерирует такие данные, что в процентном соотношении в байтовом представлении строки занимают 75%, а примитивы занимают 25%. В таком случае, даже если наш алгоритм оптимизации примитивов сократит необходимое для их хранения место до нуля, мы получим экономию всего в 1/4.
В некоторых случаях компактность сериализация является весьма критичной, например для мобильных приложений в условиях плохой/дорогой связи. В таких случаях без дополнительной компрессии поверх protobuf не обойтись, иначе мы будем впустую гонять избыточные данные в строках. Но тогда вдруг выясняется, что аналогичный комплект [JSON+GZIP] при сериализации дает несильно больший размер по сравнению с [PROTOBUF+ZIP]. Конечно, вариант [JSON+GZIP] будет также потреблять больше ресурсов CPU при работе, но в тоже время, он зачастую также является еще и более удобным.
protoc v3
В protobuf третьей версии появился новый режим генерации «Java Nano». Его еще нет в документации, а runtime этого режима еще в стадии alpha, но пользоваться им можно уже сейчас при помощи переключателя «—javanano_out».
В этом режиме генератор создает анемичные бины с публичными полями (без сеттеров и без геттеров) и с простыми методами сериализации. Лишнего копирования нет, поэтому проблема #2 решена. Остальные проблемы остались, более того при наличии циклических ссылок сериализатор выпадает в StackOverflowError.
Принятие решения о сериализации каждого поля производится на основании его текущего значения, а не отдельной битовой маски, что несколько упрощает сами бины.
protostuff
Альтернативная реализация протокола protobuf. В бою не испытывал, но на первый взгляд выглядит очень добротно. Не требует proto-файлов (однако умеет с ними работать, если это необходимо), поэтому решены проблемы #0, #1 и #2. Кроме этого умеет сохранять в свой собственный формат, а также в JSON, XML и YAML. Также интересной является возможность перегонять данные из одного формата в другой потоком, без необходимости полной десериализации в промежуточный бин.
К сожалению, если отдать на сериализацию обычный POJO без схемы, аннотаций и без proto-файлов (так тоже можно), protostuff будет сохранять все поля объекта подряд, в независимости от того были они проинициализированы значением или нет, а это снова сильно бьет по компактности в случае, когда заполнены не все поля. Но насколько я вижу, такое поведение при желании можно подправить, переопределив пару классов.