o-perator/json-rpc

一个 JSON-RPC 2.0 服务器

v4.1.0 2023-04-21 11:23 UTC

This package is auto-updated.

Last update: 2024-09-26 10:33:43 UTC


README

Build Status Code Coverage

一个针对 PHP 8.0+ 的现代、面向对象的 JSON-RPC 2.0 服务器,具有 JSON Schema 集成和中间件功能。

目录

安装

$ composer require uma/json-rpc

基本用法

设置 JsonRpc\Server 涉及三个独立的步骤:编写所需的进程、将它们注册为服务以及最后配置和运行服务器。

创建过程

进程类似于 MVC 模式中的 HTTP 控制器,并且必须实现 UMA\JsonRpc\Procedure 接口。

以下示例展示了 JSON-RPC 2.0 规范示例中找到的 subtract 进程的可能实现

declare(strict_types=1);

namespace Demo;

use stdClass;
use UMA\JsonRpc;

class Subtractor implements JsonRpc\Procedure
{
    /**
     * {@inheritdoc}
     */
    public function __invoke(JsonRpc\Request $request): JsonRpc\Response
    {
        $params = $request->params();

        if ($params instanceof stdClass) {
            $minuend = $params->minuend;
            $subtrahend = $params->subtrahend;
        } else {
            [$minuend, $subtrahend] = $params;
        }

        return new JsonRpc\Success($request->id(), $minuend - $subtrahend);
    }

    /**
     * {@inheritdoc}
     */
    public function getSpec(): ?stdClass
    {
        return \json_decode(<<<'JSON'
{
  "$schema": "https://json-schema.fullstack.org.cn/draft-07/schema#",

  "type": ["array", "object"],
  "minItems": 2,
  "maxItems": 2,
  "items": { "type": "integer" },
  "required": ["minuend", "subtrahend"],
  "additionalProperties": false,
  "properties": {
    "minuend": { "type": "integer" },
    "subtrahend": { "type": "integer" }
  }
}
JSON
        );
    }
}

逻辑假设 $request->params() 是两个整数的数组,或者是一个具有 minuendsubtrahend 属性的 stdClass,这两个属性都是整数。

这完全安全,因为服务器在调用 __invoke() 之前,会将定义在上面的 JSON 模式与 $request->params() 进行匹配。如果输入不符合规范,则返回一个 -32602 (Invalid params) 错误,并且进程不会运行。

注册服务

下一步是在一个 PSR-11 兼容的容器中定义进程并在配置服务器。在这个例子中,我使用了 uma/dic

declare(strict_types=1);

use Demo\Subtractor;
use UMA\DIC\Container;
use UMA\JsonRpc\Server;

$c = new Container();

$c->set(Subtractor::class, function(): Subtractor {
    return new Subtractor();
});

$c->set(Server::class, function(Container $c): Server {
    $server = new Server($c);
    $server->set('subtract', Subtractor::class);

    return $server;
});

此时,我们有一个具有一个单一方法(subtract)的 JSON-RPC 服务器,该方法映射到 Subtractor::class 服务。此外,进程定义是懒加载的,所以除非服务器中实际调用了 subtract,否则不会实际实例化 Subtractor

这在示例中可能不是很重要。但在有数十个进程定义,每个都有其依赖树的情况下,它就很重要了。

运行服务器

一旦设置完成,相同的服务器可以运行任意次数,并代表库的用户处理 JSON-RPC 规范中定义的大多数错误。

declare(strict_types=1);

use UMA\JsonRpc\Server;

$server = $c->get(Server::class);

// RPC call with positional parameters
$response = $server->run('{"jsonrpc":"2.0","method":"subtract","params":[2,3],"id":1}');
// $response is '{"jsonrpc":"2.0","result":-1,"id":1}'

// RPC call with named parameters
$response = $server->run('{"jsonrpc":"2.0","method":"subtract","params":{"minuend":2,"subtrahend":3},"id":1}');
// $response is '{"jsonrpc":"2.0","result":-1,"id":1}'

// Notification (request with no id)
$response = $server->run('{"jsonrpc":"2.0","method":"subtract","params":[2,3]}');
// $response is NULL

// RPC call with invalid params
$response = $server->run('{"jsonrpc":"2.0","method":"subtract","params":{"foo":"bar"},"id":1}');
// $response is '{"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params"},"id":1}'

// RPC call with invalid JSON
$response = $server->run('invalid input {?<derp');
// $response is '{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"},"id":null}'

// RPC call on non-existent method
$response = $server->run('{"jsonrpc":"2.0","method":"add","params":[2,3],"id":1}');
// $response is '{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}'

自定义验证

从版本 4.0.0 开始,您可以通过容器覆盖 Opis 验证器。这允许您使用自定义 Opis 过滤器格式媒体类型

为此,只需在 PSR-11 容器中定义一个 Opis\JsonSchema\Validator::class 服务并将其设置为验证器类的自定义实例。

以下示例定义了一个新的“prime”格式,您可以将其用于您的 json 模式。(PrimeNumberFormat 实现为简洁起见而省略)

$validator = new Opis\JsonSchema\Validator();
$formats = $validator->parser()->getFormatResolver();
$formats->register('integer', 'prime', new PrimeNumberFormat());

$psr11Container->set(Opis\JsonSchema\Validator::class, $validator);
$jsonServer = new UMA\JsonRpc\Server($psr11Container);

// ...

中间件

中间件是一个实现了 UMA\JsonRPC\Middleware 接口的类,其唯一方法接受一个 UMA\JsonRPC\Request、一个 UMA\JsonRPC\Procedure 并返回一个 UMA\JsonRPC\Response。在其主体中的某个点,此方法必须调用 $next($request),否则请求将无法到达后续中间件或最终进程。中间件是在需要在每个请求之前或之后运行代码块时的首选选项,无论方法如何。

以下是中间件的最小框架

declare(strict_types=1);

namespace Demo;

use UMA\JsonRpc;

class SampleMiddleware implements JsonRpc\Middleware
{
    public function __invoke(JsonRpc\Request $request, JsonRPC\Procedure $next): JsonRpc\Response
    {
        // Code run before procedure

        $response = $next($request);

        // Code run after procedure finished

        return $response;
    }
}

为了激活一个中间件,您需要将其注册为依赖注入容器中的服务,就像程序一样。

declare(strict_types=1);

use Demo\SampleMiddleware;
use UMA\DIC\Container;
use UMA\JsonRpc\Server;

$c = new Container();

$c->set(SampleMiddleware::class, function(): SampleMiddleware {
    return new SampleMiddleware();
});

$c->set(Server::class, function(Container $c): Server {
    $server = new Server($c);

    // method definitions would go here...

    $server->attach(SampleMiddleware::class);

    return $server;
});

中间件保证

每当执行流程进入用户定义的中间件的 __invoke 方法时,以下关于请求可以假设

  • 原始负载是一个有效的 JSON-RPC 2.0 请求。

  • 它的 method 属性指向服务器中实际注册的过程。

  • 它的 params 属性符合该过程 getSchema() 中定义的 Json Schema。

简而言之,它们是可以在程序内部做出的相同保证。

中间件排序

从某种意义上说,中间件可以被视为服务器的装饰器,每个中间件都将其包裹在一个新的层中。因此,最后附加的层将是第一个运行的(以及最后一个,当退出过程时)。Slim 框架文档通过以下图片描述了他们自己的中间件系统。相同的原理适用于 uma\json-rpc

middleware depiction

中间件示例

假设您想将传入的通知排队到 Beanstalk 管道中,并在单独的进程中执行这些操作,而不在 HTTP 上下文中。记住,通知是一个没有 ID 属性的 JSON-RPC 请求。根据 JSON-RPC 2.0 规范,当服务器接收到此类请求时,它必须正常运行方法,但不能返回任何输出。

您不需要在每个程序的开始或在尴尬的基类中放置该逻辑,可以使用类似此处的中间件,利用 Request 对象可以被重新编码回原始负载的事实。

declare(strict_types=1);

namespace Demo;

use Pheanstalk\Pheanstalk;
use UMA\JsonRpc;

/**
 * A middleware that enqueues all incoming notifications to a Beanstalkd tube,
 * thus avoiding their execution overhead.
 */
class AsyncNotificationsMiddleware implements JsonRpc\Middleware
{
    /**
     * @var Pheanstalk
     */
    private $producer;

    public function __construct(Pheanstalk $producer)
    {
        $this->producer = $producer;
    }

    public function __invoke(JsonRpc\Request $request, JsonRPC\Procedure $next): JsonRpc\Response
    {
        if (null === $request->id()) {
            $this->producer->put(\json_encode($request));

            return new JsonRpc\Success(null);
        }

        return $next($request);
    }
}

常见问题解答

JSON-RPC 2.0 与 REST 相比有什么优势?

是的,有一些!最重要的是,规范是在 JSON 之上构建的,没有其他(即,其中不涉及 HTTP 动词、标题或身份验证方案)。因此,JSON-RPC 2.0 完全与传输层解耦,它可以在 HTTP、WebSockets、控制台 REPL 或甚至通过 avian carriers 或纸张运行。这实际上是为什么 Server::run() 的接口可以使用纯字符串的原因。

此外,规范简短且明确,支持“发射和遗忘”调用和批量处理。

我如何将中间件附加到特定过程而不是整个服务器?

我做出了不包含此功能的明确决定,因为它大大增加了服务器的复杂性。因此,中间件总是为所有请求运行。

然而,作为用户,您可以手动跳过它们,当方法不是您想要的那个时。

use UMA\JsonRpc;

class PickyMiddleware implements JsonRpc\Middleware
{
    /**
     * @var string[]
     */
    private $targetMethods;

    public function __construct(array $targetMethods)
    {
        $this->targetMethods = $targetMethods;
    }

    public function __invoke(JsonRpc\Request $request, JsonRPC\Procedure $next): JsonRpc\Response
    {
        if (!in_array($request->method(), $this->targetMethods)) {
            return $next($request);
        }

        // Actual logic goes here
    }
}

如何将 uma/json-rpc 集成到其他框架中?

我正在准备一个包含一些示例的存储库。它将涵盖 HTTP、TCP、WebSockets 和命令行界面上的 JSON-RPC 2.0。

最佳实践

依靠 JSON 模式来验证参数

虽然不需要从您的程序返回 JSON Schema,但强烈建议这样做,因为这样您的程序可以假设它接收到的输入参数是有效的,这将大大简化它们的逻辑。

如果您不熟悉 JSON Schema,可以在 Understanding JSON Schema 找到非常好的介绍。

尽可能推迟实际工作

由于 PHP 是一种没有“主流”支持并发编程的语言,因此每当服务器接收到批量请求时,它都必须顺序处理每个子请求,这可能会增加总响应时间。

因此,通过 HTTP 服务的 JSON-RPC 服务器应努力通过依赖例如 BeanstalkdRabbitMQ 的工作队列来推迟任何实际工作。然后,第二个、出站的 JSON-RPC 服务器可以消费队列并执行实际工作。

实际上,该协议支持这种用法:当传入的请求没有 id 时,服务器必须不发送响应回(这类请求在规范中称为 Notifications)。

限制批处理请求的数量

当服务器通过HTTP暴露时,批处理请求是一个拒绝服务向量(即使PHP能够并发处理它们)。恶意客户端可能发送包含数千个子请求的批处理请求,从而有效地阻塞服务器的资源。

为了降低这种风险,Server 有一个可选的 batchLimit 参数,该参数指定服务器可以处理的最大批处理请求数量。将其设置为1将有效地禁用批处理处理,如果您不需要该功能的话。

PS. 攻击者也可以发送数百或数千个单独的请求,同样会阻塞服务器。但是,由于这些是所有单独的HTTP请求,它们可以在web服务器级别进行速率限制。

$server = new \UMA\JsonRpc\Server($container, 2);
$server->set('add', Adder::class);

$response = $server->run('[
  {"jsonrpc": "2.0", "method": "add", "params": [], "id": 1},
  {"jsonrpc": "2.0", "method": "add", "params": [1,2], "id": 2},
  {"jsonrpc": "2.0", "method": "add", "params": [1,2,3,4], "id": 3}
]');
// $response is '{"jsonrpc":"2.0","error":{"code":-32000,"message":"Too many batch requests sent to server","data":{"limit":2}},"id":null}'