[译] 如何创建你自己的 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

未完待续

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容

  • Composer Repositories Composer源 Firegento - Magento模块Comp...
    零一间阅读 3,956评论 1 66
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,793评论 25 707
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,633评论 18 139
  • 夜渐愈凉 空气是清凉的 夜空是寂寥的 路人是匆忙的 穿着臃肿的衣服 埋着头,呵着气 路灯闪着断断续续的光 这般陈旧...
    688a2e4be2af阅读 139评论 0 3
  • 坚持星球,彼此加油,大家好,我是姣姣,很高兴今天能在这里和大家分享。大家可以先看下我的身材,是不是很瘦呢?我现在的...
    小馒头0601阅读 440评论 0 0