HttpFoundation组件
在我们进入框架的创建进程前,让我们后退一步,来看看为什么我们想要使用一个框架而不是让你的普通的旧PHP应用保持原样。为什么使用一个框架是一个好主意,即使是对于最简单的代码片段。为什么基于Symfony组件创建你的框架会比从头创建框架更好。
在一个多人协作的大型项目中使用框架所带来的常见的传统的优点,我们将不在讨论;关于这个话题,在互联网上已经有大量的优秀资源。
尽管在上一个章节中我们写的应用已经是很简单的了,但是它仍然存在一些问题:
// framework/index.php
$input = $_GET['name'];
printf('Hello %s', $input);
首先,如果name
查询参数在URL查询字符串中不存在,你将收到一个PHP警告;所以让我们先来解决这个问题:
// framework/index.php
$input = isset($_GET['name']) ? $_GET['name'] : 'World';
printf('Hello %s', $input);
那么现在,这个应用是不安全的。你能相信吗?尽管是这么一个简单的PHP代码片段,却有着最广泛的网络安全问题,XSS(跨站脚本)。下面是更安全的版本:
$input = isset($_GET['name']) ? $_GET['name'] : 'World';
header('Content-Type: text/html; charset=utf-8');
printf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8'));
你可能注意到了,用
htmlspecialchars
来保证你的代码安全是乏味的并且容易出错的。这就是为什么去使用模板引擎(比如Twig)是一个好主意,它默认会自动的转义,而且你可以使用一个简单的e
过滤器来减少显式转移的痛苦。
你可以看到,我们之前写的那段简单的代码,现在已经变得不那么简单了,如果我们想避免PHP警告或提示并且是代码变得更安全。
除了安全性,这个代码是不容易测试的。尽管它没有太多的内容可以测试,为可能是最简单的PHP代码写单元测试还是让我觉得不自然的,丑陋的(Even if there is not much to test, it strikes me that writing unit tests for the simplest possible snippet of PHP code is not natural and feels ugly)。下面是试验性的PHPUnit单元测试:
// framework/test.php
use PHPUnit\Framework\TestCase;
class IndexTest extends TestCase
{
public function testHello()
{
$_GET['name'] = 'Fabien';
ob_start();
include 'index.php';
$content = ob_get_clean();
$this->assertEquals('Hello Fabien', $content);
}
}
如果我们的应用越来越大,我们将会发现更多问题。如果你对它们很好奇,可以去阅读这本书的Symfony versus Flat PHP章节。
现在,如果你还不觉得安全性和测试是两个很好的理由驱使你停止使用旧方法编写代码而是选择使用框架(不管使用框架在这里意味着什么),那么你可以停止阅读本书了,回到你以前工作的代码中吧。
当然,使用一个框架给你的不只是安全性和可测试性,你要记住,更重要的是你所选择的框架带给你的更快更好的编程体验。
使用HttpFoundation组件实现OOP
编写web程序代码就是和HTTP交互,因此,框架的基本原则应当时围绕着HTTP规范。
HTTP规范描述了一个客户端(例如浏览器)和服务端(运行在web服务器上的应用)的交互。客户端和服务端的对话由明确定义的消息规定,请求和响应: 客户端发送一个请求到服务端,服务端基于这个请求返回一个响应。
在PHP中,全局变量($_GET
, $_POST
,
$_FILE
, $_COOKIE
, $_SESSION
...)代表了请求,相应由一些函数(echo
, header
, setcookie
, ...)生成。
良好代码的第一步是使用一个面对对象的方式;Symfony HttpFoundation组件的主要目标:通过一个面对对象的分层来取代默认的PHP全局变量和函数。
为了使用这个组件,将它作为依赖加入我们的项目:
$ composer require symfony/http-foundation
运行这个命令将会同时自动的下载Symfony HttpFoundation组件并安装到vendor/
目录下。同时会生成一个composer.json
和一个composer.lock
,包含了新的需求。
Class Autoloading 每当安装一个新的依赖,Composer会生成一个
vendor/autoload.php
文件,使得所有的类可以被简单的自动加载。没有自动加载的话,你需要在使用类前引入定义类的文件。感谢PSR-4,我可以让Composer和PHP来我们做这些杂活。
现在,让我们使用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');
$response = new Response(sprintf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8')));
$response->send();
createFromGlobals()
方法将基于当前的PHP全局变量创建一个Request
对象。
send()
方法发回一个Response
对象到客户端(它将输出内容以及HTTP头信息)
在
send()
调用之前,我们应该增加对prepare()
方法($response->prepare($request);)的调用来保证我们的响应和HTTP规范兼容。例如,如果我们用HEAD
模式调用页面,它将去除响应的内容For instance, if we were to call the page with theHEAD
method, it would remove the content of the Response.
现在的代码和之前的代码最主要的不同点在于你可以控制整个HTTP信息。你能创建你想要的任何请求,你也可以在任何你想要的时间点发送响应。
我们还没有在重写的代码中显式地设置
Content-Type
头,Response对象默认会设置为UTF-8
。
感谢Request
类带来的简单又漂亮的api,我们可以掌控所有的请求信息:
// 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::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);
为了调试一个响应,你应该将它转换成一个字符串;它将返回响应的HTTP的表现(头和内容)。
最后,也是比较重要一点,这些类就像Symfony中的其他类一样,已经由一个独立的公司审计过了安全性的问题。并且,作为一个开源的项目,这意味着有很多来自全世界的开发者已经阅读过它的源码,也已经修复了潜在的安全问题。最后一次是什么时候,你为了你的自制框架预约了一个专业的安全审计?
即使是一些获取客户端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
}
你应当在一开始就使用Request::getClientIp()
方法,它将提供给你正确的行为(并且它在你链接代理的情况下同样有效)。:
$request = Request::createFromGlobals();
if ($myIp === $request->getClientIp()) {
// the client is a known one, so give it some more privilege
}
并且有一个额外的好处,它默认是安全的。这意味着什么?在没有代理的情况下,$_SERVER['HTTP_X_FORWARDED_FOR']
的值可能被最后的用户所操纵,导致无法被信任。因此,如果你在没有代理的生成环境中使用这段代码,它可能会轻易的滥用你的系统。如果你必须明确地信任你的反向代理,只调用getClientIp()
方法不是明智的选择,你还应该调用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()
放法可以安全的工作在任何的情况下。你可以在你的项目中使用这段代码,无论你的项目构造是怎么样,它都可以正确地安全地运行。这就是使用框架的目标之一。如果你从头写一个框架,你需要考虑到所有的情况。为什么不使用一个已经可以工作的技术呢?
如果你想要去更多的学习HttpFoundation组件,你可以看一下HttpFoundation的API或者阅读它专用文档documentation。
相信与否,我们已经有了我们的第一个框架。如果你想你可以就此停手,使用Symfony HttpFoundation组件已经可以让你写出更好的更易测试的代码。它还可以让你更快的编写代码,因为它已经帮你解决了许多日积月累的问题。
事实上,像Drupal这样的项目也采用了HttpFoundation;如果HttpFoundation可以为Drupal工作,那么它也可以为你工作。不要重复造轮子。
我差点忘了告诉你另一个好处:使用HttpFoundation,是你与那些用它的框架和应用更好互通的开始(比如Symfony, Drupal 8, phpBB 4, ezPublish 5, Laravel, Silex and more)。