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-кодированию. Строки и сообщения - длина + содержимое. Это даёт компактность и быструю десериализацию: парсер просто считывает теги один за другим.
Генерация кода
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 и убедись, что компилируется.