Изобретая protoc

16 мин чтения

В отличие от OpenAPI, сделавшего schema-first и code-first подходы одинаково популярными, в протобафе преобладает первый. Пока я не написал свои сериализацию и кодогенерацию, я был уверен, что в этом есть большая заслуга самой схемы. Только представьте: вам не нужно выбирать http метод — все отправляется методом POST, вам не нужно выбирать стиль именования запросов (и даже выбирать, как их называть: запросы, ручки или пути), решать, куда класть данные — в путь или тело, как возвращать ошибки — статус-кодом или телом ответа (впрочем, в gRPC с этим тоже неразбериха). Дело скорее не в простоте схемы, а в сложности написания кода без нее.

В отличие от JSON, где все числа, массивы и вложенные объекты выглядят одинаково, в протобафе приходится выбирать: какого размера будет число, в каком формате — сжатом или нет — будет записан список, в каком формате — DELIMITED или LENGTH PREFIXED — будет записан вложенный объект. В общем, свойств так много, что их не только кодом записать тяжело, их даже перечислить не получится. Нужен кодогенератор, а еще раньше — парсер схемы протобафа, который позволяет перечисленные свойства записать и воспроизводить одинаково.

Тут появляется третье «в отличие»: в отличие от protoc, OpenAPI работает с популярными форматами, вроде json и yaml, парсеры и даже маперы (или десериализаторы? или декодеры?) для которых есть, пожалуй, в любом языке программирования, и поэтому написать кодогенератор будет несложно. Возможно, вам повезет еще больше — и в вашем языке программирования есть даже валидаторы на базе json-schema, позволяющие минимально провалидировать схему перед кодогенерацией. В то же время избытка в парсерах протобафа ни один язык не испытывает, поэтому перед кодогенерацией потребовалось бы сначала написать парсер или хотя бы сгенерировать его, используя генераторы на базе грамматик, например ANTLR. Можно даже найти готовые грамматики, сделав задачу еще проще. Правда, остаются еще семантическая валидация, обнаружение импортов, поддержка разных версий и много всего еще, перед тем как удастся приступить к непосредственно кодогенерации. По незнанию этим путем я уже однажды проходил.

Проблема не в том, что это сложно, а в том, что это неправильно. Нужна единственная точка соприкосновения, которая, во-первых, будет гарантировать, что все плагины получают одинаковую спецификацию, а во-вторых, упростит проникновение экосистемы в другие языки программирования. Этой точкой стал protoc. Он парсит, валидирует и разрешает импорты, перед тем как вызвать кодогенератор. На самом деле это принято называть плагином — и это слово я уже использовал, — потому что он не обязан генерировать код. Вместо этого плагин может выступать линтером или фиксером, как делает protolint.

По понятным причинам плагин должен быть исполняемым файлом, с которым protoc общается через stdin, куда передает ему схему, и stdout, откуда получает от него сгенерированный код. Файлы с кодом создает тоже protoc, используя имена, полученные от плагина. Обычно вызов protoc с плагином выглядит следующим образом:

protoc \
  --plugin=protoc-gen-php-plugin=/usr/local/bin/protoc-gen-php \
  --php-plugin_out=genproto \
  ./protos/*.proto

Разделавшись со способом передачи схемы, нужно узнать протокол передачи, и было бы странно, если бы это был не протобаф. Правда, это неудобно, поскольку вы пишете кодогенератор для формата, который в то же время выступает источником входящих данных. Проще говоря, написать кодогенератор на том же языке, для которого вы хотите генерировать код, трудно. Поэтому вы либо пишете плагин на другом языке, как делают google/protobuf, rr, swoole, где уже есть сгенерированные структуры для интеграции с плагином, либо пишете этот код руками, как сделал я, а после того, как плагин готов, удаляете его и генерируете заново написанным плагином. Можно провести аналогию, возможно всем хорошо знакомую, с тем, как пишутся компиляторы современных языков программирования: сначала для языка X пишется первая версия компилятора на языке Y, например Си, затем пишется вторая версия компилятора уже на языке X, то есть на нем самом. Оставить его на языке Y значило бы усложнить поддержку и развитие для возможных контрибьюторов, поэтому наш плагин написан на php, причем изначально.

Таким образом, плагин получает протобаф сообщение CodeGeneratorRequest и возвращает CodeGeneratorResponse, схемы для которых можно найти прямо в репозитории protoc. Внутри CodeGeneratorRequest лежат, как их называют в protoc, дескрипторы, схемы для которых так же можно найти в репозитории. Еще набор таких дескрипторов можно назвать знакомым всем словом «рефлексия». Это информация о типах, а не данные, представленные этим типом. Например:

message CreateUserRequest {
    string name = 1;
    int32 age = 2;
}

Пока клиент и сервер работают буквально с протобаф сообщением CreateUserRequest, плагин получает его схему:

message FieldDescriptorProto {
    enum Type {
        TYPE_INT32 = 5;
        TYPE_STRING = 9;
        ...
    }

    enum Label {
        LABEL_OPTIONAL = 1;
        LABEL_REQUIRED = 2;
        LABEL_REPEATED = 3;
    }

    optional string name = 1;
    optional int32 number = 3;
    optional Label label = 4;
    optional Type type = 5;
}

message DescriptorProto {
  optional string name = 1;

  repeated FieldDescriptorProto field = 2;
}

Что в переводе на php выглядит как:

new DescriptorProto(
    name: 'CreateUserRequest',
    field: [
        new FieldDescriptorProto(
            name: 'name',
            number: 1,
            label: Label::LABEL_OPTIONAL,
            type: Type::TYPE_STRING,
        ),
        new FieldDescriptorProto(
            name: 'age',
            number: 2,
            label: Label::LABEL_OPTIONAL,
            type: Type::TYPE_INT32,
        ),
    ],
);

По сути дела, это то же самое, как использовать new \ReflectionClass(CreateUserRequest::class), только в контексте протобафа, его типов и модификаторов (которые у них называются лейблами). Остается дело за малым — сгенерировать код. Все бы ничего, если бы не разные версии протобафа — proto2 и proto3 — и появление изданий. Для экосистемы разница заключается в возможности гибче управлять настройками сериализации и кодогенерации, используя фича-флаги, появившиеся в изданиях, когда точные версии ранее это поведение хардкодили, а для плагинов — необходимость поддержки всего доступного разработчикам разнообразия.

Получив CodeGeneratorRequest, плагин должен сгенерировать код только для файлов, перечисленных в поле fileToGenerate. В нем лежат названия файлов, дескрипторы которых можно найти в другом поле — в поле protoFile. Отдельный список только для файлов, которые нужно сгенерировать, нужен из-за того, что protoc передает дескрипторы всех файлов, включая импорты, код для которых мы генерировать не должны, если этих файлов нет в списке для генерации. Например, в списке всех файлов может быть импорт google/protobuf/timestamp.proto, который уже сгенерирован и поставляется отдельной библиотекой, чтобы все приложения и библиотеки использовали общие known типы.

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

// auth_capabilities.proto
syntax = "proto3";

package auth.api;

option php_namespace = "Auth\\Capabilities";

message Token {}

// auth_v1.proto
syntax = "proto3";

package auth.api.v1;

import "auth_capabilities.proto";

message Request {
    auth.api.Token token = 1;
}

Для текущего кода мы не хотим генерировать auth_capabilities.proto, потому что это отдельная, стабильная библиотека, сгенерированная лишь однажды, но protoc — как скоро окажется, и нам тоже — нужно знать этот импорт, чтобы убедиться, что в нем действительно есть сообщение Token. Это же компилятор, в конце концов. А нам этот импорт нужен из-за неймспейса, под которым будет сгенерирован Token: поскольку в файле указана опция php_namespace, наш плагин, как и другие, положит Token под неймспейсом Auth\Capabilities, поэтому в Auth\Api\V1\Request классе мы не можем использовать в качестве fqcn имя Auth\Api\Token. Именно по этой и некоторым другим причинам, которые будут раскрыты позже, нам нужны дескрипторы импортов.

Кроме данных, передаваемых плагину компилятором, иногда может возникнуть — а в нашем случае непременно возникает — необходимость передавать плагину опции командной строки. Ну, как-то же надо влиять на работу плагина. Однако, если память нас не подводит, мы запускаем не свой плагин, а protoc, которому мы, между прочим, будучи плагином, сами передаемся как опция. А у опции опций быть не может. Но я бы об этом даже не заикался, если бы эта проблема не была решена. Для опций нужно использовать форму --<name>_opt. Например:

protoc \
  --plugin=protoc-gen-php-plugin=/usr/local/bin/protoc-gen-php \
  --php-plugin_opt=php_namespace="Thesis\\Api\\V1" \
  --php-plugin_out=genproto \
  protos/*.proto

Или более нагруженный синтаксис — передавать в --<name>_out опции как opt=value:/path/to/generated:

protoc \
  --plugin=protoc-gen-php-plugin=/usr/local/bin/protoc-gen-php \
  protos/*.proto \
  --php-plugin_out=php_namespace="Thesis\\Api\\V1":genproto

Но эти опции попадут не в argv, как вы привыкли, а в поле запроса parameter. Так, с помощью опций нашему плагину можно запретить кодогенерацию для gRPC клиента, сервера или обоих сразу, определить неймспейс или даже повлиять на вложенность сгенерированных файлов.

Полстатьи написано, а мы даже до сообщений не добрались, или до сервисов, или хотя бы до енамов — в общем, до чего-нибудь, наблюдаемого глазами разработчика. Но перед этим позволю себе еще несколько комментариев. Я имею в виду настоящие комментарии, написанные в схеме протобафа — как быть с ними? Разумеется, нам хотелось бы, чтобы комментарии, указанные в схеме, также оказались и в коде. Я делаю акцент на этом не просто так: комментарии не передаются готовыми с каждым дескриптором, иначе они бы не стоили внимания, а вычисляются по набору координат типа Location. Рассмотрим пример:

// Сообщение запроса.
message Request {
  // Идентификатор пользователя.
  string userId = 1;
}

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

FileDescriptorProto
   └── message_type (4)
       └── [0] Request
           └── field (2)
               └── [0] userId

Или в виде таблицы соответствия пути к комментарию:

path comment
4.0 Сообщение запроса.
4.0.2.0 Идентификатор пользователя.

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

// Первое сообщение.
message CreateUserRequest {
   // Имя.
   string name = 1;
}

// Второе сообщение.
message DeleteUserRequest {
   // Идентификатор.
   string id = 1;
}

Таким будет дерево:

FileDescriptorProto
   └── message_type (4)
       └── [0] CreateUserRequest
           └── field (2)
               └── [0] name
       └── [1] DeleteUserRequest
           └── field (2)
               └── [0] id

И такой будет таблица соответствия:

path comment
4.0 Первое сообщение.
4.0.2.0 Имя.
4.1 Второе сообщение.
4.1.2.0 Идентификатор.

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

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

edition = "2023";

package auth.api.v1;

option features.field_presence = IMPLICIT;

message Request {
    string name = 1 [features.field_presence = LEGACY_REQUIRED];
    int32 age = 2;
}

Объединив значения фича-флагов, для age останется IMPLICIT, в то время как name будет требоваться обязательно, что соответствует поведению лейбла required из proto2. Значения фича-флагов также нужно вычислить заранее, пока мы собираем граф всех объектов, потому что граф получается однонаправленный, то есть из поля узнать фича-флаги сообщения, в котором оно находится, будет чересчур неудобно.

Последней задачей в подготовительном списке, отделяющем нас от кодогенерации, будет создание карты типов. Мало того, что у нас есть импорты, также есть вложенные сообщения неопределенной глубины, на которые можно ссылаться из других пакетов и даже из вложенных сообщений ссылаться на вложенные сообщения другого сообщения. Короче говоря, в каждом таком месте необходимо подставлять правильный fqcn в терминах php. Чтобы определить неймспейс будущего класса или енама, необходимо посмотреть, есть ли в proto-файле опция php_namespace, иначе в качестве неймспейса будет выступать package. Если же нет и его, что на практике на самом деле не встречается, необходимо передать опцию плагину, про которую я уже писал выше. Это справедливо для сообщений всего файла, включая вложенные сообщения и енамы. В то же время вложенные сообщения получают в качестве неймспейса не только корневой, которыми будут php_namespace или package, но и все имена своих родителей. Например:

syntax = "proto3";

package api.auth.v1;

message AuthenticationRequest {
    message AuthenticationToken {
        enum Type {
            TYPE_UNSPECIFIED = 0;
            TYPE_JWT = 1;
        }

        Type type = 1;
        bytes opaque = 2;
    }

    AuthenticationToken token = 1;
}

После кодогенерации получится дерево файлов следующего вида:

Api/
  Auth/
    V1/
      AuthenticationRequest.php
      AuthenticationRequest/
        AuthenticationToken.php
        AuthenticationToken/
           Type.php

А неймспейс класса, например AuthenticationToken, будет выглядеть как: namespace Api\Auth\V1\AuthenticationRequest. Это устраняет конфликты имен на случай, если в одном proto-файле будут сообщения или енамы с одинаковыми именами. В других языках, например в go, такая задача может быть решена по-другому: к именам вложенных сообщений добавляется префикс имен их родителей. Хотя это не самое удачное решение, в таких языках оно оправдывается отсутствием поддержки циклических импортов, которые в случае разнесения сообщений по разным папкам могут появиться. Также стоит обратить внимание, что, как бы мы ни старались сокращать неймспейсы, убирая их в use, все равно могут появиться ситуации, при которых use потребует переименование с помощью as. Иначе говоря, с нашим плагином после кодогенерации все классы используют полные неймспейсы в качестве ссылок:

namespace Api\Auth\V1;

/**
 * @api 
 */
final readonly class AuthenticationRequest
{
    public function __construct(
        public ?\Api\Auth\V1\AuthenticationRequest\AuthenticationToken $token = null,
    ) {}
}

Наконец, когда небольшое множество непростых задач закрыто, дело пойдет на лад. В самом деле, что может быть проще, чем сгенерировать код? Хотя сейчас для этого принято использовать Claude Code или Codex, мы остановились на nette/php-generator. Он неплохо справляется с генерацией каркаса — классов, методов, функций, их сигнатур — и вообще не умеет генерировать тела методов и файлов. К счастью, тела методов и файлов оказались бедными на токены разного вида, поэтому обошлись без nikic/php-parser.

Самое важное в сообщении (читай: классе) — это его конструктор с полями. Чтобы понять, какого типа поле, нужно использовать комбинацию FieldDescriptorProto.type с FieldDescriptorProto.type_name. Первое почти всегда заполнено (возможно, не заполнено для расширений, которые наш плагин пока или вообще не поддерживает), а второе заполнено только для сообщений, енамов и — кто бы мог подумать — мап. Когда type_name не заполнен, перед нами простой тип — строка, байты, число, булево или список одного из них. Через обычный match можно: вычислить нативный тип (например, все числа разного размера схлопываются в int или BcMath\Number), определить атрибут рефлексии, где разнообразие чисел уже сохраняется, потому что это влияет на сериализацию, и распарсить значение по умолчанию, если вдруг окажется, что его по правилам этого поля (например, когда значение по умолчанию указано явно или требуется правилами «присутствия») нужно указать.

Когда type_name заполнен, его полное имя можно получить из карты типов, собранной ранее. Что касается сообщений: у них не может быть значений по умолчанию и они nullable, если не указан required в proto2 или LEGACY_REQUIRED в editions. В то же время у енамов значение по умолчанию может быть указано явно или взят первый кейс, если это proto3 или editions, а правило обязательности (то есть когда енам не nullable) такое же, как у сообщений.

Однако type_name может быть заполнен и для мап. Из прошлой статьи, где я писал про protobuf, — если, конечно, у вас хватило на нее терпения, — можно было узнать, что мапы — это список «виртуальных» сообщений, где ключ и значение являются его полями. Такой подход повторяется и в дескрипторах, где мапа — на этот раз даже буквально — также является «виртуальным» сообщением. В этом случае type_name будет формироваться как {package}.{field}Entry. Например:

edition = "2024";

package api.kafka;

message CreateTopicsRequest {
    map<string, string> configs = 1;
}

Что в результате дает:

new DescriptorProto(
    name: 'CreateTopicsRequest',
    field: [
        new FieldDescriptorProto(
            name: 'configs',
            label: Label::LABEL_REPEATED,
            type: Type::TYPE_MESSAGE,
            typeName: '.api.kafka.CreateTopicsRequest.ConfigsEntry',
        ),
    ],
    nestedType: [
        new DescriptorProto(
            name: 'ConfigsEntry',
            field: [
                new FieldDescriptorProto(name: 'key', ...),    
                new FieldDescriptorProto(name: 'value', ...),    
            ],
            options: new MessageOptions(mapEntry: true, ...),
        ),
    ],
);

То есть мапой будет поле, у которого заполнен type_name, дескриптор которого находится в nestedType и имеет опцию mapEntry. Также для мап игнорируем Label::LABEL_REPEATED, хотя его можно использовать в качестве индикатора, является ли дескриптор мапой, потому что мапы в php коде будут представлены объектом Thesis\Protobuf\Map, мотивацию которого — необходимость хранить объектные ключи — я объяснял в прошлом. Кроме этих особенностей, мапу тяжело отличить от обычных сообщений.

Чего не скажешь про объединения — тут разработчики проявили изобретательность. В прошлом я уже рассказывал: объединения являются обычными полями со сквозной по отношению ко всему сообщению нумерацией. Более того, на уровне протокола сериализации объединения никак не выделяются, что делает их бесплатными. Это условный представитель zero cost abstractions, когда в «compile-time», то есть на уровне php кода, у вас есть абстракции, позволяющие ввести необходимые ограничения, например дать всем вариантам объединения общий интерфейс и обернуть их в объекты, что никоим образом не отражается на runtime — размере и даже содержимом бинарного буфера.

Этот подход был практически как есть перенесен на уровень протокола общения плагинов с protoc, где объединения так же являются обычными полями. Например:

syntax = "proto3";

package api.kafka;

message CreateTopicsRequest {
    oneof listener {
        string controller = 1;
        string broker = 2;
    }
}

Плагин для этой схемы получит:

new DescriptorProto(
    name: 'CreateTopicsRequest',
    field: [
        new FieldDescriptorProto(
            name: 'controller',
            number: 1,
            oneofIndex: 0,
        ),
        new FieldDescriptorProto(
            name: 'broker',
            number: 2,
            oneofIndex: 0,
        ),
    ],
    oneofDecl: [
        0 => new Google\Protobuf\OneofDescriptorProto(
            name: 'listener',
        ),
    ],
);

Как видно, все, что есть общего между этими полями, — это наличие oneofIndex, указывающего на индекс в списке oneofDecl, каждый элемент которого имеет название объединения. Хотя эти поля можно генерировать как обычные, и так даже делает плагин от Google, наш плагин поступает иначе: для каждого варианта генерируется свой объект, имплементирующий sealed (пока только на уровне статического анализа) интерфейс; позже все варианты перечисляются в атрибуте рефлексии. Важно также отличать настоящие oneof от синтетических, которыми являются optional поля в proto3, представленные как объединения из одного поля. Их нужно генерировать как обычные nullable поля. В остальном же генерация oneof варианта проходит по правилам генерации обычных сообщений, разве что это сообщение — ненастоящее.

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

message User {
    reserved 2, 5, 9 to 11;

    string name = 1;
    int32 age = 3;
}

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

message User {
    reserved "email", "phone";

    string name = 1;
    int32 age = 3;
}

В этом случае, как и в случае с числами, protoc не даст создать поля с такими именами. Имена нужны для сериализации сгенерированных полей в json, для чего даже написана спецификация. Правда, это ограничение ломается — если знать как:

message User {
    reserved "email", "phone";

    string name = 1 [json_name = "email"];
    int32 age = 3;
}

Старые клиенты, использующие json для передачи protobuf-структур и по-прежнему ожидающие поле email, получат вместо него данные из name поля из-за приоритета именования, в котором опция json_name стоит выше. Если спросят, я вам это не показывал.

Кроме протобаф определений, плагин генерирует код для gRPC, частью которого являются классы для клиента и интерфейсы сервера. Хотя у клиентов фиксированный конструктор, что значительно упрощает кодогенерацию по сравнению с протобафом, их методы имеют тела, которые, как я выше написал, nette генерировать совсем не умеет. Также я написал, что тела у них совсем простые, не требующие условия и даже нечитаемой конкатенации:

/** @var Client\Invoke<?, ?> $invoke */
$invoke = new Client\Invoke(
    method: ?,
    type: ?::class,
);

$stream = $this->client->createStream(
    invoke: $invoke,
    md: $md,
    cancellation: $cancellation,
);

$stream->send($request);
$stream->close();

return new Client\ServerStreamChannel($stream);

Здесь необходимо подставить дженерики, название rpc и fqcn ответа. Между rpc с клиентским, серверным и двунаправленным стримами практически нет разницы, кроме возвращаемого типа — ClientStreamChannel, ServerStreamChannel и BidirectionalStreamChannel соответственно. У серверного интерфейса в этом месте так же отличаются соответствующие входные или выходные параметры.

В пару к интерфейсу сервера генерируется класс {Service}ServerRegistry, необходимый для подключения реализации сервиса пользователя в общий gRPC сервер. Можно назвать это устранением бойлерплейта, который пришлось бы писать пользователю каждый раз самостоятельно. В том или ином виде он есть в плагинах к разным языкам программирования. Хотя это можно было бы сделать частью абстрактного класса, который пользователю надо было бы унаследовать, решено было не использовать наследование в принципе, чтобы не закрывать эту возможность, поскольку в php множественного наследования нет.

И последнее — генерация автозагрузчика метаданных. В первую очередь, метаданные нужны для сериализации типа google.protobuf.Any, в котором type_url указывает на полное имя сообщения, включая название пакета, в котором оно находится. Другими словами, нам не хватит информации из php кода сообщения, чтобы его правильно передать по сети. Так появляется необходимость хранить метаданные дополнительно. Поскольку классы сообщений нагружать такой информацией не хотелось, метаданные хранятся отдельно от них. Например, для такой схемы:

syntax = "proto3";

package api.kafka;

message CreateTopicsRequest {
    string topic = 1;
}

Сгенерируется такое хранилище метаданных:

/**
 * @api
 */
final readonly class ApiDescriptorRegistry implements Registry\Registrar
{
    private const string DESCRIPTOR_BUFFER = '...';

    #[Override]
    public function register(Registry\Pool $pool): void
    {
        $pool->add(Registry\Descriptor::base64(self::DESCRIPTOR_BUFFER), new File(
            name: 'api.proto',
            messages: [
                new File\MessageDescriptor('api.kafka.CreateTopicsRequest', \Api\Kafka\CreateTopicsRequest::class),
            ],
        ));
    }
}

Он хранит сообщения, енамы, сервисы и зависимости между файлами. С его помощью можно узнать, что классу \Api\Kafka\CreateTopicsRequest соответствует имя api.kafka.CreateTopicsRequest, которое поймут клиенты и серверы на других языках программирования. Хотя сейчас имена совпадают, пусть и в разных форматах, вы должны помнить, что полное название класса из-за опции php_namespace может отличаться от его полного названия в схеме.

Кроме этой задачи, есть не менее важная — реализовать серверную рефлексию. Такая рефлексия помогает инструментам, вроде grpcurl и grpcui, не имея сгенерированного кода, делать запросы к вашему серверу динамически. Для этого им нужно знать схему, но не в виде proto-файла, который придется парсить, а в виде протобаф сообщения — какого же, какого? — DescriptorProto. То есть дескрипторы, которые плагин получил от protoc и использовал для генерации кода, необходимо где-то сохранить и привязать к именам сообщений и сервисов, запрашиваемым этими инструментами. Для нашего генератора этим местом будет константа {File}DescriptorRegistry::DESCRIPTOR_BUFFER.

Загрузчиков будет по числу файлов, но автозагрузчик — тот, кто регистрирует загрузчики так, чтобы все метаданные были доступны из любого места проекта, использующего thesis/grpc, — по возможности будет один. Для этого плагину приходится хранить все пути и находить наиболее общий префикс между ними, где файл autoload.metadata.php будет создан и выглядеть будет следующим образом:

<?php

\Thesis\Protobuf\Registry\Pool::get()->register(
    new \Thesis\Protobuf\Registry\OnceRegistrar(new \Api\Kafka\ApiDescriptorRegistry()),
);

Такой файл необходимо зарегистрировать в composer.json:

"autoload": {
    "psr-4": {...},
    "files": [
        "genproto/Grpc/Reflection/autoload.metadata.php"
    ]
},

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

Также важно, что во входящем запросе есть версия protoc, которую вкупе с версией плагина стоит сохранять в комментариях сгенерированного кода, большую пользу чего, пожалуй, объяснять будет излишне:

<?php

/**
 * Code generated by thesis/protoc-plugin. DO NOT EDIT.
 * Versions:
 *   thesis/protoc-plugin — v0.1.20
 *   protoc               — v6.32.1
 * Source: api.proto
 */

Разумеется, protoc на этом не заканчивается: есть возможность помечать сообщения устаревшими с помощью опции deprecated, которую наш плагин тоже проставляет, писать собственные расширения, например для статического анализа, или реализовать уже существующие, вроде validate1 или http. Однако это все несущественные (несущественные, конечно, только в рамках этой статьи — вообще-то всё это очень важно) детали, не требующие ни вашего пристального внимания, ни моего письменного участия.