• Laravel 处理 Options 请求的原理以及批处理方案


    0. 背景

    在前后端分离的应用中,需要使用CORS完成跨域访问。在CORS中发送非简单请求时,前端会发一个请求方式为OPTIONS的预请求,前端只有收到服务器对这个OPTIONS请求的正确响应,才会发送正常的请求,否则将抛出跨域相关的错误。

    这篇文章主要总结对Laravel中处理OPTIONS请求处理机制的探索,以及如何正确处理这类OPTIONS请求的解决方案。

    1. 问题描述

    Laravel处理OPTIONS方式请求的机制是个谜。

    假设我们请求的URL是http://localhost:8080/api/test,请求方式是OPTIONS

    如果请求的URL不存在相关的其它方式(如GETPOST)的请求,则会返回404 NOT FOUND的错误。

    如果存在相同URL的请求,会返回一个状态码为200的成功响应,但没有任何额外内容。

    举例而言,在路由文件routes/api.php中如果存在下面的定义,则以OPTIONS方式调用/api/test请求时,返回状态码为200的成功响应。

    Route::get('/test', 'TestController@test');
    

    但同时通过分析可以发现,这个OPTIONS请求不会进到此api路由文件的生命周期内,至少该GET请求所在路由文件api所绑定的中间件是没有进入的。

    此时如果手动添加一个OPTIONS请求,比如:

    Route::get('/test', 'TestController@test');
    Route::options('/test', function(Request $request) {
        return response('abc');
    });
    

    则至少会进入该GET请求所在路由文件api绑定的中间件,可以在相关handle函数中捕获到这个请求。

    2. 分析源码

    通过仔细查看Laravel的源码,发现了一些端倪。

    在文件vendor/laravel/framework/src/Illuminate/Routing/RouteCollection.php的第159行左右,源码内容如下:

            $routes = $this->get($request->getMethod());
    
            // First, we will see if we can find a matching route for this current request
            // method. If we can, great, we can just return it so that it can be called
            // by the consumer. Otherwise we will check for routes with another verb.
            $route = $this->matchAgainstRoutes($routes, $request);
    
            if (! is_null($route)) {
                return $route->bind($request);
            }
    
            // If no route was found we will now check if a matching route is specified by
            // another HTTP verb. If it is we will need to throw a MethodNotAllowed and
            // inform the user agent of which HTTP verb it should use for this route.
            $others = $this->checkForAlternateVerbs($request);
    
            if (count($others) > 0) {
                return $this->getRouteForMethods($request, $others);
            }
    
            throw new NotFoundHttpException;
    

    这里的逻辑是:

    1. 首先根据当前HTTP方法(GET/POST/PUT/...)查找是否有匹配的路由,如果有(if(! is_null($route))条件成立),非常好,绑定后直接返回,继续此后的调用流程即可;

    2. 否则,根据$request的路由找到可能匹配的HTTP方法(即URL匹配,但是HTTP请求方式为其它品种的),如果count($others) > 0)条件成立,则继续进入$this->getRouteForMethods($request, $others);方法;

    3. 否则抛出NotFoundHttpException,即上述说到的404 NOT FOUND错误。

    倘若走的是第2步,则跳转文件的234行,可看到函数逻辑为:

        protected function getRouteForMethods($request, array $methods)
        {
            if ($request->method() == 'OPTIONS') {
                return (new Route('OPTIONS', $request->path(), function () use ($methods) {
                    return new Response('', 200, ['Allow' => implode(',', $methods)]);
                }))->bind($request);
            }
    
            $this->methodNotAllowed($methods);
        }
    

    判断如果请求方式是OPTIONS,则返回状态码为200的正确响应(但是没有添加任何header信息),否则返回一个methodNotAllowed状态码为405的错误(即请求方式不允许的情况)。

    此处Laravel针对OPTIONS方式的HTTP请求处理方式已经固定了,这样就有点头疼,不知道在哪里添加代码针对OPTIONS请求的header进行处理。最笨的方法是对跨域请求的每一个GETPOST请求都撰写一个同名的OPTIONS类型的路由。

    3. 解决办法

    解决方案有两种,一种是添加中间件,一种是使用通配路由匹配方案。

    总体思想都是在系统处理OPTIONS请求的过程中添加相关header信息。

    3.1 中间件方案

    在文件app/Http/Kernel.php中,有两处可以定义中间件。

    第一处是总中间件$middleware,任何请求都会通过这里;第二处是群组中间件middlewareGroups,只有路由匹配上对应群组模式的才会通过这部分。

    这是总中间件$middleware的定义代码:

        protected $middleware = [
            IlluminateFoundationHttpMiddlewareCheckForMaintenanceMode::class,
            IlluminateFoundationHttpMiddlewareValidatePostSize::class,
            AppHttpMiddlewareTrimStrings::class,
            IlluminateFoundationHttpMiddlewareConvertEmptyStringsToNull::class,
            AppHttpMiddlewareTrustProxies::class,
        ];
    

    这是群组中间件$middlewareGroups的定义代码:

        /**
        * The application's route middleware groups.
        *
        * @var array
        */
        protected $middlewareGroups = [
            'web' => [
                AppHttpMiddlewareEncryptCookies::class,
                IlluminateCookieMiddlewareAddQueuedCookiesToResponse::class,
                IlluminateSessionMiddlewareStartSession::class,
                // IlluminateSessionMiddlewareAuthenticateSession::class,
                IlluminateViewMiddlewareShareErrorsFromSession::class,
                AppHttpMiddlewareVerifyCsrfToken::class,
                IlluminateRoutingMiddlewareSubstituteBindings::class,
            ],
            'api' => [
                'throttle:60,1',
                'bindings',
                IlluminateSessionMiddlewareStartSession::class,
            ],
        ];
    

    由于群组路由中间件是在路由匹配过程之后才进入,因此之前实验中提及的OPTIONS请求尚未通过此处中间件的handle函数,就已经返回了。

    因此我们添加的中间件,需要添加到$middleware数组中,不能添加到api群组路由中间件中。

    app/Http/Middleware文件夹下新建PreflightResponse.php文件:

    <?php
    
    namespace AppHttpMiddleware;
    use Closure;
    class PreflightResponse
    {
        /**
        * Handle an incoming request.
        *
        * @param  IlluminateHttpRequest  $request
        * @param  Closure  $next
        * @param  string|null  $guard
        * @return mixed
        */
        public function handle($request, Closure $next, $guard = null)
        {
            if($request->getMethod() === 'OPTIONS'){
                $origin = $request->header('ORIGIN', '*');
                header("Access-Control-Allow-Origin: $origin");
                header("Access-Control-Allow-Credentials: true");
                header('Access-Control-Allow-Methods: POST, GET, OPTIONS, PUT, DELETE');
                header('Access-Control-Allow-Headers: Origin, Access-Control-Request-Headers, SERVER_NAME, Access-Control-Allow-Headers, cache-control, token, X-Requested-With, Content-Type, Accept, Connection, User-Agent, Cookie, X-XSRF-TOKEN');
            }
            return $next($request);
        }
    }
    

    其中这里针对OPTIONS请求的处理内容是添加多个header内容,可根据实际需要修改相关处理逻辑:

    $origin = $request->header('ORIGIN', '*');
    header("Access-Control-Allow-Origin: $origin");
    header("Access-Control-Allow-Credentials: true");
    header('Access-Control-Allow-Methods: POST, GET, OPTIONS, PUT, DELETE');
    header('Access-Control-Allow-Headers: Origin, Access-Control-Request-Headers, SERVER_NAME, Access-Control-Allow-Headers, cache-control, token, X-Requested-With, Content-Type, Accept, Connection, User-Agent, Cookie, X-XSRF-TOKEN');
    

    至此,所有OPTIONS方式的HTTP请求都得到了相关处理。

    3.2 通配路由匹配方案

    如果不使用中间件,查询Laravel官方文档Routing,可知如何在路由中使用正则表达式进行模式匹配。

    Route::get('user/{id}/{name}', function ($id, $name) {
        //
    })->where(['id' => '[0-9]+', 'name' => '[a-z]+']);
    

    类似的,可以撰写针对OPTIONS类型请求的泛化处理路由条件:

    Route::options('/{all}', function(Request $request) {
         return response('options here!');
    })->where(['all' => '([a-zA-Z0-9-]|/)+']);
    

    *注:这里正则表达式中不能使用符号*

    因此,针对跨域问题,对于OPTIONS方式的请求可以撰写如下路由响应:

    Route::options('/{all}', function(Request $request) {
        $origin = $request->header('ORIGIN', '*');
        header("Access-Control-Allow-Origin: $origin");
        header("Access-Control-Allow-Credentials: true");
        header('Access-Control-Allow-Methods: POST, GET, OPTIONS, PUT, DELETE');
        header('Access-Control-Allow-Headers: Origin, Access-Control-Request-Headers, SERVER_NAME, Access-Control-Allow-Headers, cache-control, token, X-Requested-With, Content-Type, Accept, Connection, User-Agent, Cookie');
    })->where(['all' => '([a-zA-Z0-9-]|/)+']);
    

    这样所有的OPTIONS请求都能找到匹配的路由,在此处可统一处理所有OPTIONS请求,不需要额外进行处理。

    4. 参考链接

    The PHP Framework For Web Artisanslaravel.com

    https://medium.com/@neo/handling-xmlhttprequest-options-pre-flight-request-in-laravel-a4c4322051b9medium.com

  • 相关阅读:
    查询手机内联系人
    加载媒体库里的音频
    用ContentProvider获取通讯录联系人
    TensorFlow学习笔记:保存和读取模型
    如何「优雅」地标数据
    Bagging, Boosting, Bootstrap
    3D中的旋转变换
    PCA算法浅析
    SQL Server数据库邮件配置
    浅谈checkpoint与内存缓存
  • 原文地址:https://www.cnblogs.com/mouseleo/p/8427669.html
Copyright © 2020-2023  润新知