[译] 如何创建你自己的 PHP 框架

平常在开发工作里,重复早轮子的机会其实不很多。今天去SegmentFault论坛看到时候,翻到了以前的一个帖子,说的是如何写自己的PHP框架。意见不一。但是有幸看到了Symphony作者写的一个系列博文:How to create your own PHP framework,先动手翻译看看(原文已经整理在Symphony官网)。

介绍

Symphony是一个解决常见web开发问题的框架,它由一系列可复用的独立,解耦,并具有内在联系的PHP组件构成。

与其选择使用较为底层的组件,你可以使用已经完备的全栈式web框架Symphony,或者,你也可以自己造一个。这个系列教程就是告诉你如何建造自己的框架。

你为什么要建造自己的框架?

为什么把建造自己的框架放在第一位呢?如果你看看周围,每个人都会告诉你重复造轮子是个坏主意,因为你可以选择现成的,更好的框架。大多数时候,他们确实是对的。但是一下几点可以告诉你,为什么你要自己造轮子:

  • 为了学习流行web框架中更底层的知识,尤其是与Symphony框架相关的;
  • 为了满足你特定的需求而定制框架(前提是你必须非常清楚你的需求);
  • 仅仅为了好玩而学习;
  • 为了重构很久以前的框架,融入流行框架的设计思想;
  • 为了向别人炫耀你可以的!

这个教程会一步一步教你如何构造框架,每一步你都会得到一个投入使用的框架,你可以用它作为自己最初的起点。慢慢的,它会从一个简单框架变为具有多种特性的框架,最终你将获得一个全功能的完备web框架。

如果没有足够的时间读完整个教程,你看一看 Slix 可以快速上手,这是一个基于Symphony的微型框架。代码非常简洁,考量了许多Symphony本身的组件

许多流行web框架将他们描述为MVC框架,这篇教程不会告诉你MVC设计模式,因为Symphony组件可以满足各种设计模式,而不仅仅是MCV,当然了,如果你看一看MVC语义,这本书会告诉你如何构造MVC当中的Controller。至于Model还有View,这要看你个人口味,而且你可以使用第三方库来满足需求(Doctrine,Propel 或者 plain-old PDO 来完成Model;PHP 或者 Twig 来完成View)。

当决定构造一个框架的时候,按照MVC的设计模式来未必是一个正确的目标。最为正确的目标应该是Separation of Concerns(需求的分离),这可能是唯一一个你需要关心的设计模式。Symphony的基础概念关注点在HTTP的定义上。所以说,你将要打造的框架应该更加准确的定义为HTTP框架或者说响应/请求框架。

正式开始之前

仅仅阅读如何构造框架是不够的。你需要自己动手尝试教程里的每一个例子。当然,你需要一个PHP环境(5.3.9或者更新),一个web服务器(比如Apache,Nginx,或者PHP自建的web服务器),了解PHP基本知识以及面向对象编程。

准备好了么,开始吧!

Bootstrapping 启动

在你开始构思你的框架之前,你需要想一想一些conventions(惯例):你的代码将存贮子在哪里?怎么命名你的class(类),怎么引用外部依赖包,等等

我们将新建一个目录,来存放你的代码:

$ mkdir framework
$ cd framework

Dependency Management 依赖管理

为了安装Symfony组件,你将使用Composer,一个依赖包管理工具。如果你还没有安装。点击这里下载。

我们的项目

这里,我们没有从0开始构建(from the scratch),我们将不断的重写“应用”,每一次加入一些抽象的成分。我们先从写一个最简单的web应用开始:

// framework/index.php
$input = $_GET['name'];
printf('Hello %s', $input);

如果你使用PHP 5.4,你可以使用PHP自建的服务器来运行这个应用,地址是http://localhost:4321/index.php?name=Fabien。否则,你需要用到Apache后者Nginx其他web服务器。

$ php -S 127.0.0.1:4321

下一章,我们将介绍HttpFoundation组件。

HttpFoundation 组件

在开始之前,我们回过头来想想为什么你需要一个PHP框架而不是纯PHP应用(plain-old)。为什么使用框架,甚至使用最简单的代码片段(code snippet)是一个好主意。还有为什么创造一个基于Symphony组建的框架要好于从零开始搭框架。

我们不谈论仅仅需要几个程序员,就可以利用框架创造大型应用的传统好处。互联网上已经有很多丰富的资源。

尽管我们前一章写的小应用已经足够简单,它仍然有很多问题:

// framework/index.php
$input = $_GET['name'];
printf('Hello %s', $input);

第一点,如果name参数没有在URL里面定义,你会得到一个PHP warning,我们这样解决:

// framework/index.php
$input = isset($_GET['name']) ? $_GET['name'] : 'World';
printf('Hello %s', $input);

但是,这样的应用依然是不安全的,因为即使是这样一个简单的PHP代码片段在面对世界上范围最广的安全威胁XSS(Cross0Site Scripting) 跨站攻击面前,也是脆弱的。这里有一个更安全的版本:

$input = isset($_GET['name']) ? $_GET['name'] : 'World’;
header('Content-Type: text/html; charset=utf-8');
printf('Hello %s', htmlspecialchars($input, ENT_QUOTES, 'UTF-8'));

你可能已经注意到了,使用 htmlsepcialchars
乏味而且容易出错(tedious and error prone)。这就是为什么要使用类似Twig模板引擎的原因了。它可以默认autoescatping,使用准确的escaping要比使用一个简单的escaping过滤要更好

正如你所见的,假如我们要考虑避免PHP warning/notices 还有让代码更安全的话,我们所写的代码已经不是最简单的了。

更进一步说,代码甚至已经不能被简单的测试了。就算没有太多可以测试的地方,针对这种最简单的代码片段使用单元测试是一种不自然的,感觉不漂亮到方式。这里我们写了一个试探性的PHPUnit 单元测试:

// 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);
}

如果我们的应用稍微复杂,我们可能会遇到更多的问题。如果你对此表示好奇,可以阅读Symphony versus Flat PHP的文档。
如果到了这一步,你还对使用框架来构建项目不放心的话(安全和测试是使用框架最好的理由),那么你可以回去写自己的代码了。
当然,使用框架不仅仅是为了更好的测试和安全性,更重要的是要记住使用框架可以让开发更快速。

使用HttpFoundation组建来面向对象

写web应用就是和HTTP协议打交道。所以,框架的核心应该是围绕HTTP的规范。
HTTP 规范描述了客户端(比如浏览器)如何与服务端(web服务器)进行交互。 严格规范的消息(well defined message),请求和响应,构成了客户端与服务器之间的对话:客户端发送请求到服务器,服务器返回一个响应。

在PHP中,请求通过全局变量($_GET, $_POST, $_FILE, $_COOKIE, $_SESSION)来获得,响应通过方法(echo, header, setcookie) 来实现。

写出优美代码的第一步就是使用面向对象的理念,即通过Symphony HttpFoundation组件来取代默认的PHP全局变量和方法。

在使用这个组件之前,我们需要添加组件的依赖:

$ composer require symfony/http-foundation

运行这个命令将自动下载Symphony HttpFoundation组件,并且将他安装在当前目录下的vendor/目录下。同时也产生了composer.json和composer.lock文件,包含了如下内容:

{
    "require": {
        "symfony/http-foundation": "^2.7"
    }
}

上面的代码展示了composer.json的内容。

Class Autoloading 类的自动加载

当安装一个新的依赖时,Composer也会自动生成一个vendor/autoloadphp
文件,让类能够自动加载 autoloaded。没有自动加载,你需要在使用这个类之前,require这个类文件。 但是由于PSR-0,我们可以使用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 header)。

在调用send()之前,我们需要再调用prepare()方法($response->prepare($request))来保证我们的响应是符合HTTP规范的。例如,如果我们使用HEAD方法,这将会移除响应的内容

这里使用组件的最主要区别就是你对HTTP 消息有足够的掌控权,你可以根据需求创造任意的请求和响应。

我们没有明确设置Content-Type头部,因为默认情况下,响应的头部就是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=Fabian');

通过 Response 类,你可以生成一个响应(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);

如果要debug一个响应,把它转化成一个string,它会返回Http协议形式的header和content.

最后,以上的这些Sympony当中的类,他们的安全性是得到了第三方独立公司的审查(audit)的。作为开源软件,Symphony的源码接受了来自世界各地的开发者的贡献和完善(对于潜在的安全性问题)。你最后一次对你创建的框架进行安全审查,是在什么时候?
甚至简单到获取客户端的ip地址都可以变得不安全:

if ($myIp == $_SERVER['REMOTE_ADDR']) {
    // the client is a known one, so give it some more privilege
}

上面的代码已经很好了,除非你在生产服务器的上一层加了逆向代理(reverse proxy)。如果是这样,你需要编辑代码满足同时在开发环境(没有代理的环境)以及远程的生产环境的正常使用。

使用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’] 这个获取得到的值是不能被信任打,因为在实际情况中,当没有代理的时候它可以被用户篡改。所以,如果你在生产环境中没有使用代理,它既容易被系统拒绝处理(因为_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 Component
, 你可以查阅 HttpFoundation 的API,或者阅读完备的文档。

到这里,我们已经写了我们第一个框架了,如果你不想再深入下去也可以。 单单使用 Symphony HttpFoundation 组件以及让你可以写出更好,更易于测试的代码了。它也帮你处理了很多开发过程中遇到过的历史问题。

事实上,类似 Drupal 的项目已经适配 HttpFoundation 组件来为他们所用, 这也同样对你适用。不要重复造轮子。

我忘记告诉你了,学会使用 Symphony HttpFoundation 组件还有一个好处,由于它在目前主流框架中的流行(Sympony, Drupal 8, phpBB 4, ezPublish 5, Laravel, Silex, 还有其他),这些框架内部操作性会更好。上手会更快。

前端控制器 The Front Controller

到目前为止,我们的应用就是简单的单页面,我们通过新建一个页面,让事情变得更有趣。

// 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();

正如你所看到的,大多数代码和第一页是一样的。我们这里提炼出通用的代码,这样可以在不同的页面间使用。代码的共享听起来似乎是一个构件框架的不错的计划。

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();

实践效果如下

// 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();

GoodBye 页面设置如下

// framework/bye.php
require_once __DIR__.'/init.php';

$response->setContent('Goodbye!');
$response->send();

我们确实需要把大部分重复性的代码放在一个地方,但是这不是所谓的抽象。我们需要每个页面都放置一个send方法,让页面以模板的形式表现出来,可以很方便的测试代码。

而且,新建一个新页面意味着我们需要新的php脚本文件,文件名通过URL(http://127.0.0.1:4321/bye.php)暴露到客户端。实际上,每一个php脚本文件都对应了一个特定的URL,这个过程通过web服务器直接完成。如果我们能把这个URL请求的派遣功能交给框架管理,这对我们来说会非常灵活,即框架的路由功能。

把单个php脚本文件暴露给客户端用户,是一种叫做 front 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')));

在 front.php 脚本中,$map 变量把URL和对应的php脚本文件联系起来。

题外话,假如客户端请求一个路径,但是这个路径没有在 $map 变量中定义,我们则需要返回一个自定义的404页面;现在你自己已经可以控制网站了。

如果要访问某个页面,你必须在 front.php 脚本中定义。

http://127.0.0.1:4321/front.php/hello?name=Fabien
http://127.0.0.1:4321/front.php/bye
/path 和 /bye 是页面的路径。

大多数的 web 服务器比如 Apache 或者 Nginx 都具有重写请求地址的功能,把 front controller 去掉,用户只要输入 http://127.0.0.1:4321/hello?name=Fabien
就可以直接访问。

使用 Request::getPathInfo() 能够获取去除 front controller 的路径地址。

你甚至不需要通过启动服务器来测试代码,采用 $request = Request::create('/hello?name=Fabien'); 即可生成自定义的请求,参数即自定义的URL路径。

现在所有的页面都会先访问统一的脚本文件(front.php),然后通过把所有其他的代码放到公共访问得到目录以外的地方,可以提高网站的安全性。

example.com
├── composer.json
├── composer.lock
├── src
│   └── pages
│       ├── hello.php
│       └── bye.php
├── vendor
│   └── autoload.php
└── web
    └── front.php

配置web服务器的根目录到 web/,这样其他的文件将不会被客户端直接访问。
我们在浏览器测试(http://localhost:4321/?name=Fabien),运行 php 自建的服务器:

$ php -S 127.0.0.1:4321 -t web/ web/front.php

未完待续

你可能感兴趣的:([译] 如何创建你自己的 PHP 框架)