HttpFoundation 組件
在深入探討框架建立過程之前,我們先退後一步,看看為什麼您會想要使用框架,而不是保持您原有的 PHP 應用程式原樣。 為什麼使用框架實際上是一個好主意,即使對於最簡單的程式碼片段也是如此,以及為什麼在 Symfony 組件之上建立您的框架比從頭開始建立框架更好。
注意
我們不會討論在多位開發人員共同開發大型應用程式時,使用框架的傳統優點;網路上已經有很多關於該主題的優良資源。
即使我們在前一章節中編寫的「應用程式」已經夠簡單了,它仍然存在一些問題
1 2 3 4
// framework/index.php
$name = $_GET['name'];
printf('Hello %s', $name);
首先,如果未在 URL 查詢字串中定義 name
查詢參數,您將收到 PHP 警告;所以讓我們修正它
1 2 3 4
// framework/index.php
$name = $_GET['name'] ?? 'World';
printf('Hello %s', $name);
然後,這個應用程式不安全。 您相信嗎? 即使是這個簡單的 PHP 程式碼片段也容易受到最普遍的網路安全問題之一 XSS(跨網站指令碼攻擊)的攻擊。 以下是一個更安全的版本
1 2 3 4 5
$name = $_GET['name'] ?? 'World';
header('Content-Type: text/html; charset=utf-8');
printf('Hello %s', htmlspecialchars($name, ENT_QUOTES, 'UTF-8'));
注意
您可能已經注意到,使用 htmlspecialchars
保護您的程式碼既繁瑣又容易出錯。 這就是為什麼使用像 Twig 這樣的樣板引擎可能是一個好主意的原因之一,在 Twig 中,預設啟用自動跳脫,並且使用簡單的 e
篩選器,顯式跳脫也比較不痛苦。
如您所見,如果我們想要避免 PHP 警告/通知並使程式碼更安全,我們最初編寫的簡單程式碼並非那麼簡單。
除了安全性之外,此程式碼可能難以測試。 即使沒有太多要測試的內容,但對我來說,為最簡單的 PHP 程式碼片段編寫單元測試並不自然且感覺很醜陋。 以下是上述程式碼的暫定 PHPUnit 單元測試
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// framework/test.php
use PHPUnit\Framework\TestCase;
class IndexTest extends TestCase
{
public function testHello(): void
{
$_GET['name'] = 'Fabien';
ob_start();
include 'index.php';
$content = ob_get_clean();
$this->assertEquals('Hello Fabien', $content);
}
}
注意
如果我們的應用程式稍微大一點,我們就能夠發現更多問題。 如果您對它們感到好奇,請閱讀本書的 Symfony 與 Flat PHP 章節。
在這一點上,如果您仍然不相信安全性和測試確實是停止以舊方式編寫程式碼並改用框架(無論在這種情況下採用框架意味著什麼)的兩個非常好的理由,您可以現在停止閱讀本書,然後回到您之前正在處理的任何程式碼。
注意
使用框架應該給您更多的不僅僅是安全性和可測試性,但更重要的是要記住,您選擇的框架必須讓您能夠更快地編寫更好的程式碼。
使用 HttpFoundation 組件進行 OOP
編寫網路程式碼是關於與 HTTP 互動。 因此,我們框架的基本原則應該圍繞 HTTP 規範。
HTTP 規範描述了用戶端(例如瀏覽器)如何與伺服器(我們的應用程式透過網路伺服器)互動。 用戶端和伺服器之間的對話由明確定義的訊息、請求和回應指定:用戶端向伺服器發送請求,並且基於此請求,伺服器傳回回應。
在 PHP 中,請求由全域變數表示($_GET
、$_POST
、$_FILE
、$_COOKIE
、$_SESSION
...),而回應由函數產生(echo
、header
、setcookie
、...)。
邁向更好程式碼的第一步可能是使用物件導向方法;這是 Symfony HttpFoundation 組件的主要目標:用物件導向層取代預設的 PHP 全域變數和函數。
要使用此組件,請將其新增為專案的依賴項
1
$ composer require symfony/http-foundation
執行此命令也會自動下載 Symfony HttpFoundation 組件,並將其安裝在 vendor/
目錄下。 也將產生 composer.json
和 composer.lock
檔案,其中包含新的需求。
現在,讓我們使用 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();
$name = $request->query->get('name', 'World');
$response = new Response(sprintf('Hello %s', htmlspecialchars($name, ENT_QUOTES, 'UTF-8')));
$response->send();
createFromGlobals()
方法根據目前的 PHP 全域變數建立 Request
物件。
send()
方法將 Response
物件送回用戶端(它首先輸出 HTTP 標頭,然後輸出內容)。
提示
在呼叫 send()
之前,我們應該新增對 prepare()
方法的呼叫($response->prepare($request);
),以確保我們的回應符合 HTTP 規範。 例如,如果我們要使用 HEAD
方法呼叫頁面,它將移除回應的內容。
與之前的程式碼的主要區別在於,您可以完全控制 HTTP 訊息。 您可以建立任何您想要的請求,並且您可以負責在您認為合適時發送回應。
注意
我們沒有在重寫的程式碼中明確設定 Content-Type
標頭,因為回應物件的字元集預設為 UTF-8
。
透過 Request
類別,您可以透過良好且簡單的 API 輕鬆存取所有請求資訊
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
// the URI being requested (e.g. /about) minus any query parameters
$request->getPathInfo();
// retrieves GET and POST variables respectively
$request->query->get('foo');
$request->getPayload()->get('bar', 'default value if bar does not exist');
// retrieves SERVER variables
$request->server->get('HTTP_HOST');
// retrieves an instance of UploadedFile identified by foo
$request->files->get('foo');
// retrieves a COOKIE value
$request->cookies->get('PHPSESSID');
// retrieves a 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
您也可以模擬請求
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
$response->setMaxAge(10);
提示
要偵錯回應,請將其轉換為字串;它將傳回回應的 HTTP 表示法(標頭和內容)。
最後但並非最不重要的一點是,這些類別,就像 Symfony 程式碼中的其他每個類別一樣,都已經由獨立公司針對安全問題進行了審核。 並且作為一個開放原始碼專案,也意味著世界各地許多其他開發人員已經閱讀過程式碼,並且已經修正了潛在的安全問題。 您上次為您自製的框架訂購專業安全審核是什麼時候?
即使是取得用戶端 IP 位址這樣簡單的事情也可能不安全
1 2 3
if ($myIp === $_SERVER['REMOTE_ADDR']) {
// the client is a known one, so give it some more privilege
}
在您在生產伺服器前面新增反向代理之前,它都可以完美運作;此時,您將必須變更您的程式碼,使其在您的開發機器(您沒有代理)和您的伺服器上都能運作
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()
方法會給您正確的行為(並且它會涵蓋您擁有鏈式代理的情況)
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']
值不可信任,因為在沒有代理的情況下,終端使用者可以操縱它。 因此,如果您在沒有代理的情況下在生產環境中使用此程式碼,則會變得非常容易濫用您的系統。 使用 getClientIp()
方法則不然,因為您必須透過呼叫 setTrustedProxies()
來明確信任您的反向代理
1 2 3 4 5
Request::setTrustedProxies(['10.0.0.1'], Request::HEADER_X_FORWARDED_FOR);
if ($myIp === $request->getClientIp()) {
// the client is a known one, so give it some more privilege
}
因此,getClientIp()
方法在所有情況下都能安全地運作。 您可以在所有專案中使用它,無論組態如何,它都將正確且安全地運作。 這是使用框架的目標之一。 如果您要從頭開始編寫框架,您將必須自己考慮所有這些情況。 為什麼不使用已經有效的技術呢?
注意
如果您想了解更多關於 HttpFoundation 組件的資訊,您可以查看 Symfony\Component\HttpFoundation
API 或閱讀其專門的文件。
信不信由你,我們已經有了第一個框架。 如果您願意,現在可以停止了。 僅僅使用 Symfony HttpFoundation 組件已經可以讓您編寫更好且更可測試的程式碼。 它還可以讓您更快地編寫程式碼,因為許多日常問題已經為您解決了。
事實上,像 Drupal 這樣的專案已經採用了 HttpFoundation 組件;如果它適用於他們,它也可能適用於您。 不要重新發明輪子。
我幾乎忘了談論一個額外的好處:使用 HttpFoundation 組件是所有框架和使用它的應用程式之間更好的互操作性的開始(例如 Symfony、Drupal 8、phpBB 3、Laravel 和 ezPublish 5,以及 更多)。