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

未完待續

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • Composer Repositories Composer源 Firegento - Magento模塊Comp...
    零一間閱讀 3,967評論 1 66
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,833評論 25 708
  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,837評論 18 139
  • 夜漸愈涼 空氣是清涼的 夜空是寂寥的 路人是匆忙的 穿著臃腫的衣服 埋著頭,呵著氣 路燈閃著斷斷續續的光 這般陳舊...
    688a2e4be2af閱讀 143評論 0 3
  • 堅持星球,彼此加油,大家好,我是姣姣,很高興今天能在這里和大家分享。大家可以先看下我的身材,是不是很瘦呢?我現在的...
    小饅頭0601閱讀 455評論 0 0