gRPC в мире PHP: пошаговый гайд по сборке микросервиса на Symfony 7 и RoadRunner

от автора

Классические PHP-приложения живут по модели «запрос — ответ»: веб-сервер (как правило, Nginx) передаёт HTTP-запрос в менеджер процессов PHP-FPM, процесс поднимается, выполняет скрипт и затем завершается. Для REST API и большинства монолитных систем это привычно, надёжно и работает десятилетиями. Но в микросервисной архитектуре всё иначе: сервисы постоянно общаются между собой, а к задержке (latency) и соблюдению контрактов требования высоки. Индустриальным стандартом здесь стал gRPC — бинарный протокол на базе HTTP/2, предлагающий строгую типизацию сообщений (Protobuf), нативную потоковую передачу данных (streaming) и автоматическую кодогенерацию для десятков языков.

Долго gRPC в PHP считался экзотикой: расширение grpc сложно настроить, а вокруг долгоживущих процессов приходилось городить костыли. Например, в Go или Java gRPC — привычная часть экосистемы: официальные библиотеки, кодогенерация из .proto, серверы проектируют как long-running процессы. В PHP интерпретатор сам по себе не рассчитан на разбор бинарных HTTP/2-потоков и мультиплексирование на уровне сокетов. Поэтому сообщество разработало расширение grpc: его низкоуровневый C-код в теории должен давать максимальную скорость сериализации и работы с сетью. Но на практике связка PECL grpc + PHP-FPM упирается в модель жизненного цикла процесса. FPM обслуживает запрос и сбрасывает либо завершает worker. TCP-соединение HTTP/2 при этом рвётся, плохо переиспользуется, а мультиплексирование нескольких RPC в одном канале теряет смысл. На каждый RPC Symfony инициализируется заново: autoload, прогрев DI-контейнера, подключение к БД. Остаётся два пути: держать отдельный PHP-процесс, который слушает gRPC, или проксировать вызовы в FPM. Оба варианта усложняют инфраструктуру и плохо вписываются в привычную модель Symfony-приложения.

Выход — разделить транспорт и приложение:

  1. RoadRunner (Go) принимает gRPC поверх HTTP/2 и передаёт задачи в PHP через Goridge. spiral/roadrunner-grpc превращает бинарные сообщения в вызовы методов.

  2. Symfony по-прежнему отвечает за всё, к чему привыкли в обычном проекте: DI-контейнер, Doctrine, миграции, консоль.

В статье мы с нуля спроектируем и запустим gRPC-микросервис на стеке Symfony 8 + RoadRunner + PostgreSQL, реализуем два RPC-сервиса: HelloService — сервис для базовой проверки связи и демонстрации работы контракта. OrderService (Заказы) — бизнес-ориентированный сервис с методами создания заказа и получения списка. Здесь мы настроим полноценную работу с базой данных через Doctrine ORM и валидацию данных. А тестировать готовое API и отправлять бинарные запросы мы будем с помощью консольной утилиты grpcurl.

При написании статьи мы сделали репозиторий на GitHub, который можно скопировать себе и воспроизвести примеры на практике.

Почему gRPC — стандарт для межсервисного взаимодействия

Перед тем как перейти к практике, подробно рассмотрим, зачем gRPC в микросервисной архитектуре.

Для внутреннего взаимодействия между сервисами gRPC обычно выигрывает у REST по скорости сериализации, строгости контракта и возможностям HTTP/2:

Критерий

REST (JSON)

gRPC (Protobuf)

Формат

Текст

Бинарный, компактный

Контракт

OpenAPI (часто постфактум)

.proto

Транспорт

HTTP/1.1

HTTP/2, мультиплексирование

Стриминг

SSE, WebSocket

Unary, server/client/bidi

Кодогенерация

Опциональна

Часть toolchain

Публичный API для браузеров по-прежнему чаще делают на REST или GraphQL. gRPC — это про service-to-service: каталоги, биллинг, оркестрация, всё, что живёт за gateway.

Так почему RoadRunner, а не PHP-FPM

FPM заточен под короткий HTTP-запрос: процесс поднялся, отработал, ушёл в пул или завершился. Для gRPC это неудобно — HTTP/2-канал должен жить долго, а bootstrap Symfony на каждый RPC слишком дорог по ресурсам и времени.

Сервер приложения RoadRunner реализует транспорт на стороне Go: держит пул PHP worker’ов, принимает gRPC и передаёт вызовы через Goridge (pipes или TCP). PHP видит уже готовый метод с типизированными request/response. На следующем рисунке показаны схемы работы RoadRunner и PHP-FPM:

Быстрый старт

Если вы скопировали демонстрационный проект и в системе установлен Docker, то для запуска достаточно выполнить:

docker compose up -d --build

Сразу можно проверить, что всё поднялось:

grpcurl -plaintext \  -import-path proto -proto hello/v1/hello.proto \  -d '{"name":"Alex"}' \  localhost:9001 hello.v1.HelloService/SayHello

Ожидаемый ответ:

{"message": "Hello, Alex!"}

Давайте разберём, как это работает. Клиент шлёт запрос protobuf по HTTP/2 → RoadRunner принимает gRPC → PHP worker получает вызов через Goridge → Symfony-обработчик возвращает ответ. Взаимодействие показано на схеме ниже:

В таблице список основных директорий проекта с кратким описанием.

Путь

Назначение

proto/

Публичный контракт, версии API

generated/

DTO и интерфейсы — не править вручную

src/Grpc/

Реализация RPC

src/Entity/, migrations/

Модель и схема БД

public/grpc-worker.php

Склейка RoadRunner ↔ Symfony

.rr.yaml

Порты, proto, команда worker

docker/php-roadrunner/entrypoint.sh

protoc, composer, миграции при старте

Hello-сервис: от контракта до RPC

Hello — минимальный пример RPC-сервиса.

1. Контракт

Для начала нужно создать файл контракта proto/hello/v1/hello.proto. Ниже показано содержимое этого файла:

syntax = "proto3";

package hello.v1;

option php_metadata_namespace ="Generated\\GRPC\\Hello\\V1\\GPBMetadata";

option php_namespace = "Generated\\GRPC\\Hello\\V1";

message SayHelloRequest {
  string name = 1;
}

message SayHelloResponse {
  string message = 1;
}

service HelloService {
  rpc SayHello(SayHelloRequest) returns (SayHelloResponse);
}

Разберём этот пример:

Элемент

Зачем

syntax = "proto3"

Версия языка Protobuf; для новых API — всегда proto3

package hello.v1

Логическое имя API и версия; в gRPC сервис будет называться hello.v1.HelloService

php_namespace

Путь, куда protoc положит PHP-классы; должен совпадать с PSR-4 autoload

message

Структура запроса/ответа; поле name = 1 — имя и номер (не порядок в файле)

service + rpc

Публичные методы; здесь unary — один запрос, один ответ

Правила совместимости: номера полей (1, 2, …) никогда не переиспользуют и не меняют тип. Новое поле — новый номер. Удалённое поле помечают reserved. Breaking change → новый пакет hello.v2, старый hello.v1 оставляют для клиентов.

Полное имя в gRPC: hello.v1.HelloService, метод: SayHello. Именно так вызывает grpcurl: hello.v1.HelloService/SayHello.

2. Генерация PHP

Из .proto можно сгенерировать типизированные DTO и интерфейс сервиса (этот код не создаётся и не редактируется вручную).

mkdir -p generatedprotoc \  --proto_path=proto \  --php_out=generated \  --plugin=protoc-gen-php-grpc=/usr/local/bin/protoc-gen-php-grpc \  --php-grpc_out=generated \  hello/v1/hello.proto

Флаг

Назначение

—proto_path=proto

Корень для import и путей к файлам

—php_out=generated

Сообщения (SayHelloRequest, SayHelloResponse)

—php-grpc_out=generated

gRPC-интерфейс (HelloServiceInterface)

—plugin=protoc-gen-php-grpc=…

Плагин RoadRunner для PHP gRPC

Структура после генерации кода:

generated/└── Generated/└── GRPC/└── Hello/└── V1/├── HelloServiceInterface.php├── SayHelloRequest.php├── SayHelloResponse.php└── GPBMetadata/└── Hello.php

Обратите внимание на вложенность generated/Generated/ — это особенность вывода protoc, в composer.json секции Autoload должно быть прописано следующее: "Generated\\": "generated/Generated/".

Сгенерированный интерфейс — контракт, который обязана выполнить реализация:

interface HelloServiceInterface extends GRPC\ServiceInterface {    public const NAME = "hello.v1.HelloService";    public function SayHello(ContextInterface $ctx, SayHelloRequest $in): SayHelloResponse;}

Константа NAME совпадает с полным именем сервиса в gRPC — RoadRunner использует её при маршрутизации вызовов. Файлы в generated/ не редактируют вручную: при изменении .proto их нужно перегенерировать.

3. Обработчик в Symfony

gRPC-сервис в Symfony — обычный PHP-класс, реализующий сгенерированный интерфейс. HTTP-контроллеры, роуты и framework.router для этого не нужны.

Создадим src/Grpc/HelloService.php:

<?phpdeclare(strict_types=1);namespace App\Grpc;use Generated\GRPC\Hello\V1\HelloServiceInterface;use Generated\GRPC\Hello\V1\SayHelloRequest;use Generated\GRPC\Hello\V1\SayHelloResponse;use Spiral\RoadRunner\GRPC\ContextInterface;final class HelloService implements HelloServiceInterface {    public function SayHello(ContextInterface $ctx, SayHelloRequest $in): SayHelloResponse {        $name = trim($in->getName()) !== '' ? $in->getName() : 'World';        return new SayHelloResponse()->setMessage(sprintf('Hello, %s!', $name));    }}

Что важно в сигнатуре:

  • ContextInterface $ctx — metadata входящего вызова (аналог HTTP-заголовков): trace-id, JWT, deadline. В Hello не используем, но параметр обязателен по интерфейсу.

  • SayHelloRequest $in — уже декодированный protobuf; доступ к полям читаем через методы getName(), hasName().

  • Возврат SayHelloResponse — объект ответа; у protobuf-классов методы set* возвращают $this (fluent).

Затем нужно зарегистрировать сервис в DI. По умолчанию сервисы Symfony приватные; worker получает их через $container->get(), поэтому помечаем наш сервис публичным:

services:   App:     resource: '../src/'   App\Grpc\HelloService:      public: true

На этом этапе Hello ещё не доступен снаружи — класс есть, но RoadRunner о нём не знает. Связку «интерфейс → реализация → gRPC-порт» делаем в worker (следующий раздел).

Worker и RoadRunner

public/grpc-worker.php — единственная точка входа PHP:(new Dotenv())->bootEnv(dirname(__DIR__) . '/.env');$kernel = new Kernel($_SERVER['APP_ENV'] ?? 'dev', (bool) ($_SERVER['APP_DEBUG'] ?? true));$kernel->boot();$container = $kernel->getContainer();$server = new Server();$server->registerService(HelloServiceInterface::class, $container->get(HelloService::class));$server->registerService(OrderServiceInterface::class, $container->get(OrderService::class));$server->serve(Worker::create());

В spiral/roadrunner-grpc 3.x registerService принимает FQCN интерфейса и экземпляр реализации. RoadRunner не связывает .proto с PHP автоматически — каждую пару нужно зарегистрировать явно.

.rr.yaml:

version: "3"server:command: "php public/grpc-worker.php"relay: pipesgrpc:listen: "tcp://0.0.0.0:9001"proto:- "proto/hello/v1/hello.proto"- "proto/order/v1/order.proto"rpc:listen: "tcp://127.0.0.1:6001"server:  command: "php public/grpc-worker.php"  relay: pipesgrpc:  listen: "tcp://0.0.0.0:9001"  proto:    - "proto/hello/v1/hello.proto"    - "proto/order/v1/order.proto"rpc:  listen: "tcp://127.0.0.1:6001"

Ключевое: server.command запускает worker, grpc.listen — адрес gRPC, grpc.proto — файлы для reflection/валидации на стороне RR.

Сервис заказов

Давайте сделаем практический пример и превратим абстрактный Hello-воркер во что-то более прикладное. Мы создадим сервис управления заказами — OrderService. Он будет поддерживать два базовых метода: CreateOrder (создание заказа) и ListOrders (получение списка).

Архитектура и логика работы

  1. База данных: Сущность Order отображается на таблицу orders. Для создания таблицы используем Doctrine-миграцию Version20260530120000.

  2. Метод CreateOrder:

    • Принимает данные, валидирует входящие поля через компонент symfony/validator.

    • В случае успеха сохраняет заказ в БД со статусом new.

    • Возвращает сгенерированный id и текущий статус.

  3. Метод ListOrders:

    • Реализует постраничную навигацию с помощью параметров limit и offset.

    • Возвращает массив объектов Order и общее количество записей (total) для пагинации.

  4. Обработка ошибок: Если валидация не пройдена, сервис выбрасывает GRPCException со стандартным gRPC-статусом INVALID_ARGUMENT, передавая детали ошибки клиенту.

Проверка работы через grpcurl

После генерации DTO и запуска RoadRunner мы можем протестировать методы с помощью утилиты grpcurl.

1. Создание нового заказа:

grpcurl -plaintext -import-path proto -proto order/v1/order.proto \  -d '{"customer_name":"Alex","product":"Widget","quantity":2}' \  localhost:9001 order.v1.OrderService/CreateOrder
# Ожидаемый ответ:# { "id": "1", "status": "new" }

2. Получение списка заказов:

grpcurl -plaintext -import-path proto -proto order/v1/order.proto \  -d '{"limit":10,"offset":0}' \  localhost:9001 order.v1.OrderService/ListOrders

Заключение

Связка Symfony + RoadRunner + Protobuf доказывает: современный PHP полностью готов к высоким нагрузкам и эффективной работе в микросервисной архитектуре. В рамках одного проекта нам удалось развернуть производительный gRPC-сервер, сохранив привычный DX (Developer Experience). При этом каждый компонент стека четко изолирует свою зону ответственности:

  • Protobuf гарантирует строгую и кроссплатформенную спецификацию контрактов данных.

  • Symfony обеспечивает гибкое управление зависимостями (DI) и привычную реализацию бизнес-логики.

  • RoadRunner берет на себя сетевой уровень (HTTP/2), мультиплексирование запросов и эффективное управление пулом долгоживущих (long-running) воркеров.

Однако написанный нами прототип — это лишь демонстрация механики взаимодействия компонентов, а не готовое production-решение. Чтобы превратить этот каркас в надежный боевой сервис, необходимо учесть специфику long-running среды.

При переходе от прототипа к enterprise-эксплуатации вам в обязательном порядке придется решить следующие задачи:

  • Менеджмент памяти (Memory Leaks): В режиме долгоживущих процессов любая утечка памяти (например, накопление данных в статических переменных или неотключаемый логгер в контейнере) приведет к падению воркера. Необходимо настроить директиву max_jobs или лимиты по памяти в roadrunner.yaml для автоматического рестарта воркеров.

  • Состояние ресурсов (Stateful Connections): Соединения с базой данных (Doctrine) или брокерами сообщений могут отваливаться по таймауту. Нужно реализовать проверку активности соединений (ping/reconnect) перед каждым запросом или сбрасывайте EntityManager после обработки задачи.

  • Обработка системных ошибок: Ошибки уровня сети (broken pipes, таймауты сокетов) не должны приводить к падению всего мастера. Нужно настроить корректный перехват исключений и трансляцию их в понятные gRPC-статусы (INTERNAL, UNAVAILABLE).

  • Observability (Мониторинг): Интегрируйте сбор метрик. RoadRunner из коробки умеет отдавать метрики для Prometheus (плагин metrics).

  • Безопасность (TLS/mTLS): Внутри периметра микросервисов общение должно быть защищено. Нужно настроить шифрование трафика (gRPC over TLS) на уровне конфигурации RoadRunner или делегируйте это на сторону Service Mesh (например, Istio).

Ссылки

Автор текста — Александр Донцов


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ нового VDS — HABRFIRSTVDS.

Положение об акции

ссылка на оригинал статьи https://habr.com/ru/articles/1055128/