0x00 前言
Slim 是由《PHP The Right Way》作者開發的一款 PHP 微框架,程式碼量不算多(比起其它重型框架來說),號稱可以一下午就閱讀完(我覺得前提是熟悉 Slim 所用的元件)。不過比起其它框架來說真的還算容易閱讀的了,所以是比較適合我這種新手學習一款框架。因為文章篇幅限制所以採用抓大放小的方式所以會避過一些不重要的內容(我才不會告訴你有些地方我還沒看很明白/(ㄒoㄒ)/)。
0x01 生命週期
0x02 從入口檔案開始
在 Slim 專案的 README 裡我們可以看見官方所給出的入口檔案 index.php 的 demo,真的是很微(⊙﹏⊙)。
<?php
require 'vendor/autoload.php';
$app = new Slim\App();
$app->get('/hello/{name}', function ($request, $response, $args) {
return $response->getBody()->write("Hello, " . $args['name']);
});
$app->run();
複製程式碼
上面這段程式碼作用如下
- 引入 composer 的自動載入指令碼
vendor/autoload.php
- 例項化
App
類 - 定義一個閉包路由
App
例項執行run
方法
很容易看出整段程式碼最重要的便是 App
類,下面我們來分析一下 App
類。
0x03 構造一切的核心 App
首先我們看看 App
的建構函式
/**
* Create new application
*
* @param ContainerInterface|array $container Either a ContainerInterface or an associative array of app settings
* @throws InvalidArgumentException when no container is provided that implements ContainerInterface
*/
public function __construct($container = [])
{
if (is_array($container)) {
$container = new Container($container);
}
if (!$container instanceof ContainerInterface) {
throw new InvalidArgumentException('Expected a ContainerInterface');
}
$this->container = $container;
}
複製程式碼
這裡我們發現 App
依賴一個容器介面 ContainerInterface
。如果沒有傳遞容器,建構函式將例項化 Container
類,作為 App
的容器。因為 App
依賴的是 ContainerInterface
介面而不是具體實現,所以我們可以使用任意實現了 ContainerInterface
介面的容器作為引數注入 App
但是因為我們現在研究 Slim 框架所以還是要分析 Container
類。
0x04 容器 Container
Slim 的容器是基於 pimple/pimple 這個容器實現的(想了解 Pimple
容器可以看這篇文章 PHP容器--Pimple執行流程淺析),Container
類增加了配置使用者設定、註冊預設服務的功能並實現了 ContainerInterface
介面。部分程式碼如下:
private $defaultSettings = [
// 篇幅限制省略不貼
];
public function __construct(array $values = [])
{
parent::__construct($values);
$userSettings = isset($values['settings']) ? $values['settings'] : [];
$this->registerDefaultServices($userSettings);
}
// 註冊預設服務
private function registerDefaultServices($userSettings)
{
$defaultSettings = $this->defaultSettings;
/**
* 向容器中註冊 settings 服務
* 該服務將返回 App 相關的設定
*
* @return array|\ArrayAccess
*/
$this['settings'] = function () use ($userSettings, $defaultSettings) {
// array_merge 將 $defaultSettings 和 $userSettings 合併
// $defaultSettings 與 $userSettings 中相同的鍵名會覆蓋為 $userSettings 的值
return new Collection(array_merge($defaultSettings, $userSettings));
};
$defaultProvider = new DefaultServicesProvider();
$defaultProvider->register($this);
}
複製程式碼
例項化該容器時的任務就是將 $values
陣列包含的服務註冊到容器裡,如果 $values
存在 settings
則將其和$defaultSettings
合併後再註冊到容器中,最後通過 DefaultServicesProvider
將預設的服務都註冊到容器裡。
0x05 註冊預設服務 DefaultServicesProvider
DefaultServicesProvider
的 register
方法向容器註冊了許多服務包括 environment
、request
、response
、router
等,由於篇幅限制下面只展示 register
方法裡比較重要的片段。
if (!isset($container['environment'])) {
/**
* This service MUST return a shared instance
* of \Slim\Interfaces\Http\EnvironmentInterface.
*
* @return EnvironmentInterface
*/
$container['environment'] = function () {
return new Environment($_SERVER);
};
}
if (!isset($container['request'])) {
/**
* PSR-7 Request object
*
* @param Container $container
*
* @return ServerRequestInterface
*/
$container['request'] = function ($container) {
return Request::createFromEnvironment($container->get('environment'));
};
}
if (!isset($container['response'])) {
/**
* PSR-7 Response object
*
* @param Container $container
*
* @return ResponseInterface
*/
$container['response'] = function ($container) {
$headers = new Headers(['Content-Type' => 'text/html; charset=UTF-8']);
$response = new Response(200, $headers);
return $response->withProtocolVersion($container->get('settings')['httpVersion']);
};
}
if (!isset($container['router'])) {
/**
* This service MUST return a SHARED instance
* of \Slim\Interfaces\RouterInterface.
*
* @param Container $container
*
* @return RouterInterface
*/
$container['router'] = function ($container) {
$routerCacheFile = false;
if (isset($container->get('settings')['routerCacheFile'])) {
$routerCacheFile = $container->get('settings')['routerCacheFile'];
}
$router = (new Router)->setCacheFile($routerCacheFile);
if (method_exists($router, 'setContainer')) {
$router->setContainer($container);
}
return $router;
};
}
複製程式碼
0x06 註冊路由
在入口檔案中我們可以看見通過 $app->get(...)
註冊路由的方式,在 App
類裡我們看見如下程式碼:
/********************************************************************************
* Router proxy methods
*******************************************************************************/
/**
* Add GET route
*
* @param string $pattern The route URI pattern
* @param callable|string $callable The route callback routine
*
* @return \Slim\Interfaces\RouteInterface
*/
public function get($pattern, $callable)
{
return $this->map(['GET'], $pattern, $callable);
}
/**
* Add route with multiple methods
*
* @param string[] $methods Numeric array of HTTP method names
* @param string $pattern The route URI pattern
* @param callable|string $callable The route callback routine
*
* @return RouteInterface
*/
public function map(array $methods, $pattern, $callable)
{
// 若是閉包路由則通過 bindTo 方法繫結閉包的 $this 為容器
if ($callable instanceof Closure) {
$callable = $callable->bindTo($this->container);
}
// 通過容器獲取 Router 並新增一條路由
$route = $this->container->get('router')->map($methods, $pattern, $callable);
// 將容器新增進路由
if (is_callable([$route, 'setContainer'])) {
$route->setContainer($this->container);
}
// 設定 outputBuffering 配置項
if (is_callable([$route, 'setOutputBuffering'])) {
$route->setOutputBuffering($this->container->get('settings')['outputBuffering']);
}
return $route;
}
複製程式碼
App
類中的 get
、post
、put
、patch
、delete
、options
、any
等方法都是對 Router
的 map
方法簡單封裝,讓我好奇的那路由組是怎麼實現的?下面我們看看 Slim\App
的 group
方法,示例如下:
/**
* Route Groups
*
* This method accepts a route pattern and a callback. All route
* declarations in the callback will be prepended by the group(s)
* that it is in.
*
* @param string $pattern
* @param callable $callable
*
* @return RouteGroupInterface
*/
public function group($pattern, $callable)
{
// pushGroup 將構造一個 RouteGroup 例項並插入 Router 的 routeGroups 棧中,然後返回該 RouteGroup 例項,即 $group 為 RouteGroup 例項
$group = $this->container->get('router')->pushGroup($pattern, $callable);
// 設定路由組的容器
$group->setContainer($this->container);
// 執行 RouteGroup 的 __invoke 方法
$group($this);
// Router 的 routeGroups 出棧
$this->container->get('router')->popGroup();
return $group;
}
複製程式碼
上面程式碼中最重要的是 $group($this);
這句執行了什麼?我們跳轉到 RouteGroup
類中找到 __invoke
方法,程式碼如下:
/**
* Invoke the group to register any Routable objects within it.
*
* @param App $app The App instance to bind/pass to the group callable
*/
public function __invoke(App $app = null)
{
// 處理 callable,不詳細解釋請看 CallableResolverAwareTrait 原始碼
$callable = $this->resolveCallable($this->callable);
// 將 $app 繫結到閉包的 $this
if ($callable instanceof Closure && $app !== null) {
$callable = $callable->bindTo($app);
}
// 執行 $callable 並將 $app 傳參
$callable($app);
}
複製程式碼
注: 對 bindTo
方法不熟悉的同學可以看我之前寫的博文 PHP CLOURSE(閉包類) 淺析
上面的程式碼可能會有點蒙但結合路由組的使用 demo 便可以清楚的知道用途。
$app->group('/users/{id:[0-9]+}', function () {
$this->map(['GET', 'DELETE', 'PATCH', 'PUT'], '', function ($request, $response, $args) {
// Find, delete, patch or replace user identified by $args['id']
});
});
複製程式碼
當App
類的 group
方法被呼叫時 $group($this)
便會執行,在 __invoke
方法裡將 $app
例項繫結到了 $callable
中(如果 $callable
是閉包),然後就可以通過 $this->map(...)
的方式註冊路由,因為閉包中的 $this
便是 $app
。如果 $callable
不是閉包,還可以通過引數的方式獲取 $app
例項,因為在 RouteGroup
類的 __invoke
方法中通過 $callable($app);
來執行 $callable
。
0x07 註冊中介軟體
Slim 的中介軟體包括「全域性中介軟體」和「路由中介軟體」的註冊都在 MiddlewareAwareTrait
性狀裡,註冊中介軟體的方法為 addMiddleware
,程式碼如下:
/**
* Add middleware
*
* This method prepends new middleware to the application middleware stack.
*
* @param callable $callable Any callable that accepts three arguments:
* 1. A Request object
* 2. A Response object
* 3. A "next" middleware callable
* @return static
*
* @throws RuntimeException If middleware is added while the stack is dequeuing
* @throws UnexpectedValueException If the middleware doesn't return a Psr\Http\Message\ResponseInterface
*/
protected function addMiddleware(callable $callable)
{
// 如果已經開始執行中介軟體則不允許再增加中介軟體
if ($this->middlewareLock) {
throw new RuntimeException('Middleware can’t be added once the stack is dequeuing');
}
// 中介軟體為空則初始化
if (is_null($this->tip)) {
$this->seedMiddlewareStack();
}
// 中介軟體打包
$next = $this->tip;
$this->tip = function (
ServerRequestInterface $request,
ResponseInterface $response
) use (
$callable,
$next
) {
$result = call_user_func($callable, $request, $response, $next);
if ($result instanceof ResponseInterface === false) {
throw new UnexpectedValueException(
'Middleware must return instance of \Psr\Http\Message\ResponseInterface'
);
}
return $result;
};
return $this;
}
複製程式碼
這個函式的功能主要就是將原中介軟體閉包和現中介軟體閉包打包為一個閉包,想了解更多可以檢視 PHP 框架中介軟體實現
0x08 開始與終結 Run
在經歷了建立容器、向容器註冊預設服務、註冊路由、註冊中介軟體等步驟後我們終於到了 $app->run();
這最後一步(ㄒoㄒ),下面讓我們看看這 run
方法:
/********************************************************************************
* Runner
*******************************************************************************/
/**
* Run application
*
* This method traverses the application middleware stack and then sends the
* resultant Response object to the HTTP client.
*
* @param bool|false $silent
* @return ResponseInterface
*
* @throws Exception
* @throws MethodNotAllowedException
* @throws NotFoundException
*/
public function run($silent = false)
{
// 獲取 Response 例項
$response = $this->container->get('response');
try {
// 開啟緩衝區
ob_start();
// 處理請求
$response = $this->process($this->container->get('request'), $response);
} catch (InvalidMethodException $e) {
// 處理無效的方法
$response = $this->processInvalidMethod($e->getRequest(), $response);
} finally {
// 捕獲 $response 以外的輸出至 $output
$output = ob_get_clean();
}
// 決定將 $output 加入到 $response 中的方式
// 有三種方式:不加入、尾部追加、頭部插入,具體根據 setting 決定,預設為尾部追加
if (!empty($output) && $response->getBody()->isWritable()) {
$outputBuffering = $this->container->get('settings')['outputBuffering'];
if ($outputBuffering === 'prepend') {
// prepend output buffer content
$body = new Http\Body(fopen('php://temp', 'r+'));
$body->write($output . $response->getBody());
$response = $response->withBody($body);
} elseif ($outputBuffering === 'append') {
// append output buffer content
$response->getBody()->write($output);
}
}
// 響應處理,主要是對空響應進行處理,對響應 Content-Length 進行設定等,不詳細解釋。
$response = $this->finalize($response);
// 傳送響應至客戶端
if (!$silent) {
$this->respond($response);
}
// 返回 $response
return $response;
}
複製程式碼
注 1:對 try...catch...finally
不熟悉的同學可以看我之前寫的博文 PHP 異常處理三連 TRY CATCH FINALLY
注 2:對 ob_start
和 ob_get_clean
函式不熟悉的同學也可以看我之前寫的博文 PHP 輸出緩衝區應用
可以看出上面最重要的就是 process
方法,該方法實現了處理「全域性中介軟體棧」並返回最後的 Response
例項的功能,程式碼如下:
/**
* Process a request
*
* This method traverses the application middleware stack and then returns the
* resultant Response object.
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
* @return ResponseInterface
*
* @throws Exception
* @throws MethodNotAllowedException
* @throws NotFoundException
*/
public function process(ServerRequestInterface $request, ResponseInterface $response)
{
// Ensure basePath is set
$router = $this->container->get('router');
// 路由器設定 basePath
if (is_callable([$request->getUri(), 'getBasePath']) && is_callable([$router, 'setBasePath'])) {
$router->setBasePath($request->getUri()->getBasePath());
}
// Dispatch the Router first if the setting for this is on
if ($this->container->get('settings')['determineRouteBeforeAppMiddleware'] === true) {
// Dispatch router (note: you won't be able to alter routes after this)
$request = $this->dispatchRouterAndPrepareRoute($request, $router);
}
// Traverse middleware stack
try {
// 處理全域性中介軟體棧
$response = $this->callMiddlewareStack($request, $response);
} catch (Exception $e) {
$response = $this->handleException($e, $request, $response);
} catch (Throwable $e) {
$response = $this->handlePhpError($e, $request, $response);
}
return $response;
}
複製程式碼
然後我們看處理「全域性中介軟體棧」的方法 ,在 MiddlewareAwareTrait
裡我們可以看見 callMiddlewareStack
方法程式碼如下:
// 註釋討論的是在 Slim\APP 類的情景
/**
* Call middleware stack
*
* @param ServerRequestInterface $request A request object
* @param ResponseInterface $response A response object
*
* @return ResponseInterface
*/
public function callMiddlewareStack(ServerRequestInterface $request, ResponseInterface $response)
{
// tip 是全部中介軟體合併之後的閉包
// 如果 tip 為 null 說明不存在「全域性中介軟體」
if (is_null($this->tip)) {
// seedMiddlewareStack 函式的作用是設定 tip 的值
// 預設設定為 $this
$this->seedMiddlewareStack();
}
/** @var callable $start */
$start = $this->tip;
// 鎖住中介軟體確保在執行中介軟體程式碼時不會再增加中介軟體導致混亂
$this->middlewareLock = true;
// 開始執行中介軟體
$response = $start($request, $response);
// 取消中介軟體鎖
$this->middlewareLock = false;
return $response;
}
複製程式碼
看到上面可能會有疑惑,「路由的分配」和「路由中介軟體」的處理在哪裡?如果你發現 $app
其實也是「全域性中介軟體」處理的一環就會恍然大悟了,在 Slim\App
的 __invoke
方法裡,我們可以看見「路由的分配」和「路由中介軟體」的處理,程式碼如下:
/**
* Invoke application
*
* This method implements the middleware interface. It receives
* Request and Response objects, and it returns a Response object
* after compiling the routes registered in the Router and dispatching
* the Request object to the appropriate Route callback routine.
*
* @param ServerRequestInterface $request The most recent Request object
* @param ResponseInterface $response The most recent Response object
*
* @return ResponseInterface
* @throws MethodNotAllowedException
* @throws NotFoundException
*/
public function __invoke(ServerRequestInterface $request, ResponseInterface $response)
{
// 獲取路由資訊
$routeInfo = $request->getAttribute('routeInfo');
/** @var \Slim\Interfaces\RouterInterface $router */
$router = $this->container->get('router');
// If router hasn't been dispatched or the URI changed then dispatch
if (null === $routeInfo || ($routeInfo['request'] !== [$request->getMethod(), (string) $request->getUri()])) {
// Router 分配路由並將路由資訊注入至 $request
$request = $this->dispatchRouterAndPrepareRoute($request, $router);
$routeInfo = $request->getAttribute('routeInfo');
}
// 找到符合的路由
if ($routeInfo[0] === Dispatcher::FOUND) {
// 獲取路由例項
$route = $router->lookupRoute($routeInfo[1]);
// 執行路由中介軟體並返回 $response
return $route->run($request, $response);
// HTTP 請求方法不允許處理
} elseif ($routeInfo[0] === Dispatcher::METHOD_NOT_ALLOWED) {
if (!$this->container->has('notAllowedHandler')) {
throw new MethodNotAllowedException($request, $response, $routeInfo[1]);
}
/** @var callable $notAllowedHandler */
$notAllowedHandler = $this->container->get('notAllowedHandler');
return $notAllowedHandler($request, $response, $routeInfo[1]);
}
// 找不到路由處理
if (!$this->container->has('notFoundHandler')) {
throw new NotFoundException($request, $response);
}
/** @var callable $notFoundHandler */
$notFoundHandler = $this->container->get('notFoundHandler');
return $notFoundHandler($request, $response);
}
複製程式碼
上面的程式碼拋開異常和錯誤處理,最主要的一句是 return $route->run($request, $response);
即 Route
類的 run
方法,程式碼如下:
/**
* Run route
*
* This method traverses the middleware stack, including the route's callable
* and captures the resultant HTTP response object. It then sends the response
* back to the Application.
*
* @param ServerRequestInterface $request
* @param ResponseInterface $response
*
* @return ResponseInterface
*/
public function run(ServerRequestInterface $request, ResponseInterface $response)
{
// finalize 主要功能是將路由組上的中介軟體加入到該路由中
$this->finalize();
// 呼叫中介軟體棧,返回最後處理的 $response
return $this->callMiddlewareStack($request, $response);
}
複製程式碼
其實 Route
和 App
在處理中介軟體都使用了 MiddlewareAwareTrait
性狀,所以在處理中介軟體的邏輯是一樣的。那現在我們就看最後一步,Route
類的 __invoke
方法。
/**
* Dispatch route callable against current Request and Response objects
*
* This method invokes the route object's callable. If middleware is
* registered for the route, each callable middleware is invoked in
* the order specified.
*
* @param ServerRequestInterface $request The current Request object
* @param ResponseInterface $response The current Response object
* @return \Psr\Http\Message\ResponseInterface
* @throws \Exception if the route callable throws an exception
*/
public function __invoke(ServerRequestInterface $request, ResponseInterface $response)
{
$this->callable = $this->resolveCallable($this->callable);
/** @var InvocationStrategyInterface $handler */
$handler = isset($this->container) ? $this->container->get('foundHandler') : new RequestResponse();
$newResponse = $handler($this->callable, $request, $response, $this->arguments);
if ($newResponse instanceof ResponseInterface) {
// if route callback returns a ResponseInterface, then use it
$response = $newResponse;
} elseif (is_string($newResponse)) {
// if route callback returns a string, then append it to the response
if ($response->getBody()->isWritable()) {
$response->getBody()->write($newResponse);
}
}
return $response;
}
複製程式碼
這段程式碼的主要功能其實就是執行本路由的 callback
函式,若 callback
返回 Response
例項便直接返回,否則將 callback
返回的字串結果寫入到原 $response
中並返回。
0x09 總結
額……感覺寫的不好,但總算將整個流程解釋了一遍。有些瑣碎的地方就不解釋了。其實框架的程式碼還算好讀,有些地方解釋起來感覺反而像畫蛇添足,所以乾脆貼了很多程式碼/(ㄒoㄒ)/~~。說實話將整個框架的程式碼通讀一遍對水平的確會有所提升O(∩_∩)O,有興趣的同學還是自己通讀一遍較好,所以說這只是一篇走馬觀花的水文/(ㄒoㄒ)/~。 歡迎指出文章錯誤和話題討論。