Protocol Buffers: бинарная сериализация

Protocol Buffers: бинарная сериализация

Protocol Buffers (protobuf) - бинарный формат от Google. В 3-10 раз компактнее JSON, в 5-100 раз быстрее в парсинге, строго типизирован, со схемой и автогенерацией кода. Стандарт де-факто для межсервисного общения.

Установка

# protoc compiler
brew install protobuf  # macOS
# apt install -y protobuf-compiler  # Linux

# Go плагины
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

Для PHP отдельно ставится PECL-расширение и плагин grpc_php_plugin (собирается из исходников gRPC). Composer-пакеты google/protobuf и grpc/grpc подключают runtime:

# Расширение ext-grpc (нужно для клиента/сервера)
pecl install grpc

# protoc compiler уже стоит, нужен plugin для PHP
# Собираем из grpc/grpc исходников:
git clone -b v1.60.x https://github.com/grpc/grpc
cd grpc && git submodule update --init
make grpc_php_plugin
sudo cp bins/opt/grpc_php_plugin /usr/local/bin/

# Composer-зависимости проекта
composer require grpc/grpc google/protobuf
{
    "require": {
        "php": "^8.2",
        "ext-grpc": "*",
        "grpc/grpc": "^1.57",
        "google/protobuf": "^3.25",
        "spiral/roadrunner-grpc": "^3.2"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/",
            "GPBMetadata\\": "generated/GPBMetadata/",
            "User\\": "generated/User/"
        }
    }
}

Первый .proto файл

syntax = "proto3";
package user;
option go_package = "github.com/example/user/pb";

message User {
  string id = 1;
  string name = 2;
  string email = 3;
  int32 age = 4;
  Role role = 5;
  repeated string tags = 6;
  optional string bio = 7;
}

enum Role {
  ROLE_UNSPECIFIED = 0;
  ROLE_USER = 1;
  ROLE_ADMIN = 2;
}

message CreateUserRequest {
  string name = 1;
  string email = 2;
}

message CreateUserResponse {
  User user = 1;
}

syntax = "proto3" - современная версия. Номера полей (1, 2, 3...) - ключевая идея: они кодируются в wire-формате, а не имена. Поэтому переименование поля не ломает совместимость, а смена номера ломает всё.

Wire format в двух словах

Каждое поле кодируется как [tag][value]. Tag = field_number << 3 | wire_type. Маленькие int (0-127) занимают 1 байт благодаря varint-кодированию. Строки и сообщения - длина + содержимое. Это даёт компактность и быструю десериализацию: парсер просто считывает теги один за другим.

Генерация кода

Pipeline: .proto файл компилируется protoc в Go server interface и Go client interface

protoc --go_out=. --go_opt=paths=source_relative \
 --go-grpc_out=. --go-grpc_opt=paths=source_relative \
       proto/user.proto

Создаются файлы user.pb.go (структуры и (Un)marshal) и user_grpc.pb.go (gRPC-сервисы). В CI обычно есть проверка, что сгенерированный код актуален относительно .proto.

Для PHP та же команда с другими плагинами. --php_out генерирует Message-классы, --grpc_out с protoc-gen-grpc=grpc_php_plugin генерирует клиентские стабы:

protoc -I=./proto \
    --php_out=./generated \
    --grpc_out=./generated \
    --plugin=protoc-gen-grpc=$(which grpc_php_plugin) \
    proto/user.proto

Структура generated/: GPBMetadata/User.php (метаданные), User/User.php, User/CreateUserRequest.php, User/CreateUserResponse.php (классы сообщений), User/UserServiceClient.php (gRPC-клиент). Все классы используют namespace из option php_namespace. В CI добавляется шаг проверки, что generated/ соответствует свежей генерации (git diff --exit-code).

Использование сгенерированного кода

user := &pb.User{
    Id:    "123",
    Name:  "Alice",
    Email: "alice@example.com",
    Age:   30,
    Role:  pb.Role_ROLE_ADMIN,
    Tags:  []string{"backend", "go"},
}

// Сериализация
data, err := proto.Marshal(user)

// Десериализация
var decoded pb.User
err = proto.Unmarshal(data, &decoded)
<?php
declare(strict_types=1);

use User\User;
use User\Role;

$user = new User();
$user->setId('123')
    ->setName('Alice')
    ->setEmail('alice@example.com')
    ->setAge(30)
    ->setRole(Role::ROLE_ADMIN)
    ->setTags(['backend', 'php']);

// Сериализация
$data = $user->serializeToString();

// Десериализация
$decoded = new User();
$decoded->mergeFromString($data);

echo $decoded->getName(); // Alice

В PHP сгенерированные классы наследуются от \Google\Protobuf\Internal\Message. Сеттеры возвращают $this для fluent-стиля. Сериализация - метод serializeToString(), десериализация - mergeFromString().

oneof - взаимоисключающие поля

Когда нужно «либо одно, либо другое»:

message Notification {
  string user_id = 1;
  oneof channel {
    EmailChannel email = 2;
    SmsChannel sms = 3;
    PushChannel push = 4;
  }
}

В сообщении заполнено ровно одно поле из channel. Это discriminated union: в Go он разбирается через type switch, в PHP через match:

switch ch := notification.Channel.(type) {
case *pb.Notification_Email:
    sendEmail(ch.Email.Address)
case *pb.Notification_Sms:
    sendSms(ch.Sms.PhoneNumber)
}
<?php
declare(strict_types=1);

use Notification\Notification;

match ($notification->getChannel()) {
    'email' => $emailSender->send($notification->getEmail()->getAddress()),
    'sms'   => $smsSender->send($notification->getSms()->getPhoneNumber()),
    'push'  => $pushSender->send($notification->getPush()->getDeviceToken()),
    default => throw new \LogicException('channel is not set'),
};

В PHP сгенерированный класс даёт метод getChannel() (возвращает имя выбранного поля как строку) и геттеры по конкретному полю. match идеально подходит для дискриминированного юниона.

map - словари

message UserAttributes {
  map<string, string> labels = 1;
  map<int32, User> users_by_id = 2;
}

Под капотом - список пар (key, value). Ключи только примитивы (string, integer), значения - что угодно, кроме map (нет вложенных map).

Well-known types - стандартные обёртки

import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";
import "google/protobuf/empty.proto";

message Event {
  google.protobuf.Timestamp created_at = 1;
  google.protobuf.Duration timeout = 2;
}

service Health {
  rpc Check(google.protobuf.Empty) returns (google.protobuf.Empty);
}

Используйте Timestamp вместо int64 unix-time - это переносимо и читаемо. Empty для запросов/ответов без параметров.

Protobuf vs JSON

<ComparisonTable data={{ headers: ["", "Protobuf", "JSON"], rows: [ ["Размер", "3-10x меньше", "Базовый"], ["Скорость", "5-100x быстрее", "Базовый"], ["Типизация", "Строгая", "Нет"], ["Читаемость", "Бинарный", "Да"], ["Backward compat", "Встроенная", "Ручная"] ] }} />

Правила backward compatibility

  • НЕ меняй номера полей - это сломает всех существующих клиентов
  • НЕ переиспользуй удалённые номера - используй reserved
  • Можно добавлять новые поля (старый код их проигнорирует)
  • Можно переименовывать поля (имя в wire format не используется)
  • Можно добавлять enum-значения - старые клиенты увидят как UNKNOWN
  • НЕ меняй тип существующего поля (int32 ↔ string ломает wire format)
message User {
  reserved 4, 8;            // эти номера навсегда заняты
  reserved "phone", "fax";  // эти имена тоже
  string id = 1;
  string name = 2;
  // string phone = 4;  // ОШИБКА - поле зарезервировано
}

proto3 поля и nullability

В proto3 у скалярных полей нет понятия «не задано» - int32 age = 4 всегда возвращает 0, если не установлено. Если различие важно (например, age=0 vs age не передан), используй optional (proto3 v3.15+) или Int32Value из wrappers.proto.

Мини-практика

Создай .proto файл для сервиса задач (Todo): сообщения Task, CreateTaskRequest, ListTasksResponse. Используй oneof для статуса (открыта/в работе/закрыта с разными полями), Timestamp для дедлайна, map для меток. Сгенерируй Go-код через protoc и убедись, что компилируется.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.