Bootstrap 文件中使用 $_SERVER ['REQUEST_URI'] 遇到的一个小坑

接手了别人的代码,框架是 lumen,发现做单元测试的时候,报错如下:


  [ErrorException]
  Undefined index: REQUEST_URI

查了一下代码发现 bootstrap/app.php 中有这么一段代码:

if (strpos($_SERVER['REQUEST_URI'], 'backend') === 0) {
    $app->register(App\Providers\PermissionServiceProvider::class);
    $app->group(['middleware' => ['operation', 'permission'], 'namespace' => 'App\Http\Controllers\Backend'], function() use ($app) {
        require __DIR__ . '/../routes/backend.php';
    });
} else {
    $app->register(App\Providers\AuthServiceProvider::class);
    require __DIR__ . '/../routes/frontend.php';
}

在 phpunit 这种命令行方式运行的情况下,$_SERVER['REQUEST_URI'] 的确是找不到的。但是为什么单元测试可以模拟发送请求呢?为什么query路径可以被单元测试模拟呢?小小的挖一下不难发现,在单元测试中使用的get,post,put,delete 等之类的方法,是对 call 方法的包装,这里就直接贴一下 部分源码:
举例 get 方法源码:

    public function get($uri, array $headers = [])
    {
        $server = $this->transformHeadersToServerVars($headers);

        $this->call('GET', $uri, [], [], [], $server);

        return $this;
    }

发现其中核心部分就是 $this->call() 这句,继续追 call:

    public function call($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null)
    {
        $this->currentUri = $this->prepareUrlForRequest($uri);

        $symfonyRequest = SymfonyRequest::create(
            $this->currentUri, $method, $parameters,
            $cookies, $files, $server, $content
        );

        return $this->response = $this->app->prepareResponse(
            $this->app->handle(Request::createFromBase($symfonyRequest))
        );
    }

会发现其中,就到了SymfonyRequest::create() 这句($this->currentUri 这里比较简单,有兴趣过程自己追下),于是我们就来看看这个 create 是如何工作的(这下要追到这里了 \vendor\symfony\http-foundation\Request.php):
照例还是先贴源码,

public static function create($uri, $method = 'GET', $parameters = array(), $cookies = array(), $files = array(), $server = array(), $content = null)
    {
        $server = array_replace(array(
            'SERVER_NAME' => 'localhost',
            'SERVER_PORT' => 80,
            'HTTP_HOST' => 'localhost',
            'HTTP_USER_AGENT' => 'Symfony/3.X',
            'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
            'HTTP_ACCEPT_LANGUAGE' => 'en-us,en;q=0.5',
            'HTTP_ACCEPT_CHARSET' => 'ISO-8859-1,utf-8;q=0.7,*;q=0.7',
            'REMOTE_ADDR' => '127.0.0.1',
            'SCRIPT_NAME' => '',
            'SCRIPT_FILENAME' => '',
            'SERVER_PROTOCOL' => 'HTTP/1.1',
            'REQUEST_TIME' => time(),
        ), $server);

        $server['PATH_INFO'] = '';
        $server['REQUEST_METHOD'] = strtoupper($method);

        $components = parse_url($uri);
        if (isset($components['host'])) {
            $server['SERVER_NAME'] = $components['host'];
            $server['HTTP_HOST'] = $components['host'];
        }

        if (isset($components['scheme'])) {
            if ('https' === $components['scheme']) {
                $server['HTTPS'] = 'on';
                $server['SERVER_PORT'] = 443;
            } else {
                unset($server['HTTPS']);
                $server['SERVER_PORT'] = 80;
            }
        }

        if (isset($components['port'])) {
            $server['SERVER_PORT'] = $components['port'];
            $server['HTTP_HOST'] = $server['HTTP_HOST'].':'.$components['port'];
        }

        if (isset($components['user'])) {
            $server['PHP_AUTH_USER'] = $components['user'];
        }

        if (isset($components['pass'])) {
            $server['PHP_AUTH_PW'] = $components['pass'];
        }

        if (!isset($components['path'])) {
            $components['path'] = '/';
        }

        switch (strtoupper($method)) {
            case 'POST':
            case 'PUT':
            case 'DELETE':
                if (!isset($server['CONTENT_TYPE'])) {
                    $server['CONTENT_TYPE'] = 'application/x-www-form-urlencoded';
                }
                // no break
            case 'PATCH':
                $request = $parameters;
                $query = array();
                break;
            default:
                $request = array();
                $query = $parameters;
                break;
        }

        $queryString = '';
        if (isset($components['query'])) {
            parse_str(html_entity_decode($components['query']), $qs);

            if ($query) {
                $query = array_replace($qs, $query);
                $queryString = http_build_query($query, '', '&');
            } else {
                $query = $qs;
                $queryString = $components['query'];
            }
        } elseif ($query) {
            $queryString = http_build_query($query, '', '&');
        }

        $server['REQUEST_URI'] = $components['path'].('' !== $queryString ? '?'.$queryString : '');
        $server['QUERY_STRING'] = $queryString;

        return self::createRequestFromFactory($query, $request, array(), $cookies, $files, $server, $content);
    }

有趣的地方来了,先随便浏览下发现了一个好像不太对的地方:不是说没有那个啥 REQUEST_URI,怎么看起来好像这里有,那再看清楚一点这里是 $server['REQUEST_URI'] ,之前那个地方是 $_SERVER['REQUEST_URI'],一个是普通变量,一个是超全局变量。那么为什么 symfony 不去使用 PHP 自带的超全局变量,而非要自己搞个 $server 呢?
这个类里面还有一个方法解释了这个疑问:

          /**
     * Creates a new request with values from PHP's super globals.
     *
     * @return static
     */
    public static function createFromGlobals()
    {
        // With the php's bug #66606, the php's built-in web server
        // stores the Content-Type and Content-Length header values in
        // HTTP_CONTENT_TYPE and HTTP_CONTENT_LENGTH fields.
        $server = $_SERVER;
        if ('cli-server' === PHP_SAPI) {
            if (array_key_exists('HTTP_CONTENT_LENGTH', $_SERVER)) {
                $server['CONTENT_LENGTH'] = $_SERVER['HTTP_CONTENT_LENGTH'];
            }
            if (array_key_exists('HTTP_CONTENT_TYPE', $_SERVER)) {
                $server['CONTENT_TYPE'] = $_SERVER['HTTP_CONTENT_TYPE'];
            }
        }

        $request = self::createRequestFromFactory($_GET, $_POST, array(), $_COOKIE, $_FILES, $server);

        if (0 === strpos($request->headers->get('CONTENT_TYPE'), 'application/x-www-form-urlencoded')
            && in_array(strtoupper($request->server->get('REQUEST_METHOD', 'GET')), array('PUT', 'DELETE', 'PATCH'))
        ) {
            parse_str($request->getContent(), $data);
            $request->request = new ParameterBag($data);
        }

        return $request;
    }

重点$server = $_SERVER 这句,回答了刚才的疑问,这句就说明 $server 本质上是对 $_SERVER 的包装,在没有对它进行后面的处理之前,他们基本是一样的。(注释中的 bug ,有兴趣就可以看下。)
回到完毕这个问题,我们继续跳回,刚才那个 create 方法, symfony 自己通过 http_build_query 对 query path 进行了重新包装。所以在使用phpunit的时候,不需要依赖于超全局的系统变量就可以模拟 HTTP 请求了。
开头的那个坑其实也很好填,加个prefix就行了,无需自己再去判断路径:

//backend
{
    $app->register(App\Providers\PermissionServiceProvider::class);
    $app->group(['middleware' => ['operation', 'permission'],
        'namespace' => 'App\Http\Controllers\Backend',
        'prefix' => 'backend',
        ], function() use ($app) {
        require __DIR__ . '/../routes/backend.php';
    });
}

//frontend
{
    $app->register(App\Providers\AuthServiceProvider::class);
    require __DIR__ . '/../routes/frontend.php';
}

水平有限,如有不妥之处,欢迎指正。

本作品采用《CC 协议》,转载必须注明作者和本文链接
每天进步一点点
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!