中间件在路由与控制器中的应用
中间件是什么?在传统框架的年代,很少会有中间件这个概念。我最早接触这个概念其实是在学习 MySQL 的时候,了解过 MyCat 这类的组件也被称为中间件。既然是中间,那么它就是一个夹在应用和调用中间的东西。我们还是以请求为例,一个请求要经过接收、处理、返回这三个过程,而中间件,就可以看作是夹在这三个操作中间的一些操作。比如说,我们的请求发过来,在没有到达路由或者控制器的时候,就可以通过中间件做一些预判,像参数合法不合法、登录状态的判断之类的。就像我们用 Laravel 做业务开发的时候,经常需要自己写的的中间件就是处理登录信息和解决跨域问题的中间件(Laravel8有自己的跨域组件了)。
在之前学习 Node.js 的时候,express 框架中也是有中间件这个东西的,而且概念和 Laravel 的中间件是完全相同的。现在,这种中间件技术也已经是各种现代化框架的必备功能之一了。在 TP3 的时候,其实那几个勾子方法也可以视为是中间件的一种,只不过它们是请求已经到达控制器了,但在调用具体的控制器方法之前,预埋了一些勾子函数而已,关于勾子函数的相关知识可以参考 【PHP设计模式-模板方法模式】https://mp.weixin.qq.com/s/2sX1ASQpnMybJ2xFqRR3Ig 。
好了,不扯远了,我们直接来看看中间件在 Laravel 中,是如何使用的。
定义中间件
创建一个中间件也是可以通过命令行的。
代码语言:javascript复制php artisan make:middleware MiddlewareTest
通过这个命令,我们会发现在 app/Http/Middleware 这个目录下就创建了一个名为 MiddlewareTest.php 的文件。这就是一个中间件文件,当然,你也可以自己创建,只需要将创建的文件放到这个目录下就可以了。同时,在这个目录里面,我们还能看到许多系统已经为我们准备好的中间件。一会儿我们将拿其中的一两个来学习,不过在此之前,我们还是先看看这个自动生成的 MiddlewareTest.php 文件里有什么内容吧。
代码语言:javascript复制class MiddlewareTest
{
/**
* Handle an incoming request.
*
* @param IlluminateHttpRequest $request
* @param Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
return $next($request);
}
}
貌似有点简单啊,就一个 handle() 方法,然后有两个参数,一个是 Request ,另一个是闭包类型的 next 参数。Request 就不多说了,之前的文章中已经讲过,这个 Request 是贯穿整个 Laravel 应用的,所以在中间件中有也不稀奇。更主要的是,其实我们的中间件主要就是对于 请求 和 响应 的中间操作,所以这个 Request 是非常重要的。
另外这个 next() 是什么鬼?怎么是一个闭包类型的参数?这里如果学习过之前我写过的设计模式系列文章的同学一定不会陌生,想一想 责任链 这个模式,记不起来或者没看过的朋友可以移步 【PHP设计模式之责任链模式】https://mp.weixin.qq.com/s/ZA9vyCEkEg9_KTll-Jkcqw 先学习一下再回来,相信你对中间件的基础原理马上就明白了。
好了,不卖关子,这个 next 其实就是在框架中形成的一个责任链,或者说是 管道 也可以,它们略有区别但大体本质上还是相似的,就是让请求像水一样在一个管道中向下流,然后到达一个终点(比如控制器)之后,再换另一条管子流回来(也就是响应)。而这个 next 就是下一个要处理这个请求的节点。具体的内容还是参考 责任链模式 的讲解,因为它们的原理确实是相通的。
先不自己写代码,我们先看下框架为我们提供的中间件里面都写了什么。首先我们看到的是上篇文章中提到过的预防 CSRF 攻击的功能,它就是通过中间件来进行判断 _token 标签是否存在的。
代码语言:javascript复制// laravel/framework/src/Illuminate/Foundation/Http/Middleware/VerifyCsrfToken.php
public function handle($request, Closure $next)
{
if (
$this->isReading($request) ||
$this->runningUnitTests() ||
$this->inExceptArray($request) ||
$this->tokensMatch($request)
) {
return tap($next($request), function ($response) use ($request) {
if ($this->shouldAddXsrfTokenCookie()) {
$this->addCookieToResponse($request, $response);
}
});
}
throw new TokenMismatchException('CSRF token mismatch.');
}
app/Http/Middleware/VerifyCsrfToken.php 继承的是 laravel/framework/src/Illuminate/Foundation/Http/Middleware/VerifyCsrfToken.php ,也就是说源代码是在框架底层的,所以我们直接进入 laravel/framework/src/Illuminate/Foundation/Http/Middleware/VerifyCsrfToken.php 来查看。它的里面也是有一个 handle() 也就是上面的这段代码。
handle() 里面会读取请求中是否有 _token 参数或者头信息里是否有 X-CSRF-TOKEN 信息,取出来之后与 session 中的 _token 信息进行比对。成功之后会在 if 条件内部调用 next ,也就是通知后面的中间件或者其它管道节点继续请求的处理。如果失败的话,则会返回 CSRF token mismatch 的错误,请求也就中止了。相关的源码都在 VerifyCsrfToken.php 中,这里就不一一展示了,大家可以自行查阅。
其它的默认提供的中间件大家可以自己尝试分析一下是干什么用的,怎么实现的,接下来我们就自己定义一下我们刚刚创建的这个中间件。就做一些简单的功能。
代码语言:javascript复制public function handle(Request $request, Closure $next)
{
if($request->a){
$request->aa = $request->a;
}
$response = $next($request);
$response->setContent($response->content() . ' time:' . time());
return $response;
}
咦,貌似和我们默认提供的中间件有些不同,为什么我们不是直接返回 next() ,而是用一个变量接住了 next() 然后又做了一些操作之后再 return 呢?这个其实就是 后置中间件 的作用。
其实就像我们前面说的,前置中间件,就是在 next() 之前对请求进行处理操作,比如我们这里给请求中新增加了一个字段。而后置中间件,则是在 next() 结束之后,管道回流的时候,可以对响应进行一些操作,比如我们为响应增加了一个时间的输出。当然,一般情况下,响应数据我们还是尽量在控制器那边搞定,而后置中间件最大的好处是可以针对一次请求进行完整的请求和响应的日志记录。不过这些还是以业务功能的需求为基础,大家只要知道有这个功能就可以了。
而前置中间件在业务开发中,我们使用得最多的其实是对于登录鉴权的验证,比如用户是否登录,是否有权限,都可以在未到达控制器之前通过中间件进行判断,如果未登录或者权限不够就直接返回错误信息。就像 CSRF 的中间件一样,如果没有 _token 的话,根本到不了控制器,直接就会返回错误信息。
接下来,我们还要准备一个控制器。
代码语言:javascript复制class MiddlewareTestController extends Controller
{
public function test(){
$a = request()->a;
$aa = request()->aa;
return $a $aa;
}
}
这个控制器非常简单,我们只是将接收到的请求中的参数获取并相加了一下。前面在中间件中我们看到如果有 a 参数的话,我们会复制一个 aa 参数
中间件和控制器我们准备好了,接下来就是如何使用中间件了,分几种情况,我们一个一个来说。
路由上使用中间件
在路由上使用中间件非常简单,我们只需要一个 middleware 方法就可以了。
代码语言:javascript复制Route::get('middleware/test', 'AppHttpControllersMiddlewareTestController@test')->middleware(AppHttpMiddlewareMiddlewareTest::class);
是不是感觉有点简单的过分了,现在我们就为这个路由指定了一个我们自己定义的中间件。注意,其它没有写的路由是不是走这个中间件的。也就是说,在路由中定义中间件,只有我们指定的路由才会执行相应的中间件代码。
控制器里使用中间件
在路由中配置中间件是最简单也是最方便的做法,但如果我们说不想在路由中配置,比如说这个控制器里面的方法可能会定义多种路由,我们想让所有定义的路由都可以走这个中间件的话,那么除了后面要讲的全局配置中间件以外,我们还可以在某个控制器中定义要使用的中间件。
代码语言:javascript复制// routes/web.php
Route::get('middleware/noroute/test', 'AppHttpControllersMiddlewareTestController@test');
// app/Http/Controllers
class MiddlewareTestController extends Controller
{
public function __construct()
{
$this->middleware(MiddlewareTest::class);
}
// ……………………
// ……………………
// ……………………
}
在上面的测试代码中,我们使用的依然是和上面那个路由相同的控制器方法,只不过在这个路由上,我们没有指定中间件,而是在控制器的代码中,在 构造函数 里面通过 middleware() 方法指定了中间件,这样就可以让这个控制器中的所有方法都去执行指定的中间件内容。我们再定义一个新的控制器方法并且指定一个没有中间件的路由来测试。
代码语言:javascript复制// routes/web.php
Route::get('middleware/noroute/test2', 'AppHttpControllersMiddlewareTestController@test2');
// app/Http/Controllers
public function test2(){
$a = request()->a;
$aa = request()->aa;
return $a * $aa;
}
可以看到对这个新的路由和控制器方法来说,中间件也是正常发挥作用的。
全局使用中间件
上面说过的内容,都是在某一个特定的情况下使用中间件,比如说指定的路由,或者是指定的控制器。Laravel 也为我们准备了全局中间件定义的地方,全局的意思就很明显了,所有的请求都会加上这个中间件。
代码语言:javascript复制// AppHttpKernel.php
protected $middleware = [
// AppHttpMiddlewareTrustHosts::class,
AppHttpMiddlewareTrustProxies::class,
FruitcakeCorsHandleCors::class,
AppHttpMiddlewarePreventRequestsDuringMaintenance::class,
IlluminateFoundationHttpMiddlewareValidatePostSize::class,
AppHttpMiddlewareTrimStrings::class,
IlluminateFoundationHttpMiddlewareConvertEmptyStringsToNull::class,
AppHttpMiddlewareMiddlewareTest::class,
];
我们只需要找到 AppHttpKernel.php 文件,在其中的 middleware 变量中添加最后一行,也就是我们自定义的那个中间件就可以了。这样,所有的请求都会走这个中间件。Kernel.php 是一个核心文件,我们继续看它,会发现下面还有两个变量,一个是 middlewareGroups ,一个是 routeMiddleware 。其实从名字就可以看出,middlewareGroups 是为中间件分组的,里面默认定义了两个中间件组,分别是 web 和 api 。其实他们对应的就是路由文件夹下的 api.php 和 web.php 所要加载的中间件。在源代码中,我们可以找到 app/Providers/RouteServiceProvider.php 这个文件,查看里面的 boot() 方法。
代码语言:javascript复制public function boot()
{
$this->configureRateLimiting();
$this->routes(function () {
Route::prefix('api')
->middleware('api')
->namespace($this->namespace)
->group(base_path('routes/api.php'));
Route::middleware('web')
->namespace($this->namespace)
->group(base_path('routes/web.php'));
});
}
在这个 boot() 方法中,就可以看到,它定义了两个路由,加载的分别是 routes 目录下对应的两个文件,然后使用 middleware() 指定的中间件其实就是我们在中间件组中定义的那两个中间件组。既然是组的概念,那么在组中的所有中间件都会在这两个路由文件中被执行。大家可以尝试注释掉 web 分组下面的 AppHttpMiddlewareVerifyCsrfToken::class 这个中间件,就会发现 web.php 下的所有请求都不需要进行 CSRF 验证了。
另外一个 routeMiddleware 的意思其实是给中间件起个别名,比如我们在这个变量中增加一个:
代码语言:javascript复制'middlewaretest' => AppHttpMiddlewareMiddlewareTest::class,
然后在路由中,直接在 middleware() 方法中使用这个定义的名称就可以了。
代码语言:javascript复制Route::get('middleware/test', 'AppHttpControllersMiddlewareTestController@test')->middleware('middlewaretest');
中间件调用源码分析
中间件的核心用法就是上面的内容了,其它的一些功能大家可以参考官方文档来进行学习。接下来,我们就进入到中间件源码的调用分析。其实在之前的文章和这篇文章的开头就已经说过了,中间件就是 责任链模式 的一个典型应用。而在 Laravel 中,这个责任链又是以管道的形式实现的。
在执行入口文件 public/index.php 时,第一步就会来到 laravel/framework/src/Illuminate/Foundation/Http/Kernel.php 中,注意这个 Kernel.php 是源码中的文件,也是整个 Laravel 框架的核心文件。它的构造函数里面,就会调用一个 syncMiddlewareToRouter() 方法。
代码语言:javascript复制// laravel/framework/src/Illuminate/Foundation/Http/Kernel.php
protected function syncMiddlewareToRouter()
{
$this->router->middlewarePriority = $this->middlewarePriority;
foreach ($this->middlewareGroups as $key => $middleware) {
$this->router->middlewareGroup($key, $middleware);
}
foreach ($this->routeMiddleware as $key => $middleware) {
$this->router->aliasMiddleware($key, $middleware);
}
}
从方法名可以看出,这个方法的作用是给路由同步中间件,它就是把我们在 app/Http/Kernel.php 中定义的中间件数组放到路由对象 laravel/framework/src/Illuminate/Routing/Router.php 中。这个时候,中间件就已经全部被读取到了。接下来,在 index.php 中调用的 handle() 方法里面,会通过 sendRequestThroughRouter() 方法构造路由管道。
代码语言:javascript复制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());
}
Pipeline 就是一个管道,在 through() 中,我们会将默认的全局中间件保存在 Pipeline 的 pipes 变量中,然后让请求像水一样在这个中间件管道中一路流下去。
上面是处理全局中间件,还记得在 Kernel.php 中我们会将中间件传递给路由对象吗?接下来,就是在路由构造完成之后,通过路由 Router.php 中的 runRouteWithinStack() 方法,构造路由中间件相关的管道。
代码语言:javascript复制// laravel/framework/src/Illuminate/Routing/Router.php
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(function ($request) use ($route) {
return $this->prepareResponse(
$request, $route->run()
);
});
}
关于管道的分析,我们将在核心架构相关的文章再次学习,现在,你只需要知道这个水管已经铺好了,接下来就是把请求,也就是让我们的水在管道中流动就可以了。中间件就是这个管道中的一个个的阀门,我们可以对水进行过滤处理,也可以关掉阀门不让水流过,也可以让水再从另一个管道流回,发挥你的想象力吧。
总结
关于中间件的内容就是这些,使用的方法其实有这些就已经足够我们日常的开发应用了。对于源码的分析并没有太深入,因为再往下走的话就是管道相关的实现了。因此,在这里我们只是简单的指出了中间件在何时加载,在何时放到管道中而已,后续的内容我们后面再说,不要心急,一口吃下热豆腐可是会烫伤嘴的。意犹未尽的小伙伴不如自己调试一下,看看管道又是如何实现的吧,我们将在比较后期的内容中才会再讲到管道这一块。
参考文档:
https://learnku.com/docs/laravel/8.x/middleware/9366#b53cb2