Bootstrap 檔案中使用 $_SERVER ['REQUEST_URI'] 遇到的一個小坑

wilson_yang發表於2018-04-14

接手了別人的程式碼,框架是 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';
}

水平有限,如有不妥之處,歡迎指正。

每天進步一點點

相關文章