iT邦幫忙

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

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

整合前端控制器模式

前言

Goodbye World

建立一個檔案名為 bye.php,會向我們輸出 Goodbye World

// rafax/public/bye.php
<?php

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

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

$request = Request::createFromGlobals();

$response = new Response('Goodbye World');
$response->prepare($request)->send();

我們不難察覺,bye.phpindex.php 就像味噌湯與排骨酥湯一樣,相似度高達九成
於是我們著手重構這兩個檔案,將其優先整合於 index.php 後再分發到兩個檔案(hello.phpbye.php)之中。

重構一:模組化

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

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

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

$request = Request::createFromGlobals();
$response = new Response(); 

// rafax/public/hello.php
<?php

require __DIR__ . '/index.php';

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

$response->setContent("Hello $name");
$response->prepare($request)->send();

// rafax/public/bye.php
<?php

require __DIR__ . '/index.php';

$response->setContent('Goodbye World');
$response->prepare($request)->send();

我們會發現,無論是在 hello.php 或是 bye.php,都必須要重複調用 require __DIR__ . '/index.php'$response->prepare($request)->send()
這實在不是個好現象,這表示我們的模組化並不完全成功,讓我們試試導入前端控制器模式。

重構二:前端控制器模式

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

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

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

$request = Request::createFromGlobals();
$response = new Response();

$map = [
    '/hello' => __DIR__ . '/hello.php',
    '/bye' => __DIR__ . '/bye.php',
];

$path = $request->getPathInfo();

if (isset($map[$path])) {
    include $map[$path];
} else {
    $response->setStatusCode(404);
    $response->setContent('Not Found');
}

$response->prepare($request)->send();

// rafax/public/hello.php
<?php

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

// rafax/public/bye.php
<?php

$response->setContent('Goodbye World');

現在看起來舒服多了。
不過還有些缺點,誠如各位所知,PHP 不僅僅是個腳本語言,同時也是個模板語言。
也就是說,我們的頁面很有可能是各種「HTML 頁面 + PHP 語法」的集合,而不僅只是純 PHP 腳本。

是否有辦法將 hello.phpbye.php 模板化呢?

重構三:模板化

<!-- rafax/public/hello.php -->
Hello <?= htmlspecialchars($request->get('name', 'World'), ENT_QUOTES, 'UTF-8') ?>
<!-- rafax/public/bye.php --> 
Goodbye World
// rafax/public/index.php
<?php

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

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

$request = Request::createFromGlobals();
$response = new Response();

$map = [
    '/hello' => __DIR__ . '/hello.php',
    '/bye' => __DIR__ . '/bye.php',
];

$path = $request->getPathInfo();

if (isset($map[$path])) {
    ob_start(); 
    include $map[$path];
    $response->setContent(ob_get_clean());
} else {
    $response->setStatusCode(404);
    $response->setContent('Not Found');
}

$response->prepare($request)->send();

對於模板,目前還有些地方令人不太滿意:在模板中調用 $request->get('name', 'World') 感覺太過冗餘,如果可以用 $name 以一概之的話應該會讓程式碼更加簡潔。

<!-- rafax/public/hello.php -->
Hello <?= hrmlspecialchars($name ?? 'World', ENT_QUOTES, 'UTF-8') ?>

為了達成這個目的,我們需要自動化地展開(extract)$_GET 參數,將 $_GET['name'] 自動變為 $name

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

// ... 

if (isset($map[$path])) {
    ob_start(); 
    extract($request->query->all(), EXTR_SKIP);
    include $map[$path];
    $response->setContent(ob_get_clean());
} 

// ...

討論:為何要在 extract() 中加入第二個參數 EXTR_SKIP

這是因為預設的 extract() 行為會覆蓋掉已存在的變數,可能造成安全弱點。
舉例而言

<?php
extract($_GET);
var_dump($_SERVER['REMOTE_ADDR']);

此時我們若是使用 curl http://localhost:10000/?_SERVER[REMOTE_ADDR]=192.168.1.1 就可以將真正的 $_SERVER['REMOTE_ADDR'] 竄改,甚至可以竄改 session 資訊。
還是必須強調:絕對 不能相信來自客戶端的資料。

現在我們的框架看起來友善許多,對吧?
用 Symfony HttpFoundation 建構而成的 RequestResponse、簡單又安全的 Router Mapping 、變數的自動展開以及原生的 PHP 模板支援。
現在只剩一個地方需要考慮:所有的檔案全部擠在 public/ 下,似乎不是個好主意,不僅造成程式碼凌亂,更無助於未來擴展。
於是我們決定把 hello.phpbye.php 兩個模板搬到其它地方,稍微整理一下。

重構四:模版模組化

定義資料夾結構如下:

rafax
  |--- src/ # Chivincent\Rafax namespace root
  |--- Bach 
     |--- app/ # Chivincent\Bach namespace root
     |--- templates/
        |--- hello.php
        |--- bye.php
  |--- public/
     |--- index.php
  |--- vendor/
  |--- composer.json
  |--- composer.lock

修改 index.php 如下(省略大部份未更動程式碼)。

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

// ... 

$map = [
    '/hello' => __DIR__.'/../Bach/templates/hello.php',
    '/bye' => __DIR__.'/../Bach/templates/bye.php',
];

// ... 

因為 index.php 目前兼作路由(Routing)功能,故未來啟動 PHP 內置伺服器的指令改為。

php -S localhost:10000 public/index.php

參考資料

  1. Davidnoren.com: PHP extract() Vulnerability

上一篇
設計模式 - 前端控制器模式(Front Controller Pattern)
下一篇
加入 Symfony Routing 路由元件
系列文
重新理解 PHP:從頭打造 Web Framework9
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言