Miles' Blog

天涯何處無幹話,何必要講實務話

今天要來分析內建測試是如何實作的,先來看官方測試範例程式碼。

public function testBasicTest()
{
$response = $this->get('/');

$response->assertStatus(200);
}

它對應會跑的 Route 如下:

Route::get('/', function () {
return view('welcome');
});

Routing 的分析可以再回頭看 Day 12

這幾會來看 Laravel 是如何從 get() 去取得 response,並得知 statue code 或其他結果。

共用的 Laravel TestCase

Laravel Framework 有一個抽象的 TestCase 類別,首先要了解測試一開始初始化了哪些東西,這可以從 setUp() 得知:

protected function setUp()
{
// 初始化 Application
if (! $this->app) {
$this->refreshApplication();
}

// 初始化 trait
$this->setUpTraits();

// Application 初始化後的 hook
foreach ($this->afterApplicationCreatedCallbacks as $callback) {
call_user_func($callback);
}

// 清除 Facade 的 instance
Facade::clearResolvedInstances();

// 重新設定 Model 的 event
Model::setEventDispatcher($this->app['events']);

// 當初始化完成,做一個標記
$this->setUpHasRun = true;
}

Application 是 Laravel 的核心,所以一開始得先初始化。

protected function refreshApplication()
{
$this->app = $this->createApplication();
}

// createApplication 預設是由 CreateApplication trait 實作的

public function createApplication()
{
$app = require __DIR__.'/../bootstrap/app.php';

$app->make(Kernel::class)->bootstrap();

return $app;
}

分析 bootstrap 流程曾提過,bootstrap/app.php 的任務是提供一個可以用在任何場景的 Application,包括測試。而 bootstrap() 方法是為了載入必要的設定檔等,這樣測試程式碼才能正常使用 Config 等元件。

接著,如果 TestCase 有標記特定的 trait,如 DatabaseTransactions,就會有特定的行為,這個 magic 是由 setUpTraits() 所實作的。後面則是清除設定,讓測試可以從乾淨的狀態從頭執行。

get() 與其他 HTTP 相關的方法是寫在 MakesHttpRequests 裡:

public function get($uri, array $headers = [])
{
// 將 header 轉成 server 的環境變數
$server = $this->transformHeadersToServerVars($headers);

// 呼叫 call
return $this->call('GET', $uri, [], [], [], $server);
}

其他相關的 HTTP method 與 get() 一樣,最終都會呼叫 call()

public function call($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null)
{
// 取得 HTTP Kernel
$kernel = $this->app->make(HttpKernel::class);

// 處理 file 參數
$files = array_merge($files, $this->extractFilesFromDataArray($parameters));

// 從各參數產生 symfony request
$symfonyRequest = SymfonyRequest::create(
$this->prepareUrlForRequest($uri), $method, $parameters,
$cookies, $files, array_replace($this->serverVariables, $server), $content
);

// 呼叫 handle() 方法
$response = $kernel->handle(
$request = Request::createFromBase($symfonyRequest)
);

// 如果啟用 followRedirects 就再繼續呼叫 get() 方法,直到不再 redirect
if ($this->followRedirects) {
$response = $this->followRedirects($response);
}

// Request 結束
$kernel->terminate($request, $response);

// 建立 fesponse 測試輔助物件
return $this->createTestResponse($response);
}

仔細觀察可以發現,它跟 index.php 有很多相同的呼叫方法,而 index.php 有多呼叫 Response send 方法。

最後的 response 測試輔助物件則提供了 assertStatus() 等方法,來驗證最後的 response。

繼續看 RoutesRequests 下面的方法。

  • createDispatcher()
  • sendExceptionToHandler()
  • prepareResponse()

createDispatcher()

Lumen 並沒有使用 Laravel 的 Routing,而是使用自定義的 Router,把 Router 和 FastRoute 結合的就是這個方法:

protected function createDispatcher()
{
return $this->dispatcher ?: \FastRoute\simpleDispatcher(function ($r) {
foreach ($this->router->getRoutes() as $route) {
$r->addRoute($route['method'], $route['uri'], $route['action']);
}
});
}

simpleDispatcher 是 FastRoute 提供 Dispatcher 的工廠方法。傳入的 $r 會是 FastRoute 專用的 RouteCollection。可以看到它使用 addRoute() 把 Lumen Router 存放的 Route 再轉存到 FastRoute Collection 裡。

最後 dispatcher 屬性,將會是 FastRoute 的 Dispatcher。

sendExceptionToHandler()

protected function sendExceptionToHandler($e)
{
// 這裡將會取得 ExceptionHandler 實例
$handler = $this->resolveExceptionHandler();

// 轉換 Error 為 FatalThrowableError
if ($e instanceof Error) {
$e = new FatalThrowableError($e);
}

// 這裡的 report 與 render 和 Laravel 的使用方法大同小異
$handler->report($e);

return $handler->render($this->make('request'), $e);
}

Laravel 的 ErrorHandler 分析可以參考 Day31 分析自定義錯誤頁

prepareResponse()

Laravel 也有 prepareResponse(),Lumen 就像是精簡版一樣:

public function prepareResponse($response)
{
$request = app(Request::class);

// 轉換 Responsable
if ($response instanceof Responsable) {
$response = $response->toResponse($request);
}

// 轉換 PSR7 Response
if ($response instanceof PsrResponseInterface) {
$response = (new HttpFoundationFactory)->createResponse($response);

// 若不是 Symfony Response 的實例,則預期會是字串,直接依字串產生 response
} elseif (! $response instanceof SymfonyResponse) {
$response = new Response($response);

// 轉換 BinaryFileResponse
} elseif ($response instanceof BinaryFileResponse) {
$response = $response->prepare(Request::capture());
}

// 處理 Header 後回傳
return $response->prepare($request);
}

以上就是 dispatch() 詳細分析的過程,接著明天再來看 Router 的實作。

與 Laravel 類似地,Lumen 也是被拆成 LumenLumen Framework 兩部分。而 Lumen Framework 則是使用第一天提到的 Illuminate 套件實作出來的。

最近幾天會來研究一下 Lumen 怎麼使用 Laravel 抽離出來的套件,會使用 v5.7.6 版,從 src 目錄可以知道 Lumen Framework 客製化的套件庫樣貌:

Auth
Bus
Console
Exceptions
Http
Providers
Routing
Testing

ConcernsProviders 不大像是客製化的一部分,而比較像 helper

了解 Lumen 的做法後,就能知道如何使用 Laravel 提供的 Contract 與輪子,組合出一個自幹框架。

事實上,重頭戲還是會在 Routing,因為 Lumen 使用了 nikic/fast-route 作為解析路由器。如何取代原有 Routing 正是 Lumen 程式碼最值得看的地方。

今天只起個頭,休息一下,明天再繼續努力了。

tap() 之前有提過,是 helpers.php 的方法之一。

function tap($value, $callback = null)
{
if (is_null($callback)) {
return new HigherOrderTapProxy($value);
}

$callback($value);

return $value;
}

先不管 HigherOrderTapProxy,來看剩下的原始碼:

function tap($value, $callback)
{
$callback($value);

return $value;
}

它需要傳入一個 $value,然後它會再回傳出來,因此可以知道下面這個寫法是可行的:

tap(new Collection(), $callback)->each->pay();

再來因為它中間有做 $callback($value),因此上面這個方法的全貌可能會是長這樣的:

$callback = function($collection) {
$collection->set(new Invoice);
};

tap(new Collection(), $callback)->each->pay();

反過來看,如果沒有 tap() 函式的話,我們可能需要這樣寫:

$collection = new Collection();
$collection->set(new Invoice);

$collection->each->pay();

咦,看起來似乎不用 tap() 寫起來比較乾淨。不是這樣的,是 Collection 並不適合用在這個地方。

最常看到的就是在物件初始化的時候,比方說分析 Session 提到了下面這段程式碼:

protected function startSession(Request $request)
{
return tap($this->getSession($request), function ($session) use ($request) {
$session->setRequestOnHandler($request);

$session->start();
});
}

使用 tap() 的好處之一是剛剛有提到的,這對要使用串聯方法是有利的;另一個好處則是:有時候我們會希望對某個實例做某些事,而做這些事會需要產生一些專用的暫時變數,這時 tap() 因為可以使用 Closure,所以可以把這些暫存變數「關」在裡面,外面就不會被這些暫時變數干擾到。

HigherOrderTapProxy

雖然是這樣,但每次要寫一堆 callable 就很煩,因此出現了另一個選擇:HigherOrderTapProxy,來看看它的原始碼:

// 前面只是建構的時候把 value 存到 target 而已,所以省略
public function __call($method, $parameters)
{
$this->target->{$method}(...$parameters);

return $this->target;
}

這很像 proxy pattern,唯一不同的地方在於,它固定會回傳 self。以 分析 Log 的例子來說,原本程式碼與改寫後的程式碼如下:

tap($this->createEmergencyLogger(), function ($logger) use ($e) {
$logger->emergency('Unable to create configured logger. Using emergency logger.', [
'exception' => $e,
]);
});

tap($this->createEmergencyLogger())
->emergency('Unable to create configured logger. Using emergency logger.', [
'exception' => $e,
]);

Higher Order Messages 很像,可以省略掉一層 callback,但同時也有一樣的使用條件:以 callback 的寫法,只允許一行程式碼。

Higher Order Messages 是一個像魔法一般的功能。

來看看官方提供的範例:

$invoices->each(function($invoice) {
$invoice->pay();
});

// 下面的呼叫結果相同
$invoices->each->pay();

// ------------

$employees->reject(function($employee) {
return $employee->retired;
})->each(function($employee){
$employee->sendPayment();
});

// 下面的呼叫結果相同
$employees->reject->retired->each->sendPayment();

原本要寫一堆 callback,現在只要使用一連串屬性的取法,就能得到一樣的結果。

分析

以上例第一個例子為例:

$invoices->each->pay();

可以猜想得到,each 會是一個委任出去的角色,這從原始碼最上面註解的屬性可以找得到:

/**
* @property-read HigherOrderCollectionProxy $each
*/

通常 Laravel 或大部分開源的原始碼,會寫在最上面的屬性和方法,通常都是 magic method 實作出來的。本例是屬性,所以會是由 __get() 實作:

protected static $proxies = [
'average', 'avg', 'contains', 'each', 'every', 'filter', 'first',
'flatMap', 'groupBy', 'keyBy', 'map', 'max', 'min', 'partition',
'reject', 'sortBy', 'sortByDesc', 'sum', 'unique',
];

public function __get($key)
{
if (! in_array($key, static::$proxies)) {
throw new Exception("Property [{$key}] does not exist on this collection instance.");
}

return new HigherOrderCollectionProxy($this, $key);
}

這裡直接建構出 HigherOrderCollectionProxy 出來用,而帶入的 $key 以上例來說,會是 each。這個元件的實作超級簡單,除了建構子單純是保存狀態之外,就只有實作兩個 magic method:

註:下面原始碼的 $this->method,即為上面建構帶入的的 $key

public function __get($key)
{
return $this->collection->{$this->method}(function ($value) use ($key) {
return is_array($value) ? $value[$key] : $value->{$key};
});
}

public function __call($method, $parameters)
{
return $this->collection->{$this->method}(function ($value) use ($method, $parameters) {
return $value->{$method}(...$parameters);
});
}

由原始碼可以看出,原本要寫的 callback,將會由委任的物件建立。同時也可以得知,要成為 Higher Order Messages 的一員,參數必須要有 callback。

而哪時會使用 __get(),哪時會使用 __call(),下面是一個比較容易理解的比較:

// __get()
$invoices->each->name;

// __call()
$invoices->each->pay();

接著來看 __get() 實際在做的事,裡面的 $key 指的是上例的 name。因此原始碼與等價的程式碼比對如下:

return $this->collection->{$this->method}(function ($value) use ($key) {
return is_array($value) ? $value[$key] : $value->{$key};
});

return $this->collection->each(function ($value) {
return is_array($value) ? $value['name'] : $value->name;
});

同理,__call()$keypay。原始碼與等價的程式碼比對如下:

return $this->collection->{$this->method}(function ($value) use ($method, $parameters) {
return $value->{$method}(...$parameters);
});

return $this->collection->each(function ($value) {
return $value->pay();
});

從上述等價原始碼可以了解,使用 Higher Order Messages 的時機會是,當方法是在 proxy 的列表裡,且 callback 裡面單純只回傳一個特定值、或呼叫一個特定方法時,就能考慮使用它。反之,需要兩行以上的程式碼時,就無法使用這個功能了。

Higher Order Messages 是個很有趣的實作,短短不到 100 行程式碼,就能讓使用它的開發者少寫非常多程式碼,而且可讀性也不差,真的是非常厲害。

先大概列幾個與原生 PHP 函式相關的方法。

Native Collection
array_chunk() chunk()
array_combine() combine()
array_diff() diff()
array_flip() flip()
array_filter() filter()
array_map() map()
array_reduce() reduce()
array_slice() slice()
array_walk() each()

通常 Collection 的實作,都有加強原生對 array 處理的方便性,這些就不多做說明,來提一些比較有趣的。

transform()

這個方法跟 map() 其實是一樣的,但它會改變原本的物件的內容:

public function transform(callable $callback)
{
$this->items = $this->map($callback)->all();

return $this;
}

pipe()

這個 pipe 的意義,與 Bash 的 | 類似,把該物件交由 callback 處理

public function pipe(callable $callback)
{
return $callback($this);
}

tap()

public function tap(callable $callback)
{
$callback(new static($this->items));

return $this;
}

之前曾提過 tap() 函式。這個方法也是類似的概念,它的等價程式碼如下:

$callback = function($items) {
//
};

$collection->tap($callback);

tap($collection, $callback);

讓 collection 自帶 tap() 方法,會串聯方法或是語意,都會比原本的 tap() 函式來的好。

toBase()

這個方法是用在繼承 Collection 的類別如 Eloquent Collection,如果想轉成單純的 Collection 的話,可以使用這個。

public function toBase()
{
return new self($this);
}

曾有自定義類別是繼承 Collection 的,不過設計上比較單純,所以還沒用過這個方法。

wrap()unwrap()

wrap() 會使用 Collection 把傳入的參數包裝起來,unwrap() 則是解包裝。

public static function wrap($value)
{
return $value instanceof self
? new static($value)
: new static(Arr::wrap($value));
}

public static function unwrap($value)
{
return $value instanceof self ? $value->all() : $value;
}

其中會注意到,warp() 裡面還用到了 Arr::wrap(),因此非 array 的參數也可以正常使用。

// class Arr
public static function wrap($value)
{
if (is_null($value)) {
return [];
}

return ! is_array($value) ? [$value] : $value;
}

when()unless()

這兩個方法很有趣,when() 是當 $value 是 true 的時候,就會執行 callback;unless() 則相反。

public function when($value, callable $callback, callable $default = null)
{
if ($value) {
// 如果 $value 是 true 就執行 callback
return $callback($this, $value);
} elseif ($default) {
// 如果 $value 不是 true 且有 default callback 的話,就換執行 default callback
return $default($this, $value);
}

return $this;
}

public function unless($value, callable $callback, callable $default = null)
{
return $this->when(! $value, $callback, $default);
}

可以注意到上面 unless() 寫法很有趣,它把 $value 反相後丟到 when,即可做出 unless 的方法。這特性在 Laravel 也很常見,比方說 isEmpty()isNotEmpty()

public function isEmpty()
{
return empty($this->items);
}

public function isNotEmpty()
{
return ! $this->isEmpty();
}

可以看到 isNotEmpty() 其實就是 isEmpty() 的相反。

會這樣設計的原因是:用起來比較直覺語意,可以參考下面兩段程式碼即可了解:

if (!$collection->isEmpty()) {
//
}

if ($collection->isNotEmpty()) {
//
}

Laravel 框架很多設計都是環繞在直覺語意上的,非常值得參考。

0%