Что такое protocol buffers
Использование Google Protocol Buffers (protobuf) в Java
Привет, хабровчане. В рамках курса «Java Developer. Professional» подготовили для вас перевод полезного материала.
Недавно вышло третье издание книги «Effective Java» («Java: эффективное программирование»), и мне было интересно, что появилось нового в этой классической книге по Java, так как предыдущее издание охватывало только Java 6. Очевидно, что появились совершенно новые разделы, связанные с Java 7, Java 8 и Java 9, такие как глава 7 «Lambdas and Streams» («Лямбда-выражения и потоки»), раздел 9 «Prefer try-with-resources to try-finally» (в русском издании «2.9. Предпочитайте try-с-ресурсами использованию try-finally») и раздел 55 «Return optionals judiciously» (в русском издании «8.7. Возвращайте Optional с осторожностью»). Но я был слегка удивлен, когда обнаружил новый раздел, не связанный с нововведениями в Java, а обусловленный изменениями в мире разработки программного обеспечения. Именно этот раздел 85 «Prefer alternatives to Java Serialization» (в русском издании «12.1 Предпочитайте альтернативы сериализации Java») и побудил меня написать данную статью об использовании Google Protocol Buffers в Java.
В разделе 85 «Prefer alternatives to Java Serialization» (12.1 «Предпочитайте альтернативы сериализации Java») Джошуа Блох (Josh Bloch) выделяет жирным шрифтом следующие два утверждения, связанные с сериализацией в Java:
«Лучший способ избежать проблем, связанных с сериализацией, — никогда ничего не десериализовать».
«Нет никаких оснований для использования сериализации Java в любой новой системе, которую вы пишете».
После описания в общих чертах проблем с десериализацией в Java и, сделав эти смелые заявления, Блох рекомендует использовать то, что он называет «кроссплатформенным представлением структурированных данных» (чтобы избежать путаницы, связанной с термином «сериализация» при обсуждении Java). Блох говорит, что основными решениями здесь являются JSON (JavaScript Object Notation) и Protocol Buffers (protobuf). Мне показалось интересным упоминание о Protocol Buffers, так как в последнее время я немного читал о них и игрался с ними. В интернете есть довольно много материалов по использованию JSON (даже в Java), в то время как осведомленность о Protocol Buffers среди java-разработчиков гораздо меньше. Поэтому я думаю, что статья об использовании Protocol Buffers в Java будет полезной.
На странице проекта Google Protocol Buffers описывается как «не зависящий от языка и платформы расширяемый механизм для сериализации структурированных данных». Также там есть пояснение: «Как XML, но меньше, быстрее и проще». И хотя одним из преимуществ Protocol Buffers является поддержка различных языков программирования, в этой статье речь пойдет исключительно про использование Protocol Buffers в Java.
album.proto
Несмотря на простоту приведенного выше определения формата протокола, в нем присутствует довольно много информации. В первой строке явно указано, что используется proto3 вместо proto2, используемого по умолчанию, если явно ничего не указано. Две строки, начинающиеся с option, указывают параметры генерации Java-кода (имя генерируемого класса и пакет этого класса) и они нужны только при использовании Java.
Ключевое слово «message» определяет структуру «Album», которую мы хотим представить. В ней есть четыре поля, три из которых строки (string), а одно — целое число (int32). Два из них могут присутствовать в сообщении более одного раза, так как для них указано зарезервированное слово repeated. Обратите внимание, что формат сообщения определяется независимо от Java за исключением двух option, которые определяют детали генерации Java-классов по данной спецификации.
Сгенерированный Java-класс AlbumProtos.java содержит более 1000 строк, и я не буду приводить его здесь, он доступен на GitHub. Среди нескольких интересных моментов относительно сгенерированного кода я хотел бы отметить отсутствие выражений import (вместо них используются полные имена классов с пакетами). Более подробная информация об исходном коде Java, сгенерированном protoc, доступна в руководстве Java Generated Code. Важно отметить, что данный сгенерированный класс AlbumProtos пока никак не связан с моим Java-приложением, и сгенерирован исключительно из текстового файла album.proto, приведенного ранее.
Прежде чем двигаться дальше, нам понадобится простой Java-класс для демонстрации Protocol Buffers. Для этого я буду использовать класс Album, который приведен ниже (код на GitHub).
Album.java
Создадим экземпляр Album с помощью следующего кода:
Чтение массива byte[] обратно в экземпляр Album может быть выполнено следующим образом:
Здесь мы видим, что в обоих экземплярах соответствующие поля одинаковы и эти два экземпляра действительно разные. При использовании Protocol Buffers, действительно, нужно сделать немного больше работы, чем при «почти автоматическом» механизме сериализации Java, когда надо просто наследоваться от интерфейса Serializable, но есть важные преимущества, которые оправдывают затраты. В третьем издании книги Effective Java («Java: эффективное программирование») Джошуа Блох обсуждает уязвимости безопасности, связанные со стандартной десериализацией в Java, и утверждает, что «Нет никаких оснований для использования сериализации Java в любой новой системе, которую вы пишете».
Смотреть открытый вебинар на тему «gRPC для микросервисов или не REST-ом единым».
Protocol Buffers
Протокол сериализации для гетерогенных систем
Контекст
Основная идея
Язык общения
Общие приемущества
Недостатки
Пожалуй, о недостатках лучше всего скажет тот, кто с ними столкнулся и здесь я посоветую вам прочитать вот эту статью на хабре, дабы развеять ненужные иллюзии безоблачного неба.
Пример
Шаг 1. Определяем формат протокола
Модификаторы дают нам больше представления о том как поле используется, например, модификатор required позволяет описать обязательное поле в сообщении, если десериализатор не обнаружит этого поля, то весь процесс десериализации закончится с ошибкой. Это важно учитывать при проектировании API (снова взгляните на второй абзац в разделе “Недостатки” этой статьи). Модификатор optional, говорит о том, что поле может быть, а может отсутствовать, своего рода nullable поле. Модификатор repeated используется для работы с множеством значений для одного поля (аналогично коллекциям в Java).
Вы можете вкладывать messages друг в друга, использовать перечисления enum, в общем очень похоже на Java. Кроме того, есть возможность определить значения по умолчанию.
*Шаг 2. Компилируем файл
* опциональный, для понимания
Шаг 3. Собираем проект
Прежде всего хочу сказать, что не смотря на то, что все примеры на java, работа на других платформах с protobuf аналогична.
Поигрались с терминалом и хватит, перейдем к практическому применению. Создадим gradle проект, цель которого будет перегнать через массив байт группу со студентами. Для автоматизации рутинной деятельности нам поможет инструмент автоматизации сборки gradle. Для вашего случая инструмент может отличаться, но идея должна быть понятна. Для того, чтобы добавить поддержку protocol buffers в цикле сборки нашего проекта, дополним типичный build.gradle файл следующими настройками:
Комментарии к коду исчерпывающие, а в конце статьи я оставлю ссылку на репозиторий, в котором вы найдете запускаемый код.
Шаг 4. Взаимодействуем со сгенерированным кодом
Как вы уже поняли, создавать студентов мы можем аналогично:
Итак, данные мы создали, получили заполненный объект типа Group, теперь необходимо перегнать его в массив байт. Сделать это можно следующим образом:
Для того, чтобы проверить результат выведем его на экран (компилятор создает человекопонятные методы toString для классов, так что с отладкой нет проблем).
В результате, в консоли мы видим:
За ширмой, для полноты примера, я добавил еще одного студента к группе.
Заключение
Здравствуйте, а что такое Protocol Buffers?
Почему мне стоит использовать эту библиотеку вместо встроенных средств?
Так вы утверждаете, что protobuf не уступает бинарной сериализации и к тому же переносим?
namespace Proto.Sample
<
public enum TaskPriority
<
Low,
Medium,
High
>
[ Serializable ] //
[ProtoContract]
public class Task
<
[ProtoMember(1)]
public int Id
[ProtoMember(2)]
public DateTime CreatedAt
[ProtoMember(3)]
public string CreatedBy
[ProtoMember(4)]
public TaskPriority Priority
using System;
using System.Collections. Generic ;
using System.Diagnostics;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using ProtoBuf;
const string file1 = «tasks1.bin» ;
TestBinaryFormatter(tasks, file1, 1000);
TestBinaryFormatter(tasks, file1, 2000);
TestBinaryFormatter(tasks, file1, 3000);
TestBinaryFormatter(tasks, file1, 4000);
TestBinaryFormatter(tasks, file1, 5000);
const string file2 = «tasks2.bin» ;
TestProtoBuf(tasks, file2, 1000);
TestProtoBuf(tasks, file2, 2000);
TestProtoBuf(tasks, file2, 3000);
TestProtoBuf(tasks, file2, 4000);
TestProtoBuf(tasks, file2, 5000);
for ( var i = 0; i var restoredTasks = ( List )formatter.Deserialize(file);
>
for ( var i = 0; i var restoredTasks = Serializer.Deserialize List >(file);
>
The test of binary formatter:
1000 iterations in 423 ms
2000 iterations in 381 ms
3000 iterations in 532 ms
4000 iterations in 660 ms
5000 iterations in 814 ms
The test of protobuf-net:
1000 iterations in 1056 ms
2000 iterations in 76 ms
3000 iterations in 129 ms
4000 iterations in 152 ms
5000 iterations in 202 ms
The comparision of file size:
The size of tasks1.bin is 710 bytes
The size of tasks2.bin is 101 bytes
var model = TypeModel.Create();
model.Add( typeof (Task), true );
var compiledModel = model.Compile(path);
compiledModel.Serialize(file, tasks);
Остальные тесты и результаты можно посмотреть здесь.
Ок. Относительно скорости и сжатия вы меня убедили, но как решается проблема переносимости?
Понимаете, если существуют реализация для нужной вам платформы, вопрос переносимости в большинстве случаев снимается. А реализациии protobuf существует для более чем 20 языков. Полный список можно увидеть здесь. Отмечу только, что для некоторых языков существует более одной реализации. Так что у вас всегда есть выбор.
Протокол Buffers основные вещи, которые вы должны знать
Protocol Buffers — это двоичный протокол, разработанный в Google и сделанный общедоступным. Первой общедоступной версией была Protocol Buffers version 2. Самая последняя реализация на момент написания этой статьи — Protocol Buffers version 3. Версия 1 никогда не была общедоступной.
Буферы протокола — это независимый от языка, платформенно-независимый, расширяемый механизм Google для сериализации структурированных данных. Буферы протокола в настоящее время поддерживают сгенерированный код в Java, Python, Objective-C, C ++, C #, JS и других. Вы определяете, как вы хотите, чтобы ваши данные были структурированы один раз, затем вы можете использовать специальный сгенерированный исходный код, чтобы легко записывать и считывать ваши структурированные данные в различные потоки данных и из них, используя различные языки.
Причины смотреть на буфер протокола
Вы можете спросить себя, зачем мне вообще рассматривать или рассматривать буферы протокола, поэтому давайте рассмотрим несколько причин.
Размер данных
Тот факт, что Protobuf является двоичным протоколом, дает ему очень хорошие характеристики. По умолчанию его пропускная способность намного выше по сравнению с текстовыми протоколами, например, JSON и XML. Это не должно быть сюрпризом. Однако, например, разница в пропускной способности между буферами протокола и JSON может быть уменьшена путем сжатия данных при отправке с использованием текстовых протоколов, и можно использовать некоторые другие приемы. Если это будет сделано, буферные протоколы будут по-прежнему лучше пропускной способности, но не с таким большим запасом, поэтому я советую всегда делать это, если вы используете текстовые форматы для передачи данных.
Загрузка процессора
Еще одна важная вещь, которую необходимо принять во внимание, — это интенсивная загрузка ЦП маршалинга и демаршалинга данных. По умолчанию текстовые протоколы будут намного интенсивнее загружать процессор, и это не так легко решить, как, например, разница в пропускной способности.
Если вы думаете, что, возможно, это не так уж и важно, подумайте на секунду о вашем сценарии использования. Сколько данных вы передаете и кто потребляет и производит ваши данные. Если вы размещаете свои серверы в каком-либо облаке и оплачиваете их процессором, это может повлиять на ваш счет. Мобильные устройства не такие мощные, как наши компьютеры с процессором, и любая интенсивная загрузка процессора приведет к разрядке их батарей. В обоих случаях двоичный протокол Protobuf является хорошим выбором для повышения производительности и удобства работы пользователей.
использование
Теперь, когда мы знаем несколько причин, по которым вам следует больше узнать о буферах протоколов, давайте посмотрим, как это выглядит и как мы можем его использовать.
В случае протокольных буферов все начинается со схемы или, если быть более точным, прото-файл. Если этот файл протока используется как часть gRPC, то есть плагины maven и gradle для автоматической генерации классов и тому подобное для нас. В случае, если буферы протокола будут использоваться в качестве автономных, мы можем сгенерировать все классы и аналогичные вручную, используя компилятор буферов протокола.
Protobuffers — это неправильно
Значительную часть своей профессиональной жизни я выступаю против использования Protocol Buffers. Они явно написаны любителями, невероятно узкоспециализированы, страдают от множества подводных камней, сложно компилируются и решают проблему, которой на самом деле нет ни у кого, кроме Google. Если бы эти проблемы протобуферов остались в карантине абстракций сериализации, то мои претензии на этом и закончились бы. Но, к сожалению, плохой дизайн Protobuffers настолько навязчив, что эти проблемы могут просочиться и в ваш код.
Узкая специализация и разработка любителями
Остановитесь. Закройте свой почтовый клиент, где уже написали мне полписьма о том, что «в Google работают лучшие в мире инженеры», что «их разработки по определению не могут быть созданы любителями». Не хочу этого слышать.
Давай просто не будем обсуждать эту тему. Полное раскрытие: мне доводилось работать в Google. Это было первое (но, к сожалению, не последнее) место, где я когда-либо использовал Protobuffers. Все проблемы, о которых я хочу поговорить, существуют в кодовой базе Google; это не просто «неправильное использование протобуферов» и тому подобная ерунда.
Безусловно, самая большая проблема с Protobuffers — ужасная система типов. Поклонники Java должны чувствовать себя здесь как дома, но, к сожалению, буквально никто не считает Java хорошо спроектированной системой типов. Ребята из лагеря динамической типизации жалуются на излишние ограничения, в то время как представители лагеря статической типизации, вроде меня, жалуются на излишние ограничения и отсутствие всего того, что вы на самом деле хотите от системы типов. Проигрыш в обоих случаях.
Узкая специализация и разработка любителями идут рука об руку. Многое в спецификациях словно прикручено в последний момент — и оно явно было прикручено в последний момент. Некоторые ограничения заставят вас остановиться, почесать голову и спросить: «Какого чёрта?» Но это всего лишь симптомы более глубокой проблемы:
Очевидно, протобуферы созданы любителями, потому что предлагают плохие решения широко известных и уже решённых проблем.
Отсутствие композиционности
Protobuffers предлагают несколько «фич», которые не работают друг с другом. Например, посмотрите на список ортогональных, но в то же время ограниченных функций типизации, которые я нашёл в документации.
Ваши догадки о проблеме с enum так же верны, как и мои.
Что так расстраивает во всём этом, так это слабое понимание, как работают современные системы типов. Это понимание позволило бы кардинально упростить спецификацию Protobuffers и одновременно удалить все произвольные ограничения.
Решение заключается в следующем:
Например, можно переделать поля optional :
Создание полей repeated тоже просто:
Конечно, реальная логика сериализации позволяет делать что-то умнее, чем пушить связанные списки по сети — в конце концов, реализация и семантика не обязательно должны соответствовать друг другу.
Сомнительный выбор
Конечно же, в двух разновидностях типов совершенно разная семантика.
Невозможно отличить поле, которое отсутствовало в протобуфере, от поля, которому присвоено значение по умолчанию. Предположительно, это решение сделано для оптимизации, чтобы не пересылать скалярные значения по умолчанию. Это лишь предположение, потому что в документации не упоминается эта оптимизация, так что ваше предположение будет не хуже моего.
Когда будем обсуждать претензии Protobuffers на идеальное решение для обратной и будущей совместимости с API, мы увидим, что эта неспособность различать неустановленные значения и значения по умолчанию — настоящий кошмар. Особенно если это действительно сознательное решение, чтобы сохранить один бит (установлено или нет) для поля.
Сравните это поведение с типами сообщений. В то время как скалярные поля являются «тупыми», поведение полей сообщений совершенно безумно. Внутренне, поля сообщений либо есть, либо их нет, но поведение сумасшедшее. Небольшой псевдокод для их аксессора стоит тысячи слов. Представьте такое в Java или где-то ещё:
Такое поведение особенно вопиюще, потому что оно нарушает закон! Мы ожидаем, что задание msg.foo = msg.foo; не будет работать. Вместо этого реализация фактически втихаря изменяет msg на копию foo с инициализацией нулями, если её раньше не было.
(но макросы препроцессора запрещены руководством по стилю Google).
Это не очень приятно слышать, тем более тем из нас, кто любит параметрический полиморфизм, который обещает в точности противоположное.
Ложь обратной и будущей совместимости
Одна из часто упоминаемых «киллер-фич» Protobuffers — их «беспроблемная способность писать обратно- и вперёд-совместимые API». Это утверждение повесили у вас перед глазами, чтобы заслонить правду.
Что Protobuffers являются разрешительными. Им удаётся справиться с сообщениями из прошлого или будущего, потому что они не дают абсолютно никаких обещаний, как будут выглядеть ваши данные. Всё опционально! Но если вам это нужно, Protobuffers с удовольствием приготовит и подаст вам что-то с проверкой типов, независимо от того, имеет ли это смысл.
Это означает, что Protobuffers выполняют обещанные «путешествия во времени», втихую делая неправильные вещи по умолчанию. Конечно, осторожный программист может (и должен) написать код, выполняющий проверку корректности полученных протобуферов. Но если на каждом сайте писать защитные проверки корректности, может, это просто означает, что шаг десериализации был слишком разрешительным. Всё, что вам удалось сделать, это децентрализовать логику проверки корректности с чётко определённой границы и размазать её по всей кодовой базе.
Один из возможных аргументов — что протобуферы сохранят в сообщении любую информацию, которую не понимают. В принципе, это означает неразрушающую передачу сообщения через посредника, который не понимает эту версию схемы. Это же явная победа, не так ли?
Конечно, на бумаге это классная функция. Но я ни разу не видел приложения, где действительно сохраняется это свойство. За исключением программного обеспечения для маршрутизации, ни одна программа не хочет проверять только некоторые биты сообщения, а затем пересылать его в неизменном виде. Подавляющее большинство программ на протобуферах будут декодировать сообщение, трансформировать его в другое и отправлять в другое место. Увы, эти преобразования делаются на заказ и кодируются вручную. И ручные преобразования из одного протобуфера в другой не сохраняют неизвестные поля, потому что это буквально бессмысленно.
Это повсеместное отношение к протобуферам как универсально совместимым проявляется и другими уродливыми способами. Руководства по стилю для Protobuffers активно выступают против DRY и предлагают по возможности встраивать определения в код. Они аргументируют тем, что это позволит в будущем использовать отдельные сообщения, если определения разойдутся. Подчеркну, они предлагают отказаться от 60-летней практики хорошего программирования на всякий случай, вдруг когда-то в будущем вам потребуется что-то изменить.
Корень проблемы в том, что Google объединяет значение данных с их физическим представлением. Когда вы находитесь в масштабе Google, такое имеет смысл. В конце концов, у них есть внутренний инструмент, который сравнивает почасовую оплату программиста с использованием сети, стоимостью хранения X байтов и другими вещами. В отличие от большинства технологических компаний, зарплата программистов — одна из самых маленьких статей расходов Google. Финансово для них имеет смысл тратить время программистов, чтобы сэкономить пару байтов.
Кроме пяти ведущих технологических компаний, больше никто не находится в пределах пяти порядков масштаба Google. Ваш стартап не может позволить тратить инженерные часы на экономию байтов. Но экономия байтов и трата времени программистов в процессе — это именно то, для чего оптимизированы Protobuffers.
Давайте посмотрим правде в глаза. Вы не соответствуете масштабу Google, и никогда не будете соответствовать. Прекратите карго-культ использования технологии только потому, что «Google использует её», и потому что «это лучшие отраслевые практики».
Protobuffers загрязняет кодовые базы
Если бы можно было ограничить использование Protobuffers только сетью, я бы не высказывался так жёстко об этой технологии. К сожалению, хотя в принципе существует несколько решений, ни одно из них не достаточно хорошо, чтобы фактически использоваться в реальном программном обеспечении.
Protobuffers соответствуют данным, которые вы хотите отправить по каналу связи. Они часто соответствуют, но не идентичны фактическим данным, с которыми приложение хотело бы работать. Это ставит нас в неудобное положение, необходимо выбирать между одним из трёх плохих вариантов:
Вместо этого код, использующий протобуферы, позволяет им распространяться по всей кодовой базе. Это реальность. Моим основным проектом в Google был компилятор, который брал «программу», написанную на одной разновидности Protobuffers, и выдавал эквивалентную «программу» на другой. Форматы ввода и вывода достаточно отличались, чтобы их правильные параллельные версии C++ никогда не работали. В результате мой код не мог использовать ни одну из богатых техник написания компиляторов, потому что данные Protobuffers (и сгенерированный код) были слишком жёстким, чтобы сделать с ними что-нибудь интересное.
В результате вместо 50 строк схем рекурсии использовались 10 000 строк специального тасования буфера. Код, который я хотел написать, был буквально невозможен при наличии протобуферов.
Хотя это один случай, он не уникален. В силу жёсткой природы генерации кода, проявления протобуферов в языках никогда не будут идиоматическими, и их невозможно сделать такими — разве что переписать генератор кода.
Но даже тогда у вас останется проблема встроить дерьмовую систему типов в целевой язык. Поскольку большинство функций Protobuffers плохо продуманы, эти сомнительные свойства просачиваются в наши кодовые базы. Это означает, что мы вынуждены не только реализовывать, но и использовать эти плохие идеи в любом проекте, который надеется взаимодействовать с Protobuffers.
На прочной основе легко реализовать бессмысленные вещи, но если пойти в другом направлении, в лучшем вы столкнётесь со сложностями, а в худшем — с настоящим древним ужасом.
В общем, оставь надежду каждый, кто внедрит Protobuffers в свои проекты.