Разберемся как приготовить gRPC сервер на PHP при том, что из коробки gRPC не предоставляет сервер под этот язык. В этом нам поможет небезызвестный Roadrunner, способный взять часть задач на себя
Есть еще другая статья по теме, где пишем gRPC сервер на Symfony.
Предисловие
Что такое gRPC
gRPC - Кроссплатформенный фреймворк удаленного вызова процедур (RPC - Remote Procedure Calls), разработанный Google. Позволяет:
- Декларативно описать контракты взаимодействия на языке Protocol Buffers
- Сгенерировать по описанным контрактам клиентский и частично серверный код любого поддерживаемого языка (в том числе и PHP)
- Эффективно передавать данные по сети благодаря компактной сериализации
gRPC сервер на PHP?
Проблема в том, что из коробки на PHP нет реализации gRPC сервера. В официальной документации сказано, что на PHP можно создать только клиент под GRPC, а для сервера стоит использовать другой язык.
Also note that currently, you can only create clients in PHP for gRPC services.
Use another language to create a gRPC server.
Причины при желании можно подробно изучить в данном топике.
Чтобы не отказываться от идеи писать серверную логику приложения на PHP в связке с gRPC, нам нужно промежуточное звено, которое взяло бы на себя
всю работу по обеспечению gRPC сервера, конкретно транспортную часть. А данные на обработку передавало бы в PHP. Чтобы вся полезная нагрузка,
бизнес логика, выполнялась на PHP.
Именно по такой схеме работает
Roadrunner
.
Roadrunner
Roadrunner - высокопроизводительный application сервер, балансировщик нагрузки и диспетчер PHP процессов. Написан на Go. Позволяет эффективно создавать гибридные PHP/Go приложения, комбинируя лучшее из обоих языков.
Roadrunner создает пул демонизированных PHP процессов. Выступая как http
или gRPC
сервер, Roadrunner принимает запрос и
передает его на обработку одному из поднятых PHP процессов по бинарному протоколу Goridge
.
PHP обрабатывает запрос и возвращает ответ Roadrunner - у.
Процесс PHP при этом “не умирает”, как это привычно происходит в классической схеме Nginx + Php-fpm
, а тут же принимает следующий
запрос, не тратя повторно время на инициализацию. Это само по себе значительно улучшает производительность PHP. Платой
за прирост производительности выступает необходимость следить за возможными утечками памяти, которыми зачастую пренебрегают из-за природы
PHP.
Разработка ведется в Белорусской компании Spiral Scout, которая также известна и другими своими превосходными open source
разработками Cycle ORM
и Spiral Framework
.
Аналоги
Достойных аналогов, предоставляющих gRPC сервер для PHP, кроме Open Swoole я не нашел. Проект выглядит интересным, но опыта с ним почти не имел. Начинать не хочется только потому, что в нем нет поддержки Xdebug.
Реализация gRPC сервера
Цели и постановка задачи
Наша цель - получить навык разработки gRPC сервера на PHP.
Для ее достижения выдумаем и реализуем простенький демо сервис, предоставляющий информацию о курсах валют. Любой клиент средствами gRPC должен иметь возможность запрашивать текущий курс интересующей валюты у сервера.
Сервер в свою очередь будет по http или иным образом ходить в источник курсов валют (какая - нибудь биржа или сервис), и возвращать актуальный курс на момент времени по запрошенной валютной паре.
Я произвожу все действия на ПК с дистрибутивом Manjaro Linux
на борту. Думаю все можно повторить и на MacOS/Windows,
но скорее всего с оговорками, которые в данной статье затронуты не будут.
Установка Spiral
Начнем с установки PHP фреймворка Spiral.
composer create-project spiral/app currency-rate
На первых шагах обходимся локально установленным php
и composer
, чтобы пройти консольный мастер установки от Spiral,
настроив скелет приложения. Далее все завернем в docker
, как положено.
На вопрос: “Какой пресет приложения мы хотим установить?” отвечаем вариант 3, gRPC
Which application preset do you want to install?
[1] Web
[2] Cli
[3] gRPC
Make your selection (default: 1): 3
На следующих вопросах отказываемся от демо данных и от установки любых компонентов, чтобы на старте не заиметь ничего лишнего.
После чего установщик любезно выкачает зависимости, скачает плагин protoc-gen-php-grpc
и бинарник
роадранера rr
, разместив их в корне.
Docker окружение
Нам потребуется собрать контейнер с Roadrunner
. По сути это будет образ на базе контейнера с php
c нужными
расширениями, с установленными внутри утилитами:
composer
rr
(бинарник Roadrunner - а)protoc
утилита кодогенерацииprotoc-gen-php-grpc
плагин для утилиты кодогенерации
Создадим в корне проекта директорию deployment
, где будем хранить всю конфигурацию, связанную с инфраструктурой
развертывания.
mkdir -p deployment/docker/roadrunner
Создадим Dockerfile
под Roadrunner:
# ./deployment/docker/roadrunner/Dockerfile
# Базируемся на официальном образе php cli alpine
FROM php:8.2-cli-alpine
# Рабочая директория /app
WORKDIR /app
# Копируем скрипт install-php-extensions от mlocati
# Данный проект рекомендован на официальной странице образов php https://hub.docker.com/_/php
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/bin/
# Устанавливаем необходимые расширения
RUN install-php-extensions opcache zip sockets \
protobuf \
xdebug \
# Устанавливаем утилиту protoc
&& apk update && apk add --no-cache make protobuf-dev
# Копируем composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Команда и аргументы запуска
# Используем бинарник Roadrunner и конфиг из корня проекта
# Корень проекта будет смонтирован при запуске в /app
CMD ["/app/rr", "serve", "-d", "-c", "/app/.rr.yaml"]
Создадим docker-compose.yml
в корне проекта:
# ./docker-compose.yml
version: '2'
services:
rr:
build:
dockerfile: ./deployment/docker/roadrunner/Dockerfile
ports:
- '9001:9001' # шарим порт 9001
user: '1000:1000' # работа от текущего юзера в системе
volumes:
- ./:/app # монтируем проект в /app
Пока просто соберем образ, без запуска:
docker-compose build
Пишем .proto контракты
Спроектируем контракты для нашего выдуманного сервиса получения курсов валют.
В корне проекта создадим директорию proto
, в которой и разместим наши .proto
файлы:
// ./proto/currency/message.proto
syntax = "proto3";
package currency.dto;
option php_namespace = "GRPCContracts\\Currency";
option php_metadata_namespace = "GRPCContracts\\Currency\\GPBMetadata";
message GetRateRequest {
string ticker = 1;
}
message CurrencyRateResponse {
string ticker = 1;
string rate = 2;
}
// ./proto/currency/service.proto
syntax = "proto3";
package currency;
option php_namespace = "GRPCContracts\\Currency";
option php_metadata_namespace = "GRPCContracts\\Currency\\GPBMetadata";
import "message.proto";
service CurrencyRateService {
rpc GetRate (currency.dto.GetRateRequest) returns (currency.dto.CurrencyRateResponse) {
}
}
- В
message.proto
описаны DTO запросаGetRateRequest
и ответаCurrencyRateResponse
. - В
service.proto
описан сервисCurrencyRateService
c методом получения курсаGetRate
. В качестве аргумента метод принимает описанный выше тип запроса, а возвращает тип ответа. Для их использования в файле производится импорт соседнегоmessage.proto
.
Кодогенерация
Сгенерируем php код по написанным proto контрактам.
Внесем изменения в конфигурационном файле Spiral фреймворка
./app/config/grpc.php
.
- Ключам массива
namespace
иservicesBasePath
установим значениеnull
- В массив
services
добавим путь к нашемуservice.proto
<?php
// ./app/config/grpc.php
declare(strict_types=1);
return [
'binaryPath' => directory('root') . 'protoc-gen-php-grpc',
'generatedPath' => directory('root') . '/generated',
'namespace' => null, // тут ставим null
'services' => [
// сюда прописываем путь до нашего контракта сервиса
directory('root') . '/proto/currency/service.proto'
],
'servicesBasePath' => null, // тут ставим null
];
И запустим консольную команду генерации:
docker-compose run rr php app.php grpc:generate
В корне проекта обнаружим директорию generated
, в которой находится
сгенерированный код.
composer.json
Добавим директорию generated/GRPCContracts
в автолоад composer - а. Для этого в
composer.json
в секцию autoload.psr-4
добавим строку:
"GRPCContracts\\": "generated/GRPCContracts"
Получится примерно следующее:
...
"autoload": {
"psr-4": {
"App\\": "app/src",
"GRPCContracts\\": "generated/GRPCContracts"
}
},
...
И не забудем применить изменения:
docker-compose run rr composer du
Конфиг Roadrunner
Внесем изменения в конфиг .rr.yaml
Roadrunner:
- В секциях
rpc.listen
иgrpc.listen
изменим ip адрес на0.0.0.0
для того, чтобы все работало из подdocker
- В секции
grpc.proto
укажем путь к основномуproto
файлу контракта
Итоговый файл .rr.yaml
будет выглядеть так:
# ./.rr.yaml
version: '3'
rpc:
listen: 'tcp://0.0.0.0:6001'
grpc:
listen: 'tcp://0.0.0.0:9001'
proto:
- ./proto/currency/service.proto
server:
command: 'php app.php'
relay: pipes
Реализуем код сервиса
Нам необходимо имплементировать сгенерированный интерфейс \GRPCContracts\Currency\CurrencyRateServiceInterface.php
:
<?php
// ./app/src/CurrencyRateService.php
declare(strict_types=1);
namespace App;
use GRPCContracts\Currency\CurrencyRateResponse;
use GRPCContracts\Currency\CurrencyRateServiceInterface;
use GRPCContracts\Currency\GetRateRequest;
use Spiral\RoadRunner\GRPC;
final class CurrencyRateService implements CurrencyRateServiceInterface
{
public function GetRate(GRPC\ContextInterface $ctx, GetRateRequest $in): CurrencyRateResponse
{
$response = new CurrencyRateResponse();
$rate = \random_int(70, 75) . '.00';
$response->setRate($rate);
$response->setTicker($in->getTicker());
return $response;
}
}
Чтобы не усложнять пример, в реальные источники курсов валют мы тут не ходим. Вместо этого значение курса заданной валюты генерируем рандомно.
В возвращаемый ответ CurrencyRateResponse
кладем запрашиваемый тикер и его курс.
Первый запуск
docker-compose up -d
Проверяем, стартанул ли контейнер:
docker-compose ps
В выводе должен находиться наш контейнер, а в колонке STATUS
не должно быть ошибок. Если в статусе ошибка, или вовсе
контейнер отсутствует, то необходимо анализировать логи docker-compose logs rr
.
Первый тест через Postman
Можно использовать любой gRPC клиент. В силу популярности Postman
как http клиента, возьмем его. Postman,
помимо http, поддерживает и gRPC протокол.
В дропдауне выбора метода необходимо сначала вручную загрузить proto файл service.proto
, и выбрать директорию, где
лежат остальные proto
файлы. Это приходится делать потому, что Roadrunner
, к сожалению, пока еще
не поддерживает механизм “рефлексии”,
который автоматически вернул бы контракты, без необходимости предоставлять клиенту контракты вручную.
Можно было еще воспользоваться например grpcox. Я же предпочитаю в основном этот gRPC плагин на PhpStorm.
Настройка Xdebug
-
Убедимся, что в
deployment/docker/roadrunner/Dockerfile
, в списке расширений на установку присутствуетxdebug
- Создадим файл
deployment/docker/roadrunner/xdebug.ini
со следующим содержимым:# ./deployment/docker/roadrunner/xdebug.ini xdebug.mode=debug xdebug.client_host=172.17.0.1 xdebug.start_with_request=yes xdebug.discover_client_host=1 xdebug.idekey=PHP_STORM
- Примонтируем его в
docker-compose.yml
внутрь контейнераrr
, секцияservices.rr.volumes
:volumes: - ./deployment/docker/roadrunner/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini ...
- В
docker-compose.yml
, в секциюservices.rr.environment
добавим переменную окружения:environment: PHP_IDE_CONFIG: 'serverName=rr' ...
- В конфиг
.rr.yaml
добавим секциюgrpc.pool.num_workers
и установим значение1
. Добавим секциюgrpc.pool.debug
и установим значениеtrue
grpc: ... pool: num_workers: 1 debug: true
Параметр debug: true
позволяет нормально дебажить код, запущенный в воркере. Если его не указать, то после изменения
кода в файлах вы обнаружите, что брейкпойнты в IDE указывают не на ожидаемые строки. Это происходит потому, что
воркер был запущен на старой версии кода, до внесения изменений.
Раньше этот же результат достигался с помощью плагина reload
, который следил за
изменениями файлов и перезапускал воркеры. Работал он не идеально, видимо поэтому начиная с версии v2023.1.0
его заменили на новое решение.
И если у вас по каким-то причинам Roadrunner версии <= v2.12.3
, то
необходимо будет использовать Auto-Reloading.
Настроим IDE PhpStorm. Зайдем в настройки Settings
-> PHP
-> Servers
-> Add
. Имя сервера зададим rr
,
Host localhost
, выберем галочку Use path mapping
, установим маппинг текущей директории проекта на /app
.
В настройках дебага Settings
-> PHP
-> Debug
сверим, что установлены порты 9000,9003
, внешние соединения разрешены.
Я предпочитаю выставлять максимальное количество одновременных соединений в значение 1, но это вкусовщина. И это только при условии, что мы дебажим одновременно только один сервис. Если же к примеру мы в этом же приложении будем запускать код grpc клиента, который будет обращаться по сети к grpc серверу, то одного коннекта будет недостаточно, и скорее всего отладка встанет в ожидании
После чего:
- Установим breakpoint, например, в недавно реализованном сервисе
CurrencyRateService
- “Включим трубку” в PhpStorm (иконка телефонной трубки
Start Listening for PHP Debug Connections
) - Запустим контейнер
docker-compose up -d
- Повторим вызов метода с Postman - а
и как результат, мы должны увидеть успешную точку останова:
Хоть это и так должно быть понятно, но не могу не отметить, что данный сетап в целом не рассчитан на запуск в продакшене. Все
вышеперечисленные настройки в таком виде не должны ехать в прод. Продакшн docker образ должен быть собран без xdebug
,
а pool.debug
не должен быть включен, т.к. это все очень негативно сказывается на производительности, и включается сугубо
для локальной разработки.
PHP клиент к gRPC серверу
По сути, при кодогенерации серверного кода нам был сгенерирован также и клиентский код. Находится он там же, в
директории ./generated
, конкретно класс клиента GRPCContracts\Currency\CurrencyRateServiceClient
.
Мы хотим иметь консольную команду, которая будет инжектить в себя этот клиентский сервис, обращаться через него к поднятому в той же docker сети gRPC серверу, получать данные и выводить их на экран. Для этого нам нужно:
- Добавить в окружение
php-cli
docker контейнер, из-под которого будем запускать консольную команду - Написать саму консольную команду
- Подключить в автолоад
composer
директории./generated/Bootloader
и./generated/Config
- Зарегистрировать gRPC клиент в di контейнере приложения, подключив уже любезно сгенерированный bootloader
- Заинжектить в консольную команду сгенерированный gRPC клиент
- Подключить в автолоад
- Добавить в
.env
переменную окруженияCURRENCY_RATE_SERVICE_HOST
Docker контейнер php-cli
Консольную утилиту запускать будем из отдельного php-cli
контейнера,
который опишем в уже имеющемся docker-compose.yml
:
# ./docker-compose.yml
...
php-cli:
build:
dockerfile: ./deployment/docker/php-cli/Dockerfile
user: '1000:1000'
environment:
PHP_IDE_CONFIG: 'serverName=rr'
tty: true
volumes:
- ./:/app
- ./deployment/docker/roadrunner/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini
...
Как видим, он собирается из другого Dockerfile
, в котором нет за ненадобностью утилит
protoc
, git
, расширения protobuf
. Зато есть extension grpc
, нужный для запуска клиентского кода.
Настройки xdebug аналогичные.
# ./deployment/docker/php-cli/Dockerfile
# Базируемся на официальном образе php cli alpine
FROM php:8.2-cli-alpine
# Рабочая директория /app
WORKDIR /app
# Копируем скрипт install-php-extensions от mlocati
# Данный проект рекомендован на официальной странице образов php https://hub.docker.com/_/php
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/bin/
# Устанавливаем необходимые расширения
RUN install-php-extensions opcache zip sockets xdebug \
grpc
# Копируем composer
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
Специально разделил контейнеры клиента и сервера чтобы наглядно показать, что они содержат разный набор расширений и утилит. Могут содержать и разную кодовую базу в теории, но чтобы не усложнять мануал, код у нас содержится в рамках одного php приложения.
Composer autoload
В автолоад composer - а, в секцию autoload.psr-4
добавим строки:
...
"autoload": {
"psr-4": {
...
"App\\Application\\Bootloader\\": "generated/Bootloader",
"App\\Application\\Config\\": "generated/Config",
...
}
},
...
Тем самым подключим сгенерированные классы, необходимые для подключения клиентского сервиса к di контейнеру фреймворка.
Не забудем применить изменения в composer
:
docker-compose run rr composer du
Подключим di bootloader
Добавим в класс App\Application\Kernel
в секцию LOAD
наш бутлоадер:
...
// Grpc service bootloader
\App\Application\Bootloader\ServiceBootloader::class,
...
Пишем консольную команду
<?php
// ./app/src/CurrencyRateCliCommand.php
declare(strict_types=1);
namespace App;
use GRPCContracts\Currency\CurrencyRateServiceInterface;
use GRPCContracts\Currency\GetRateRequest;
use Spiral\Console\Command;
use Spiral\RoadRunner\GRPC\Context;
final class CurrencyRateCliCommand extends Command
{
// Сигнатура команды = имя команды + аргумент "ticker"
public const SIGNATURE = 'grpc:get-currency-rate {ticker : Currency ticker}';
public const DESCRIPTION = 'Get currency rate command';
// Инжектим экземпляр клиента по интерфейсу
public function __construct(
private readonly CurrencyRateServiceInterface $currencyRateGrpcClient,
) {
parent::__construct();
}
protected function perform(): int
{
// значение тикера берем из аргумента команды
$ticker = $this->argument('ticker');
// создаем запрос, помещаем в него тикер
$request = new GetRateRequest();
$request->setTicker($ticker);
// вызываем gRPC метод клиента, получаем ответ от сервера
$response = $this->currencyRateGrpcClient->GetRate(new Context([]), $request);
// выводим результат на экран
$this->output?->writeln("{$response->getTicker()}: {$response->getRate()}");
return self::SUCCESS;
}
}
.env
В .env
файл добавим в конец переменную окружения со значением хост:порт
запущенного gRPC сервера,
к которому будет обращаться клиент:
# grpc app
CURRENCY_RATE_SERVICE_HOST=rr:9001
rr
- имя сервиса с roadrunner контейнером из docker-compose.yaml
. Оба контейнера, rr
и php-cli
, будучи
запущенными в одной docker сети, могут обращаться друг к другу по алиасу сервиса, который docker любезно
разрезолвит в ip-адрес внутри этой сети, уже по факту.
Запуск
Остановим и запустим с пересборкой наши сервисы:
docker-compose stop && docker-compose up -d --build
Из под контейнера php-cli
запустим новую консольную команду, передав тикер TRYUSD
как аргумент:
docker-compose exec php-cli php app.php grpc:get-currency-rate TRYUSD
Вывод от команды:
TRYUSD: 75.00
Т.е. все работает и клиент и сервер. Запущенный сервер слушает порт 9001. Клиент к серверу обращается по сети через вызов gRPC клиента, получает и выводит ответ на экран.
Планы
В данном материале мы использовали самый быстрый путь к достижению результата, а именно использовали Spiral Framework
.
Ничего против него не имею, мне он даже понравился. Но выбор/смена фреймворка является важнейшим шагом как на старте
проекта, так и в рамках существующих проектов. И многим Spiral
может попросту не подойти по разным причинам.
Поэтому есть мысли по написанию подобных гайдов под Symfony
, или даже без фреймворка. И если интересно
продолжение в этом ключе, то ставь кита (слона там нет, а жаль)
как реакцию на соответствующий пост в телеграм канале.
Заключение
В данном примере многое было сделано на скорую руку. Многие аспекты намеренно были мной упрощены и опущены.
Но не потому, что по другому не умею. А ради того, чтобы
как можно легче донести основной посыл. Показать пример пусть простейшего, но рабочего gRPC сервера на php.
Чутка с натяжкой, ведь всю работу за нас делает софт Roadrunner
, написанный на Go
. Но я в этом не вижу ничего плохого.
Серверный код логики нашего приложения мы пишем на php
.
А Roadrunner действительно классный и гибкий продукт, советую изучить его глубже, в сети куча интересных материалов.
Комментарии
Любые обсуждения по теме приветствуются под постом данной статьи в телеграм канале. Буду рад, если поделитесь своим опытом в затронутом вопросе. Или найдете и укажете на неточности.