Encoding "Кодирование"

Объясняет, как Protocol Buffers кодирует данные в файлы или для передачи по сети.

Этот документ описывает wire format (формат передачи) protocol buffer, который определяет детали того, как ваше сообщение отправляется по сети и сколько места оно занимает на диске. Вероятно, вам не нужно понимать это для использования protocol buffers в вашем приложении, но это полезная информация для оптимизаций.

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

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

Например, обратные апострофы обозначают необработанный шестнадцатеричный литерал, например `70726f746f6275660a`. Это кодируется в точные байты, обозначенные шестнадцатеричным значением в литерале. Кавычки обозначают строки UTF-8, например "Hello, Protobuf!". Этот литерал синонимичен `48656c6c6f2c2050726f746f62756621` (который, если присмотреться, состоит из байтов ASCII). Мы представим больше языка Protoscope по мере обсуждения аспектов формата передачи.

Инструмент Protoscope также может выводить закодированные protocol buffers в виде текста. См. https://github.com/protocolbuffers/protoscope/tree/main/testdata для примеров.

Все примеры в этой теме предполагают, что вы используете Редакцию 2023 или новее.

Простое сообщение

Допустим, у вас есть следующее очень простое определение сообщения:

message Test1 {
  int32 a = 1;
}

В приложении вы создаете сообщение Test1 и устанавливаете a в 150. Затем вы сериализуете сообщение в выходной поток. Если бы вы могли исследовать закодированное сообщение, вы бы увидели три байта:

08 96 01

Пока все мало и численно - но что это значит? Если вы используете инструмент Protoscope для вывода этих байтов, вы получите что-то вроде 1: 150. Как он узнал, что это содержимое сообщения?

Base 128 Varints

Целые числа с переменной шириной, или varints, являются основой формата передачи. Они позволяют кодировать 64-битные целые числа без знака, используя от одного до десяти байтов, причем маленькие значения используют меньше байтов.

Каждый байт в varint имеет бит продолжения, который указывает, является ли следующий за ним байт частью varint. Это старший бит (MSB) байта (иногда также называемый знаковым битом). Младшие 7 бит являются полезной нагрузкой; результирующее целое число строится путем объединения 7-битных полезных нагрузок его составных байтов.

Итак, например, вот число 1, закодированное как `01` - это один байт, поэтому MSB не установлен:

0000 0001
^ msb

А вот 150, закодированное как `9601` - это немного сложнее:

10010110 00000001
^ msb    ^ msb

Как понять, что это 150? Сначала вы отбрасываете MSB из каждого байта, так как он просто говорит нам, достигли ли мы конца числа (как вы можете видеть, он установлен в первом байте, так как в varint больше одного байта). Эти 7-битные полезные нагрузки находятся в порядке little-endian. Преобразуйте в порядок big-endian, объедините и интерпретируйте как 64-битное целое число без знака:

10010110 00000001        // Исходные входные данные.
 0010110  0000001        // Убрать биты продолжения.
 0000001  0010110        // Преобразовать в big-endian.
   00000010010110        // Объединить.
 128 + 16 + 4 + 2 = 150  // Интерпретировать как 64-битное целое число без знака.

Поскольку varints так важны для protocol buffers, в синтаксисе protoscope мы ссылаемся на них как на простые целые числа. 150 это то же самое, что `9601`.

Структура сообщения

Сообщение protocol buffer - это серия пар ключ-значение. Двоичная версия сообщения использует номер поля как ключ - имя и объявленный тип для каждого поля могут быть определены только на стороне декодирования путем ссылки на определение типа сообщения (т.е. файл .proto). Protoscope не имеет доступа к этой информации, поэтому он может предоставить только номера полей.

Когда сообщение кодируется, каждая пара ключ-значение превращается в запись, состоящую из номера поля, типа провода и полезной нагрузки. Тип провода сообщает парсеру, насколько велика полезная нагрузка после него. Это позволяет старым парсерам пропускать новые поля, которые они не понимают. Этот тип схемы иногда называют Tag-Length-Value (TLV).

Существует шесть типов провода: VARINT, I64, LEN, SGROUP, EGROUP и I32

IDИмяИспользуется для
0VARINTint32, int64, uint32, uint64, sint32, sint64, bool, enum
1I64fixed64, sfixed64, double
2LENstring, bytes, вложенные сообщения, упакованные повторяющиеся поля
3SGROUPначало группы (устарело)
4EGROUPконец группы (устарело)
5I32fixed32, sfixed32, float

"Тег" записи кодируется как varint, образованный из номера поля и типа провода по формуле (field_number << 3) | wire_type. Другими словами, после декодирования varint, представляющего поле, младшие 3 бита говорят нам тип провода, а остальная часть целого числа говорит нам номер поля.

Теперь давайте снова посмотрим на наш простой пример. Теперь вы знаете, что первое число в потоке всегда является varint ключом, и здесь это `08`, или (отбрасывая MSB):

000 1000

Вы берете последние три бита, чтобы получить тип провода (0), а затем сдвигаете вправо на три, чтобы получить номер поля (1). Protoscope представляет тег как целое число, за которым следует двоеточие и тип провода, поэтому мы можем записать вышеуказанные байты как 1:VARINT.

Поскольку тип провода равен 0, или VARINT, мы знаем, что нам нужно декодировать varint, чтобы получить полезную нагрузку. Как мы видели выше, байты `9601` декодируются в varint как 150, что дает нам нашу запись. Мы можем записать это в Protoscope как 1:VARINT 150.

Protoscope может вывести тип для тега, если после : есть пробел. Он делает это, заглядывая вперед на следующий токен и угадывая, что вы имели в виду (правила подробно описаны в language.txt Protoscope). Например, в 1: 150 сразу после нетипизированного тега идет varint, поэтому Protoscope выводит его тип как VARINT. Если бы вы написали 2: {}, он увидел бы { и угадал LEN; если бы вы написали 3: 5i32, он угадал бы I32, и так далее.

Больше целочисленных типов

Логические значения и перечисления

Логические значения и перечисления кодируются так, как если бы они были int32. Логические значения, в частности, всегда кодируются как `00` или `01`. В Protoscope false и true являются псевдонимами для этих строк байтов.

Целые числа со знаком

Как вы видели в предыдущем разделе, все типы protocol buffer, связанные с типом провода 0, кодируются как varints. Однако varints беззнаковые, поэтому различные знаковые типы, sint32 и sint64 против int32 или int64, кодируют отрицательные целые числа по-разному.

Типы intN кодируют отрицательные числа как дополнение до двух, что означает, что, как беззнаковые 64-битные целые числа, у них установлен старший бит. В результате это означает, что все десять байтов должны быть использованы. Например, -2 преобразуется protoscope в

11111110 11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 00000001

Это дополнение до двух от 2, определенное в беззнаковой арифметике как ~0 - 2 + 1, где ~0 - это все-единицы 64-битное целое число. Полезно понять, почему это производит так много единиц.

sintN использует кодировку "ZigZag" вместо дополнения до двух для кодирования отрицательных целых чисел. Положительные целые числа p кодируются как 2 * p (четные числа), в то время как отрицательные целые числа n кодируются как 2 * |n| - 1 (нечетные числа). Таким образом, кодировка "зигзагообразно" переходит между положительными и отрицательными числами. Например:

Исходное со знакомЗакодировано как
00
-11
12
-23
......
0x7fffffff0xfffffffe
-0x800000000xffffffff

Другими словами, каждое значение n кодируется с использованием

(n << 1) ^ (n >> 31)

для sint32, или

(n << 1) ^ (n >> 63)

для 64-битной версии.

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

В protoscope добавление суффикса z к целому числу заставит его кодироваться как ZigZag. Например, -500z это то же самое, что varint 999.

Не-varint числа

Не-varint числовые типы просты. double и fixed64 имеют тип провода I64, который говорит парсеру ожидать фиксированный восьмибайтовый блок данных. Значения double кодируются в формате IEEE 754 двойной точности. Мы можем указать запись double, написав 5: 25.4, или запись fixed64 с 6: 200i64.

Аналогично float и fixed32 имеют тип провода I32, который говорит ему ожидать четыре байта вместо этого. Значения float кодируются в формате IEEE 754 одинарной точности. Синтаксис для них состоит в добавлении суффикса i32. 25.4i32 выдаст четыре байта, как и 200i32. Типы тегов выводятся как I32.

Записи с указанием длины

Префиксы длины - еще одна основная концепция в формате провода. Тип провода LEN имеет динамическую длину, заданную varint сразу после тега, за которым следует полезная нагрузка, как обычно.

Рассмотрим эту схему сообщения:

message Test2 {
  string b = 2;
}

Запись для поля b является строкой, и строки кодируются с помощью LEN. Если мы установим b в "testing", мы закодируем как запись LEN с номером поля 2, содержащую строку ASCII "testing". Результат - `120774657374696e67`. Разбивая байты,

12 07 [74 65 73 74 69 6e 67]

мы видим, что тег, `12`, это 00010 010, или 2:LEN. Байт, который следует, - это int32 varint 7, и следующие семь байтов - это кодировка UTF-8 для "testing". Int32 varint означает, что максимальная длина строки составляет 2GB.

В Protoscope это записывается как 2:LEN 7 "testing". Однако может быть неудобно повторять длину строки (которая в тексте Protoscope уже ограничена кавычками). Обертывание содержимого Protoscope в фигурные скобки сгенерирует для него префикс длины: {"testing"} является сокращением для 7 "testing". {} всегда выводится полями как запись LEN, поэтому мы можем записать эту запись просто как 2: {"testing"}.

Поля bytes кодируются таким же образом.

Подсообщения

Поля подсообщений также используют тип провода LEN. Вот определение сообщения с вложенным сообщением нашего исходного примера сообщения, Test1:

message Test3 {
  Test1 c = 3;
}

Если поле a Test1 (т.е. поле c.a Test3) установлено в 150, мы получаем `1a03089601`. Разбивая это:

 1a 03 [08 96 01]

Последние три байта (в []) точно такие же, как из нашего самого первого примера. Эти байты предшествуют тегу типа LEN и длине 3, точно так же, как кодируются строки.

В Protoscope подсообщения довольно кратки. `1a03089601` можно записать как 3: {1: 150}.

Отсутствующие элементы

Отсутствующие поля легко закодировать: мы просто опускаем запись, если она отсутствует. Это означает, что "огромные" protos с установленными только несколькими полями довольно разрежены.

Повторяющиеся элементы

Начиная с Редакции 2023, repeated поля примитивного типа (любой скалярный тип, который не string или bytes) по умолчанию "упакованы".

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

message Test4 {
  string d = 4;
  repeated int32 e = 6;
}

и мы конструируем сообщение Test4 с d, установленным в "hello", и e, установленным в 1, 2 и 3, это может быть закодировано как `3206038e029ea705`, или записано как Protoscope,

4: {"hello"}
6: {3 270 86942}

Однако, если повторяющееся поле установлено в расширенное (переопределяя состояние по умолчанию packed) или не упаковывается (строки и сообщения), то кодируется запись для каждого отдельного значения. Также записи для e не должны появляться последовательно и могут перемежаться с другими полями; сохраняется только порядок записей для одного и того же поля относительно друг друга. Таким образом, это может выглядеть следующим образом:

6: 1
6: 2
4: {"hello"}
6: 3

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

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

6: {3 270}
6: {86942}

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

Oneofs

Поля Oneof кодируются так же, как если бы поля не были в oneof. Правила, применяемые к oneofs, не зависят от того, как они представлены на проводе.

Последний побеждает

Обычно закодированное сообщение никогда не будет иметь более одного экземпляра не-repeated поля. Однако ожидается, что парсеры обработают случай, когда они есть. Для числовых типов и строк, если одно и то же поле появляется несколько раз, парсер принимает последнее значение, которое он видит. Для вложенных полей сообщений парсер объединяет несколько экземпляров одного и того же поля, как с методом Message::MergeFrom - то есть все одиночные скалярные поля в последнем экземпляре заменяют те в первом, одиночные вложенные сообщения объединяются, а repeated поля объединяются. Эффект этих правил заключается в том, что разбор конкатенации двух закодированных сообщений дает точно такой же результат, как если бы вы разобрали два сообщения отдельно и объединили результирующие объекты. То есть это:

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

эквивалентно этому:

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

Это свойство иногда полезно, так как позволяет объединить два сообщения (путем конкатенации), даже если вы не знаете их типы.

Карты

Поля карт - это просто сокращение для специального вида повторяющегося поля. Если у нас есть

message Test6 {
  map<string, int32> g = 7;
}

это фактически то же самое, что

message Test6 {
  message g_Entry {
    string key = 1;
    int32 value = 2;
  }
  repeated g_Entry g = 7;
}

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

Группы

Группы - это устаревшая функция, которую не следует использовать, но они остаются в формате провода и заслуживают упоминания.

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

Группа с номером поля 8 начинается с тега 8:SGROUP. Записи SGROUP имеют пустую полезную нагрузку, так что все это просто обозначает начало группы. После того как все поля в группе перечислены, соответствующий тег 8:EGROUP обозначает ее конец. Записи EGROUP также не имеют полезной нагрузки, так что 8:EGROUP - это вся запись. Номера полей групп должны совпадать. Если мы встречаем 7:EGROUP, где ожидаем 8:EGROUP, сообщение неправильно сформировано.

Protoscope предоставляет удобный синтаксис для записи групп. Вместо записи

8:SGROUP
  1: 2
  3: {"foo"}
8:EGROUP

Protoscope позволяет

8: !{
  1: 2
  3: {"foo"}
}

Это сгенерирует соответствующие маркеры начала и конца группы. Синтаксис !{} может встречаться только сразу после нетипизированного выражения тега, такого как 8:.

Порядок полей

Номера полей могут быть объявлены в любом порядке в файле .proto. Выбранный порядок не влияет на то, как сообщения сериализуются.

Когда сообщение сериализуется, нет гарантированного порядка, в котором его известные или неизвестные поля будут записаны. Порядок сериализации - это деталь реализации, и детали любой конкретной реализации могут измениться в будущем. Поэтому парсеры protocol buffer должны быть способны разбирать поля в любом порядке.

Последствия

  • Не предполагайте, что байтовый вывод сериализованного сообщения стабилен. Это особенно верно для сообщений с транзитивными полями bytes, представляющими другие сериализованные сообщения protocol buffer.
  • По умолчанию повторные вызовы методов сериализации на одном и том же экземпляре сообщения protocol buffer могут не производить один и тот же байтовый вывод. То есть, сериализация по умолчанию не является детерминированной.
    • Детерминированная сериализация гарантирует только тот же байтовый вывод для конкретного бинарника. Байтовый вывод может меняться между разными версиями бинарника.
  • Следующие проверки могут не сработать для экземпляра сообщения protocol buffer foo:
    • foo.SerializeAsString() == foo.SerializeAsString()
    • Hash(foo.SerializeAsString()) == Hash(foo.SerializeAsString())
    • CRC(foo.SerializeAsString()) == CRC(foo.SerializeAsString())
    • FingerPrint(foo.SerializeAsString()) == FingerPrint(foo.SerializeAsString())
  • Вот несколько примеров сценариев, где логически эквивалентные сообщения protocol buffer foo и bar могут сериализоваться в разные байтовые выводы:
    • bar сериализован старым сервером, который рассматривает некоторые поля как неизвестные.
    • bar сериализован сервером, реализованным на другом языке программирования и сериализующим поля в другом порядке.
    • bar имеет поле, которое сериализуется недетерминированным образом.
    • bar имеет поле, которое хранит сериализованный байтовый вывод сообщения protocol buffer, которое сериализовано по-другому.
    • bar сериализован новым сервером, который сериализует поля в другом порядке из-за изменения реализации.
    • foo и bar являются конкатенациями одних и тех же отдельных сообщений в разном порядке.

Ограничения размера закодированного Proto

Protos должны быть меньше 2 GiB при сериализации. Многие реализации proto откажутся сериализовать или разбирать сообщения, превышающие этот предел.

Сокращенная шпаргалка

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

message    := (tag value)*

tag        := (field << 3) bit-or wire_type;
                encoded as uint32 varint
value      := varint      for wire_type == VARINT,
              i32         for wire_type == I32,
              i64         for wire_type == I64,
              len-prefix  for wire_type == LEN,
              <empty>     for wire_type == SGROUP or EGROUP

varint     := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64;
                encoded as varints (sintN are ZigZag-encoded first)
i32        := sfixed32 | fixed32 | float;
                encoded as 4-byte little-endian (float is IEEE 754
                single-precision); memcpy of the equivalent C types (u?int32_t,
                float)
i64        := sfixed64 | fixed64 | double;
                encoded as 8-byte little-endian (double is IEEE 754
                double-precision); memcpy of the equivalent C types (u?int64_t,
                double)

len-prefix := size (message | string | bytes | packed);
                size encoded as int32 varint
string     := valid UTF-8 string (e.g. ASCII);
                max 2GB of bytes
bytes      := any sequence of 8-bit bytes;
                max 2GB of bytes
packed     := varint* | i32* | i64*,
                consecutive values of the type specified in `.proto`

См. также Справку по языку Protoscope.

Ключ

message := (tag value)* : Сообщение кодируется как последовательность из нуля или более пар тегов и значений.

tag := (field << 3) bit-or wire_type : Тег - это комбинация wire_type, хранящегося в трех младших битах, и номера поля, определенного в файле .proto.

value := varint for wire_type == VARINT, ... : Значение хранится по-разному в зависимости от wire_type, указанного в теге.

varint := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64 : Вы можете использовать varint для хранения любого из перечисленных типов данных.

i32 := sfixed32 | fixed32 | float : Вы можете использовать fixed32 для хранения любого из перечисленных типов данных.

i64 := sfixed64 | fixed64 | double : Вы можете использовать fixed64 для хранения любого из перечисленных типов данных.

len-prefix := size (message | string | bytes | packed) : Значение с префиксом длины хранится как длина (закодированная как varint), а затем один из перечисленных типов данных.

string := valid UTF-8 string (e.g. ASCII) : Как описано, строка должна использовать кодировку символов UTF-8. Строка не может превышать 2GB.

bytes := any sequence of 8-bit bytes : Как описано, bytes могут хранить пользовательские типы данных размером до 2GB.

packed := varint* | i32* | i64* : Используйте тип данных packed, когда вы храните последовательные значения типа, описанного в определении протокола. Тег опускается для значений после первого, что амортизирует затраты на теги до одного на поле, а не на элемент.