编程

Laravel 管道(Pipeline)工作原理及其使用

479 2024-11-11 01:09:00

介绍

在构建 web 应用时,无论是使用单独的函数还是类,将功能的复杂过程分解为更小、更易于管理的部分都很方便。这样做有助于保持代码干净、可维护和可测试。

在 Laravel 应用中分割这些较小步骤的一种方法是使用 Laravel 管道。管道允许你在返回结果之前通过多层逻辑发送数据。事实上,Laravel 实际上在内部使用管道来处理请求,并将其传递给应用的每个中间件(我们稍后会对此进行介绍)。

本文中,我们将看看什么是管道以及 Laravel 如何在内部使用它们,并展示如何创建自己的管道。我们还将介绍如何编写测试以确保你的管道按预期工作。

在本文结束时,你应该了解什么是管道,并有信心在 Laravel 应用中使用它们。

什么是 Laravel Pipeline 类?

根据 Laravel 文档,管道提供了一种“通过一系列可调用类、闭包或 callable 对给定输入进行管道化的便捷方式,使每个类都有机会检查或修改输入,并调用管道中的下一个 callable。”

什么意思呢?为了解 Laravel 管道是什么,让我们来看一个基本的例子。

想象一下,你正在构建一个博客平台,你目前正在编写允许用户对博客帖子发表评论的功能。在我们将评论存储到数据库之前,当用户提交评论时,你可能需要遵循以下步骤:

  1. 检查评论中是否有任何违禁词。如果有,用星号(*)替换。
  2. 检查评论中是由垃圾内容(如指向恶意外部网站的链接)并将其删除。
  3. 删除评论中任何有害的内容。

这些步骤中的每一个都可以被视为管道中的一个阶段。评论数据通过每个阶段传递,每个阶段在将修改后的数据传递给下一阶段之前执行特定的任务。当评论出现在管道的另一边时,它应该已经对其进行了所有必要的检查和修改。

如果我们使用 Laravel 的管道特性来实现这一点,它可能看起来像这样:

use App\Pipelines\Comments\RemoveProfanity;
use App\Pipelines\Comments\RemoveSpam;
use App\Pipelines\Comments\RemoveHarmfulContent;
use Illuminate\Pipeline\Pipeline;
 
// Grab the comment from the incoming request
$commentText = $request->validated('comment')

// Pass the comment string through the pipeline.
// $comment will be equal to a string with all the checks and modifications applied.
$comment = app(Pipeline::class)
    ->send($commentText)
    ->through([
        RemoveProfanity::class,
        RemoveSpam::class,
        RemoveHarmfulContent::class,
    ])
    ->thenReturn();

上例中,我们首先获取初始数据(在本例中为请求中的 comment 字段),然后将其传递给三个阶段中的每一个。那么每个阶段是什么样子的?让我们来看一个非常基本的 RemoveProfanity 阶段的示例:

app/Pipelines/Comments/RemoveProfanity

declare(strict_types=1);
 
namespace App\Pipelines\Comments;

use Closure;
 
final readonly class RemoveProfanity
{
    private const array PROFANITIES = [
        'fiddlesticks',
        'another curse word'
    ];
 
    public function __invoke(string $comment, Closure $next): string
    {
        $comment = str_replace(
            search: self::PROFANITIES,
            replace: '****',
            subject: $comment,
        );
 
        return $next($comment);
    }
}

在上面的 RemoveProfanity 类中,我们可以看到该类有一个接受两个参数的 __invoke 方法:

  • $comment - 这是我们想要删除违规内容的评论本身。它可能是在管道开始时传入的,也可能是被前一阶段修改过的( RemoveProfanity 类不知道也不关心这一点)。
  • $next - 第二个参数是表示管道中下一阶段的闭包。当我们调用 $next($comment)时,我们将修改后的评论(删除其中的违禁内容)传递给管道的下一阶段。

另外,我们使用硬编码数组来识别和删除违禁内容。当然,在真实的应用场景中,你可能会有一种更复杂的方法来确定什么是违禁词。但这只是一个简单的例子,展示了管道中的一个阶段可能是什么样子。

你可能已经注意到,这个类看起来很像中间件类。事实上,管道中的每个阶段看起来都很像你通常编写的中间件类。这是因为中间件类本质上是管道中的阶段。Laravel 使用 Illuminate\Pipeline\Pipeline 类来处理 HTTP 请求,并将其传递给应用的每个中间件类。我们很快就会对此进行更详细的探索。

使用 Larave l管道来处理这种工作流的好处在于,每个阶段都可以被隔离,并专门设计为做一件事。这使得它们更容易进行单元测试和维护。因此,Laravel 管道提供了一种灵活且可定制的架构,你可以使用它按顺序执行多个操作。假设你发现某个违禁词没有被过滤器删除。无需深入大的服务类,试图找到与过滤器相关的特定代码行,你可以查看 RemoveProfanity 类并在那里进行必要的更改。这也使得为管道中的每个阶段编写隔离测试变得容易。

一般来说,管道的每个阶段都不应该知道它之前或之后的其他阶段。这使得 Laravel 管道具有高度的可扩展性,因为你可以在管道中添加一个新阶段,而不会影响其他阶段。这使得管道在构建 Laravel 项目时成为一个强大的工具。

例如,假设你想在评论管道中添加一个新阶段,以缩短评论中的任何 URL。例如,你可能希望这样做,以便向作者提供链接被点击次数的跟踪统计数据。你可以通过添加一个新的 \App\Pipelines\Comments\ShortenUrls::class 来完成此操作,而不会影响管道中的其他阶段:

use App\Pipelines\Comments\RemoveProfanity;
use App\Pipelines\Comments\RemoveSpam;
use App\Pipelines\Comments\RemoveHarmfulContent;
use App\Pipelines\Comments\ShortenUrls;
use Illuminate\Pipeline\Pipeline;
 
$commentText = $request()->validated('comment')

$comment = app(Pipeline::class)
    ->send($commentText)
    ->through([
        RemoveProfanity::class,
        RemoveSpam::class,
        RemoveHarmfulContent::class,
        ShortenUrls::class,
    ])
    ->thenReturn();

Laravel 如何使用 Pipeline 类?

现在我们已经了解了什么是管道,以及如何使用管道将复杂的操作分解为更小、更易于管理的部分,让我们来看看 Laravel 是如何在内部使用管道的。

正如我们已经简要提到的,Laravel 本身使用管道来处理请求,并通过应用的每个中间件传递它们。

Laravel 处理请求时,会将 Illuminate\Http\Request 实例传递给 \Illuminate\Foundation\Http\Kernel 类中的sendRequestThroughRouter 方法。该方法通过全局中间件发送请求,所有请求都应经过这些中间件。此中间件在 \Illuminate\Foundation\Configuration\Middleware 类中的 getGlobalMiddleware 方法中定义,如下所示:

public function getGlobalMiddleware()
{
    $middleware = $this->global ?: array_values(array_filter([
        $this->trustHosts ? \Illuminate\Http\Middleware\TrustHosts::class : null,
        \Illuminate\Http\Middleware\TrustProxies::class,
        \Illuminate\Http\Middleware\HandleCors::class,
        \Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance::class,
        \Illuminate\Http\Middleware\ValidatePostSize::class,
        \Illuminate\Foundation\Http\Middleware\TrimStrings::class,
        \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
    ]));
 
    // The rest of the method...
}

sendRequestThroughRouter 方法(其中包含管道)像这样:

/**
 * Send the given request through the middleware / router.
 *
 * @param  \Illuminate\Http\Request  $request
 * @return \Illuminate\Http\Response
 */
protected function sendRequestThroughRouter($request)
{
    $this->app->instance('request', $request);
 
    Facade::clearResolvedInstance('request');
 
    $this->bootstrap();
 
    return (new Pipeline($this->app))
                ->send($request)
                ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
                ->then($this->dispatchToRouter());
}

在该方法中,在引导框架的一些必要组件后,请求传入管道中。我们来看看管道怎么做的:

return (new Pipeline($this->app))
            ->send($request)
            ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
            ->then($this->dispatchToRouter());

我们创建一个管道,并指示它通过每个中间件发送 Illuminate\Http\Request 对象(假设我们没有跳过中间件,例如运行一些测试)。然后调用 then 方法,以便我们可以获取管道的结果(结果应该是修改后的 Illuminate\Http\Request 对象)并将其传递给 dispatchToRouter 方法。

在请求通过此管道修改请求对象后,它会被传递到更深入的框架中进行进一步处理。在此过程中,Illuminate\Http\Request 对象将通过 \Illuminate\Routing\Router 类中 runRouteWithinStack 方法中的另一个管道传递。此方法通过特定于路由的中间件传递请求,例如在路由本身、路由组和控制器上定义的中间件:

/**
 * Run the given route within a Stack "onion" instance.
 *
 * @param  \Illuminate\Routing\Route  $route
 * @param  \Illuminate\Http\Request  $request
 * @return mixed
 */
protected function runRouteWithinStack(Route $route, Request $request)
{
    $shouldSkipMiddleware = $this->container->bound('middleware.disable') &&
                            $this->container->make('middleware.disable') === true;
 
    $middleware = $shouldSkipMiddleware ? [] : $this->gatherRouteMiddleware($route);
 
    return (new Pipeline($this->container))
                    ->send($request)
                    ->through($middleware)
                    ->then(fn ($request) => $this->prepareResponse(
                        $request, $route->run()
                    ));
}

在上述方法中,请求穿过给定请求路由的所有中间件。在此之后,最终结果将传递给 prepareResponse 方法,该方法将构建 HTTP 响应,该而响应将从 HTTP 控制器发送回用户(由 $route->run() 方法中确定的)。

我认为能够看到 Laravel 在内部使用此功能是了解管道功能有多强大的好方法,尤其是因为它被用来交付请求生命周期的基本部分之一。这也是一个完美的例子,展示了如何构建复杂的工作流程,这些工作流程可以分解为更小、更独立的逻辑块,这些逻辑块可以很容易地进行测试、维护和扩展。

如何创建 Laravel 管道(pipeline)

现在我们已经了解了 Larave l如何在内部使用管道,让我们来看看如何创建自己的管道。

我们将构建一个非常简单的管道,可以用作博客评论系统的一部分。管道将接收一个评论字符串,并将其传递到以下阶段:

  • 删除违禁词并使用星号代替
  • 删除有害内容。
  • 将外部链接替换为短 URL

我们不会过多关注每个阶段中使用的业务逻辑——我们更感兴趣的是管道是如何工作的。然而,我们将提供一个简单的例子,说明每个阶段可能是什么样子的。

当然,我不想在文章中使用任何真正的敏感词,所以我们将假设 fiddlesticks 是一个敏感词。同时还将把 I hate Laravel 这个词视为有害内容。
因此,如果我们通过管道传递以下评论:

This is a comment with a link to https://honeybadger.io and some fiddlesticks. I hate Laravel!

我们取代管道的另一端输出像这样的结果:

This is a comment with a link to https://my-app.com/s/zN2g4 and some ****. ****

让我们首先创建三个管道阶段。我们将创建一个新的 app/Pipelines/Comments 目录,然后在其中创建三个新类:

  1. App\Pipelines\Comments\RemoveProfanity
  2. App\Pipelines\Comments\RemoveHarmfulContent
  3. App\Pipelines\Comments\ShortenUrls

首先,我们来看看 RemoveProfanity

app/Pipelines/Comments/RemoveProfanity

declare(strict_types=1);
 
namespace App\Pipelines\Comments;
 
use Closure;
 
final readonly class RemoveProfanity
{
    private const array PROFANITIES = [
        'fiddlesticks',
        'another curse word'
    ];
 
    public function __invoke(string $comment, Closure $next): string
    {
        $comment = str_replace(
            search: self::PROFANITIES,
            replace: '****',
            subject: $comment,
        );
 
        return $next($comment);
    }
}

在上面的 RemoveProfanity 类中,我们可以看到该类有一个 __invoke 方法(这意味着它是一个可调用的类),它接受两个参数:

  • $comment - 这是我们想删除敏感内容的评论本身。
  • $next - 这是一个表示管道下一阶段的闭包。当我们调用 $next($comment) 时,我们将修改后的评论(删除了敏感语言)传递给管道的下一阶段。

RemoveProfanity 类将评论中的任何敏感词替换为星号,然后将修改后的评论传递给管道中的下一阶段,即 RemoveHarmfulContent 类:

app/Pipelines/Comments/RemoveHarmfulContent.php

declare(strict_types=1);
 
namespace App\Pipelines\Comments;
 
use Closure;
 
final readonly class RemoveHarmfulContent
{
    public function __invoke(string $comment, Closure $next): string
    {
        // Remove the harmful content from the comment.
        $comment = str_replace(
            search: $this->harmfulContent(),
            replace: '****',
            subject: $comment,
        );
 
        return $next($comment);
    }
 
    private function harmfulContent(): array
    {
        // Code goes here that determines what is harmful content.
        // Here's some hardcoded harmful content for now.
 
        return [
            'I hate Laravel!',
        ];
    }
}

上述代码示例中,可以看到我们使用了一个硬编码数组来识别和删除任何有害内容。当然,在真实的应用场景中,你可能会有一种更复杂的方法来确定什么是有害内容。然后,RemoveHarmfulContent 类将修改后的评论传递给管道中的下一阶段,即 ShortenUrls 类:

app/Pipelines/Comments/ShortenUrls

declare(strict_types=1);
 
namespace App\Pipelines\Comments;
 
use AshAllenDesign\ShortURL\Facades\ShortURL;
use Closure;
 
final readonly class ShortenUrls
{
    public function __invoke(string $comment, Closure $next): string
    {
        $urls = $this->findUrlsInComment($comment);
 
        foreach ($urls as $url) {
            $shortUrl = ShortURL::destinationUrl($url)->make();
 
            $comment = str_replace(
                search: $url,
                replace: $shortUrl->default_short_url,
                subject: $comment,
            );
        }
 
        return $next($comment);
    }
 
    private function findUrlsInComment(string $comment): array
    {
        // Code goes here to find all URLs in the comment and
        // return them in an array.
 
        return [
            'https://honeybadger.io',
        ];
    }
}

在这个可调用类中,我们使用我的 “ashallendesign/short-url” Laravel 包来短化评论中的 url。为了使示例简单,我们现在使用硬编码的 URL,但在真实的应用场景中,你可以使用更复杂的方法在评论中查找 URL,例如使用正则表达式。

现在,让我们将所有这些类绑定到一个管道中:

use App\Pipelines\Comments\RemoveHarmfulContent;
use App\Pipelines\Comments\RemoveProfanity;
use App\Pipelines\Comments\ShortenUrls;
use Illuminate\Pipeline\Pipeline;
 
$comment = 'This is a comment with a link to https://honeybadger.io and some fiddlesticks. I hate Laravel!';
 
$modifiedComment = app(Pipeline::class)
    ->send($comment)
    ->through([
        RemoveProfanity::class,
        RemoveHarmfulContent::class,
        ShortenUrls::class,
    ])
    ->thenReturn();

运行以上代码,$modifiedComment 变量应该包含如下字符串:

This is a comment with a link to https://my-app.com/s/zN2g4 and some ****. ****

在管道中根据条件运行一个阶段

有时,你可能希望条件性地运行管道中的某个阶段。例如,如果留下评论的用户是管理员,你可能希望跳过 ShortenUrls 阶段。

有两种不同的方法可以解决这个问题。第一种方法是使用 Illuminate\Pipeline\Pipeline 类中的 pipe 方法。此方法允许你将新阶段推送到管道上:

$commentPipeline = app(Pipeline::class)
    ->send($comment)
    ->through([
        RemoveProfanity::class,
        RemoveHarmfulContent::class,
    ]);
 
// If the user is an admin, we don't want to shorten the URLs.
if (!auth()->user()->isAdmin()) {
    $commentPipeline->pipe(ShortenUrls::class);
}
 
$modifiedComment = $commentPipeline->thenReturn();

同样,你也可以使用 Illuminate\Pipeline\Pipeline 类上可用的 when 方法。这与使用 Eloquent 查询构建器构建查询时可以使用的 “when” 方法的工作原理相同。它方法允许你有条件地运行管道中的阶段:

$modifiedComment = app(Pipeline::class)
    ->send($comment)
    ->through([
        RemoveProfanity::class,
        RemoveHarmfulContent::class,
    ])
    ->when(
        value: !auth()->user()->isAdmin(),
        callback: function (Pipeline $pipeline): void {
            $pipeline->pipe(ShortenUrls::class);
        }
    )
    ->thenReturn();

上述两个代码示例执行相同的任务。ShortenUrls 阶段仅在用户不是管理员时运行。在上面的代码示例中,如果传递给 when 方法的第一个参数为真,则将执行回调。否则,将不会执行回调。

thenthenReturn 的不同之处

正如我们已经讲过的,管道中的每个阶段都返回 $next(...),以便我们可以将数据传递给管道中的下一阶段。但是,要运行管道并执行每个任务以从最后阶段获得结果,我们需要使用 thenthenReturn 方法。这两者非常相似,但两者之间存在微小差异。

正如我们在前面的示例中看到的,thenReturn 方法运行从管道的最后一个阶段返回的最终回调以获取结果。例如,在前面的示例中,我们在管道中的每个阶段返回 return $next($comment); 。因此,当我们调用 thenReturn 时,它将返回传递给最后那个回调函数的值(在本例中,无论 $comment 等于什么)。

then 方法用于从管道的最后阶段获取结果,然后将其传递给一个回调函数。如果你想在返回结果之前对其执行一些额外的逻辑,这很有用。如果我们看看前面的 Laravel 内部示例,我们可以看到 then 方法用于将管道的结果传递给(Illuminate\Foundation\Http\Kernel 类中的) dispatchToRouter 方法:

return (new Pipeline($this->app))
        ->send($request)
        ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
        ->then($this->dispatchToRouter());

因此 Laravel 通过全局中间件运行请求。然后,在管道完成后,它将结果传递给 dispatchToRouter 方法,该方法将运行任何特定于路由的中间件,调用正确的控制器,并返回响应。本例中,dispatchToRouter 方法如下:

/**
 * Get the route dispatcher callback.
 *
 * @return \Closure
 */
protected function dispatchToRouter()
{
    return function ($request) {
        $this->app->instance('request', $request);
 
        return $this->router->dispatch($request);
    };
}

正如我们所看到的,该方法返回一个闭包,该闭包接受一个请求对象,将其分派给路由,然后返回结果。
在  Laravel 管道中使用闭包

虽然你可能希望将管道阶段作为类分离,但有时可能希望使用闭包。这对于不需要整个类的非常简单的任务很有用。作为一个非常基本的例子,让我们想象一下,你想在管道中添加一个阶段,将所有评论文本转换为大写;你可以这样做:

 $modifiedComment = app(Pipeline::class)
    ->send($comment)
    ->through([
        RemoveProfanity::class,
        RemoveHarmfulContent::class,
        ShortenUrls::class,
        function (string $comment, Closure $next): string {
            return $next(strtoupper($comment));
        },
    ])
    ->thenReturn();

上例中,我们向管道添加了一个新阶段,将评论文本转换为大写。与我们一直使用的类中的 __invoke 方法使用相同的方法签名,并返回 $next,以便我们可以继续使用管道。

顺便说一句,如果你选择在管道中使用这样的闭包,你可能会失去类的一些可单元测试能力。稍后我们将更深入地介绍测试,但为管道中的每个阶段使用类的一个巨大好处是,你可以专门为该阶段编写独立的单元测试。但对于闭包,你不能那么容易做到这一点。如果你有覆盖整个管道的测试,这可能是你愿意为非常简单的任务做出的权衡。

使用 via 修改调用的方法

目前为止,我们已经通过使用 __invoke 方法使所有类都可调用。然而,这并不是管道阶段可以使用的唯一选项。

事实上,默认情况下,Laravel 实际上会尝试在管道类上调用 handle 方法。然后,如果 handle 方法不存在,它将尝试调用类或 callable

因此,这意味着我们可以更新示例管道方法签名如下:

public function __invoke(string $comment, Closure $next): string

该外:

public function handle(string $comment, Closure $next): string

管道仍可如期运行。

但是,如果希望使用不同的方法名,你可以在管道上使用 via 方法。例如,假设你希望管道的类使用 execute 方法:

public function execute(string $comment, Closure $next): string

创建管道时,你可以使用 via 方法像这样指定:

$modifiedComment = app(Pipeline::class)
    ->send($comment)
    ->through([
        RemoveProfanity::class,
        RemoveHarmfulContent::class,
        ShortenUrls::class,
    ])
    ->via('execute')
    ->thenReturn();

小结

本文中,我们探讨了 Laravel 管道是什么,Laravel 如何使用它,以及如何创建自己的管道。
既然已经了解了它们的工作原理,那么你会如何在自己的应用和包中使用管道?