[Symfony] Why would you Like to Create your Own Framework?
https://symfony.com/doc/current/create_framework/http_foundation.html
為了更加了解框架如何運作及設計的思想,決定研究這份文件,雖然過程中有先觀念似懂非懂,但是到了SOC章節時還是有種奇妙的發現,雖然只看第一遍還沒辦法完全掌握,但也有了些體會,相信多看幾遍認真搞懂後應該有不錯的幫助!
為了更加了解框架如何運作及設計的思想,決定研究這份文件,雖然過程中有先觀念似懂非懂,但是到了SOC章節時還是有種奇妙的發現,雖然只看第一遍還沒辦法完全掌握,但也有了些體會,相信多看幾遍認真搞懂後應該有不錯的幫助!
Index
- Introduction
- Why would you Like to Create your Own Framework?
- Before You Start
- Bootstrapping
- Dependency Management
- Our Project
- The HttpFoundation Component
- Going OOP with the HttpFoundation Component
- The Front Controller
- The Routing Component
- Templating 不太懂
- The HttpKernel Component: the Controller Resolver
- The Separation of Concerns (框架雛形出現)
- Unit Testing
- The EventDispatcher Component
- The HttpKernel Component: HttpKernelInterface
- The HttpKernel Component: The HttpKernel Class
- The DependencyInjection Component 不太懂
Start
建立一個資料夾
[eric_tu@localhost ~]$ mkdir framework
[eric_tu@localhost ~]$ cd framework
新增一個 index.php 檔案
[eric_tu@localhost framework]$ vim index.php
// framework/index.php
$input = $_GET['name'];
printf('Hello %s', $input);
php -S 127.0.0.1:4321
訪問
http://localhost:4321/index.php?name=Eric
可以看到 Hello Eric
The HttpFoundation Component
First, if the name query parameter is not defined in the URL query string, you will get a PHP warning; so let’s fix it:
若 query 參數 name 時,則 name 的值為 world,
可以透過php中的三元運算子來達到功能
可以透過php中的三元運算子來達到功能
$input = isset($_GET['name']) ? $_GET['name'] : 'World';
printf('Hello %s', $input);
訪問
[eric_tu@localhost ~]$ links http://localhost:4321/index.php?
會看到 Hello World
不過這樣會有XSS(Cross-Site Scripting)的問題,
所以我們用 htmlspecialchars() 如字面上意思,透過將特殊符號轉為html實體,來讓特殊符號只剩下顯示功能。
所以我們用 htmlspecialchars() 如字面上意思,透過將特殊符號轉為html實體,來讓特殊符號只剩下顯示功能。
$input = isset($_GET['name']) ? $_GET['name'] : 'World';
header('Content-Type: text/html; charset=utf-8');
printf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8'));
Going OOP with the HttpFoundation Component
除了上面的XSS問題,其實還有許多HTTP細節要注意,
他們都定義在HTTP規範中,
在HTTP規範中清楚地描述了 client 與 server 間運作的細節,
簡單的原理基本上就是 client 發出一個 request 給 server,
server 在基於 client 發出的 request 回傳一個 response。
他們都定義在HTTP規範中,
在HTTP規範中清楚地描述了 client 與 server 間運作的細節,
簡單的原理基本上就是 client 發出一個 request 給 server,
server 在基於 client 發出的 request 回傳一個 response。
在 PHP 中 request 可以用一些全域變數代表,例如
$_GET, $_POST, $_FILE, $_COOKIE, $_SESSION ...
,response 可以用一些function 來產生,例如 echo, header, setcookie, ...
。
這邊我們使用 Symfony HttpFoundation component 將這些東西包裝成元件使用,讓 code 符合 OOP 原則。
首先透過 composer 安裝,輸入
首先是 vendor 資料夾,所有透過 composer 安裝的元件都會放在這下面,
在來是 composer.json 所有安裝過的東西都會記錄在這裡,之後東西遺失或是壞了,就可以透過 composer install 來從 composer.json 清單從新安裝。 composer.lock 則是 composer.json 更動的時候會先鎖住,有問題就從這邊回復。
首先透過 composer 安裝,輸入
composer require symfony/http-foundation
,等待一段時間安裝完後,會發現目錄中產生了一些新東西,首先是 vendor 資料夾,所有透過 composer 安裝的元件都會放在這下面,
在來是 composer.json 所有安裝過的東西都會記錄在這裡,之後東西遺失或是壞了,就可以透過 composer install 來從 composer.json 清單從新安裝。 composer.lock 則是 composer.json 更動的時候會先鎖住,有問題就從這邊回復。
vendor/autoload.php 在安裝的時候也會一併產生,目的是用來讓class require 更容易,aotoload 的機制是在PSR-4中定義出來的,在沒有autoload 之前,所有的 class 都必須透過 require 後才能使用。
Class Autoloading
When installing a new dependency, Composer also generates a vendor/autoload.php file that allows any class to be easily autoloaded. Without autoloading, you would need to require the file where a class is defined before being able to use it. But thanks to PSR-4, we can just let Composer and PHP do the hard work for us.
When installing a new dependency, Composer also generates a vendor/autoload.php file that allows any class to be easily autoloaded. Without autoloading, you would need to require the file where a class is defined before being able to use it. But thanks to PSR-4, we can just let Composer and PHP do the hard work for us.
現在將原本的 code 用 Request 和 Response 類別來改寫:
// framework/index.php
require_once __DIR__.'/vendor/autoload.php';
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
$request = Request::createFromGlobals();
$input = $request->get('name', 'World');
*The createFromGlobals() method creates a Request object based on the current PHP global variables.
The send() method sends the Response object back to the client (it first outputs the HTTP headers followed by the content).*
$response = new Response(sprintf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8')));
$response->send();
The createFromGlobals() method creates a Request object based on the current PHP global variables.
The send() method sends the Response object back to the client (it first outputs the HTTP headers followed by the content).
The send() method sends the Response object back to the client (it first outputs the HTTP headers followed by the content).
Before the send() call, we should have added a call to the prepare() method (request);) to ensure that our Response were compliant with the HTTP specification. For instance, if we were to call the page with the HEAD method, it would remove the content of the Response.
與上面的不同的是,這邊可以完全的自訂response,request,透過這兩支class提供的多種API,可以快速地建立res,req
// the URI being requested (e.g. /about) minus any query parameters
$request->getPathInfo();
// retrieve GET and POST variables respectively
$request->query->get('foo');
$request->request->get('bar', 'default value if bar does not exist');
// retrieve SERVER variables
$request->server->get('HTTP_HOST');
// retrieves an instance of UploadedFile identified by foo
$request->files->get('foo');
// retrieve a COOKIE value
$request->cookies->get('PHPSESSID');
// retrieve an HTTP request header, with normalized, lowercase keys
$request->headers->get('host');
$request->headers->get('content_type');
$request->getMethod(); // GET, POST, PUT, DELETE, HEAD
$request->getLanguages(); // an array of languages the client accepts
也可以自己模擬request:
$request = Request::create('/index.php?name=Fabien');
設定 response:
$response = new Response();
$response->setContent('Hello world!');
$response->setStatusCode(200);
$response->headers->set('Content-Type', 'text/html');
// configure the HTTP cache headers
$response->setMaxAge(10);
使用這些知名的的框架或套件還有一個好處就是許多資安的問題,有著社群幫你把關,比起自幹框架考慮有限來的安全多。
舉例來說,今天要取得client IP的話可以這樣做:
舉例來說,今天要取得client IP的話可以這樣做:
if ($myIp === $_SERVER['REMOTE_ADDR']) {
// the client is a known one, so give it some more privilege
}
如果使用了反向代理伺服器,則要改成:
if ($myIp === $_SERVER['HTTP_X_FORWARDED_FOR'] || $myIp === $_SERVER['REMOTE_ADDR']) {
// the client is a known one, so give it some more privilege
}
Using the Request::getClientIp() method would have given you the right behavior from day one (and it would have covered the case where you have chained proxies):
$request = Request::createFromGlobals();
if ($myIp === $request->getClientIp()) {
// the client is a known one, so give it some more privilege
}
不過還有一個問題就是在沒有代理的情況下,可以透過偽造client IP 來干擾你的系統,
這時候就必須使用setTrustedProxies() 來設定允許的代理IP。
這時候就必須使用setTrustedProxies() 來設定允許的代理IP。
And there is an added benefit: it is secure by default. What does it mean? The $_SERVER[‘HTTP_X_FORWARDED_FOR’] value cannot be trusted as it can be manipulated by the end user when there is no proxy. So, if you are using this code in production without a proxy, it becomes trivially easy to abuse your system. That’s not the case with the getClientIp() method as you must explicitly trust your reverse proxies by calling setTrustedProxies():
Request::setTrustedProxies(array('10.0.0.1'));
if ($myIp === $request->getClientIp(true)) {
// the client is a known one, so give it some more privilege
}
經過這些設定之後,就可以確保我們的getClientIP是正確且安全的,這就是使用框架的好處,如果今天你自幹的話,你能考慮到所有細節嗎?為何不使用現成的東西呢?
結論:不要重新造輪子…
So, the getClientIp() method works securely in all circumstances. You can use it in all your projects, whatever the configuration is, it will behave correctly and safely. That’s one of the goal of using a framework. If you were to write a framework from scratch, you would have to think about all these cases by yourself. Why not using a technology that already works?
So, the getClientIp() method works securely in all circumstances. You can use it in all your projects, whatever the configuration is, it will behave correctly and safely. That’s one of the goal of using a framework. If you were to write a framework from scratch, you would have to think about all these cases by yourself. Why not using a technology that already works?
Believe or not but we have our first framework. You can stop now if you want. Using just the Symfony HttpFoundation component already allows you to write better and more testable code. It also allows you to write code faster as many day-to-day problems have already been solved for you.
As a matter of fact, projects like Drupal have adopted the HttpFoundation component; if it works for them, it will probably work for you. Don’t reinvent the wheel.
I’ve almost forgot to talk about one added benefit: using the HttpFoundation component is the start of better interoperability between all frameworks and applications using it (like Symfony, Drupal 8, phpBB 4, ezPublish 5, Laravel, Silex and more).
The Front Controller
到目前為止我們只有簡單的一頁,現在讓我們來加上新的一頁讓我們的專案更加豐富。
首先像之前一樣新增一個會response Goodbye的頁面
// framework/bye.php
require_once __DIR__.'/vendor/autoload.php';
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
$request = Request::createFromGlobals();
$response = new Response('Goodbye!');
$response->send();
接著來重構一下,讓他更有框架的樣子。
新增一個 init.php 來取代重複的程式碼。
新增一個 init.php 來取代重複的程式碼。
// framework/init.php
require_once __DIR__.'/vendor/autoload.php';
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
$request = Request::createFromGlobals();
$response = new Response();
然後修改index.php 與 bye.php 如下:
// framework/index.php
require_once __DIR__.'/init.php';
$input = $request->get('name', 'World');
$response->setContent(sprintf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8')));
$response->send();
// framework/bye.php
require_once __DIR__.'/init.php';
$response->setContent('Goodbye!');
$response->send();
不過這樣似乎還不夠,接著要怎樣才可以達到在url中分別拜訪 index.php 與 bye.php 呢?
為此我們需要在設計一個 controller 來控制路徑拜訪到對應的檔案。
為此我們需要在設計一個 controller 來控制路徑拜訪到對應的檔案。
// framework/front.php
require_once __DIR__.'/vendor/autoload.php';
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
$request = Request::createFromGlobals();
$response = new Response();
$map = array(
'/hello' => __DIR__.'/hello.php',
'/bye' => __DIR__.'/bye.php',
);
$path = $request->getPathInfo();
if (isset($map[$path])) {
require $map[$path];
} else {
$response->setStatusCode(404);
$response->setContent('Not Found');
}
$response->send();
原本的hello.php 則可以簡化成如下:
// framework/hello.php
$input = $request->get('name', 'World');
$response->setContent(sprintf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8')));
這時候透過url拜訪 front.php 帶上query參數,後面的參數被解讀之後就可以在map array 中找到對應的檔案require進來,若失敗則跳404,最後再送出 setContent 後的 response。
http://127.0.0.1:4321/front.php/hello?name=Fabien
http://127.0.0.1:4321/front.php/bye
Most web servers like Apache or nginx are able to rewrite the incoming URLs and remove the front controller script so that your users will be able to type http://127.0.0.1:4321/hello?name=Fabien, which looks much better.
The trick is the usage of the Request::getPathInfo() method which returns the path of the Request by removing the front controller script name including its sub-directories (only if needed – see above tip).
透過模擬request來測試。
You don’t even need to setup a web server to test the code. Instead, replace the $request = Request::createFromGlobals(); call to something like $request = Request::create(’/hello?name=Fabien’); where the argument is the URL path you want to simulate.
You don’t even need to setup a web server to test the code. Instead, replace the $request = Request::createFromGlobals(); call to something like $request = Request::create(’/hello?name=Fabien’); where the argument is the URL path you want to simulate.
接下來將現有的檔案做分類,因為有些東西我們不想被訪問到,所以將會被訪問的丟一起,設定丟一起,controller獨立出來,套件放在一起,
然後將網頁的root設定在controller所在的資料夾,這樣使用者只能透過controller訪問就會比較安全。
然後將網頁的root設定在controller所在的資料夾,這樣使用者只能透過controller訪問就會比較安全。
example.com
├── composer.json
├── composer.lock
├── src
│ └── pages
│ ├── hello.php
│ └── bye.php
├── vendor
│ └── autoload.php
└── web
└── front.php
使用模板改寫hello.php
<!-- example.com/src/pages/hello.php -->
<?php $name = $request->get('name', 'World') ?>
Hello <?php echo htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?>
新的front.php
// example.com/web/front.php
require_once __DIR__.'/../vendor/autoload.php';
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
$request = Request::createFromGlobals();
$response = new Response();
$map = array(
'/hello' => __DIR__.'/../src/pages/hello.php',
'/bye' => __DIR__.'/../src/pages/bye.php',
);
$path = $request->getPathInfo();
if (isset($map[$path])) {
ob_start();
include $map[$path];
$response->setContent(ob_get_clean());
} else {
$response->setStatusCode(404);
$response->setContent('Not Found');
}
$response->send();
The Routing Component
在開始使用routing 元件之前,先來重構一下front.php
// example.com/web/front.php
require_once __DIR__.'/../vendor/autoload.php';
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
$request = Request::createFromGlobals();
$map = array(
'/hello' => 'hello',
'/bye' => 'bye',
);
$path = $request->getPathInfo();
if (isset($map[$path])) {
ob_start();
extract($request->query->all(), EXTR_SKIP);
include sprintf(__DIR__.'/../src/pages/%s.php', $map[$path]);
$response = new Response(ob_get_clean());
} else {
$response = new Response('Not Found', 404);
}
$response->send();
<!-- example.com/src/pages/hello.php -->
Hello <?php echo htmlspecialchars(isset($name) ? $name : 'World', ENT_QUOTES, 'UTF-8') ?>
雖然看起來已經很不錯了,但還可以讓URL更加的有彈性,例如動態產生URL或者是直接將參數當作URL而不是query吃進來。
One very important aspect of any website is the form of its URLs. Thanks to the URL map, we have decoupled the URL from the code that generates the associated response, but it is not yet flexible enough. For instance, we might want to support dynamic paths to allow embedding data directly into the URL (e.g. /hello/Fabien) instead of relying on a query string (e.g. /hello?name=Fabien).
One very important aspect of any website is the form of its URLs. Thanks to the URL map, we have decoupled the URL from the code that generates the associated response, but it is not yet flexible enough. For instance, we might want to support dynamic paths to allow embedding data directly into the URL (e.g. /hello/Fabien) instead of relying on a query string (e.g. /hello?name=Fabien).
要達到這件事首先先安裝Symfony Routing Component:
composer require symfony/routing
接著將原本的RUL map用routing元件中的routeCollection 來取代
use Symfony\Component\Routing\RouteCollection;
$routes = new RouteCollection();
接著再將hello與bye加進來:
// example.com/src/app.php
use Symfony\Component\Routing;
$routes = new Routing\RouteCollection();
$routes->add('hello', new Routing\Route('/hello/{name}', array('name' => 'World')));
$routes->add('bye', new Routing\Route('/bye'));
return $routes;
Based on the information stored in the RouteCollection instance, a UrlMatcher instance can match URL paths:
use Symfony\Component\Routing\RequestContext;
use Symfony\Component\Routing\Matcher\UrlMatcher;
$context = new RequestContext();
$context->fromRequest($request);
$matcher = new UrlMatcher($routes, $context);
$attributes = $matcher->match($request->getPathInfo());
The match() method takes a request path and returns an array of attributes (notice that the matched route is automatically stored under the special _route attribute):
print_r($matcher->match('/bye'));
/* Gives:
array (
'_route' => 'bye',
);
*/
print_r($matcher->match('/hello/Fabien'));
/* Gives:
array (
'name' => 'Fabien',
'_route' => 'hello',
);
*/
print_r($matcher->match('/hello'));
/* Gives:
array (
'name' => 'World',
'_route' => 'hello',
);
*/
The URL matcher throws an exception when none of the routes match:
$matcher->match('/not-found');
// throws a Symfony\Component\Routing\Exception\ResourceNotFoundException
新的front.php
// example.com/web/front.php
require_once __DIR__.'/../vendor/autoload.php';
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing;
$request = Request::createFromGlobals();
$routes = include __DIR__.'/../src/app.php';
$context = new Routing\RequestContext();
$context->fromRequest($request);
$matcher = new Routing\Matcher\UrlMatcher($routes, $context);
try {
extract($matcher->match($request->getPathInfo()), EXTR_SKIP);
ob_start();
include sprintf(__DIR__.'/../src/pages/%s.php', $_route);
$response = new Response(ob_get_clean());
} catch (Routing\Exception\ResourceNotFoundException $e) {
$response = new Response('Not Found', 404);
} catch (Exception $e) {
$response = new Response('An error occurred', 500);
}
$response->send();
一些產生URL的原理
Using the Routing component has one big additional benefit: the ability to generate URLs based on Route definitions. When using both URL matching and URL generation in your code, changing the URL patterns should have no other impact. Want to know how to use the generator? Insanely easy:
Using the Routing component has one big additional benefit: the ability to generate URLs based on Route definitions. When using both URL matching and URL generation in your code, changing the URL patterns should have no other impact. Want to know how to use the generator? Insanely easy:
use Symfony\Component\Routing;
$generator = new Routing\Generator\UrlGenerator($routes, $context);
echo $generator->generate('hello', array('name' => 'Fabien'));
// outputs /hello/Fabien
The code should be self-explanatory; and thanks to the context, you can even generate absolute URLs:
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
echo $generator->generate(
'hello',
array('name' => 'Fabien'),
UrlGeneratorInterface::ABSOLUTE_URL
);
// outputs something like http://example.com/somewhere/hello/Fabien
Templating
先前都把邏輯寫在模板當中,但當我們需要新增越來越多複雜的邏輯得時候,這樣就顯得有些亂,我們應該要遵守關注點分離(Separation of concerns,SOC)原則,將不同任務性質的邏輯拆開來,模板歸模版,剩下的邏輯我們可以新增一個controller層來完成,在controller中我們專注負責生成response 與 處理來自client 的quest。
首先我們先簡化我們的tmeplate如下:
首先我們先簡化我們的tmeplate如下:
六大設計原則-S.O.L.I.D.
補充:
The astute reader has noticed that our framework hardcodes the way specific “code” (the templates) is run. For simple pages like the ones we have created so far, that’s not a problem, but if you want to add more logic, you would be forced to put the logic into the template itself, which is probably not a good idea, especially if you still have the separation of concerns principle in mind.
Let’s separate the template code from the logic by adding a new layer: the controller: The controller’s mission is to generate a Response based on the information conveyed by the client’s Request.
Change the template rendering part of the framework to read as follows:
// example.com/web/front.php
// ...
try {
$request->attributes->add($matcher->match($request->getPathInfo()));
$response = call_user_func('render_template', $request);
} catch (Routing\Exception\ResourceNotFoundException $e) {
$response = new Response('Not Found', 404);
} catch (Exception $e) {
$response = new Response('An error occurred', 500);
}
現在我們將處理與解析request和回傳response獨立在controller layer 中,
今天我們有人對了某個URL做request我們需要去解析這URL並且回傳對應的template,這時候我們需要在
front.php中新增一個render_template的public funciton 去回傳對應的template。
今天我們有人對了某個URL做request我們需要去解析這URL並且回傳對應的template,這時候我們需要在
front.php中新增一個render_template的public funciton 去回傳對應的template。
As the rendering is now done by an external function (render_template() here), we need to pass to it the attributes extracted from the URL. We could have passed them as an additional argument to render_template(), but instead, let’s use another feature of the Request class called attributes: Request attributes is a way to attach additional information about the Request that is not directly related to the HTTP Request data.
You can now create the render_template() function, a generic controller that renders a template when there is no specific logic. To keep the same template as before, request attributes are extracted before the template is rendered:
function render_template($request)
{
extract($request->attributes->all(), EXTR_SKIP);
ob_start();
include sprintf(__DIR__.'/../src/pages/%s.php', $_route);
return new Response(ob_get_clean());
}
As render_template is used as an argument to the PHP call_user_func() function, we can replace it with any valid PHP callbacks. This allows us to use a function, an anonymous function or a method of a class as a controller… your choice.
為了方便起見我們可以用 _controller 來設定我們的 controller 呼叫 render_template
As a convention, for each route, the associated controller is configured via the _controller route attribute:
As a convention, for each route, the associated controller is configured via the _controller route attribute:
$routes->add('hello', new Routing\Route('/hello/{name}', array(
'name' => 'World',
'_controller' => 'render_template',
)));
try {
$request->attributes->add($matcher->match($request->getPathInfo()));
$response = call_user_func($request->attributes->get('_controller'), $request);
} catch (Routing\Exception\ResourceNotFoundException $e) {
$response = new Response('Not Found', 404);
} catch (Exception $e) {
$response = new Response('An error occurred', 500);
}
A route can now be associated with any controller and of course, within a controller, you can still use the render_template() to render a template:
$routes->add('hello', new Routing\Route('/hello/{name}', array(
'name' => 'World',
'_controller' => function ($request) {
return render_template($request);
}
)));
更加的客製化render_template的內容,包括自訂HEADER,屬性等。
This is rather flexible as you can change the Response object afterwards and you can even pass additional arguments to the template:
This is rather flexible as you can change the Response object afterwards and you can even pass additional arguments to the template:
$routes->add('hello', new Routing\Route('/hello/{name}', array(
'name' => 'World',
'_controller' => function ($request) {
// $foo will be available in the template
$request->attributes->set('foo', 'bar');
$response = render_template($request);
// change some header
$response->headers->set('Content-Type', 'text/plain');
return $response;
}
)
完整的front.php
// example.com/web/front.php
require_once __DIR__.'/../vendor/autoload.php';
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing;
function render_template($request)
{
extract($request->attributes->all(), EXTR_SKIP);
ob_start();
include sprintf(__DIR__.'/../src/pages/%s.php', $_route);
return new Response(ob_get_clean());
}
$request = Request::createFromGlobals();
$routes = include __DIR__.'/../src/app.php';
$context = new Routing\RequestContext();
$context->fromRequest($request);
$matcher = new Routing\Matcher\UrlMatcher($routes, $context);
try {
$request->attributes->add($matcher->match($request->getPathInfo()));
$response = call_user_func($request->attributes->get('_controller'), $request);
} catch (Routing\Exception\ResourceNotFoundException $e) {
$response = new Response('Not Found', 404);
} catch (Exception $e) {
$response = new Response('An error occurred', 500);
}
$response->send();
現在加上一個新功能,直接建立一個app.php
// example.com/src/app.php
use Symfony\Component\Routing;
use Symfony\Component\HttpFoundation\Response;
function is_leap_year($year = null) {
if (null === $year) {
$year = date('Y');
}
return 0 === $year % 400 || (0 === $year % 4 && 0 !== $year % 100);
}
$routes = new Routing\RouteCollection();
$routes->add('leap_year', new Routing\Route('/is_leap_year/{year}', array(
'year' => null,
'_controller' => function ($request) {
if (is_leap_year($request->attributes->get('year'))) {
return new Response('Yep, this is a leap year!');
}
return new Response('Nope, this is not a leap year.');
}
)));
return $routes;
The is_leap_year() function returns true when the given year is a leap year, false otherwise. If the year is null, the current year is tested. The controller is simple: it gets the year from the request attributes, pass it to the is_leap_year() function, and according to the return value it creates a new Response object.
The HttpKernel Component: the Controller Resolver
在前面我們雖然做到分離 controller 跟 view 的功能了,可是實際上我們的 controller 卻可以是任意的有效 PHP callbacks。所以現在我們要把controller包裝起來寫成一個Controller class。
class LeapYearController
{
public function indexAction($request)
{
if (is_leap_year($request->attributes->get('year'))) {
return new Response('Yep, this is a leap year!');
}
return new Response('Nope, this is not a leap year.');
}
}
接著更新我們的app.php
$routes->add('leap_year', new Routing\Route('/is_leap_year/{year}', array(
'year' => null,
'_controller' => array(new LeapYearController(), 'indexAction'),
)));
看起來架構的關西清楚多了,可是我們仍然遇到一個問題就是我們 LeapYearController 不管有沒有被使用到都會進行實例化,理想的狀況應該是被request到再去進行實例化,而不是一開始就在,所以我們使用 lazy-loaded 來載入 match request url 的 route,要達成這件事,我們必須先安裝 symfony/http-kernel,使用composer安裝命令如下:
The move is pretty straightforward and makes a lot of sense as soon as you create more pages but you might have noticed a non-desirable side-effect… The LeapYearController class is always instantiated, even if the requested URL does not match the leap_year route. This is bad for one main reason: performance wise, all controllers for all routes must now be instantiated for every request. It would be better if controllers were lazy-loaded so that only the controller associated with the matched route is instantiated.
To solve this issue, and a bunch more, let’s install and use the HttpKernel component:
composer require symfony/http-kernel
在 HttpKernel component 中有許多有趣的東西,但這邊我們需要的只有 controller resolver 和 argument resolver 兩樣。
前者定義了 controller 的執行行為,後者定義了 controller 是如何去解析並回傳一個 request 物件,全部的 controller都是繼承自以下的介面:
前者定義了 controller 的執行行為,後者定義了 controller 是如何去解析並回傳一個 request 物件,全部的 controller都是繼承自以下的介面:
The HttpKernel component has many interesting features, but the ones we need right now are the controller resolver and argument resolver. A controller resolver knows how to determine the controller to execute and the argument resolver determines the arguments to pass to it, based on a Request object. All controller resolvers implement the following interface:
namespace Symfony\Component\HttpKernel\Controller;
// ...
interface ControllerResolverInterface
{
function getController(Request $request);
function getArguments(Request $request, $controller);
}
如同先前所規定的一樣,getController() 也必須在_controller request中被當作php callbacks來使用,用來處理 request 與 controller 的關係,而在這邊 getController() 方法也可以用雙冒號的方式來被呼叫,例如 ‘class::method’。
The getController() method relies on the same convention as the one we have defined earlier: the _controller request attribute must contain the controller associated with the Request. Besides the built-in PHP callbacks, getController() also supports strings composed of a class name followed by two colons and a method name as a valid callback, like ‘class::method’:
$routes->add('leap_year', new Routing\Route('/is_leap_year/{year}', array(
'year' => null,
'_controller' => 'LeapYearController::indexAction',
)));
To make this code work, modify the framework code to use the controller resolver from HttpKernel:
use Symfony\Component\HttpKernel;
$controllerResolver = new HttpKernel\Controller\ControllerResolver();
$argumentResolver = new HttpKernel\Controller\ArgumentResolver();
$controller = $controllerResolver->getController($request);
$arguments = $argumentResolver->getArguments($request, $controller);
$response = call_user_func_array($controller, $arguments);
小確性:controller resolver 會在你訪問錯誤的 route 時噴錯誤訊息。
As an added bonus, the controller resolver properly handles the error management for you: when you forget to define a _controller attribute for a Route for instance.
接下來我們來看 controller 是如何去解析參數的。
透過 PHP 原生的 reflection class ,getArguments() 會去解析那些參數該被傳遞。
透過 PHP 原生的 reflection class ,getArguments() 會去解析那些參數該被傳遞。
Now, let’s see how the controller arguments are guessed. getArguments() introspects the controller signature to determine which arguments to pass to it by using the native PHP reflection.
下面的例子中,getArguments() 會去辨別參數是否正確以及何時該注入。
public function indexAction(Request $request)
// won't work
public function indexAction($request)
還可以解析任何自訂的request object
More interesting, getArguments() is also able to inject any Request attribute; the argument just needs to have the same name as the corresponding attribute:
public function indexAction($year)
也可以同時混著傳入 request參數 與其他屬性的參數
You can also inject the Request and some attributes at the same time (as the matching is done on the argument name or a type hint, the arguments order does not matter):
public function indexAction(Request $request, $year)
public function indexAction($year, Request $request)
還可以設定傳入參數的預設值喔!
Finally, you can also define default values for any argument that matches an optional attribute of the Request:
public function indexAction($year = 2012)
現在讓我們注入 $year request 屬性到 controller 中:
Let’s just inject the $year request attribute for our controller:
class LeapYearController
{
public function indexAction($year)
{
if (is_leap_year($year)) {
return new Response('Yep, this is a leap year!');
}
return new Response('Nope, this is not a leap year.');
}
}
AppKernel 提供的 resolvers 會自動驗證帶入 controller 的參數是否正確,若錯誤會拋出錯誤例外,例如 controller 不存在, request method 錯誤,參數不正確等。
The resolvers also take care of validating the controller callable and its arguments. In case of a problem, it throws an exception with a nice message explaining the problem (the controller class does not exist, the method is not defined, an argument has no matching attribute, …).
可以透過 controllers as services 來對 controller 做更細微的設定。
With the great flexibility of the default controller resolver and argument resolver, you might wonder why someone would want to create another one (why would there be an interface if not?). Two examples: in Symfony, getController() is enhanced to support controllers as services; and getArguments() provides an extension point to alter or enhance the resolving of arguments.
完整的 front.php
// example.com/web/front.php
require_once __DIR__.'/../vendor/autoload.php';
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing;
use Symfony\Component\HttpKernel;
function render_template(Request $request)
{
extract($request->attributes->all(), EXTR_SKIP);
ob_start();
include sprintf(__DIR__.'/../src/pages/%s.php', $_route);
return new Response(ob_get_clean());
}
$request = Request::createFromGlobals();
$routes = include __DIR__.'/../src/app.php';
$context = new Routing\RequestContext();
$context->fromRequest($request);
$matcher = new Routing\Matcher\UrlMatcher($routes, $context);
$controllerResolver = new HttpKernel\Controller\ControllerResolver();
$argumentResolver = new HttpKernel\Controller\ArgumentResolver();
try {
$request->attributes->add($matcher->match($request->getPathInfo()));
$controller = $controllerResolver->getController($request);
$arguments = $argumentResolver->getArguments($request, $controller);
$response = call_user_func_array($controller, $arguments);
} catch (Routing\Exception\ResourceNotFoundException $e) {
$response = new Response('Not Found', 404);
} catch (Exception $e) {
$response = new Response('An error occurred', 500);
}
$response->send();
問題 controller 與 front.php 的關係
The Separation of Concerns
依照前面的SOC設計原則,目前看了 front.php是可重複利用的,因此我們將它包裝起來,方便測試也方便使用。
One down-side of our framework right now is that we need to copy and paste the code in front.php each time we create a new website. 60 lines of code is not that much, but it would be nice if we could wrap this code into a proper class. It would bring us better reusability and easier testing to name just a few benefits.
仔細觀察的話,在 front.php 中只有一輸入一輸出,輸入 request,輸出 response,而我們將它包裝成 framework 之後也是依照這個原則,用 framework 來處理 response 與 request 的關係。
If you have a closer look at the code, front.php has one input, the Request and one output, the Response. Our framework class will follow this simple principle: the logic is about creating the Response associated with a Request.
現在我們來創造我們自己的 framework 叫做 Simplex,
並把 request handling 邏輯抽出來移到這裡面。
並把 request handling 邏輯抽出來移到這裡面。
// example.com/src/Simplex/Framework.php
namespace Simplex;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Controller\ArgumentResolver;
use Symfony\Component\HttpKernel\Controller\ControllerResolver;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Matcher\UrlMatcher;
class Framework
{
protected $matcher;
protected $controllerResolver;
protected $argumentResolver;
public function __construct(UrlMatcher $matcher, ControllerResolver $controllerResolver, ArgumentResolver $argumentResolver)
{
$this->matcher = $matcher;
$this->controllerResolver = $controllerResolver;
$this->argumentResolver = $argumentResolver;
}
public function handle(Request $request)
{
$this->matcher->getContext()->fromRequest($request);
try {
$request->attributes->add($this->matcher->match($request->getPathInfo()));
$controller = $this->controllerResolver->getController($request);
$arguments = $this->argumentResolver->getArguments($request, $controller);
return call_user_func_array($controller, $arguments);
} catch (ResourceNotFoundException $e) {
return new Response('Not Found', 404);
} catch (\Exception $e) {
return new Response('An error occurred', 500);
}
}
}
更改 front.php 如下,主要把 response request 的部分從 framework call :
// example.com/web/front.php
// ...
$request = Request::createFromGlobals();
$routes = include __DIR__.'/../src/app.php';
$context = new Routing\RequestContext();
$matcher = new Routing\Matcher\UrlMatcher($routes, $context);
$controllerResolver = new ControllerResolver();
$argumentResolver = new ArgumentResolver();
$framework = new Simplex\Framework($matcher, $controllerResolver, $argumentResolver);
$response = $framework->handle($request);
$response->send();
現在把重構之後的 framework 移到 namespace : Calendar 底下,
並用PSR-4 中提供的 auroload 來讓其他地方載入。
並用PSR-4 中提供的 auroload 來讓其他地方載入。
To wrap up the refactoring, let’s move everything but routes definition from example.com/src/app.php into yet another namespace: Calendar.
For the classes defined under the Simplex and Calendar namespaces to be autoloaded, update the composer.json file:
{
"...": "...",
"autoload": {
"psr-4": { "": "src/" }
}
}
Move the controller to Calendar\Controller\LeapYearController:
// example.com/src/Calendar/Controller/LeapYearController.php
namespace Calendar\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Calendar\Model\LeapYear;
class LeapYearController
{
public function indexAction(Request $request, $year)
{
$leapyear = new LeapYear();
if ($leapyear->isLeapYear($year)) {
return new Response('Yep, this is a leap year!');
}
return new Response('Nope, this is not a leap year.');
}
}
And move the is_leap_year() function to its own class too:
// example.com/src/Calendar/Model/LeapYear.php
namespace Calendar\Model;
class LeapYear
{
public function isLeapYear($year = null)
{
if (null === $year) {
$year = date('Y');
}
return 0 == $year % 400 || (0 == $year % 4 && 0 != $year % 100);
}
}
Don’t forget to update the example.com/src/app.php file accordingly:
$routes->add('leap_year', new Routing\Route('/is_leap_year/{year}', array(
'year' => null,
'_controller' => 'Calendar\Controller\LeapYearController::indexAction',
)));
To sum up, here is the new file layout:
example.com
├── composer.json
├── composer.lock
├── src
│ ├── app.php
│ └── Simplex
│ └── Framework.php
│ └── Calendar
│ └── Controller
│ │ └── LeapYearController.php
│ └── Model
│ └── LeapYear.php
├── vendor
│ └── autoload.php
└── web
└── front.php
That’s it!
Our application has now four different layers and each of them has a well defined goal:
Our application has now four different layers and each of them has a well defined goal:
web/front.php:
The front controller; the only exposed PHP code that makes the interface with the client (it gets the Request and sends the Response) and provides the boiler-plate code to initialize the framework and our application;
src/Simplex:
The reusable framework code that abstracts the handling of incoming Requests (by the way, it makes your controllers/templates easily testable – more about that later on);
src/Calendar:
Our application specific code (the controllers and the model);
src/app.php:
The application configuration/framework customization.
Unit Testing
The EventDispatcher Component (增加框架的擴展性)
任何一個優良現代的框架一定都具備高擴充性,可以讓使用者輕易的整合外部插件到框架中,在有些語言中有特定的擴充標準,而在PHP中則沒有,所以我們要使用中間件的 方式來設計,讓外部的插件可以依附在我們的框架上,而 Symfony EventDispatcher Component 提供了輕量級的中間件設計方法,可以透過 conposer 安裝:
composer require symfony/event-dispatcher
Symfony EventDispatcher Component 是如何運作的呢? 首先他透過一個叫做 dispatcher 的工具用來通知監聽者新的事件,一旦 dispatcher 收到一個新的 event ,所有註冊的監聽者都會收到。
下面以新增 Google Analytics code 到我們的服務中為例,我們必須在 return response 之前觸發事件。
// example.com/src/Simplex/Framework.php
namespace Simplex;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Controller\ArgumentResolverInterface;
use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface;
use Symfony\Component\Routing\Exception\ResourceNotFoundException;
use Symfony\Component\Routing\Matcher\UrlMatcherInterface;
class Framework
{
private $dispatcher;
private $matcher;
private $controllerResolver;
private $argumentResolver;
public function __construct(EventDispatcher $dispatcher, UrlMatcherInterface $matcher, ControllerResolverInterface $controllerResolver, ArgumentResolverInterface $argumentResolver)
{
$this->dispatcher = $dispatcher;
$this->matcher = $matcher;
$this->controllerResolver = $controllerResolver;
$this->argumentResolver = $argumentResolver;
}
public function handle(Request $request)
{
$this->matcher->getContext()->fromRequest($request);
try {
$request->attributes->add($this->matcher->match($request->getPathInfo()));
$controller = $this->controllerResolver->getController($request);
$arguments = $this->argumentResolver->getArguments($request, $controller);
$response = call_user_func_array($controller, $arguments);
} catch (ResourceNotFoundException $e) {
$response = new Response('Not Found', 404);
} catch (\Exception $e) {
$response = new Response('An error occurred', 500);
}
// dispatch a response event
$this->dispatcher->dispatch('response', new ResponseEvent($response, $request));
return $response;
}
}
再新增對應的 ResponseEvent
// example.com/src/Simplex/ResponseEvent.php
namespace Simplex;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\EventDispatcher\Event;
class ResponseEvent extends Event
{
private $request;
private $response;
public function __construct(Response $response, Request $request)
{
$this->response = $response;
$this->request = $request;
}
public function getResponse()
{
return $this->response;
}
public function getRequest()
{
return $this->request;
}
}
完成之後要到 front.php 中去註冊一個監聽器
// example.com/web/front.php
require_once __DIR__.'/../vendor/autoload.php';
// ...
use Symfony\Component\EventDispatcher\EventDispatcher;
$dispatcher = new EventDispatcher();
$dispatcher->addListener('response', function (Simplex\ResponseEvent $event) {
$response = $event->getResponse();
if ($response->isRedirection()
|| ($response->headers->has('Content-Type') && false === strpos($response->headers->get('Content-Type'), 'html'))
|| 'html' !== $event->getRequest()->getRequestFormat()
) {
return;
}
$response->setContent($response->getContent().'GA CODE');
});
$framework = new Simplex\Framework($dispatcher, $matcher, $resolver);
$response = $framework->handle($request);
$response->send();
在上面的CODE中, addListener() 把PHP callback 與 event response 綁再一起,整段的意思就是當目前的 response 不是 redirection ,那麼就加入 Google Analytics code 到 response content 中。
接下來我們想要設定 content-Length ,於是我們新增一個listener 到相同的 event 中。
$dispatcher->addListener('response', function (Simplex\ResponseEvent $event) {
$response = $event->getResponse();
$headers = $response->headers;
if (!$headers->has('Content-Length') && !$headers->has('Transfer-Encoding')) {
$headers->set('Content-Length', strlen($response->getContent()));
}
});
在預設中所有的 listener 註冊的優先順序度都是 0,想要設定先後的話可以透過加上正負整數來調整,這邊我們加上-255,使其優先度最低。
$dispatcher->addListener('response', function (Simplex\ResponseEvent $event) {
$response = $event->getResponse();
$headers = $response->headers;
if (!$headers->has('Content-Length') && !$headers->has('Transfer-Encoding')) {
$headers->set('Content-Length', strlen($response->getContent()));
}
}, -255);
提示 :
When creating your framework, think about priorities (reserve some numbers for internal listeners for instance) and document them thoroughly.
When creating your framework, think about priorities (reserve some numbers for internal listeners for instance) and document them thoroughly.
接著我們把所有 listener 獨立成一支 class:
// example.com/src/Simplex/GoogleListener.php
namespace Simplex;
class GoogleListener
{
public function onResponse(ResponseEvent $event)
{
$response = $event->getResponse();
if ($response->isRedirection()
|| ($response->headers->has('Content-Type') && false === strpos($response->headers->get('Content-Type'), 'html'))
|| 'html' !== $event->getRequest()->getRequestFormat()
) {
return;
}
$response->setContent($response->getContent().'GA CODE');
}
}
// example.com/src/Simplex/ContentLengthListener.php
namespace Simplex;
class ContentLengthListener
{
public function onResponse(ResponseEvent $event)
{
$response = $event->getResponse();
$headers = $response->headers;
if (!$headers->has('Content-Length') && !$headers->has('Transfer-Encoding')) {
$headers->set('Content-Length', strlen($response->getContent()));
}
}
}
獨立出來之後,front.php 中監聽器的部分就可簡化成以下:
$dispatcher = new EventDispatcher();
$dispatcher->addListener('response', array(new Simplex\ContentLengthListener(), 'onResponse'), -255);
$dispatcher->addListener('response', array(new Simplex\GoogleListener(), 'onResponse'));
看起來不錯了,不過這邊我們用的方法是把監聽器直接寫死在 front.php 中,這樣的缺點就是我們必須手動去設定優先順序,
而且我們的 listener method 也沒有被隱藏起來,理論上應該要像黑箱一樣只給結果就好,不用知道裡面再幹嘛,所以我們應該用一種叫做 訂閱者模式 來取代 監聽器模式 並以此重構。
而且我們的 listener method 也沒有被隱藏起來,理論上應該要像黑箱一樣只給結果就好,不用知道裡面再幹嘛,所以我們應該用一種叫做 訂閱者模式 來取代 監聽器模式 並以此重構。
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new Simplex\ContentLengthListener());
$dispatcher->addSubscriber(new Simplex\GoogleListener());
// example.com/src/Simplex/GoogleListener.php
namespace Simplex;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class GoogleListener implements EventSubscriberInterface
{
// ...
public static function getSubscribedEvents()
{
return array('response' => 'onResponse');
}
}
// example.com/src/Simplex/ContentLengthListener.php
namespace Simplex;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ContentLengthListener implements EventSubscriberInterface
{
// ...
public static function getSubscribedEvents()
{
return array('response' => array('onResponse', -255));
}
}
A single subscriber can host as many listeners as you want on as many events as needed.
The HttpKernel Component: HttpKernelInterface
前面所提到使用Symfony components可以帶來許多好處,因為他們有強大的互通性,其中很重要的一部分是靠 HttpKernelInterface 來實現。
namespace Symfony\Component\HttpKernel;
// ...
interface HttpKernelInterface
{
/**
* @return Response A Response instance
*/
public function handle(
Request $request,
$type = self::MASTER_REQUEST,
$catch = true
);
}
HttpKernelInterface 是 HttpKernel component 中最重要的部分,到處都可以見到它的蹤跡,而他也帶來許多方便的好處。
將 framework 繼承 HttpKernelInterface :
// example.com/src/Framework.php
// ...
use Symfony\Component\HttpKernel\HttpKernelInterface;
class Framework implements HttpKernelInterface
{
// ...
public function handle(
Request $request,
$type = HttpKernelInterface::MASTER_REQUEST,
$catch = true
) {
// ...
}
}
前面說過繼承完之後有許多方便的地方,其中最方便的大概是 transparent HTTP caching support。 HTTP caching 繼承自一個反向代理,它繼承自 HttpKernelInterface 然後包裝成另一個 HttpKernelInterface。
// example.com/web/front.php
$framework = new Simplex\Framework($dispatcher, $matcher, $resolver);
$framework = new HttpKernel\HttpCache\HttpCache(
$framework,
new HttpKernel\HttpCache\Store(__DIR__.'/../cache')
);
$framework->handle($request)->send();
簡單幾步就實現了HTTP暫存的功能。
要設定 Cache 要從 HTTP cache headers 來設定,舉例來說要設定一個 response 在暫存中存活十秒,可以使用 Response::setTtl() 方法來設定:
// example.com/src/Calendar/Controller/LeapYearController.php
// ...
public function indexAction(Request $request, $year)
{
$leapyear = new LeapYear();
if ($leapyear->isLeapYear($year)) {
$response = new Response('Yep, this is a leap year!');
} else {
$response = new Response('Nope, this is not a leap year.');
}
$response->setTtl(10);
return $response;
}
HTTP caching 還有其他的功能,可以自行查文件HTTP caching除了上述的方法可以用來在response中設定存活時間,Response class 之中本身也提供了許多方法,其中 setCache() 就是個很不錯的方法,它包含了幾種常見的cache策略。
$date = date_create_from_format('Y-m-d H:i:s', '2005-10-15 10:00:00');
$response->setCache(array(
'public' => true,
'etag' => 'abcde',
'last_modified' => $date,
'max_age' => 10,
's_maxage' => 10,
));
// it is equivalent to the following code
$response->setPublic();
$response->setEtag('abcde');
$response->setLastModified($date);
$response->setMaxAge(10);
$response->setSharedMaxAge(10);
When using the validation model, the isNotModified() method allows you to easily cut on the response time by short-circuiting the response generation as early as possible:
$response->setETag('whatever_you_compute_as_an_etag');
if ($response->isNotModified($request)) {
return $response;
}
$response->setContent('The computed content of the response');
return $response;
Using HTTP caching is great, but what if you cannot cache the whole page? What if you can cache everything but some sidebar that is more dynamic that the rest of the content? Edge Side Includes (ESI) to the rescue! Instead of generating the whole content in one go, ESI allows you to mark a region of a page as being the content of a sub-request call:
This is the content of your page
Is 2012 a leap year? <esi:include src="/leapyear/2012" />
Some other content
For ESI tags to be supported by HttpCache, you need to pass it an instance of the ESI class. The ESI class automatically parses ESI tags and makes sub-requests to convert them to their proper content:
$framework = new HttpKernel\HttpCache\HttpCache(
$framework,
new HttpKernel\HttpCache\Store(__DIR__.'/../cache'),
new HttpKernel\HttpCache\Esi()
);
When using complex HTTP caching strategies and/or many ESI include tags, it can be hard to understand why and when a resource should be cached or not. To ease debugging, you can enable the debug mode:
$framework = new HttpKernel\HttpCache\HttpCache(
$framework,
new HttpKernel\HttpCache\Store(__DIR__.'/../cache'),
new HttpKernel\HttpCache\Esi(),
array('debug' => true)
);
The debug mode adds a X-Symfony-Cache header to each response that describes what the cache layer did:
X-Symfony-Cache: GET /is_leap_year/2012: stale, invalid, store
X-Symfony-Cache: GET /is_leap_year/2012: fresh
HttpCache has many features like support for the stale-while-revalidate and stale-if-error HTTP Cache-Control extensions as defined in RFC 5861.
With the addition of a single interface, our framework can now benefit from the many features built into the HttpKernel component; HTTP caching being just one of them but an important one as it can make your applications fly!
The HttpKernel Component: The HttpKernel Class
在使用 symfony 的時候有可能會需要自訂錯誤訊息,雖然內建的錯誤訊息已經有 404、505 但這些 response 都是寫死在 framework 中的,要怎麼客製化自己的錯誤訊息?我們可以透過發出一個新的 event 然後去監聽它,這代表我們需要讓監聽器去監聽 controller,但當今天監聽的是一個 error controller 會導致死迴圈,因為 error controller 本身錯誤所以監聽器會噴錯,但噴錯過程又因為 error controller 有問題導致監聽器又觸發event一次,會變成一直不斷的噴錯失敗再呼叫噴錯再失敗,有沒有其他方法可以處理這種情況?
這時候 HttpKernel class 就登場了,省去重新造輪子,我們可以用它來解決許多問題,它繼承了 HttpKernelInterface ,且具有可擴展和高可用的性質。
跟先前的 framework 相似,HttpKernel class 會透過 handling request 來策略性地調整事件分派,它使用 controller resolver 來選擇要派發 request 到哪一個 controller,另外他還提供了錯誤回報。
新的 framework:
// example.com/src/Simplex/Framework.php
namespace Simplex;
use Symfony\Component\HttpKernel\HttpKernel;
class Framework extends HttpKernel
{
}
新的 front controller front.php:
// example.com/web/front.php
require_once __DIR__.'/../vendor/autoload.php';
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel;
use Symfony\Component\Routing;
$request = Request::createFromGlobals();
$requestStack = new RequestStack();
$routes = include __DIR__.'/../src/app.php';
$context = new Routing\RequestContext();
$matcher = new Routing\Matcher\UrlMatcher($routes, $context);
$controllerResolver = new HttpKernel\Controller\ControllerResolver();
$argumentResolver = new HttpKernel\Controller\ArgumentResolver();
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new HttpKernel\EventListener\RouterListener($matcher, $requestStack));
$framework = new Simplex\Framework($dispatcher, $controllerResolver, $requestStack, $argumentResolver);
$response = $framework->handle($request);
$response->send();
RouterListener is an implementation of the same logic we had in our framework: it matches the incoming request and populates the request attributes with route parameters.
現在 code 更加好看功能也更加猛了,繼續把剛剛的錯誤處理用內建的 ExceptionListener 來作客製化管理。
$errorHandler = function (Symfony\Component\Debug\Exception\FlattenException $exception) {
$msg = 'Something went wrong! ('.$exception->getMessage().')';
return new Response($msg, $exception->getStatusCode());
};
$dispatcher->addSubscriber(new HttpKernel\EventListener\ExceptionListener($errorHandler));
ExceptionListener gives you a FlattenException instance instead of the thrown Exception instance to ease exception manipulation and display. It can take any valid controller as an exception handler, so you can create an ErrorController class instead of using a Closure:
$listener = new HttpKernel\EventListener\ExceptionListener(
'Calendar\Controller\ErrorController::exceptionAction'
);
$dispatcher->addSubscriber($listener);
The error controller reads as follows:
// example.com/src/Calendar/Controller/ErrorController.php
namespace Calendar\Controller;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Debug\Exception\FlattenException;
class ErrorController
{
public function exceptionAction(FlattenException $exception)
{
$msg = 'Something went wrong! ('.$exception->getMessage().')';
return new Response($msg, $exception->getStatusCode());
}
}
Voilà! Clean and customizable error management without efforts. And of course, if your controller throws an exception, HttpKernel will handle it nicely.
In chapter two, we talked about the Response::prepare() method, which ensures that a Response is compliant with the HTTP specification. It is probably a good idea to always call it just before sending the Response to the client; that’s what the ResponseListener does:
$dispatcher->addSubscriber(new HttpKernel\EventListener\ResponseListener('UTF-8'));
This one was easy too! Let’s take another one: do you want out of the box support for streamed responses? Just subscribe to StreamedResponseListener:
And in your controller, return a StreamedResponse instance instead of a Response instance.
$dispatcher->addSubscriber(new HttpKernel\EventListener\StreamedResponseListener());
And in your controller, return a StreamedResponse instance instead of a Response instance.
Read the Symfony Framework Events reference to learn more about the events dispatched by HttpKernel and how they allow you to change the flow of a request.
Now, let’s create a listener, one that allows a controller to return a string instead of a full Response object:
class LeapYearController
{
public function indexAction(Request $request, $year)
{
$leapyear = new LeapYear();
if ($leapyear->isLeapYear($year)) {
return 'Yep, this is a leap year! ';
}
return 'Nope, this is not a leap year.';
}
}
To implement this feature, we are going to listen to the kernel.view event, which is triggered just after the controller has been called. Its goal is to convert the controller return value to a proper Response instance, but only if needed:
// example.com/src/Simplex/StringResponseListener.php
namespace Simplex;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;
use Symfony\Component\HttpFoundation\Response;
class StringResponseListener implements EventSubscriberInterface
{
public function onView(GetResponseForControllerResultEvent $event)
{
$response = $event->getControllerResult();
if (is_string($response)) {
$event->setResponse(new Response($response));
}
}
public static function getSubscribedEvents()
{
return array('kernel.view' => 'onView');
}
}
The code is simple because the kernel.view event is only triggered when the controller return value is not a Response and because setting the response on the event stops the event propagation (our listener cannot interfere with other view listeners).
Don’t forget to register it in the front controller:
$dispatcher->addSubscriber(new Simplex\StringResponseListener());
notice:
If you forget to register the subscriber, HttpKernel will throw an exception with a nice message: The controller must return a response
(Nope, this is not a leap year. given)…
If you forget to register the subscriber, HttpKernel will throw an exception with a nice message: The controller must return a response
(Nope, this is not a leap year. given)…
Hopefully, you now have a better understanding of why the simple looking HttpKernelInterface is so powerful. Its default implementation, HttpKernel, gives you access to a lot of cool features, ready to be used out of the box, with no efforts. And because HttpKernel is actually the code that powers the Symfony and Silex frameworks, you have the best of both worlds: a custom framework, tailored to your needs, but based on a rock-solid and well maintained low-level architecture that has been proven to work for many websites; a code that has been audited for security issues and that has proven to scale well.
The DependencyInjection Component
看不太懂,參考這篇寫得很清楚
深入探討依賴注入
深入探討依賴注入
In the previous chapter, we emptied the Simplex\Framework class by extending the HttpKernel class from the eponymous component. Seeing this empty class, you might be tempted to move some code from the front controller to it:
// example.com/src/Simplex/Framework.php
namespace Simplex;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Routing;
use Symfony\Component\HttpFoundation;
use Symfony\Component\HttpKernel;
class Framework extends HttpKernel\HttpKernel
{
public function __construct($routes)
{
$context = new Routing\RequestContext();
$matcher = new Routing\Matcher\UrlMatcher($routes, $context);
$requestStack = new RequestStack();
$controllerResolver = new HttpKernel\Controller\ControllerResolver();
$argumentResolver = new HttpKernel\Controller\ArgumentResolver();
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new HttpKernel\EventListener\RouterListener($matcher, $requestStack));
$dispatcher->addSubscriber(new HttpKernel\EventListener\ResponseListener('UTF-8'));
parent::__construct($dispatcher, $controllerResolver, $requestStack, $argumentResolver);
}
}
The front controller code would become more concise:
// example.com/web/front.php
require_once __DIR__.'/../vendor/autoload.php';
use Symfony\Component\HttpFoundation\Request;
$request = Request::createFromGlobals();
$routes = include __DIR__.'/../src/app.php';
$framework = new Simplex\Framework($routes);
$framework->handle($request)->send();
簡潔的 controller 可以讓你方便為一個 application 設定多種 controllers, 為什麼要這樣做? 因為你再開發環境更測試環境跟上線環境可能都需要不同的功能例如 error reporting 以及 error displayed 再開發測試中打開可以便於開發跟除錯,但是在正式環境中打開暴露太多資訊就不太好了。
ini_set('display_errors', 1);
error_reporting(-1);
不過在 controller 變簡潔的同時也會產生一些問題:
- We are not able to register custom listeners anymore as the dispatcher is not available outside the Framework class (an easy workaround could be the adding of a Framework::getEventDispatcher() method);
- We have lost the flexibility we had before; you cannot change the implementation of the UrlMatcher or of the ControllerResolver anymore;
- Related to the previous point, we cannot test our framework easily anymore as it’s impossible to mock internal objects;
- We cannot change the charset passed to ResponseListener anymore (a workaround could be to pass it as a constructor argument).
The previous code did not exhibit the same issues because we used dependency injection; all dependencies of our objects were injected into their constructors (for instance, the event dispatchers were injected into the framework so that we had total control of its creation and configuration).
Does it mean that we have to make a choice between flexibility, customization, ease of testing and not to copy and paste the same code into each application front controller? As you might expect, there is a solution. We can solve all these issues and some more by using the Symfony dependency injection container:
composer require symfony/dependency-injection
建立一支檔案來管理 dependency injection container
Create a new file to host the dependency injection container configuration:
Create a new file to host the dependency injection container configuration:
// example.com/src/container.php
use Symfony\Component\DependencyInjection;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\HttpFoundation;
use Symfony\Component\HttpKernel;
use Symfony\Component\Routing;
use Symfony\Component\EventDispatcher;
use Simplex\Framework;
$sc = new DependencyInjection\ContainerBuilder();
$sc->register('context', Routing\RequestContext::class);
$sc->register('matcher', Routing\Matcher\UrlMatcher::class)
->setArguments(array($routes, new Reference('context')))
;
$sc->register('request_stack', HttpFoundation\RequestStack::class);
$sc->register('controller_resolver', HttpKernel\Controller\ControllerResolver::class);
$sc->register('argument_resolver', HttpKernel\Controller\ArgumentResolver::class);
$sc->register('listener.router', HttpKernel\EventListener\RouterListener::class)
->setArguments(array(new Reference('matcher'), new Reference('request_stack')))
;
$sc->register('listener.response', HttpKernel\EventListener\ResponseListener::class)
->setArguments(array('UTF-8'))
;
$sc->register('listener.exception', HttpKernel\EventListener\ExceptionListener::class)
->setArguments(array('Calendar\Controller\ErrorController::exceptionAction'))
;
$sc->register('dispatcher', EventDispatcher\EventDispatcher::class)
->addMethodCall('addSubscriber', array(new Reference('listener.router')))
->addMethodCall('addSubscriber', array(new Reference('listener.response')))
->addMethodCall('addSubscriber', array(new Reference('listener.exception')))
;
$sc->register('framework', Framework::class)
->setArguments(array(
new Reference('dispatcher'),
new Reference('controller_resolver'),
new Reference('request_stack'),
new Reference('argument_resolver'),
))
;
return $sc;
上述的 code 是為了設定 objects 與 dependencies,在這之中只有靜態的描述那些需要被操作的物件以及建立他們的方法,物件只有被從 container 呼叫的時候才會建立一個新的物件。
For instance, to create the router listener, we tell Symfony that its class name is Symfony\Component\HttpKernel\EventListener\RouterListener and that its constructor takes a matcher object (new Reference(‘matcher’)). As you can see, each object is referenced by a name, a string that uniquely identifies each object. The name allows us to get an object and to reference it in other object definitions.
note:
By default, every time you get an object from the container, it returns the exact same instance. That’s because a container manages your “global” objects.
By default, every time you get an object from the container, it returns the exact same instance. That’s because a container manages your “global” objects.
The front controller is now only about wiring everything together:
// example.com/web/front.php
require_once __DIR__.'/../vendor/autoload.php';
use Symfony\Component\HttpFoundation\Request;
$routes = include __DIR__.'/../src/app.php';
$sc = include __DIR__.'/../src/container.php';
$request = Request::createFromGlobals();
$response = $sc->get('framework')->handle($request);
$response->send();
As all the objects are now created in the dependency injection container, the framework code should be the previous simple version:
// example.com/src/Simplex/Framework.php
namespace Simplex;
use Symfony\Component\HttpKernel\HttpKernel;
class Framework extends HttpKernel
{
}
note:
If you want a light alternative for your container, consider Pimple, a simple dependency injection container in about 60 lines of PHP code.
If you want a light alternative for your container, consider Pimple, a simple dependency injection container in about 60 lines of PHP code.
Now, here is how you can register a custom listener in the front controller:
// ...
use Simplex\StringResponseListener;
$sc->register('listener.string_response', StringResposeListener::class);
$sc->getDefinition('dispatcher')
->addMethodCall('addSubscriber', array(new Reference('listener.string_response')))
;
Beside describing your objects, the dependency injection container can also be configured via parameters. Let’s create one that defines if we are in debug mode or not:
$sc->setParameter('debug', true);
echo $sc->getParameter('debug');
These parameters can be used when defining object definitions. Let’s make the charset configurable:
// ...
$sc->register('listener.response', HttpKernel\EventListener\ResponseListener::class)
->setArguments(array('%charset%'))
;
After this change, you must set the charset before using the response listener object:
$sc->setParameter(‘charset’, ‘UTF-8’);
Instead of relying on the convention that the routes are defined by the $routes variables, let’s use a parameter again:
Instead of relying on the convention that the routes are defined by the $routes variables, let’s use a parameter again:
// ...
$sc->register('matcher', Routing\Matcher\UrlMatcher::class)
->setArguments(array('%routes%', new Reference('context')))
;
And the related change in the front controller:
$sc->setParameter('routes', include __DIR__.'/../src/app.php');
We have obviously barely scratched the surface of what you can do with the container: from class names as parameters, to overriding existing object definitions, from shared service support to dumping a container to a plain PHP class, and much more. The Symfony dependency injection container is really powerful and is able to manage any kind of PHP class.
Don’t yell at me if you don’t want to use a dependency injection container in your framework. If you don’t like it, don’t use it. It’s your framework, not mine.
This is (already) the last chapter of this book on creating a framework on top of the Symfony components. I’m aware that many topics have not been covered in great details, but hopefully it gives you enough information to get started on your own and to better understand how the Symfony framework works internally.
If you want to learn more, read the source code of the Silex micro-framework, and especially its Application class.
Have fun!
留言
張貼留言