Кодирование с эффективным сжатием данных Protobuf

сервер JSON protobuf Google
Кодирование с эффективным сжатием данных Protobuf

1. Что такое буферы протокола?

Буферы протокола — это независимый от языка, независимый от платформы, расширяемый формат для сериализации данных, который можно использовать для протоколов связи, хранения данных и многого другого.

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

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

2. Зачем изобретать буферы протоколов?

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

Буферы протокола изначально использовались Google для разрешения протокола запроса/ответа индексного сервера. До буферов протоколов у Google уже был формат запроса/ответа для ручной обработки запроса/ответа, сортировки и десортировки. Он также может поддерживать многоверсионные протоколы, но код уродлив:

 if (version == 3) {
   ...
 } else if (version > 4) {
   if (version == 5) {
     ...
   }
   ...
 }

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

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

Буферы протоколов были созданы для решения этих проблем. Буферам протоколов присваиваются следующие 2 характеристики:

  • Можно легко вводить новые поля, а промежуточные серверы, которым не нужно проверять данные, могут просто анализировать и передавать данные, не зная всех полей.
  • Формат данных более информативен и может обрабатываться на различных языках (C++, Java и т. д.)

Эта версия буферов протокола по-прежнему требует написанного вручную кода синтаксического анализа.

Однако с постепенным развитием и эволюцией системы протокольные буферы в настоящее время имеют больше возможностей:

  • Автоматически генерируемый код сериализации и десериализации позволяет избежать ручного синтаксического анализа. (Официально предоставляет инструменты автоматической генерации кода, в основном для всех языковых платформ)
  • В дополнение к использованию для запросов RPC (удаленного вызова процедуры) люди начали использовать буферы протокола в качестве удобного формата с самоописанием для сохранения данных (например, в Bigtable).
  • Интерфейс RPC сервера может быть сначала объявлен как часть протокола, затем компилятор протокола генерирует базовые классы, которые пользователь может переопределить с фактической реализацией интерфейса сервера.

Буферы протоколов теперь являются языком общения Google для данных. На момент написания статьи в дереве кодов Google определено 48 162 различных типа сообщений, включая 12 183 файла .proto. Они используются как в RPC-системах, так и для сохранения данных в различных системах хранения.

резюме:

Буферы протоколов были рождены для решения проблемы совместимости старых и новых протоколов (старших и младших версий) на стороне сервера, и название у них тоже весьма предусмотрительное — «буферы протоколов». Просто на более позднем этапе она постепенно превратилась в систему передачи данных..

Буферы протокола названы в честь:

Why the name "Protocol Buffers"?
The name originates from the early days of the format, before we had the protocol buffer compiler to generate classes for us. At the time, there was a class called ProtocolBuffer which actually acted as a buffer for an individual method. Users would add tag/value pairs to this buffer individually by calling methods like AddValue(tag, value). The raw bytes were stored in a buffer which could then be written out once the message had been constructed.

Since that time, the "buffers" part of the name has lost its meaning, but it is still the name we use. Today, people usually use the term "protocol message" to refer to a message in an abstract sense, "protocol buffer" to refer to a serialized copy of a message, and "protocol message object" to refer to an in-memory object representing the parsed message.

Название возникло на заре формата, до того, как компилятор буфера протокола генерировал для нас классы. В то время существовал класс ProtocolBuffer, который фактически действовал как буфер для одного метода. Пользователь может индивидуально добавлять пары тег/значение в этот буфер, вызывая такие методы, как AddValue(tag,value) . Необработанные байты хранятся в буфере и могут быть записаны после создания сообщения.

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

3. proto3 определяет сообщение

В настоящее время последней версией протокольных буферов является proto3, которая несколько отличается от старой версии proto2. Две версии API не полностью совместимы.

Названия proto2 и proto3 кажутся немного сбивающими с толку, потому что, когда мы изначально открывали буферы протоколов с открытым исходным кодом, на самом деле это была вторая версия Google, поэтому она называлась proto2, что также является нашим номером версии с открытым исходным кодом, начиная с причины v2. Первоначальная версия под названием proto1 разрабатывалась в Google с начала 2001 года.

В прототипе все структурированные данные называются сообщениями.

message helloworld 
{ 
   required int32     id = 1;  // ID 
   required string    str = 2;  // str 
   optional int32     opt = 3;  //optional field 
}

Приведенные выше строки операторов определяют сообщение helloworld, которое имеет три члена: id типа int32 и еще один член str типа string. opt является необязательным элементом, то есть член не может быть включен в сообщение.

Далее необходимо обратить внимание на некоторые моменты в proto3.

syntax = "proto3";

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
}

Если первая строка в начале не объявляетsyntax = "proto3";, по умолчанию для парсинга используется proto2.

1. Назначьте номера полей

Каждое поле в каждом определении сообщения имеетуникальный номер. Эти номера полей используются для идентификации полей в двоичном формате сообщения и не должны изменяться после использования типа сообщения. Обратите внимание, что номера полей в диапазоне от 1 до 15 требуют для кодирования одного байта, включая номер поля и тип поля (см.Принцип кодирования буфера протоколаэта глава). Для номеров полей в диапазоне от 16 до 2047 требуется два байта. Таким образом, вы должны оставить числа от 1 до 15 как очень частые элементы сообщения. Не забудьте оставить место для часто встречающихся элементов, которые могут быть добавлены в будущем.

Минимальный номер поля, который можно указать, — 1, а максимальный номер поля — 2^29^-1 или 536 870 911. Номера от 19000 до 19999 (от FieldDescriptor::kFirstReservedNumber до FieldDescriptor::kLastReservedNumber) также нельзя использовать, так как они зарезервированы для реализаций протокольных буферов.

Если один из этих зарезервированных номеров используется в .proto, Protocol Buffers скомпилируется с ошибкой.

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

2. Зарезервированные поля

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

message Foo {
  reserved 2, 15, 9 to 11;
  reserved "foo", "bar";
}

Обратите внимание, что не в том жеreservedСмешивание имен полей и номеров полей в операторах. При необходимости напишите его, как в приведенном выше примере.

3. Правила поля по умолчанию

  • Имена полей не могут повторяться и должны быть уникальными.
  • повторяющиеся поля: любое число может многократно повторяться (в том числе 0) в сообщении, но порядок этих повторяющихся значений сохраняется.

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

4. Соответствие между различными скалярными типами языка

5. Перечисление

Типы перечисления могут быть встроены в сообщение.

message SearchRequest {
  string query = 1;
  int32 page_number = 2;
  int32 result_per_page = 3;
  enum Corpus {
    UNIVERSAL = 0;
    WEB = 1;
    IMAGES = 2;
    LOCAL = 3;
    NEWS = 4;
    PRODUCTS = 5;
    VIDEO = 6;
  }
  Corpus corpus = 4;
}

Следует отметить, что тип перечисления должен иметь значение 0.

  • Перечисление 0 используется как нулевое значение, и когда ему не присвоено значение, оно будет нулевым значением.
  • Для совместимости с proto2. В proto2 нулевое значение должно быть первым значением.

Кроме того, в процессе десериализации в сообщении будут сохраняться нераспознанные значения перечисления. Потому что способ представления сообщения при десериализации зависит от языка. В таких языках, как C++ и Go, которые поддерживают открытые типы перечисления для значений, выходящих за пределы указанного символьного диапазона, неизвестные значения перечисления просто хранятся в их базовом целочисленном представлении. В языках с закрытыми типами перечисления, таких как Java, значения перечисления используются для идентификации нераспознанных значений, а специальные методы доступа могут получить доступ к базовому целому числу.

В других случаях, если сообщение сериализовано, нераспознанное значение все равно будет сериализовано вместе с сообщением.

5. Зарезервированные значения в перечислениях

Если вы обновите тип перечисления, полностью удалив запись перечисления или закомментировав ее, будущие пользователи смогут повторно использовать это значение при внесении собственных обновлений типа. Если позже загрузится в более старую версию.protoфайлы, это может привести к серьезным проблемам с сервером, таким как беспорядок в данных, ошибки конфиденциальности и т. д. Один из способов предотвратить это — указать числовое значение (или имя, которое также может вызвать проблемы с сериализацией JSON) удаленной записи какreserved. Компилятор протокольных буферов будет жаловаться, если какие-либо будущие пользователи попытаются использовать эти идентификаторы полей. ты можешь использовать этоmaxКлючевое слово указывает ваш зарезервированный числовой диапазон до максимально возможного значения.

enum Foo {
  reserved 2, 15, 9 to 11, 40 to max;
  reserved "FOO", "BAR";
}

Обратите внимание, что не в том жеreservedСмешивание имен полей и номеров полей в операторах. При необходимости напишите его, как в приведенном выше примере.

6. Разрешить вложение

Буферы протоколов определяют сообщения, позволяющие вкладывать их в более сложные сообщения.

message SearchResponse {
  repeated Result results = 1;
}

message Result {
  string url = 1;
  string title = 2;
  repeated string snippets = 3;
}

В приведенном выше примере Result используется вложенным в SearchResponse.

Еще примеры:

message SearchResponse {
  message Result {
    string url = 1;
    string title = 2;
    repeated string snippets = 3;
  }
  repeated Result results = 1;
}

message SomeOtherMessage {
  SearchResponse.Result result = 1;
}
message Outer {                  // Level 0
  message MiddleAA {  // Level 1
    message Inner {   // Level 2
      int64 ival = 1;
      bool  booly = 2;
    }
  }
  message MiddleBB {  // Level 1
    message Inner {   // Level 2
      int32 ival = 1;
      bool  booly = 2;
    }
  }
}

7. Несовместимость перечисления

Можно импортировать типы сообщений proto2 и использовать их в сообщениях proto3 и наоборот. Однако перечисления proto2 нельзя использовать непосредственно в синтаксисе proto3 (но это нормально, если их используют импортированные сообщения proto2).

8. Обновление сообщения

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

  1. Не изменяйте структуру данных исходного поля.
  2. Если вы добавите новые поля, любые сообщения, сериализованные с помощью кода, использующего «старый» формат сообщения, по-прежнему могут быть проанализированы вновь сгенерированным кодом. Вы должны помнить значения по умолчанию для этих элементов, чтобы новый код мог правильно взаимодействовать с сообщениями, сгенерированными старым кодом. Точно так же сообщения, созданные новым кодом, могут быть проанализированы старым кодом: старые двоичные файлы просто будут игнорировать новые поля при анализе. (По конкретным причинам см.неизвестное полеэта глава)
  3. Поля можно удалять, если номер поля больше не используется в обновленном типе сообщения. Возможно, вам потребуется переименовать поле, возможно, добавив префикс «OBSOLETE_» или пометив его как зарезервированный номер поля.reserved, так что будущее.protoПользователь не будет случайно повторно использовать номер.
  4. int32, uint32, int64, uint64 и bool совместимы. Это означает, что вы можете изменять поля одного из этих типов на другой без нарушения прямой или обратной совместимости. Если из цепочки выбирается число, которое не соответствует соответствующему типу, это будет иметь тот же эффект, что и преобразование числа в этот тип в C++ (например, если 64-битное число читается как int32, оно будет усечен до 32-битного).
  5. sint32 и sint64 совместимы друг с другом, но не с другими целочисленными типами.
  6. строка и байты совместимы, если байты действительны в кодировке UTF-8.
  7. Встроенные сообщения совместимы с байтами, если байты содержат закодированную версию сообщения.
  8. fixed32 совместим с sfixed32, а fixed64 совместим с sfixed64.
  9. enum совместим с int32, uint32, int64 и uint64 в том, что касается массивов (обратите внимание, что если они не подходят, значение будет усечено). Обратите внимание, однако, что когда сообщения десериализуются, клиентский код может обрабатывать их по-разному: например, нераспознанные типы перечисления proto3 останутся в сообщении, но то, как сообщение будет представлено при десериализации, зависит от языка. (Это зависит от языка, упомянуто выше) Поля Int всегда сохраняют только свое значение.
  10. поставить синглценностьИзменения в новых членах безопасны и совместимы с бинарными файлами. Если вы уверены, что нет кода для установки более одного за разполе, может быть безопасно переместить несколько полей в новое поле. поставить любойполеНебезопасно двигаться в существующее поле. (Обратите внимание на разницу между полем и значением, поле — это поле, значение — это значение)

9. Неизвестное поле

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

Реализации Proto3 могут успешно анализировать сообщения с неизвестными полями, однако реализации могут поддерживать или не поддерживать сохранение этих неизвестных полей. Вы не должны полагаться на сохранение или удаление неизвестных доменов. Для большинства реализаций буферов протокола Google неизвестные поля недоступны в proto3 через соответствующую среду выполнения proto, а при десериализации отбрасываются и забываются. Это поведение отличается от поведения proto2, где неизвестные поля всегда сохраняются и сериализуются вместе с сообщением.

10. Тип карты

Повторяющиеся типы могут использоваться для представления массивов, а типы Map могут использоваться для представления словарей.

map<key_type, value_type> map_field = N;

map<string, Project> projects = 3;

key_typeМожет быть любым типом int или string (любой скалярный тип, см. соответствующую таблицу скалярных типов выше, кроме float, double и bytes)

Значения перечисления также нельзя использовать в качестве ключей.

key_typeМожет быть любого типа, кроме карты.

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

  • карта не может быть изменена с повторением.
  • Линейные массивы и порядок итерации карты не определены, поэтому вы не можете полагаться на то, что ваша карта находится в определенном порядке.
  • за.protoПри формировании текстового формата карта сортируется по ключу. Цифровые клавиши отсортированы по номерам.
  • При синтаксическом анализе или слиянии из массива, если есть повторяющиеся ключи, используется последний увиденный ключ (принцип переопределения). При синтаксическом анализе карты из текстового формата синтаксический анализ может завершиться ошибкой, если есть повторяющиеся ключи.

Хотя Protocol Buffer не поддерживает массивы карты типов, его можно преобразовать и реализовать с помощью следующих идей:

message MapFieldEntry {
  key_type key = 1;
  value_type value = 2;
}

repeated MapFieldEntry map_field = N;

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

11. JSON Mapping

Proto3 поддерживает каноническое кодирование в JSON, что упрощает обмен данными между системами. Кодировки описаны тип за типом в таблице ниже.

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

В реализации proto3 в формате JSON предусмотрены следующие 4 параметра:

  • Отправить поля со значениями по умолчанию: поля со значениями по умолчанию игнорируются в выводе proto3 JSON по умолчанию. Реализация МОЖЕТ предоставить возможность переопределить это поведение и поля вывода с их значениями по умолчанию.
  • Игнорировать неизвестные поля: по умолчанию анализатор Proto3 JSON должен отклонять неизвестные поля, но может предоставлять возможность игнорировать неизвестные поля при анализе.
  • Используйте имена полей proto вместо имен в нижнем верблюжьем регистре: по умолчанию принтер proto3 JSON преобразует имена полей в нижний верблюжий регистр и использует их в качестве имен JSON. Реализации могут предоставлять возможность использовать необработанные имена полей в качестве имен JSON. Парсер Proto3 JSON должен принять преобразованное имя в нижнем регистре CamelCase и исходное имя поля.
  • Отправлять значения перечисления в виде перечислений вместо строк: имя значения перечисления используется по умолчанию в выводе JSON. Может быть предусмотрена возможность использования числового значения перечисляемого значения.

4. proto3 определяет Службы

Если вы хотите использовать тип сообщения системы RPC (удаленный вызов процедур), вы можете.protoИнтерфейс службы RPC определен в файле, и компилятор буфера протокола сгенерирует код интерфейса службы и заглушки на выбранном языке. Так, например, если вы определяете службу RPC, входным параметром является SearchRequest, а возвращаемым значением является SearchResponse, вы можете.protoопределите его в файле следующим образом:

service SearchService {
  rpc Search (SearchRequest) returns (SearchResponse);
}

Наиболее простой системой RPC для использования с буферами протоколов является gRPC: независимая от языка и платформы система RPC с открытым исходным кодом, разработанная в Google. gRPC очень хорошо работает в буферах протоколов и позволяет компилировать плагины со специальными буферами протоколов прямо из.protoСоздайте код, связанный с RPC, в файле.

Если вы не хотите использовать gRPC, вы также можете использовать буферы протоколов в собственной реализации RPC. Вы можете найти больше информации об этом в руководстве по языку Proto2.

Существует также несколько текущих сторонних проектов, разрабатывающих реализации RPC для протокольных буферов.

5. Соглашение об именах протокольных буферов

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

message SongServerRequest {
  required string song_name = 1;
}

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

enum Foo {
  FIRST_VALUE = 0;
  SECOND_VALUE = 1;
}

Завершайте каждое значение перечисления точкой с запятой, а не запятой.

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

service FooService {
  rpc GetSomething(FooRequest) returns (FooResponse);
}

6. Принцип кодирования протокольного буфера

Прежде чем обсуждать принцип кодирования Protocol Buffer, мы должны поговорить о кодировании Varints.

Кодировка Base 128 Varints

Varint — это компактный способ представления чисел. Он использует один или несколько байтов для представления числа, причем меньшие числа используют меньше байтов. Это уменьшает количество байтов, используемых для представления чисел.

Каждый байт в Varint (кроме последнего) имеет установленный старший бит (msb), что указывает на то, что должны поступить дополнительные байты. Младшие 7 бит каждого байта используются для хранения представления числа в дополнении до двух в группах по 7 бит, причем наименее значащая группа находится впереди.

Если используется менее 1 байта, старший бит устанавливается равным 0, как в следующем примере 1 может быть представлен одним байтом, поэтому старший бит равен 0.

0000 0001

Если требуется несколько байтовых представлений, старший бит должен быть установлен в 1 . Например, 300, если выражено в варинтах:

1010 1100 0000 0010

В обычных двоичных вычислениях это равно 88068 (65536 + 16384 + 4096 + 2048 + 4).

Так как же кодируется Varint?

Следующий код представляет собой метод вычисления кодировки Varint int 32.

char* EncodeVarint32(char* dst, uint32_t v) {
  // Operate on characters as unsigneds
  unsigned char* ptr = reinterpret_cast<unsigned char*>(dst);
  static const int B = 128;
  if (v < (1<<7)) {
    *(ptr++) = v;
  } else if (v < (1<<14)) {
    *(ptr++) = v | B;
    *(ptr++) = v>>7;
  } else if (v < (1<<21)) {
    *(ptr++) = v | B;
    *(ptr++) = (v>>7) | B;
    *(ptr++) = v>>14;
  } else if (v < (1<<28)) {
    *(ptr++) = v | B;
    *(ptr++) = (v>>7) | B;
    *(ptr++) = (v>>14) | B;
    *(ptr++) = v>>21;
  } else {
    *(ptr++) = v | B;
    *(ptr++) = (v>>7) | B;
    *(ptr++) = (v>>14) | B;
    *(ptr++) = (v>>21) | B;
    *(ptr++) = v>>28;
  }
  return reinterpret_cast<char*>(ptr);
}
300 = 100101100

Поскольку 300 превышает 7 бит (Varint имеет только 7 бит в байте, который может использоваться для представления чисел, а старший бит старшего бита используется для указания, есть ли еще байты позже), поэтому 300 необходимо представлять 2 байтами.

Кодировка Varint, возьмем для примера 300:

1. 100101100 | 10000000 = 1 1010 1100
2. 110101100 >> 7 = 1010 1100
3. 100101100 >> 7 = 10 = 0000 0010
4. 1010 1100 0000 0010 (最终 Varint 结果)

Алгоритм декодирования Варинта должен быть таким: (фактически обратный процесс кодирования)

  1. Если это более одного байта, сначала удалите старший бит каждого байта (с помощью логической операции ИЛИ), оставив только 7 бит на байт.
  2. Обратный весь результат, до 5 байт, порядок 1-2-3-4-5, после обратного порядка 5-4-3-2-1, порядок двоичных битов внутри байта не меняется , изменение Относительное положение байта.

Процесс декодирования вызывает функцию GetVarint32Ptr.Если он больше одного байта, для его обработки вызывается GetVarint32PtrFallback.

inline const char* GetVarint32Ptr(const char* p,
                                  const char* limit,
                                  uint32_t* value) {
  if (p < limit) {
    uint32_t result = *(reinterpret_cast<const unsigned char*>(p));
    if ((result & 128) == 0) {
      *value = result;
      return p + 1;
    }
  }
  return GetVarint32PtrFallback(p, limit, value);
}

const char* GetVarint32PtrFallback(const char* p,
                                   const char* limit,
                                   uint32_t* value) {
  uint32_t result = 0;
  for (uint32_t shift = 0; shift <= 28 && p < limit; shift += 7) {
    uint32_t byte = *(reinterpret_cast<const unsigned char*>(p));
    p++;
    if (byte & 128) {
      // More bytes are present
      result |= ((byte & 127) << shift);
    } else {
      result |= (byte << shift);
      *value = result;
      return reinterpret_cast<const char*>(p);
    }
  }
  return NULL;
}

К настоящему времени читатели процесса Varint должны быть знакомы. Алгоритм Varint 32 указан выше, и то же самое верно и для 64-битной, за исключением того, что 10 ветвей больше не используются для написания кода, что слишком некрасиво. (32-битный — 5 байт, 64-битный — 10 байт)

Реализация 64-битной кодировки Varint:

char* EncodeVarint64(char* dst, uint64_t v) {
  static const int B = 128;
  unsigned char* ptr = reinterpret_cast<unsigned char*>(dst);
  while (v >= B) {
    *(ptr++) = (v & (B-1)) | B;
    v >>= 7;
  }
  *(ptr++) = static_cast<unsigned char>(v);
  return reinterpret_cast<char*>(ptr);
}

Принцип остается тем же, но для его решения используется цикл.

Реализация 64-битного декодирования Varint:

const char* GetVarint64Ptr(const char* p, const char* limit, uint64_t* value) {
  uint64_t result = 0;
  for (uint32_t shift = 0; shift <= 63 && p < limit; shift += 7) {
    uint64_t byte = *(reinterpret_cast<const unsigned char*>(p));
    p++;
    if (byte & 128) {
      // More bytes are present
      result |= ((byte & 127) << shift);
    } else {
      result |= (byte << shift);
      *value = result;
      return reinterpret_cast<const char*>(p);
    }
  }
  return NULL;
}

Прочитав это, некоторые читатели могут спросить, а не Varint ли для compact int? То 300 можно было представить 2 байта, а сейчас все равно 2 байта, где компактно, а затраченное место не изменилось? !

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

Если 300 представлено int32, ему нужно 4 байта, а теперь оно представлено Varint, которому нужно только 2 байта. уменьшился вдвое!

1. Кодирование структуры сообщения

сообщение в буфере протокола представляет собой серию пар ключ-значение. Двоичная версия сообщения просто использует номер поля (номер поля и wire_type) в качестве ключа. Имя и объявленный тип каждого поля могут быть определены только на стороне декодирования путем ссылки на тип сообщения (т.е..protoфайл) для определения. Это также причина, по которой люди часто говорят, что буферы протокола безопаснее, чем JSON и XML, если нет описания структуры данных..protoфайл, после получения данных его нельзя интерпретировать как обычные данные.

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

Когда сообщение кодируется, ключ и значение объединяются в поток байтов. Когда сообщение декодировано, синтаксический анализатор должен иметь возможность пропускать поля, которые он не распознает. Таким образом, в сообщения можно добавлять новые поля, не ломая старые программы, которые о них не знают. Это называется "обратной" совместимостью.

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

Обратите внимание, что на приведенном выше рисунке 3 и 4 были заброшены, поэтому значение wire_type в настоящее время составляет только 0, 1, 2, 5..

Ключевым методом расчета является(field_number << 3) | wire_type, другими словами, последние 3 бита ключа представляютwire_type.

Например, номер поля общего сообщения начинается с 1, поэтому соответствующий тег может быть таким:

000 1000

Последние 3 бита представляют тип значения, здесь 000, что равно 0, что представляет значение varint. Сдвиньте вправо на 3 бита или 0001, что представляет номер поля. Существует так много примеров тегов, давайте возьмем пример значения или используем varint в качестве примера:

96 01 = 1001 0110  0000 0001
       → 000 0001  ++  001 0110 (drop the msb and reverse the groups of 7 bits)
       → 10010110
       → 128 + 16 + 4 + 2 = 150

Данные, которые могут быть представлены 96 01, равны 150.

message Test1 {
  required int32 a = 1;
}

Если есть структура сообщения, подобная приведенной выше, если хранится 150, двоичный файл, отображаемый в буфере протокола, должен быть 08 96 01 .

Кроме того, для типа нужно обратить внимание на случай type=2. Помимо номера поля и wire_type тег также должен содержать длину, определяющую, из какого раздела берется значение. (По конкретным причинам см.Строка буфера протоколаэта глава)

2. Кодирование целых чисел со знаком

Из таблицы выше видно, что wire_type = 0 содержит беззнаковые варианты, но что, если это беззнаковое число?

Отрицательное число обычно представляется как большое целое число, потому что компьютеры определяют бит знака отрицательного числа как старший бит числа. Если Varint используется для представления отрицательного числа, его длина должна составлять 10 байт. Для этой цели Google Protocol Buffer определяет тип sint32, использующий зигзагообразную кодировку.Сопоставьте все целые числа с целыми числами без знака и закодируйте их в кодировке varint., так что целое число с небольшим абсолютным значением также будет иметь небольшое закодированное значение varint после кодирования.

Функция отображения зигзага:

Zigzag(n) = (n << 1) ^ (n >> 31), n 为 sint32 时

Zigzag(n) = (n << 1) ^ (n >> 63), n 为 sint64 时

Таким образом, -1 будет закодировано как 1, 1 будет закодировано как 2, а -2 будет закодировано как 3, как показано в следующей таблице:

Обратите внимание, что второе преобразование(n >> 31)часть, является арифметическим преобразованием. Таким образом, другими словами, результатом сдвига являются либо все 0 (если n положительное), либо все 1 (если n отрицательное).

Когда анализируется sint32 или sint64, его значение декодируется обратно в исходную подписанную версию.

3. Non-varint Numbers

Невариантные числа относительно просты.wire_type для double и fixed64 равен 1. При синтаксическом анализе сообщите синтаксическому анализатору, что для этого типа данных требуется 64-битный блок данных. Точно так же wire_type для float и fixed32 равен 5, просто дайте ему 32-битный блок данных. В обоих случаях высокое положение находится сзади, а низкое — впереди.

Говорят, что сжатые данные Protocol Buffer не достигли предела, и причина здесь, потому что типы с плавающей запятой, такие как float и double, не сжимаются..

4. Строка

Данные типа wire_type 2 представляют собой метод кодирования с заданной длиной: ключ + длина + содержимое, метод кодирования ключа унифицирован, длина использует метод кодирования вариантов, а содержимое — это байты, длина которых определяется длиной.

Например, предположим, что определен следующий формат сообщения:

message Test2 {
  optional string b = 2;
}

Установите значение «testing» для просмотра в двоичном формате:

12 07 74 65 73 74 69 6e 67

74 65 73 74 69 6e 67 это код UTF8 для "тестирования".

Здесь ключ представлен в шестнадцатеричном формате, поэтому расширение:

12 -> 0001 0010, последние три 010 - тип провода = 2, а 0001 0010 сдвинут вправо на три позиции до 0000 0010, то есть тег = 2.

длина здесь равна 7, за которыми следуют 7 байтов, это наша строка «тестирование».

Поэтому данные wire_type типа 2 будут преобразованы в форму T-L-V (Tag - Length - Value) по умолчанию при кодировании..

5. Встроенное сообщение

Предположим, определены следующие вложенные сообщения:

message Test3 {
  optional Test1 c = 3;
}

Установите в поле целое число 150, и закодированные байты будут следующими:

1a 03 08 96 01

08 96 01Эти три представляют 150, что было объяснено выше и не будет повторяться здесь.

1a -> 0001 1010, последние три 010 это wire type = 2, а 0001 1010 сдвинут на три позиции вправо до 0000 0011, то есть tag = 3.

Длина равна 3, значит, позади 3 байта, то есть 08 96 01.

Строка, байты, встроенные сообщения, упакованные повторяющиеся поля необходимо преобразовать в форму T - L - V (то есть форма с wire_type 2 будет преобразована в форму T - L - V)

6. Кодирование необязательных и повторяющихся

Для полей, определенных как повторяющиеся в proto2 (без опции [packed=true]), закодированное сообщение имеет одну или несколько пар ключ-значение, содержащих один и тот же номер тега. Эти повторяющиеся значения не обязательно должны встречаться последовательно; они могут встречаться через определенные промежутки времени с другими полями. Хотя они неупорядочены, их необходимо упорядочивать при разборе. В proto3 повторяющиеся поля по умолчанию используют упакованную кодировку (по особым причинам см.Packed Repeated Fieldsэта глава)

Для любого неповторяющегося поля в proto3 или необязательного поля в proto2 закодированное сообщение может иметь или не иметь пару ключ-значение, содержащую номер этого поля.

Обычно закодированное сообщение имеет не более одного экземпляра обязательного поля и необязательного поля. Но синтаксический анализатор должен обрабатывать случай «многие к одному». Для числовых и строковых типов, если одно и то же значение встречается несколько раз, синтаксический анализатор принимает последнее полученное значение. Для встроенных полей синтаксический анализатор объединяет несколько экземпляров одного и того же поля, которое он получает. Так же, как и метод MergeFrom, все одиночные поля заменят предыдущие, все одиночные вложенные сообщения будут объединены (объединены), а все повторяющиеся поля будут объединены. Результатом такого правила является,Разбор двух соединенных закодированных сообщений аналогичен разбору двух сообщений по отдельности и последующему их объединению.. Например:

MyMessage message;
message.ParseFromString(str1 + str2);

Эквивалентно

MyMessage message, message2;
message.ParseFromString(str1);
message2.ParseFromString(str2);
message.MergeFrom(message2);

Этот метод иногда очень полезен. Например, можно объединять сообщения, даже не зная их типа.

7. Packed Repeated Fields

После версии 2.1.0 буферы протоколов представили этот тип, который совпадает с повторяющимся полем, за исключением того, что в конце объявляется [packed=true]. Аналогично повторяющимся полям, но отличается. Повторяющиеся поля обрабатываются таким образом по умолчанию в proto3. Для упакованных повторяющихся полей, если в сообщении нет присвоения, оно не будет отображаться в закодированных данных. В противном случае все элементы этого поля будут упакованы в единую пару ключ-значение, а его wire_type=2, длина определяется. Каждый элемент кодируется нормально, за исключением того, что перед ним нет тега tag. Например, существуют следующие типы сообщений:

message Test4 {
  repeated int32 d = 4 [packed=true];
}

Создайте поле Test4 и установите для повторяющегося поля d 3 значения: 3, 270 и 86942 после кодирования:

22 // tag 0010 0010(field number 010 0 = 4, wire type 010 = 2)

06 // payload size (设置的length = 6 bytes)
 
03 // first element (varint 3)
 
8E 02 // second element (varint 270)
 
9E A7 05 // third element (varint 86942)

Тег формы - длина - значение - значение - значение ... пары.

Только повторяющиеся поля примитивных числовых типов (использующих varint, 32-битные или 64-битные) могут быть объявлены "упакованными".

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

Анализаторы буфера протокола должны иметь возможность анализировать поля, которые перекомпилируются как упакованные, как если бы они не были упакованы, и наоборот. Это позволяет добавлять [packed=true] к существующим полям с прямой и обратной совместимостью.

8. Field Order

Кодирование/декодирование не зависит от порядка полей, что гарантируется механизмом ключ-значение.

Если в сообщении есть неизвестные поля, текущие реализации Java и C++ записывают их в произвольном порядке после известных полей по порядку. Текущая реализация Python не отслеживает неизвестные поля.

7. Преимущества и недостатки протокольных буферов

Буферы протоколов имеют много преимуществ перед XML, когда речь идет о сериализации:

  • проще
  • В 3-10 раз меньший объем данных
  • Более быстрая десериализация, в 20-100 раз быстрее
  • Может автоматизировать создание классов доступа к данным, которые проще использовать в кодировании.

Например:

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

  <person>
    <name>John Doe</name>
    <email>jdoe@example.com</email>
  </person>

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

# Textual representation of a protocol buffer.
# This is *not* the binary format used on the wire.
person {
  name: "John Doe"
  email: "jdoe@example.com"
}

После того, как буферы протокола закодированы, данные передаются в двоичном режиме, для чего требуется всего максимум 28 байт пространства и время десериализации 100-200 нс. Но для XML требуется как минимум 69 байт пространства (после сжатия, без всех пробелов) и 5000-10000 времени десериализации.

Вышесказанное является преимуществом в производительности. Далее поговорим о преимуществах кодирования.

Буферы протоколов поставляются с инструментами генерации кода, которые могут создавать удобные интерфейсы хранения доступа к данным. Так разработчикам удобнее использовать его для кодирования. Например, в приведенном выше примере, если вы используете C++ для чтения имени пользователя и электронной почты, вы можете напрямую вызвать соответствующий метод get (код для методов get и set всех атрибутов генерируется автоматически, вам нужно только вызвать его )

  cout << "Name: " << person.name() << endl;
  cout << "E-mail: " << person.email() << endl;

И данные чтения XML немного более хлопотны:

  cout << "Name: "
       << person.getElementsByTagName("name")->item(0)->innerText()
       << endl;
  cout << "E-mail: "
       << person.getElementsByTagName("email")->item(0)->innerText()
       << endl;

Protobuf имеет более четкую семантику и не требует ничего похожего на синтаксический анализатор XML (поскольку компилятор Protobuf скомпилирует файл .proto для создания соответствующих классов доступа к данным для сериализации и десериализации данных Protobuf).

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

Последняя приятная особенность буферов протоколов заключается в том, что они «обратно» совместимы, что позволяет обновлять структуры данных без нарушения развернутых программ, которые полагаются на «старые» форматы данных. Таким образом, вашей программе не нужно беспокоиться о крупномасштабном рефакторинге кода или миграции из-за изменений в структуре сообщений. Потому что добавление поля в новое сообщение не вызывает никаких изменений в опубликованной программе (т.к. способ хранения по своей сути неупорядоченный, в виде k-v).

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

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

8. Наконец

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

  1. После того, как Protocol Buffer использует принцип varint для сжатия данных, двоичные данные очень компактны, а опция также является мерой для сжатия объема. Следовательно, pb меньше по размеру, и если его выбрать в качестве сетевой передачи данных, то он неизбежно будет потреблять те же данные и потреблять меньше сетевого трафика. Но он не сжимается до предела, float, double с плавающей запятой не сжимаются.
  2. Буфер протокола содержит меньше символов {, }, :, чем JSON и XML, и его объем также уменьшен. В сочетании с сжатием varint объем меньше после сжатия gzip!
  3. Буфер протокола — это реализация метода кодирования Тег — Значение (Тэг — Длина — Значение), который уменьшает использование разделителей и делает хранение данных более компактным.
  4. Еще одна основная ценность Protocol Buffer заключается в том, что он предоставляет набор инструментов, инструмент компиляции, для автоматического создания кода получения/установки. Это упрощает многоязычное взаимодействие и делает работу по кодированию и декодированию продуктивной.
  5. Буфер протокола не является самоописываемым, оставляя описание данных.protoфайл, он не может понять поток двоичных данных. Это преимущество, поскольку данные имеют определенное «шифрование», но также и недостаток, читабельность данных крайне плохая. Таким образом, Protocol Buffer очень подходит для вызовов RPC и передачи данных между внутренними службами.
  6. Protocol Buffer обладает свойством обратной совместимости, после обновления структуры данных старая версия может оставаться совместимой, это тоже проблема, которую Protocol Buffer было поручено решить в начале своего рождения. Потому что компилятор пропустит обработку нераспознанных новых полей.

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


Ссылка:

официальная документация гугл
thrift-protobuf-compare - Benchmarking.wiki
jvm-serializers

Репозиторий GitHub:Halfrost-Field

Follow: halfrost · GitHub

Source: HAL frost.com/proto part_well…