Laravel 管道(Pipeline)工作原理及其使用
介绍
在构建 web 应用时,无论是使用单独的函数还是类,将功能的复杂过程分解为更小、更易于管理的部分都很方便。这样做有助于保持代码干净、可维护和可测试。
在 Laravel 应用中分割这些较小步骤的一种方法是使用 Laravel 管道。管道允许你在返回结果之前通过多层逻辑发送数据。事实上,Laravel 实际上在内部使用管道来处理请求,并将其传递给应用的每个中间件(我们稍后会对此进行介绍)。
本文中,我们将看看什么是管道以及 Laravel 如何在内部使用它们,并展示如何创建自己的管道。我们还将介绍如何编写测试以确保你的管道按预期工作。
在本文结束时,你应该了解什么是管道,并有信心在 Laravel 应用中使用它们。
什么是 Laravel Pipeline 类?
根据 Laravel 文档,管道提供了一种“通过一系列可调用类、闭包或 callable 对给定输入进行管道化的便捷方式,使每个类都有机会检查或修改输入,并调用管道中的下一个 callable。”
什么意思呢?为了解 Laravel 管道是什么,让我们来看一个基本的例子。
想象一下,你正在构建一个博客平台,你目前正在编写允许用户对博客帖子发表评论的功能。在我们将评论存储到数据库之前,当用户提交评论时,你可能需要遵循以下步骤:
- 检查评论中是否有任何违禁词。如果有,用星号(*)替换。
- 检查评论中是由垃圾内容(如指向恶意外部网站的链接)并将其删除。
- 删除评论中任何有害的内容。
这些步骤中的每一个都可以被视为管道中的一个阶段。评论数据通过每个阶段传递,每个阶段在将修改后的数据传递给下一阶段之前执行特定的任务。当评论出现在管道的另一边时,它应该已经对其进行了所有必要的检查和修改。
如果我们使用 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
目录,然后在其中创建三个新类:
App\Pipelines\Comments\RemoveProfanity
App\Pipelines\Comments\RemoveHarmfulContent
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
方法的第一个参数为真,则将执行回调。否则,将不会执行回调。
then
和 thenReturn
的不同之处
正如我们已经讲过的,管道中的每个阶段都返回 $next(...)
,以便我们可以将数据传递给管道中的下一阶段。但是,要运行管道并执行每个任务以从最后阶段获得结果,我们需要使用 then
或 thenReturn
方法。这两者非常相似,但两者之间存在微小差异。
正如我们在前面的示例中看到的,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 如何使用它,以及如何创建自己的管道。
既然已经了解了它们的工作原理,那么你会如何在自己的应用和包中使用管道?