iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 4
0
自我挑戰組

重新理解 PHP:從頭打造 Web Framework系列 第 5

加入 Symfony HttpFoundation 元件

前言

當主題明確地定義「要打造一個 PHP 框架」時,基本上已經確立了我們的目標是圍繞著 HTTP 協定。當然,PHP 其實可以操作非 HTTP 協定的資料流,但較為罕見,而且通常有更優秀的解決方案。

HTTP 協定主要是敘述客戶端(例如網頁瀏覽器)如何與伺服器端(在我們的案例中是 PHP 內置伺服器)互動。一般我們可以簡單歸納如下:客戶端向伺服器端發送 Request,伺服器端基於這份請求送回相應的 Response

在原生的 PHP 中,Request 可以用超全域變數具現化,例如 $_GET$_POST$_COOKIE$_SESSION 等等;而 Response 則透過幾種函式而或生成,例如 echo()header()setcookie() 等等。
站在程式設計的觀點來看,這樣的寫法雖然方便但卻顯得凌亂,同時也是 PHP 被人詬病的主要因素之一,如果可以用適當的物件導向程式設計加以包裝的話,或許會顯得更加友善。

使用 Symfony HttpFoundation

利用以下指令,將我們的框架加入 Symfony HttpFoundation。

composer require symfony/http-foundation

我們將可以看到 composer.json 大致上會如下結構。

{
  "require": {
    "php": ">=7.0.0",
    "symfony/http-foundation": "^4.0"
  },
}

現在我們嘗試用 HttpFoundation 來重寫我們的框架。

// rafax/public/index.php
<?php

require __DIR__ . '/../vendor/autoload.php';

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

$request = Request::createFromGlobals();

$name = $request->get('name', 'World');
$name = htmlspecialchars($name, ENT_QUOTES, 'UTF-8');

$response = new Response("Hello $name" . PHP_EOL);
$response->prepare()->send();

$request = Request::createFromGlobals() ,Symfony 會自動擷取既有的超全域變數,並轉化為 Request 實體。
而在 $response = new Response("Hello $name" . PHP_EOL) ,Symfony 會建立一個 Response 實體,並利用 send() 函數傳送給客戶端。

可以發現,在不改變 tests/rafax/IndexTest.php 的前提下,測試是可以正常通過的,表示這是次成功的重構(Refactoring)

討論:為何要在 send() 函數前使用 prepare() 函數?

prepare() 函數會幫助我們確保回應的內容符合 HTTP 協定,舉例而言,如果今天 Request 的方法(method)為 HEAD,依照 HTTP 協定是不能回應內容(body)的。

討論:為何不用 header('Content-Type: text/html; charset=utf-8')

Response 實體在被建立時預設就會包含 Content-Type: text/html; charset=utf-8,所以不用特別指定。

HttpFoundation 的優點

相較於原生 PHP 的寫法,使用 HttpFoundation 具有許多優點。

  • 簡潔易用的 API
  • 可模擬且可客製化的 RequestResponse 實體
  • Reqeust 提供可信任且足夠安全的資料(如客戶端 IP 位址)取得方案
  • Response 提供符合 HTTP 協定的預檢查

簡潔易用的 API

使用 Request 實體,可以利用一系列簡潔的 API 取得關於本次請求的詳細資料。

<?php

$request = Request::createFromGlobals();

// 取得 $_GET['key'] 內容,若未設置則以第二個參數取代之
$request->query->get('key');
$request->query->get('key', 'default');

// 取得 $_POST['key'] 內容,若未設置則以第二個參數取代之
$request->request->get('key');
$request->request->get('key', 'default');

// 取得 $_SERVER['HTTP_HOST'] 內容
$request->server->get('HTTP_HOST');

// 取得 $_FILES['image'] 內容
$request->files->get('image');

// 取得 $_COOKIES['PHPSESSID'] 內容
$request->cookies->get('PHPSESSID');

// 取得 HTTP Request Header
$request->headers->get('content_type');

// 取得 HTTP Request Method (GET, POST, PUT, DELETE, HEAD ... etc.)
$request->getMethod();

此處僅列出一些常用的 API,若對完整的 API 內容及用法有興趣,可以查閱 官方文件

可模擬且可客製化的 RequestResponse 實體

<?php

$fakeRequest = Request::create('/index.php?name=Vincent');

<?php

$fakeResponse = new Response();

$fakeResponse->setContent('Hello World'); // 設置回應的 body 內容
$fakeResponse->setStatusCode(403); // 設定回應狀態碼,403 代表 Permission Denied
$fakeResponse->headers->set('Content-Type', 'text/plain'); // 設置回應的 MIME Type 為 text/plain

$fakeResponse->setMaxAge(10); // 設定回應的 Cache Control 時間(單位為秒)

Reqeust 提供可信任且足夠安全的資料取得方案

取得客戶端 IP 是件相當複雜的過程,一般來說會推薦使用 $_HTTP['REMOTE_ADDR'] 取得客戶端 IP。

<?php

$clientIP = $_HTTP['REMOTE_ADDR']; 

如果你的伺服器只有一台,而且前面並未放置任何負載平衡(Load Balancer)或反向代理(Reverse Proxy),這樣的解決方式的確可行。
然而當你的網站漸漸變的熱門,你需要在前面架設 HA-Proxy 擔任負載平衡器,此時會赫然發現 $_HTTP['REMOTE_ADDR'] 只能取得你 HA-Proxy 的 IP。
翻閱 HA-Proxy 文件,會發現原來它會將原本用戶的 IP 存在 $_HTTP['HTTP_X_FORWARDED_FOR'],於是你這麼改寫了你的程式。

<?php

if (getenv('HAS_PROXY')) {
  $clientIP = $_HTTP['HTTP_X_FORWARDED_FOR'];
} else {
  $clientIP = $_HTTP['REMOTE_ADDR']
}

此時又發現一個嚴重的安全問題:$_HTTP['HTTP_X_FORWARDED_FOR'] 的值是可以任意被竄改的。
為了解決這個問題,你要先確定 $_HTTP['REMOTE_ADDR'] 的值是不是自己的 HA-Proxy,於是你又改寫了一下程式。

<?php

$trustedProxy = '192.168.10.1'; // 設定可信任的 Proxy IP

if (getenv('HAS_PROXY') && $_HTTP['REMOTE_ADDR'] === $trustedProxy) {
  $clientIP = $_HTTP['HTTP_X_FORWORDED_FOR'];
} else {
  $clientIP = $_HTTP['REMOTE_ADDR'];
}

結束了嗎?很不幸地還沒有,如果串接多個 Reverse Proxy 或是客戶端經過多個 Proxy,則 $_HTTP['HTTP_X_FORWORDED_FOR'] 的值會以逗號分隔多組 IP,而第一組即為客戶端 IP。(前提是客戶端的 Proxy 遵守 Proxy 的協定)
與其自己實作這麼複雜的流程,不如直接交給 Request 實體。

<?php

$request = Request::createFromGlobals();
$request->getClientIp();

如果你有架設 Reverse Proxy,也可以利用以下方法設定可信任的 Proxy,如此以來便可以保證自己所獲取到的資訊無論在什麼環境下都是可信任的 IP 位址。

<?php

Request::setTrustedProxies([
  '192.168.10.1',
]); // 設定可信任的 Proxy IP,可以用 array 的方式設立多組可信任 IP

$request = Request::createFromGlobals(); 
$request->getClientIp(); 

Response 提供符合 HTTP 協定的預檢查

利用 Responseprepare() 可以輕易地確定自己的回應是符合 HTTP 協定的標準。

<?php

$request = Request::createFromGlobals(); 
$response = (new Response('Hello World'))->prepare($request)->send(); 

參考資料

  1. Google Developer: HTTP 快取
  2. Devcore: 如何正確的取得使用者 IP?

上一篇
Hello World, Hello My Framework
下一篇
設計模式 - 前端控制器模式(Front Controller Pattern)
系列文
重新理解 PHP:從頭打造 Web Framework9
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言