iT邦幫忙

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

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

Hello World, Hello My Framework

  • 分享至 

  • xImage
  •  

前言

在昨天的主題中,我們創造了一個 Hello World ,老實說一成不變的網頁還真的有些乏味呢,對吧?今天我們就讓網頁加點變化。

Hello, my love

PHP 提供了一系列的超全域變數(Superblobals)用來處理資料,例如 $_GET 及 $_POST 這些變數是從 PHP 執行前就被定義的,且可以在任何地方使用。

我們將昨天的程式做一點點修改:

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

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

echo 'Hello ' . $_GET['name'] . PHP_EOL;

啟用內置伺服器後,我們可以利用 http://localhost:10000/?name=Vincent 來讓網頁顯示 "Hello Vincent"。

curl "localhost:10000/?name=Vincent"
# Hello Vincent

可是今天有個糊塗混蛋,我們估且先稱他為「隔壁老王」,他忘了輸入自己的名字,這樣會發生什麼情況呢?

curl localhost:10000
# <br />
# <b>Notice</b>:  Undefined index: name in <b>/Users/chivincent/PhpstormProjects/rafax/public/index.php</b> on line <b>5</b><br />
# Hello

Oh NO!我們的程式爆炸了 QAQQ。
PHP 對我們提出 Notice 層級的錯誤,表示我們嘗試存取未定義的索引值 name。
為了解決這個錯誤,我們需要對程式稍作修改

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

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

echo 'Hello ' . $_GET['name'] ?? 'World' . PHP_EOL;

在這裡,我們用了 ?? (NULL 合併運算子),這是 PHP 7 的新特性,表示當 $_GET['name'] 為 NULL 或未定義時,用其右邊的值取代之。
另一方面,我們最好在 composer.json 中提醒使用者,我們使用了 PHP 7 以上的特性:(註:此處省略了 composer.json 大部份未更動的部份)

{
    "require": {
        "php": ">=7.0.0"
    }
}

安全議題

絕對 不要相信使用者的任何資料。
在畫面上未經處理直接顯示使用者輸入的資料無疑是件愚蠢的事,這會讓你的應用程式存在 XSS (Cross-site Scripting,跨網站指令碼) 安全弱點。

幸好,我們可以利用 PHP 內建的 htmlspecialchars() 這個函式對字串進行預處理。

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

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

$name = $_GET['name'] ?? 'World';
$name = htmlspecialchars($name, ENT_QUOTES, 'UTF-8');

header('Content-Type: text/html; charset=utf-8');
echo "Hello $name" . PHP_EOL;

老實說,如果在每次輸出資料時都必須套用 htmlspecialchars() 其實是相當麻煩的。
在未來的系列文中,我們將會用模板(template)自動化地避免 XSS 攻擊的可能性。

討論:為何使用 htmlspecialchars() 而非 htmlentities()

當輸入內容僅使用 ASCII 時,則 htmlspecialchars()htmlentities() 的輸出會是完全相同的。
然而當處理 UTF-8 或是具有中文內容時 htmlentities() ,有可能會出現亂碼的情況,更糟的是,在某些很罕見的情況下(舊式瀏覽器在編碼不明確加上使用舊版 PHP 時),仍有可能在使用 htmlentities() 的情況下觸發 XSS。

可測試性

聊完安全議題後,讓我們談談「可測試性」。在堅實的測試下,我們有憑據證實自己的程式是足夠強韌,同時也讓自己擁有重構(Refactoring)的基礎。
先讓我們針對既有的程式進行測試看看,在測試之前,你需要用到 phpunit/phpunit 這套簡易卻強大的單元測試框架。

composer require --dev phpunit/phpunit

同時你可能會習慣用 namespace 分隔不同的專案,我們需要對既有的 composer.json 進行一些微幅的修改

{
  "require-dev": {
    "phpunit/phpunit": "^6.5"
  },
  "autoload": {
    "psr-4": {
        "Rafax\\Tests\\": "tests/rafax/"
    }
  }
}

同時建立測試稿 rafax/tests/rafax/IndexTest.php

// rafax/tests/rafax/IndexTest.php
<?php

namespace Rafax\Tests;

use PHPUnit\Framework\TestCase;

class IndexTest extends TestCase
{
  public function testVincent()
  {
    $_GET['name'] = 'Vincent';
    
    ob_start();
    include __DIR__ . '/../../public/index.php';
    $content = ob_get_clean();
    
    $this->assertEquals('Hello Vincent'.PHP_EOL, $content);
  }
}

最後,利用 php vendor/bin/phpunit --stderr tests/rafax/IndexTest.php 指令進行單元測試

光是為了考慮 header('Content-Type: text/html; charset=utf-8') ,我們必須在測試時額外加上 --stderr 參數避免 PHP 錯誤訊息出現。
再者,為了取用資料輸入及輸出,我們利用 PHP 的原生函式 ob_start()ob_get_clean() 開啟並取得輸出暫存(Output Buffer)才得以測試我們的小程式。

這還只是一個 10 行不到的小程式,卻動用了一大堆不可複用的方法進行測試,這對於程式設計沒有絲毫幫助,也無法愉快地寫程式。
為了解決這個問題,我們將在明天使用 Symfony HttpFoundation 來幫助我們進行框架的構建,並且以物件導向程式設計的方式進行 HTTP 協定下的資料交換。

參考資料

  1. GitHub sebastianbergmann/phpunit: Cannot modify header information - headers already sent by ...
  2. GitHub sebastianbergmann/phpunit: "headers already send" problem in isolated tests when running with PHAR

上一篇
準備工作
下一篇
加入 Symfony HttpFoundation 元件
系列文
重新理解 PHP:從頭打造 Web Framework9
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言