Protocol Buffers

15.12.2020


Protocol Buffers — протокол сериализации (передачи) структурированных данных, предложенный Google как эффективная бинарная альтернатива текстовому формату XML. Разработчики сообщают, что Protocol Buffers проще, компактнее и быстрее, чем XML, поскольку осуществляется передача бинарных данных, оптимизированных под минимальный размер сообщения.

Общие сведения

По замыслу разработчиков, сначала должна быть описана структура данных, которая затем компилируется в классы. Вместе с классами идёт код их сериализации в компактном формате представления. Чтение и запись данных доступна в высокоуровневых языках программирования — таких как Java, C++ или Python.

В 2010 году бэкенд Twitter перешёл на Protocol Buffers. По заявлению разработчиков Twitter, база в триллион твитов на XML занимала бы десять петабайт вместо одного.

По заявлениям Google, Protocol Buffers по сравнению с XML:

  • проще;
  • от 3 до 10 раз меньше;
  • от 20 до 100 раз быстрее;
  • более однозначный;
  • позволяет создавать классы, которые в дальнейшем легче использовать программно.

Protocol Buffers не предназначен для чтения пользователем и представляет собой двоичный формат. Для десериализации данных необходим отдельный .proto-файл, в котором определяется формат сообщения.

Формат протокола

В общем виде формат представляет собой закодированную последовательность полей, состоящих из ключа и значения. В качестве ключа выступает номер, определённый для каждого поля сообщения в proto-файле. Перед каждым полем указываются совместно закодированные номер поля в формате varint и тип поля. Если в качестве типа указана строка (string), вложенное сообщение, повторяющееся сообщение или набор байт (bytes), то следом идёт размер данных в формате varint. Далее идёт значение, соответствующее полю (данные).

Число в формате varint (int32 и int64) кодируется в последовательность байт, в которой у всех байт, кроме последнего, старший бит (MSB) выставляется в 1. При преобразовании в стандартное представление старший бит каждого байта отбрасывается, а оставшиеся 7-битные составляющие соединяются друг с другом в обратном порядке. Формат восьмиразрядного varint был выбран для уменьшения размера пакета при передаче небольших чисел. Так, если число меньше 128, то оно будет занимать лишь 1 байт. Однако, числа близкие к максимально возможным, будут занимать больше места, чем в обычном формате. Например, максимальное значение, которое можно сохранить в 8-ми байтах, в формате varint — 10 байт. Отрицательные числа в формате varint всегда занимают наибольший размер, в зависимости от типа, поскольку старший бит у знакового числа выставлен в 1.

Проблема кодирования отрицательных чисел была решена использованием алгоритма ZigZag (sint32 и sint64), суть которого сводится к переносу бита знака из старшего разряда в младший. Кодирование алгоритмом ZigZag предполагает, что положительные и отрицательные числа будут чередоваться друг с другом с увеличением закодированного значения. В таком случае чётные числа будут положительными, а нечётные — отрицательными.

Пусть value — исходное значение, а N — разрядность типа данных исходного значения, а encoded_value — закодированное алгоритмом ZigZag значение, тогда кодирование можно записать с помощью выражения на языке Си:

encoded_value = (value << 1) ^ (value >> (N - 1));

Следует учесть, что вторая операция сдвига является арифметическим сдвигом, то есть при сдвиге вправо отрицательного числа старшие биты заполняются единицами, а не нулями (сдвиг знакового бита). Декодирование выполняется более сложным способом: выполняется исключающее ИЛИ над закодированным значением, сдвинутым на 1 вправо для удаления знакового бита, и знаковым битом, полученным из закодированного числа, спроецированным на все биты через умножение на максимальное значение для N разрядов. Таким образом, знаковый бит переносится из младшего разряда в старший:

uvalue = ((encoded_value & 1) * MAX_VALUE(N)) ^ (encoded_value >> 1);

Значение MAX_VALUE(N) соответствует значению с N разрядами, заполненными единицами (например, 0xffffffff при N=32). Таким образом, умножение младшего бита, установленного в 1, на это число будет соответствовать значению -1 в знаковом типе данных. В языке Си декодирование необходимо осуществлять для значений encoded_value и uvalue беззнакового типа, а затем значение uvalue должно преобразовываться в знаковый тип, не меняя битовое представление.

Все числовые значения, кроме fixed64, sfixed64 и double, в протоколе кодируются в формате varint.

Примеры использования

Для того чтобы определить структуру сериализуемых данных, необходимо создать .proto-файл с исходным кодом этой структуры. Ниже приведён пример .proto-файла для 2-й версии Protocol Buffers, где описывается информация о машине: марка, тип кузова, цвет, год выпуска, и информация о предыдущих владельцах.

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, Go, C#, Objective C, JavaScript. Но существует ряд проектов сторонних разработчиков, которые создали компиляторы для следующих языков программирования: Action Script, C, C#, Clojure, Common Lisp, D, Erlang, Go, Haskell, Haxe, JavaScript, Lua, Matlab, Mercury, Objective C, Swift, OCaml, Perl, PHP, Python, Ruby, Rust, Scala, Visual Basic, Delphi.

Реализация для языка Си

Чтобы использовать протокол в языке Си без сторонних библиотек необходимо либо использовать вставки на языке C++, если таковые поддерживаются используемым компилятором, либо делать обёртки над сгенерированным для C++ кодом в виде библиотек. Если подобные варианты не подходят, то известны следующие генераторы кода:

  • protobuf-c (нет поддержки обработки ошибок выделения памяти, но возможно использование собственных механизмов выделения памяти);
  • nanopb (оптимизирован под низкое потребление памяти);
  • protobuf-embedded-c (архивный проект).

Альтернативные протоколы

Наиболее высокопроизводительной альтернативой может служить библиотека FlatBuffers, которая позволяет обращаться к сериализованным данным без их копирования по частям в отдельные области памяти. Соответственно, данные передаются в том же виде, в каком и используются, в связи с чем увеличивается объём передаваемых данных.

Проект Apache Avro отличается тем, что не требует генерации кода при изменении схемы данных при использовании в динамически-типизируемых языках, а сама схема описывается в формате JSON.

Сравнение с Apache Thrift

Ключевыми особенностями Apache Thrift могут служить возможность передачи ассоциативных массивов, списков и множеств, а также встроенная поддержка удалённого вызова процедур.