Изобретая протобаф

Поскольку в классическом php некоторые вещи иногда нельзя реализовать полностью, появляется настойчивая необходимость осуществить это не вполне привычными средствами. К этим наполовину сделанным или, напротив, наполовину не сделанным реализациям можно отнести и grpc, который со стороны protoc — по понятным, как мне кажется, для всех причинам — реализован только в качестве клиентского фреймворка. Чтобы пойти дальше, то есть иметь возможность принимать grpc трафик в качестве сервера, php необходимо отказаться от своей природы — то есть перестать умирать. Насколько я вижу, такую возможность сейчас могут предложить только roadrunner, где grpc сервером (или, правильнее будет сказать, «grpc гейтвеем») будет выступать воркер на go, что, правда, не совсем честно, и swoole, где реализация выглядит более нативной, хотя и вызывающей вопросы, касающиеся своей актуальности. На самом деле можно пойти другой дорогой — и изобрести весь стек заново.

Это нужно не потому, что мы хотим решить задачу другим способом, а потому, что популярность grpc как протокола общения и protobuf как протокола сериализации способна конкурировать с классическим http и json там, где браузер не нужен. Так, некоторые современные системы вроде etcd и ydb выбирают grpc в качестве основного клиентского протокола, не изобретая собственный, как делали их предшественники в лице zookeeper и postgresql. В то же время protobuf обладает другими достоинствами вроде хорошего сжатия, строгой типизации и упрощенной эволюции в сторону обратной и прямой совместимости. Вместе с protoc получается фреймворк, который, пожалуй, является современной заменой «json over http» подходу. Однако это все не для php. Сложно назвать то, как вписан php в protoc прямо сейчас, полным и тем более современным стеком. Поэтому надо взять меч в свои руки и вырубить себе дорогу самостоятельно.

У этого дракона (если на короткое время продолжить поддерживать мифологическое настроение), как это обычно бывает, есть три головы, которые в данном необычном случае нам надо не срубить, а вырастить: это protobuf сериализация, grpc протокол и плагин для protoc.

Если говорить о protobuf, его заметным преимуществом, обеспечивающим свойство эволюции, является tag-value формат. По сравнению с остальными бинарными протоколами, написанными специально для конкретной предметной области, например amqp, которые могут позволить себе не передавать лишние данные, например тег, в угоду лучшего сжатия, protobuf является протоколом сериализации общего назначения — наравне с avro, о котором я планирую написать в будущем, — который используется в приложениях с часто меняющимся api, где наличие эволюции дороже того сжатия, хотя оно все равно лучше, чем у json. Тег, в первую очередь, необходим для указания номера поля вместо его имени, что положительно влияет на сжатие. А чтобы увидеть свойство эволюции, представим схему следующего сообщения:

message PushMessageRequest {
  string content = 1;
  int32 ttl      = 2;
}

В amqp протоколе мы бы не указывали никакие номера в сериализованном буфере, потому что и без этого, полагаясь на спецификацию, знали бы, в каком порядке и какого типа поля идут. Но когда нам не надо передавать ttl, в amqp мы бы его все равно передавали, хотя бы и в виде нуля, занимая этим вполне определенное в 4 байта место. Чего не скажешь о protobuf: там мы передаем только заполненные поля. Для этого перед каждым полем указывается его тег, например: [1][content][2][ttl]. Тогда код, десериализующий такой буфер, заполняет в структуре или объекте только те поля, номера которых были указаны, а остальные заполняет значениями по умолчанию: false для булево, 0 для чисел, пустые строки для строк и так далее. На псевдокоде десериализацию можно изобразить следующим образом:

for buffer.is_not_empty() {
  tag = read_tag(buffer)

  switch tag.number:
    case 1:
      content = read_string(buffer)
    case 2:
      ttl = read_int32(buffer)
}

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

enum Type {
    VARINT  = 0;
    FIXED64 = 1;
    BYTES   = 2;
    FIXED32 = 5;
}

Были еще группы, но даже для второй версии protobuf они слишком устарели, хотя о них я тоже дальше напишу.

Эти типы данных указывают, как следует вычитывать соответствующие им незнакомые схеме поля из буфера, не испортив его. Так, для fixed32 и fixed64 типов мы должны вычитать из буфера 4 и 8 байт соответственно, а для типа bytes, которым могут быть, например, сообщения или строки, сначала прочесть его размер, записанный в varint формате, о котором я позже расскажу, и удалить из буфера байты этого размера.

Допустим, в исходное сообщение добавили новое поле subject:

message PushMessageRequest {
  ...
  string subject = 3;
}

Код десериализации, обрабатывающий незнакомые поля, надо было бы написать (а лучше всего — сгенерировать) следующим образом:

for buffer.is_not_empty() {
  tag = read_tag(buffer)

  switch tag.number:
  ...
    default:
      switch tag.type:
        case Type::BYTES:
          len = read_varint(buffer)
          read(buffer, len)
}

Иными словами, если код десериализации не обновлен в соответствие со схемой, незнакомые поля отбрасываются.

Таким образом, тег каждого поля состоит из его номера и типа, объединенных в одно число следующей формы: num << 3 | type. Это число записывается в varint формате, поэтому тег даже для сообщений с большим количеством полей может занимать не более 2-х байт. В этом месте настало время поговорить про числа в protobuf, тем более я много раз упоминал varint. Но я начну не с него, а с того, как вообще числа передаются в бинарных протоколах. Вы, наверно, знаете, что бывают 8-, 16-, 32-, 64- и даже 128-битные целые числа, которые в памяти занимают 1, 2, 4, 8 и 16 байт соответственно. Хотя это все неактуально для php, где все числа хранятся как int64, в вопросе бинарной сериализации мы становимся в один ряд с другими языками. Для этого принято использовать функции pack и unpack.

Поскольку protobuf — это все-таки формат сериализации, а не протокол общения, в качестве порядка хранения байт был выбран более распространенный, и поэтому выгодный, даже на сегодняшний день little endian, благодаря которому числа из памяти копируются в буфер как есть. Но это правило касается малого числа типов данных, которые есть в protobuf, потому что знакомые вам int32, int64 и, вероятно, незнакомые sint32, sint64 хранятся иначе. Чтобы понять, почему был придуман varint, надо увидеть проблему, которую решали авторы. Так, выше я использовал в качестве типа ttl тип данных int32, который занимает целых 4 байта. Если мы часто будем передавать малое количество секунд, например меньше 256-ти, мы будем напрасно расходовать память в размере 3-х лишних байт, заполненных нулями, потому что для всех чисел в диапазоне от 0 до 256-ти хватит одного байта. Но не передавать эти 3 байта, чтобы сэкономить размер сообщения, мы тоже не можем, потому что принимающая сторона ожидает int32, который всегда занимает 4 байта.

Если можно было бы сообщить принимающей стороне, сколько байт занимает число, это хорошо сэкономило бы место. Здесь можно провести аналогию с тем, как реализованы строки в современных языках программирования и сетевых протоколах по сравнению с Си строками: вместо того, чтобы читать байты до терминатора вида \0, мы сразу указываем длину строки перед самой строкой. Это позволяет за константу узнать ее длину и выделить память нужного размера. Но куда вернее будет провести аналогию с кодировкой utf-8, которая решила такую же проблему: передачу байт переменной длины.

Поскольку данные у нас уже есть — само число, — можно в его битовое представление записать, сколько байт оно занимает. Так делает utf-8. Для этого в первом байте количество старших единиц указывает на размер символа. Так, для ascii, совместимом с utf-8, шаблоном чисел будет форма «0xxxxxxx», а для кириллицы — «110xxxxx xxxxxxxx», в которой первые две единицы байта указывают, что для чтения символа нужно прочитать два байта. В то же время varint также эксплуатирует данные самого числа, чтобы сообщить о его размере, только вместо указания всей длины в первом байте использует бит продолжения в каждом байте, что положительно влияет на компактность и не ограничивает числа в их размере. Для этого байт разрезается на старший, 8-й, бит, в котором будет единица или ноль в зависимости от наличия или отсутствия следующих после этого байт числа, и 7 бит данных. Так, 256 в varint формате будет занимать 2 байта следующего вида 10000000 00000010, где старшая единица в первом байте указывает, что после нее есть еще один байт этого числа1. Хотя 256 помещается в один байт, для данных мы используем всего 7 бит, которых в этом случае не хватает. Это небольшая жертва, которая оправдывает большинство случаев, в которых мы будем передавать меньше байт, чем при сериализации фиксированного размера.

При всех своих преимуществах varint будет медленнее обычной сериализации, то есть простого копирования числа из памяти в буфер (благодаря, опять же, использованию little endian), потому что требует прохода по числу в цикле и использование битовых операций. Поэтому если вы уверены, что ваши числа преимущественно будут занимать 4 или 8 байт, в protobuf есть типы fixed32 и fixed64 соответственно, а также их знаковые пары — sfixed32 и sfixed64. Используйте их: это будет быстрее. Как я уже ранее говорил, эти fixed* типы не те же самые типы, которые записаны в теге, потому что там они покрывают еще и float с double, а тут fixed* типы только про целые числа. Иначе говоря, fixed32 и sfixed32 — это то, что в языках программирования принято называть uint32 и int32 соответственно.

Хотя varint кажется эффективным алгоритмом сжатия чисел, отрицательные ему не подчиняются: -1 в varint формате будет занимать — вы не поверите — 10 байт — максимум, который способен занять varint вообще. Но так происходит не из-за особенности, добавленной форматом, а из-за особенности сериализации отрицательных чисел в принципе. Поскольку отрицательных чисел в битовом представлении не существует, их надо преобразовать в такие положительные, которые при обратном преобразовании дадут исходные отрицательные. Это то, как работает приведение целочисленных типов в языках программирования, когда мы пишем uint8(int8(-1)). В этом случае применяют правило комплиментарных чисел. Если очень просто, то сериализация знакового int8 происходит следующим образом:

if (num < 0) {
    num += 256;
}

pack_uint8(num);

При десериализации выполняется обратная процедура:

num = unpack_uint8(buff);
if (num >= 128) {
   num -= 256;
}

Если выразить более формально2, для преобразования в положительное число применяют формулу 2^N+value, а для обратного — из положительное в отрицательное — value-2^N. За N в обоих случаях прячется разрядность типа, то есть 8, 16, 32, 64 и так далее.

Теперь, вернувшись к varint, становится понятно, почему отрицательные числа так много занимают: перед записью число преобразовывается в uint64, то есть к -1 добавляется 2^64, что дает в результате очень большое число — 18446744073709551615, которое, впрочем, поместилось бы в 8 байт, если бы все биты были отданы под данные.

Это не проблема, если отрицательных чисел крайне мало. Однако если их достаточно, чтобы преимущества varint обернулись недостатками, нужно использовать sint32 и sint64 типы. Их особенность состоит в использовании zigzag кодировки3 перед varint сериализацией. В отличие от комплиментарных чисел, жадно увеличивающих отрицательные числа, zigzag подходит к задаче мягче, используя следующий алгоритм:

if num < 0 {
  return -2 * num - 1
}

return 2 * num

Хотя это не исключительно эффективный алгоритм, как вы уже поняли, глядя на формулу, он способен для достаточно широкого диапазона чисел не вносить драматических изменений в их размер, чем были характерны комплиментарные числа. По сравнению с просто varint, где -1 становилось непроизносимым числом 18446744073709551615, в zigzag кодировке, применяя формулу (-2*-1)-1, мы получаем единицу, которая будет занимать 1 байт.

Следует помнить, что varint используется даже там, где вы не можете на это повлиять: при сериализации типа bool, значений enum и длин строк, сообщений, списков и так далее. И поскольку в значениях enum почему-то разрешены отрицательные числа, вы знаете, какого размера они будут, поэтому такие лучше не использовать.

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

Тип Формат сериализации Когда использовать
int32, uint32, int64, uint64 varint Когда диапазон чисел широкий, но чаще они маленькие и положительные
sint32, sint64 zigzag + varint Когда числа преимущественно отрицательные
fixed32, sfixed32 Прямые байты (LE), всегда 4 байта Когда числа преимущественно большие, до 4-х байт, или важен фиксированный размер
fixed64, sfixed64 Прямые байты (LE), всегда 8 байт Когда числа преимущественно большие, до 8-ми байт, или важен фиксированный размер

Правильный тип данных может серьезно улучшить сжатие сообщений.

Кроме примитивных типов, например строк и чисел, в protobuf есть списки, или repeated, на которых держится больше, чем вы, возможно, знаете. Списки бывают packed и non-packed, что влияет на способ их сериализации. Если в proto2 это надо было задавать самостоятельно через опции поля:

message AddItemRequest {
  repeated int32 coordinates = 1 [packed = true];
}

То в proto3 эти правила реализованы на уровне библиотеки сериализации неявно, но только для тех типов, для которых это разрешено было и в proto2 — то есть только для вещественных и целых чисел, куда еще относят bool и enum.

Чтобы понять разницу, снова можно обратиться к тому, как вообще в бинарных протоколах сериализуются перечисляемые типы вроде списков и хэш-таблиц. Для этого перед данными записывается количество элементов или их полный размер. Допустим, чтобы засериализовать список числовых координат, сначала запишем их количество, за которым пойдут координаты: [2][55, 37]. Если мы сериализуем строковый список, например список технологий, то после длины списка идут элементы, перед каждым из которых указывается его длина: [2][[3][php], [2][db]]. Таким образом, количество элементов списка помогает выделить необходимую память под массив заранее, а длина каждого элемента — память под строку. Что касается packed списков, то есть списков, состоящих из целых и вещественных чисел, protobuf придерживается традиционного подхода4, используя, правда, вместо длины списка его полный размер — иначе говоря, для примера с координатами мы получим в качестве размера сумму всех чисел в байтах: если координаты записаны в fixed32 формате, размером будет 4*N, где N — количество элементов. Кроме того, перед списком пишется тег, тип которого в данном случае записывается как BYTES, потом идут размер списка чисел и одно число за другим: [tag][N][t0...tN]. Это серьезно экономит место при передаче длинных числовых рядов.

Однако для списков сообщений, строк и байтов (которые в php будут тоже строками) в protobuf используется неожиданно простое и, на первый взгляд, кажущееся неправильным решение. Вместо того, чтобы тратить место на тег всего списка и его размер, мы можем просто записать элементы списка так, будто это идущие подряд разные поля одного сообщения с одинаковым тегом, что для примера со списком технологий будет выглядеть следующим образом: [tag1][php][tag1][db]. Самое неожиданное, что такой способ, являясь эффективным сам по себе, потому что не требует накопления промежуточного буфера для подсчета длины, в случае маленьких строк будет добавлять лишними всего несколько байт к общему размеру сообщения, что мы считаем допустимым, а в случае больших строк и сообщений не будет добавлять ничего. Так происходит из-за того, что в packed списках мы пишем длину списка, которая для больших чисел (а сумма байт всех строк или сообщений может быть довольно большой) занимает больше байт (для записи длины, как я уже писал, тоже используется varint), что просто истребляет единственное преимущество — сжатие — этого формата, поэтому packed списки строк и сообщений пользы часто не несут. Это характерный пример того, как формат сериализации учитывает природу данных, выбирая алгоритмы их сжатия.

Кроме того, что такой наивный подход оказывается еще и эффективным, он позволяет пренебречь строгим чередованием списка. Вместо того, чтобы записывать элементы подряд, они могут идти порознь, если список каким-то образом наполняется со временем. Если продолжать пример с технологиями, которые можно перемешать с другими полями, это будет выглядеть как: [tag1][php][tag2][103][tag1][db][tag1][grpc]. Правда, я не знаю ни одного реального примера, где это использовалось бы, хотя на странице документации об этой возможности говорится.

Другим примером перечислений являются карты, или map, для которых на самом деле также используются списки. Здесь protobuf не стал первооткрывателем, поскольку карты часто выражают в виде списка пар, особенно когда ключ не может быть захэширован, что является главным требованием при реализации хэш-таблиц. Тогда из карты Map<K, V> можно сделать список List<Pair<K, V>>, который решит эту проблему, часто даже с незначительным влиянием на память и алгоритмы поиска5. Иными словами, когда вы видите такую схему сообщения:

message Request {
  map<string, int32> indices = 1;
}

На уровне протокола сериализации и даже компилятора (но об этом в третьей статье) она превращается в другую6:

message IndexEntry {
  string key = 1;
  int32 value = 2;
}

message Request {
  repeated IndexEntry indices = 1;
}

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

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

message AddUserRequest {
  string name = 1;
  oneof contact {
    string phone = 2;
    string email = 3;
  }
}

Совершенно эквивалентна этой:

message AddUserRequest {
  string name = 1;
  string phone = 2;
  string email = 3;
}

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

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

message Request {
  required group Item = 1 {
    required string content = 1;
  }
}

Сейчас же вместо них принято использовать обычные сообщения:

message Request {
  message Item {
    required string content = 1;
  }

  required Item item = 1;
}

Хотя результат кодогенерации и то, как о группах и сообщениях принято было думать, были одинаковыми, сериализация между ними фундаментально отличалась. Если сообщения — это длина и байтовый буфер, соответствующий конкретной схеме, то группы длины не имели. Вместо этого они оборачивались в открывающий и закрывающий теги с разными типами и одним номером. Это позволяло не копить промежуточный буфер7 или как-либо еще считать длину вложенного сообщения, а писать одно поле за другим в общий буфер и даже использовать стриминг. Хотя такой способ имеет свои преимущества8 и используется в других протоколах вроде amqp, он плохо ложится на модель protobuf, в котором важно уметь быстро пропускать неизвестные поля. Кроме того, удаление групп идет на пользу консистентности формата, в котором сообщения уже были и выполняли ту же самую роль, которую на себя брали группы.

Хорошо на сжатие в protobuf работают не только выбранные алгоритмы, но даже просто соглашения. Одно из них довольно важное — это соглашение о значениях по умолчанию. Нет смысла сериализовать такие значения, как false, 0, '', [], если их проще восстанавливать на принимающей стороне, исходя из уверенности, что они должны быть именно такими, если отправитель не указал ничего другого. По этой причине перечисления в protobuf должны иметь вариант с нулем, что обязательно для proto3 и что вас заставит сделать protoc, если вы забудете, и что было настоятельной рекомендацией в proto2. Кроме того, значениями по умолчанию могут быть те, которые указаны в схеме — правда, в proto3 эту возможность удалили и вернули только в editions. Выглядело это следующим образом:

syntax = "proto2";

message Request {
  optional string name = 1 [default = "thesis"];
}

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

Когда вы указываете значение по умолчанию для поля с типом enum, вы указываете его вариант, не обращаясь к типу:

syntax = "proto2";

enum Color {
  UNSPECIFIED = 0;
  RED = 1;
}

message Request {
  optional Color color = 1 [default = RED];
}

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

enum Color {
  COLOR_UNSPECIFIED = 0;
  COLOR_RED = 1;
}

Тогда уникальность обеспечивается именем перечисления. Впрочем, подробнее об именах будет написано в статье про кодогенерацию.

Чтобы написать сериализацию, нужно ввести модель данных, на базе которой можно строить другие высокоуровневые инструменты вроде рефлексии, — пользоваться многословной моделью постоянно неудобно. Для модели нам достаточно типов и номеров. Из них мы сможем вывести тег и правильно (де) сериализовать значения. В качестве примера выразим модель гитхаба, в котором есть пользователи и организации, в которых они состоят и определенную в них роль занимают:

message Organization {
  string name = 1;
  string role = 2;
}

message User {
  string username = 1;
  repeated Organization organizations = 2;
}

Переложив на нашу модель, получим следующее:

use Thesis\Protobuf;

$organizationT = Protobuf\messageT(
    Protobuf\fieldT(1, Protobuf\stringT), // name
    Protobuf\fieldT(2, Protobuf\stringT), // role
);

$userT = Protobuf\messageT(
    Protobuf\fieldT(1, Protobuf\stringT), // username
    Protobuf\fieldT(2, Protobuf\listT($organizationT)), // organizations
);

Тут стоит объяснить, зачем вообще нужна модель данных, если те же json и msgpack обходятся без нее. Потому что бывают протоколы сериализации, которые принято называть schemaless, схемы которых хранятся в самом формате, например msgpack, и протоколы, называемые schema driven, схемы которых хранятся отдельно, например в файле, если мы говорим про avro, или в самом коде, как в protobuf. Преимущество первых — в простоте: мы буквально в силах прочесть любые данные; преимущество вторых — в сжатии: на схему можно переложить много полезной информации, чаще всего статической, которая не будет занимать место в буфере. Так, в protobuf вместо имен можно указывать номера, а в avro использовать позицию в схеме в качестве смещения в буфере.

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

use Thesis\Protobuf;

$organization = Protobuf\messageT(
    Protobuf\fieldT(1, Protobuf\stringT),
    Protobuf\fieldT(2, Protobuf\stringT),
);

$message = Protobuf\message(
    Protobuf\fieldOf(1, Protobuf\stringOf('kafkiansky')),
    Protobuf\fieldOf(2, $organization->list([
        Protobuf\message(
            Protobuf\fieldOf(1, Protobuf\stringOf('thesis')),
            Protobuf\fieldOf(2, Protobuf\stringOf('maintainer')),
        ),
    ]))
);

Наша модель основана на паттерне «посетитель», что делает ее фактически бесконечно расширяемой.

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


/**
 * @template-implements Visitor<WireType>
 */
enum DetermineWireType implements Visitor
{
    case Visitor;

    #[\Override]
    public function bool(BoolT $type): WireType
    {
        return WireType::VARINT;
    }

    #[\Override]
    public function float(FloatT $type): WireType
    {
        return WireType::FIXED32;
    }

    #[\Override]
    public function double(DoubleT $type): WireType
    {
        return WireType::FIXED64;
    }

    ...
}

Который в процессе сериализации вызывается для каждого поля:

/**
 * @api
 */
final readonly class Serializer
{
    public function serialize(Message $message): string
    {
        $buffer = new ByteBuffer();

        foreach ($message->fields as $field) {
            $type = $field->value->type;

            $type
                ->accept(
                    new TypeSerializerVisitor(
                        new Tag($field->num, $type->accept(DetermineWireType::Visitor)),
                    ),
                )
                ->serialize($buffer, $field->value->value);
        }

        return $buffer->flush();
    }
}

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

/**
 * @template-extends DefaultTypeVisitor<bool>
 */
final class IsPacked extends DefaultTypeVisitor
{
    #[\Override]
    public function string(StringT $type): bool
    {
        return false;
    }

    #[\Override]
    public function message(MessageT $type): mixed
    {
        return false;
    }

    #[\Override]
    protected function default(Type $type): bool
    {
        return true;
    }
}

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

Поскольку схемы, как правило, статические — вне зависимости от того, собираются они функциями или атрибутами, — у нас появляется возможность статически их верифицировать. Как я уже сказал, карты не могут быть repeated — и это ограничение можно выразить с помощью статического анализа:

/**
 * @template-covariant T
 * @template Repeatable of 'repeatable' | 'not-repeatable' = 'repeatable'
 */
interface Type
{
    /**
     * @template TResult
     * @param Type\Visitor<TResult> $visitor
     * @return TResult
     */
    public function accept(Type\Visitor $visitor): mixed;
}

Здесь Type определяет некоторый дженерик, который указывает на возможность типу быть повторяемым. Тогда для списка мы укажем (хотя необязательно, потому что указан инвариант по умолчанию), что передавать можно только тип c инвариантом repeatable:

/**
 * @template T
 * @template-implements Type<list<T>, ...>
 */
final readonly class ListT implements Type
{
    /**
     * @param Type<T, 'repeatable', *, *> $element
     */
    public function __construct(
        public Type $element,
    ) {}
}

А сам тип MapT определим как not-repeatable:

/**
 * @template K
 * @template V
 * @template-implements Type<Protobuf\Map<K, V>, 'not-repeatable', *, *>
 */
final readonly class MapT implements Type
{
    ...
}

Теперь такой код написать будет просто нельзя:

use Thesis\Protobuf;

$type = Protobuf\listT(Protobuf\mapT(
   Protobuf\stringT,
   Protobuf\stringT,
));

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

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

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

final readonly class Organization
{
    public function __construct(
        #[Reflection\Field(1, Reflection\StringT::T)]
        public string $name,
        #[Reflection\Field(2, Reflection\StringT::T)]
        public string $role,
    ) {}
}

final readonly class User
{
    public function __construct(
        #[Reflection\Field(1, Reflection\StringT::T)]
        public string $username,
        #[Reflection\Field(2, new Reflection\ListT(
            new Reflection\ObjectT(Organization::class),
        ))]
        public array $organizations = [],
    ) {}
}

Теперь схема protobuf неотделима от кода, как и должно быть и сделано в других библиотеках сериализации. Правда, появляется простая возможность сделать схему рекурсивной:

final readonly class Value
{
    public function __construct(
        #[OneOf([
            ...
            ListValueKind::class,
        ])]
        public ValueKind $kind,
    ) {}
}

final readonly class ListValueKind implements ValueKind
{
    public function __construct(
        #[Reflection\Field(6, new Reflection\ObjectT(ListValue::class))]
        public ListValue $value,
    ) {}
}

final readonly class ListValue
{
    /**
     * @param list<Value> $values
     */
    public function __construct(
        #[Reflection\Field(1, new Reflection\ListT(
            new Reflection\ObjectT(Value::class),
        ))]
        public array $values,
    ) {}
}

Если вдруг вы не узнали, это то, как в php выглядит google.protobuf.Struct. Это known тип 9, представляющий собой json объект, в котором разрешена рекурсия. В лоб собрать такую схему невозможно: вы получите segmentation fault. Чтобы этого избежать, нужно разорвать рекурсию, заменив ее некоторым типом, который ее, рекурсию, будет продолжать по мере появления данных:

/**
 * @template-implements Type<Message, 'repeatable', 'not-indexed'>
 */
final readonly class RecursionT implements Type
{
    /**
     * @param \Closure(): MessageT $continuation
     */
    public function __construct(
        private \Closure $continuation,
    ) {}

    #[\Override]
    public function accept(Visitor $visitor): mixed
    {
        return $visitor->message(($this->continuation)());
    }
}

Теперь, когда мы встретим тип, который уже посетили, мы заменим его на RecursionT, в котором получим схему оригинального типа:

#[\Override]
public function object(ObjectT $type): mixed
{
    return isset($this->visited[$type->class])
        ? Protobuf\recursionT(fn() => $this->reflector->type($type->class))
        : $this->default($type);
}

Есть и другие трудности, которые необходимо преодолеть, когда мы пишем библиотеку сериализации на таком языке, как php, где не только нет возможности управлять размерами чисел, но и существующие числа строго ограничены. Если в буфере protobuf окажется большое число — неважно, в varint или fixed64 формате, — мы получим переполнение, которое превратит целое число в вещественное. Это связано с тем, что числа в php хранятся как int64, диапазон которых меньше диапазона uint64, который есть в других языках, способных отправить такие числа по сети вашему php приложению. Поэтому для всех типов из списка int64, uint64, fixed64, sfixed64 и sint64 мы используем современный BcMath\Number, который входит в список бандлированных расширений и, скорее всего, в вашем php будет.

Это создает другую проблему: мы не можем использовать нативный массив для типа map, потому что в protobuf эти типы чисел могут быть ключами, а BcMath\Number в нашем массиве — нет. Однако решение этой проблемы я уже показывал: вместо того, чтобы использовать массив, мы используем список пар, что, впрочем, тесно отражает формат сериализации, поэтому может претендовать даже на то, чтобы считаться с ним консистентным.

Наконец, сериализацию необходимо протестировать. Можно подойти к этой задаче наивно, проверяя сериализацию с десериализацией между собой как:

self::assertEquals($type, $serializer->deserialize(
    $serializer->serialize($type),
    $type::class,
));

Хотя этот способ будет работать, он доказывает только работу сериализации внутри вашей библиотеки. Другими словами, если что-то реализовано неправильно, например varint сериализация, велик шанс, что реализованы неправильно оба направления — и десериализация тоже, — поэтому внутренние тесты будут проходить успешно, хотя остальной мир библиотеку понимать не будет. В таких случаях можно брать эталонную реализацию и сравнивать ее вывод со своим. Используя в качестве такой реализации go, оказалось, что все oneof поля в ней сериализуются последними, поэтому выводы наших библиотек никогда не совпадали.

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

Хотя все known и типы компилятора, о которых я расскажу в статье про кодогенерацию, сейчас лежат вместе с сериализацией, позже они, конечно, будут разделены. Раньше сделать нельзя, потому что нужно написать кодогенерацию, а кодогенерация, в свою очередь, сама зависит от этих типов, поэтому в бесконечном споре, кто появился раньше — курица или яйцо, — победил омлет.

Все остальное, чего я, возможно, не коснулся в этой статье, либо не стоит внимания совсем, либо уместнее будет рассказать в статье про grpc и кодогенерацию.