02 — Методы
Метод — это класс, который:
- Несёт
#[Rpc\Method('name')] - Реализует
__invoke()— любая callable-сигнатура, любой serializable-возврат
Compiler pass находит их через auto-configuration. Никакой ручной регистрации сервисов, никакого центрального реестра, который надо синхронизировать.
Атрибут
#[Rpc\Method(
name: 'user.getByEmail',
roles: ['ROLE_USER'], // см. главу о безопасности
rolesMatch: RoleMatch::Any, // any | all
allowPositionalDto: false, // см. главу о параметрах
rejectUnknown: true, // см. главу о параметрах
deprecated: 'Use user.find instead.', // помечает deprecated
description: 'Looks up a user by email.', // human-readable; идёт в MCP
outputSchema: UserDto::class, // опционально: см. главы MCP / OpenRPC
)]
Все поля кроме name опциональны. null падает к дефолтам бандла
(например, params.allow_positional_dto).
Handler
#[Rpc\Method('user.getByEmail')]
final class GetUserByEmail
{
public function __construct(
// Инжектим сервисы как любой Symfony-сервис.
private readonly UserRepository $users,
) {}
/** @return array<string, mixed> */
public function __invoke(GetUserByEmailRequest $req, Context $ctx): array
{
$user = $this->users->findOneByEmail($req->email)
?? throw new NotFoundException("No user for {$req->email}");
return [
'id' => $user->getId(),
'email' => $user->getEmail(),
];
}
}
Handlers — non-shared сервисы; каждый запрос получает свежий экземпляр. Безопасно для long-running workers (RoadRunner, Octane, Swoole): нет утечек state’а между юзерами.
Возвращаемые значения
Dispatcher нормализует результат через Symfony SerializerInterface перед
отдачей клиенту. Это значит, что handlers могут возвращать:
- Обычные массивы / скаляры / nulls — отдаются как есть
- DTO — денормализуются в массивы через настроенные нормализаторы
- Doctrine entities — сериализуются по вашим serialization groups
- Что угодно с
JsonSerializable
Нормализованная форма — это то же, что попадает в кэш и что несут события. Listeners видят одну и ту же форму независимо от того, hit это из кэша или свежий вызов.
Streaming-методы нормализуют построчно — см. Стриминг.
Batch-запросы
/rpc принимает и единичный объект, и массив объектов по спеке JSON-RPC 2.0:
[
{"jsonrpc":"2.0","method":"math.add","params":[1,2],"id":1},
{"jsonrpc":"2.0","method":"math.add","params":[3,4],"id":2}
]
Возвращает массив ответов в том же порядке. Notifications (без id)
выполняются, но не дают entry в ответе. Если batch целиком из notifications —
HTTP-ответ 204 No Content.
Batch выполняется последовательно по умолчанию
Dispatcher проходит массив по порядку и каждый item доводит до конца до
следующего — всё в одном PHP-процессе. Batch из N элементов занимает
примерно сумма длительностей хендлеров, не max. Экономится сетевой
overhead (один HTTP-запрос, один парсер, одна аутентификация), а не время
работы хендлеров.
Для реального параллелизма клиент должен слать N отдельных HTTP-запросов
параллельно (например, через json-rpc-client’s callAsync)
— PHP-FPM / RoadRunner / Swoole тогда раскидают каждый запрос на отдельного
воркера и они действительно пойдут одновременно.
Opt-in: параллельный batch через loopback fan-out
По спеке JSON-RPC 2.0 §6, сервер может обрабатывать batch в любом порядке и с любой степенью параллелизма. Бандл везёт opt-in реализацию: каждый item batch’а отсылается обратно к самому себе отдельным HTTP-запросом, и worker pool обрабатывает хендлеры параллельно.
json_rpc_server:
parallel_batch:
enabled: true # выключено по умолчанию
self_url: 'http://127.0.0.1/rpc' # обязателен при enabled: true
max_concurrency: 3 # макс параллельных sub-call'ов в одном batch
budget: 10 # общесистемный потолок (APCu)
max_depth: 1 # глубже 1 fan-out не идёт
connect_timeout: 0.5
timeout: 10
Параллельный batch выключен по умолчанию. Для включения обязателен
self_url — абсолютный http:// / https:// URL RPC-endpoint’а этого
сервера, на который уходят sub-call’ы; если он не задан или не абсолютный
http(s) URL, сборка контейнера падает с понятным сообщением. URL никогда не
выводится из incoming request: заголовок Host контролирует клиент, а
sub-call’ы несут пересланный Authorization, так что подделанный Host
отправил бы credentials клиента на сервер атакующего.
Sub-call’ы пересылают заголовки из parallel_batch.forward_headers
(по умолчанию: Authorization, X-Request-Id, X-Forwarded-For,
X-Forwarded-Proto, traceparent, tracestate). Cookie по умолчанию
не пересылается: с нативным (блокирующим) session handler’ом PHP
родительский запрос держит session lock, а sub-call’ы ждут его — каждый
висит до timeout. Приложения с аутентификацией по session cookie добавляют
Cookie в список явно и только с неблокирующими или read-only сессиями.
Каждый sub-call также несёт исходный IP клиента, подписанный родителем
(X-Rpc-Fanout-Client-Ip + X-Rpc-Fanout-Signature, HMAC-SHA256 на ключе,
выведенном из kernel.secret, по IP, глубине fan-out и точному телу
sub-call’а). Его используют RateLimitScope::Ip, per-IP bucket гостей в
RateLimitScope::User и cache scope IpScope — у каждого клиента свой
bucket, а не общий loopback-адрес сервера. Заголовки, не прошедшие проверку,
игнорируются. Пока параллельный batch включён, нужен непустой
kernel.secret (framework.secret). В своём коде берите IP клиента через
Knetesin\JsonRpcServerBundle\Http\ClientIpResolver::clientIp(), а не
Request::getClientIp() — внутри sub-call’а тот возвращает loopback-адрес.
Notification внутри fan-out batch’а никогда не даёт response entry, что бы ни ответил sub-call.
Реальный операционный риск. Наивная настройка может уложить worker pool. В бандле пять слоёв защиты, но сначала измеряйте перед включением в продакшене:
- Per-batch cap
max_concurrency. - Общесистемный
budgetчерез APCu — никогда больше N sub-call’ов в полёте суммарно. - Recursion guard через заголовок
X-Rpc-Fanout-Depth— sub-call не может снова fan-out’ить. - Per-sub-call timeout — застрявший sub-call становится одной ошибкой, а не застрявшим batch’ом.
- Рекомендуемый деплой: отдельный worker pool для fan-out
(
self_url: 'http://127.0.0.1/internal/rpc-fanout') — изоляция от пула клиентского трафика.
Когда fan-out не может работать (нет HttpClient, нет APCu, batch слишком
мал, depth-limit, budget исчерпан) — контроллер прозрачно деградирует на
sequential. Клиент не замечает ничего кроме чуть большей latency.
BatchDispatchedEvent несёт label решения (виден в Web Profiler и в OTel
трейсах) — можно мониторить когда fallback срабатывает.
Требует symfony/http-client (hard) и ext-apcu (soft). При
budget_store: apcu (default) tracker проверяет APCu в runtime, в том
PHP-процессе, что обслуживает запрос, — не при сборке контейнера, так что
cache:warmup из CLI без APCu ни на что не влияет. Если APCu нет, он
выключен (apc.enabled=0 или apc.enable_cli=0 в CLI) или отказывает в
записи, резервирование budget’а не проходит и batch идёт sequential —
никогда без потолка, — а warning пишется в лог один раз на процесс. Чтобы
осознанно fan-out’ить без общесистемного cap, выставьте budget_store: null
явно.
Notifications
Запрос без id — это notification:
{"jsonrpc":"2.0","method":"audit.log","params":{"event":"login"}}
Handler выполняется, тело ответа не отправляется, HTTP-ответ 204. Даже если
handler бросил — error envelope не возвращается (по спеке). Исключение всё
равно логируется и диспатчится через MethodInvocationFailedEvent.
Notifications не кэшируются, даже если у метода есть #[Rpc\Cache] — они
обычно несут side effects, которые надо применять каждый раз.
Deprecation
#[Rpc\Method('user.legacy_get', deprecated: 'Use user.get instead.')]
Эффекты:
- Logger. Каждый вызов пишет
warningсmethodиreason. - HTTP-заголовки. Ответы несут
Deprecation: trueиX-Rpc-Deprecated: user.legacy_get: Use user.get instead. - MCP. Deprecated-методы скрыты из
/mcp/tools(если не whitelisted) — LLM-агенты не должны цепляться за них как за свежие tools. - OpenRPC. Метод эмитится с
"deprecated": trueи кастомным полемx-deprecation-reason.
Типичные паттерны
Публичный метод (без ролей)
#[Rpc\Method('public.ping')]
final class Ping
{
public function __invoke(): array { return ['pong' => true]; }
}
Защищённый метод
#[Rpc\Method('user.delete', roles: ['ROLE_ADMIN'])]
final class DeleteUser { /* … */ }
См. Безопасность и роли.
Нужен сырой HTTP-запрос
public function __invoke(Request $request, Context $ctx): array
{
$ip = $request->getClientIp();
// …
}
Symfony\Component\HttpFoundation\Request распознаётся как injectable
параметр — бандл вытягивает его из RequestStack.
Нужен JSON-RPC envelope
public function __invoke(RpcRequest $req, Context $ctx): array
{
// $req->id, $req->method, $req->params, $req->isNotification
}
Knetesin\JsonRpcServerBundle\Request\RpcRequest тоже injectable.
Один метод, разные формы
Имена JSON-RPC методов — плоский namespace. Группируйте префиксами:
#[Rpc\Method('user.get')]
#[Rpc\Method('user.update')]
#[Rpc\Method('user.delete')]
Имя метода не может содержать % — контейнер прочитал бы его как
%parameter% placeholder; сборка падает с LogicException.
Версионирование работает так же — см. OpenRPC.