Метод — это класс, который:
#[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
)]
Все поля кроме name опциональны. null падает к дефолтам бандла
(например, params.allow_positional_dto).
#[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 могут возвращать:
JsonSerializableНормализованная форма — это то же, что попадает в кэш и что несут события. Listeners видят одну и ту же форму независимо от того, hit это из кэша или свежий вызов.
Streaming-методы нормализуют построчно — см. Стриминг.
/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.
Dispatcher проходит массив по порядку и каждый item доводит до конца до
следующего — всё в одном PHP-процессе. Batch из N элементов занимает
примерно сумма длительностей хендлеров, не max. Экономится сетевой
overhead (один HTTP-запрос, один парсер, одна аутентификация), а не время
работы хендлеров.
Для реального параллелизма клиент должен слать N отдельных HTTP-запросов
параллельно (например, через json-rpc-client’s callAsync)
— PHP-FPM / RoadRunner / Swoole тогда раскидают каждый запрос на отдельного
воркера и они действительно пойдут одновременно.
По спеке JSON-RPC 2.0 §6, сервер может обрабатывать batch в любом порядке и с любой степенью параллелизма. Бандл везёт opt-in реализацию: каждый item batch’а отсылается обратно к самому себе отдельным HTTP-запросом, и worker pool обрабатывает хендлеры параллельно.
json_rpc_server:
parallel_batch:
enabled: true # выключено по умолчанию
max_concurrency: 3 # макс параллельных sub-call'ов в одном batch
budget: 10 # общесистемный потолок (APCu)
max_depth: 1 # глубже 1 fan-out не идёт
connect_timeout: 0.5
timeout: 10
self_url: ~ # null = derive из incoming request
Реальный операционный риск. Наивная настройка может уложить worker pool. В бандле пять слоёв защиты, но сначала измеряйте перед включением в продакшене:
max_concurrency.budget через APCu — никогда больше N sub-call’ов в полёте
суммарно.X-Rpc-Fanout-Depth — sub-call не может
снова 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). Если
parallel_batch.enabled: true и budget_store: apcu (default), но APCu не
загружен, бандл откатывается на NullBudgetTracker и кидает
E_USER_WARNING на этапе сборки контейнера — общесистемный budget в этом
режиме выключен, и на FPM это рецепт исчерпания pool’а под нагрузкой.
Чтобы заглушить warning, когда вы намеренно не хотите глобальный cap,
выставьте budget_store: null явно.
Запрос без id — это notification:
{"jsonrpc":"2.0","method":"audit.log","params":{"event":"login"}}
Handler выполняется, тело ответа не отправляется, HTTP-ответ 204. Даже если
handler бросил — error envelope не возвращается (по спеке). Исключение всё
равно логируется и диспатчится через MethodInvocationFailedEvent.
Notifications не кэшируются, даже если у метода есть #[Rpc\Cache] — они
обычно несут side effects, которые надо применять каждый раз.
#[Rpc\Method('user.legacy_get', deprecated: 'Use user.get instead.')]
Эффекты:
warning с method и reason.Deprecation: true и
X-Rpc-Deprecated: user.legacy_get: Use user.get instead./mcp/tools (если не whitelisted) —
LLM-агенты не должны цепляться за них как за свежие tools."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 { /* … */ }
См. Безопасность и роли.
public function __invoke(Request $request, Context $ctx): array
{
$ip = $request->getClientIp();
// …
}
Symfony\Component\HttpFoundation\Request распознаётся как injectable
параметр — бандл вытягивает его из RequestStack.
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')]
Версионирование работает так же — см. OpenRPC.