接手了別人的程式碼,框架是 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';
}
水平有限,如有不妥之處,歡迎指正。