支付宝扫一扫付款
微信扫一扫付款
(微信为保护隐私,不显示你的昵称)
深入框架创建的进程之前,我们先退一步看,为何你要用框架来取代自己的“面向过程”原生PHP程序。使用框架,终归是好事,哪怕(这框架)是最简单的码段,而基于Symfony组件来创建框架,又远胜从零写起。
我们不讨论那些明显的和传统上的“在多人大规模开发中使用框架”的好处,互联网上关于此话题有很多不错的资源。
就算我们在前章所写的“程序”如此之简单,它仍然因某些问题而“痛苦”:
1 2 3 4 | // framework/index.php
$input = $_GET['name'];
printf('Hello %s', $input); |
首先,如果query参数name
没有被定义在URL中的query string中,你会得到一个PHP警告(译注:可能是notice)。我们来修补之:
然后呢,程序还是不安全。你能相信吗?这么简单的PHP码段在面对互联网常见安全问题之一:XSS(跨站脚本)时也是脆弱的。下面是一个更安全的版本:
1 2 3 4 5 | $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这种模板引擎的原因,它默认开启自动过滤(auto-escaping),应该是个好办法(就算显式指定escaping也省事许多,用一个简单的e
调节器足矣)。
你自行观察可知,如果我们希望避免PHP的warnings/notices同时希望代码更加安全的话,一开始写的代码到现在已经不那么简单。
除了安全性堪忧,代码还很难测试。就算没有太多需要测试的为这种简单至极的PHP代码书写单元测试也是很不自然而且感觉很糟的,令我备受打击。下面是为上述代码假定的PHPunit单元测试代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // framework/test.php
class IndexTest extends \PHPUnit_Framework_TestCase
{
public function testHello()
{
$_GET['name'] = 'Fabien';
ob_start();
include 'index.php';
$content = ob_get_clean();
$this->assertEquals('Hello Fabien', $content);
}
} |
如果我们的程序相对大些,我们可能会发现更多问题。如果你重视这些问题,参考Symfony VS 原生php中文book。
现时点,若你仍难以确信“安全”和“测试”确系两个极好的理由来停止编写老旧代码并转而采纳框架的话(采纳框架是指本系列文章中的“框架”),你可以中止阅读本文并回到你以前的代码编写中去。
当然,使用框架会给你“安全性”或“可测试性”以外的更多,但要牢记的特别重要的一点是,你所选择的框架一定要能令你编写代码“又好又快”。
书写web代码关乎操作HTTP。因此,我们这个框架的基本原则应该围绕着HTTP协议。
HTTP协议描述了客户端(例如一个浏览器)如何与服务器(我们的框架通过web server运行)互动。客户端和服务器端的对话,通过明确定义的messages,requests和responses而具体化:客户端发送一个请求到服务器,服务器端基于请求返回一个响应。
在PHP中,request(请求)通过多个超全局变量呈现($_GET
,$_POST
,$_FILE
,$_COOKIE
,$_SESSION
...),而response(响应)则被若干函数所生成(echo
,header
,setcookie
...)。
迈向优良代码的第一步可能是使用面向对象这种手段。Symfony的HttpFoundation组件的主要目标即是如此:通过OO层(Object-Oriented layer)替换掉默认的PHP超全局变量以及功能函数。
要使用这组件,将其作为本项目的依赖:
1 | $ composer require symfony/http-foundation |
运行这个命令将自动下载Symfony HttpFoundation组件,并且将其安装到vendor/
目录。一个composer.json
文件和一个composer.lock
文件会被同时生成,包含了以下全新需求:
1 2 3 4 5 | {
"require": {
"symfony/http-foundation": "^3.0"
}
} |
上面代码显示了composer.json
文件中的内容(具体版本号可能会不同)。
现在,我们通过使用Request
和Response
两个类来重写程序:
1 2 3 4 5 6 7 8 9 10 11 12 13 | // 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);
)来确保我们的Response对象兼容HTTP协议。例如,如果我们使用HEAD
方法来调用页面,那么将移除响应的内容部分。
同之前代码相比,主要区别在于,现在你可以完整控制HTTP信息。你可以创建任何你想要的请求,同时还能在你认为合适的时机完成响应的发送。
在新写的代码中,我们没有显式地指定Content-Type
头,这是因为Response
对象被默认设置了UTF-8
的charset字符集。
有了Request
以类,弹指间你便拥有了全部请求信息,这得益于一个简单实用的API:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | // the URI being requested (e.g. /about) minus any query parameters
//
$request->getPathInfo();
// retrieve GET and POST variables respectively
// 分别取出GET和POST变量
$request->query->get('foo');
$request->request->get('bar', 'default value if bar does not exist');
// retrieve SERVER variables
// 取出SERVER变量
$request->server->get('HTTP_HOST');
// retrieves an instance of UploadedFile identified by foo
// 通过foo取出一个UploadedFile实例
$request->files->get('foo');
// retrieve a COOKIE value
// 取出一个COOKIE值
$request->cookies->get('PHPSESSID');
// retrieve an HTTP request header, with normalized, lowercase keys
// 通过取出一个HTTP请求头
$request->headers->get('host');
$request->headers->get('content_type');
$request->getMethod(); // GET, POST, PUT, DELETE, HEAD
// an array of languages the client accepts
// 客户端所能接受的语言之数组
$request->getLanguages(); |
你也能够模拟一个请求:
1 | $request = Request::create('/index.php?name=Fabien'); |
使用Response
,你可以轻松调整响应:
1 2 3 4 5 6 7 8 | $response = new Response();
$response->setContent('Hello world!');
$response->setStatusCode(200);
$response->headers->set('Content-Type', 'text/html');
// configure the HTTP cache headers 配置HTTP缓存头
$response->setMaxAge(10); |
要对响应除错,将其视为一个字符串;它将返回HTTP响应的表现层(headers和content)。
最后一点,这些类,如同Symfony代码中的其他类一样,经过了中立公司的安全审查(audited
for security issues)。既为开源项目,自然有许多来自全球各地的开发者阅读了源代码并修复了潜在的安全问题。你对“自家作坊开发而成”的框架,最近一次的申请专业安全申请,是在什么时候?
就算是获取客户端IP地址这种简单事情也可能不安全:
1 2 3 4 | if ($myIp === $_SERVER['REMOTE_ADDR']) {
// the client is a known one, so give it some more privilege
// 客户端是已知IP,因此给予更多权限
} |
它在你给生产环境的服务器添加一个反向代理之前,运行极其良好。然后,你不得不修改代码来令其既可运行在开发电脑(没有使用proxy),又能运行于服务器:
1 2 3 | 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()
方法将从一开始就给你正确的结果(而且它还能覆盖到你使用chained proxies):
1 2 3 4 5 | $request = Request::createFromGlobals();
if ($myIp === $request->getClientIp()) {
// the client is a known one, so give it some more privilege
} |
这样做还有一个好处:默认即是安全的。这话啥意思?$_SERVER['HTTP_X_FORWARDED_FOR']
的值是不能被信任的,因为在没有任何proxy时它能被末级用户操作。所以,如果你在生产环境使用这些代码但却没有proxy,系统被轻易滥用是显而易见的。而这在getClientIp()
方法中不会发生,因为你必须通过setTrustedProxies()
显式指定你所信任的反向代理:
1 2 3 4 5 | 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()
方法的运行都是安全的。你可以在所有项目中使用它,毋须考虑项目具体配置,它都将正确而安全地工作。这是使用框架的目标之一。如果你从零手写框架,你不得不自行思考所有上述问题。为什么不选择久经考验的技术呢?
无论是否愿意相信,我们拥有了的第一个框架。如果愿意,你可以停止修行。使用Symfony HttpFoundation组件已经令你写出更好而且更容易测试的代码。它令你的编码速度提升,因为你每天遇到的问题都已经化解。
事实上,Drupal这样的项目已经采纳HttpFoundation组件。如果他们用得妥,你用应该没问题。别重复发明轮子了。
我几乎忘了聊一聊“特别优势”:使用HttpFoundation组件是在全体框架中增进互动性(interoperability)的起点,许多程序都已经使用它了(像是Symfony,Drupal 8,phpBB 4,ezPublish5,Laravel,Silex以及更多)。
本文,包括例程代码在内,采用的是 Creative Commons BY-SA 3.0 创作共用授权。