编程

PHP 中间件模式的工作原理及如何使用

119 2024-04-16 03:48:00

在这篇文章中,我们将研究 PHP 中的中间件。这种模式在处理请求和响应时最为常见。但是中间件模式也可以应用于其他各种地方。我们将研究什么是中间件,中间件是如何工作的,中间件何时有用,以及中间件的替代方案是什么。

注意: 中间件模式不是“Gang of Four”介绍的模式的一部分,但我个人仍然将其视为一种模式,因为它可以应用于各种情况。

什么是中间件呢?

PHP 中的中间件是一层操作/可调用程序(callable),它围绕应用程序中的一段核心逻辑进行封装。这些中间件提供了更改这些逻辑的输入或输出的能力。所以它们“生活”在输入和输出之间,就在在中间。

虽然这些简短的解释显然很容易理解,让我用一个小例子来说明这一点:

$input = [];
$output = $this->action($input);

本例中,中间件层可以::

  1. 在注入到 $this->action() 前修改 $ipnut
  2. 在注入到 $this->action() 后修改 $output 

我们来看看如何实现。

中间件工作原理

中间件层由一堆可调用(callable)的中间件组成。它们可以是简单的闭包 Closure、可调用类或类上的方法。

每一个中间件可调用程序(callable)都围绕着核心操作进行封装。好吧,我说出来;它就像一个洋葱,或者一个有多层包装纸的礼物。输入被注入到第一个中间件(或最外层)中。中间件可以对输入做一些事情,然后将(更改的)输入传递给下一个中间件,再传递给下个中间件,直到它到达最终操作(核心)。此时,输入可能会发生一点或很大的变化。

然后执行核心操作,并将其输出返回给最后一个中间件,后者将其输出返回到前一个中间件中,一直返回到第一个中间件中。在返回的过程中,每个中间件现在都可以在返回之前更改输出。

在我们看一些示例之前,让我们先探讨不同类型的中间件。

中间件类型

虽然中间件的概念在它们之间几乎相同,但实际上有两种常见的中间件类型:Single Pass Middleware & Double Pass Middleware.

单通道中间件(Single Pass middleware)

最常见的中间件类型是 Single Pass Middleware. 该类型的每个中间件接收两个参数:

  1. 输入 (e.g.请求、消息、DTO 或者标量值)
  2. 一个 callable 用来在联众调用下一个中间件,其也接收单个参数:输入。

Note: 在一些规范中,该 callable 参数称为 $next,用以说明调用下一个中间件。

这种类型的中间件可以更改输入并将修改后的版本转发给下一个中间件。要影响输出;它将首先调用下一个中间件并检索其输出。然后,它可以修改该输出并返回该输出。

这里有一个小的中间件示例来说明完整的行为。

function ($input, $next) {
  // Receive the input and change it.
  $input = $this->changeInput($input);
 
  // Call the next middleware or final action with the changed input, and receive the output.
  $output = $next($input);
 
  // Change the output, and return it to the previous middleware or final consumer.
  return $this->changeOutput($output);
}

双通道中间件(Double Pass Middleware)

第二种类型的中间件是 Double Pass Middleware。使用这种类型,每个中间件也同时接受默认的 output 对象作为参数。因此,“double” 部分指的中间件同时传递输入和输出到下一个中间件/最终操作中。

为什么该类型有用?单通道类型中,中间件要么必须:

  1. 回路时创建所需的输出对象(不调用$next,但返回不同的结果)。
  2. (要么)调用 $next 中间件来检索输出对象,并对其进行更改。

根据输出的类型,使中间件依赖于服务或工厂来创建该输出类型可能很麻烦,甚至是你不希望的。同样你也不希望调用(并可能实例化)所有其他中间件和最终操作,只为抛弃它们所做的一切;并完全改变输出对象。

因此,使用双通道,会预先创建一个默认的输出对象并传递。通过这种方式,中间件已经有了一个正确类型的输出对象,当它想要回路时可以使用它。

下面是另一个小例子来说明完整的行为。

function ($input, $output, $next) {
  // Quickly return the output object, instead of passing it along.
  if ($this->hasSomeReason($input)) {
    return $output;
  }
 
  // Call the next middleware and return that output instead of the default output.
    return $next($input, $output);
}

现在我们了解了中间件 callable 是什么,不过我们要如何将这些中间件包装到一个操作之上呢?

简单的中间件实现

让我们深入研究一些代码,并创建一个非常基本的 Single Pass 中间件实现。在这个例子中,我们将使用(重用)一个中间件类为字符串添加一些值。

注意: 中间件实现由多种方式。该示例没有特别优化,其中还有很大的改进空间。本例实际上是为了尽可能清楚地演示中间件的内部工作原理。

正如我们所看到的,我们首先需要一个核心动作(或功能)来包装我们的中间件。我们的操作将简单地接收并返回一个字符串。

$action = fn(string $input): string => $input;

现在,我们将创建一个可调用的中间件类,我们可以多次使用不同的值来进行预追加和追加。

class ValueMiddleware
{
    public function __construct(string $value) {}
 
    public function __invoke(string $input, callable $next): string
    {
        // Prepend value to the input.
        $output = $next($this->value . $input);
 
        // Append value to the output.
        return $output . $this->value;
    }
}

该中间件类使用一个 $value 进行实例化。因其是一个可调用的类,该实例就是我们要的中间件 callable。我们来创建 3 个实例:

$middlewares = [
    new ValueMiddleware('1'),
    new ValueMiddleware('2'),
    new ValueMiddleware('3'),
];

现在,我们将添加所有这些中间件作为 $action 的一层,然后我们将检查代码。

foreach ($middlewares as $middleware) {
    $action = fn(string $input): string => $middleware($input, $action);
}

我们来看看这个循环里发生了什么。

  • 首先,$action 闭包是返回 $ipnut 的简单回调。
  • 在第一次循环中 $action 使用了新的闭包进行了重写,该闭包也接收 $input 作为参数(就像初始的 action 一样)。当执行该闭包时,它首先调用第一个中间件,并提供对应的输入(input)以及初始的 $action。因此,该中间件中的 $next 现在是原始的 action。
  • 在第二和第三次循环中 $action 再次被重写,不过在中间件中 $next callable 不再是原始 action,而是我们在前一次迭代中设置的闭包。

注意: 如我们所见,$next 不会直接引用中间件。它也不能,因为中间件方法签名需要两个参数,而 $next 只接收一个参数:即输入。$next 始终是一个 callable, 负责调用下一个中间件。因此,它也可以是中间件 handler 的一个方法,该 handler 包含并跟踪所用的中间件列表。

就这样。我们的中间件实现完成了。我们所需的就是用一个值来运行 $action 并检查响应。

echo $action('value');

其结果当然是:123value123。等等,你期待的是 123value321?这才更像前文所提及的洋葱,是吧。

The result of this will of course be: 123value123... wait what? Did you expect 123value321? That would make the whole onion thing make more sense, right? But it is actually correct.

该中间件首先前置和后置追加 1,然后再用 2 包装,然后再去包装 3。因此该中间件首先再输入上前置追加 3,也是最后的中间件追加该值。这有点令人费解,但当我们检查每个中间件的 $inputreturn 时,我们最终会得到以下列表:

Middleware$inputreturn
Middleware 3value123value123
Middleware 23value123value12
Middleware 123value123value1
Core-action123value123value

通过向下搜索 $input 列表,然后备份返回 return 列表,我们可以看到值在整个中间件流中经过了哪些阶段。

小提示: 如果你希望中间件的顺序是:列表的顶部是第一个被调用的中间件,那么你应该 array_reverse 反转数组,或者像堆栈(Stack)一样使用后进先出(LIFO)迭代器。

请求和响应中间件

使用中间件最常见的地方之一是在将请求对象转换为响应对象的框架中。PHP 标准建议(PSR)中的 PSR-15:HTTP 服务器请求处理程序是关于如何处理(PSR-7)请求(Request)对象并将其转换为响应(Response)对象的建议。此建议还包含 Psr\Http\Server\MiddlewareInterface。这个接口支持在中间件类上使用进程方法,但原理是相同的。它接收一个输入(请求对象 Request),对其进行修改,并将其传递给 RequestHandler,后者将触发下一个中间件或最终操作。

请求和响应中间件示例

中间件可以用于在请求期间执行各种操作和检查,因此主操作(控制器)可以专注于手头的任务。让我们来看看一些有用的中间件示例。

跨站请求伪造 (CSRF) 验证中间件

要防止 CSRF 攻击,框架可以选择添加特定的验证到请求中。该验证应该再主操作触发器运行。这种情况下,中间件负责检查请求对象。如果它认为请求有效,它可以将其传递给下一个处理程序。但如果验证失败,中间件可以立即回避该请求,并返回 403 Forbidden 响应。

多租户中间件

一些应用程序允许多租户,这基本上意味着相同的源代码用于不同的客户;例如通过连接每个客户的独立数据库/源。中间件可以检查请求并确定(例如,通过检查请求 URL)应该选择哪个数据库,或者应该加载哪个客户。它甚至可以将 Customer 实体(如果存在的话)附加到 Request 对象,这样核心操作就可以从请求对象中读取正确的信息,而不必自己找出正确的客户。

异常处理中间件

另一种很好的中间件类型是异常处理。如果该中间件作为最外层的中间件应用,它可以将整个请求到响应流封装在 try-catch 块中。如果抛出异常,该中间件可以记录该异常,然后返回一个正确配置的响应对象,其中包含有关该异常的信息。

真实世界中的其他中间件

虽然中间件在处理请求和响应时最为常见,但也有其他用例。以下是一些例子:

Symfony Messenger 中间件

Symfony 的 symfony/messenger 的队列处理程序在消息(Message)发送到队列、从队列中读取消息以及 MessageHandler 处理时,使用中间件处理消息(Message)。

其中一个例子是 router_context 中间件。由于消息(大部分)是异步处理的,因此原始 Request 不可用。该中间件在发送到队列时存储原始请求状态(如主机和 HTTP 端口),并在处理 Message 时恢复该状态,因此处理程序可以使用此上下文来构建绝对 URL 等内容。

Guzzle 中间件

同时在请求和响应领域也有一些示例;Guzzle HTTP 客户端也支持使用中间件。这再次允许更改 HTTP 请求和响应。这个实现的工作方式有点不同,但仍然围绕着触发下一个中间件/操作的回调。

中间件的替代方案

正如我们所看到的,中间件本质上允许我们在单个函数/可调用的范围内,在核心操作之前和之后分别更改输入和输出。因此,中间件的一个很好的替代方案是在核心操作之前和之后提供事件挂钩。有了这些事件,你可以在输入达到核心操作之前更改输入,甚至可以提前返回。之后,您、你可以更改输出。

这两种选择都很有效。例如,Laravel 围绕其请求和响应流使用中间件,而 Symfony 则使用事件方法

小结

当使用中间件时,你可以很容易地在代码中创建关注点分离。每个中间件都有一个很小的工作要做。它可以很容易地进行测试;核心操作可以专注于它最擅长的事情,而不必担心任何可能的边缘用例或副作用。虽然它目前主要围绕(PSR-7)请求和响应对象,但该模式可以广泛使用。你有其他想法吗?这种模式在哪些方面有用?欢迎留言讨论!