寫出健壯的 PHP 應用程式(1): 防禦型程式寫法

Written by Simon Asika on

這幾年無論在帶團隊寫系統,還是自己開發 Opensource,感觸最深的其實是軟體健壯性這一塊。該怎麼描述軟體的健壯性呢,用 國家教育研究所 的定義來看吧,健壯性指的是:

軟體本身的周密程度。即撰寫程式時考慮到各種不同的使用情況,並事先加以定義處理,避免使用時產生錯誤。

關於健壯性

健壯性是一個很廣泛的討論主題,我想我也沒有能力描述的很完全,這裡以我自己本身常遇到的狀況來介紹一些概念,作為拋磚引玉。開發的時候顧及這些概念,可以讓系統出現未預期錯誤的機會降到最低。

這裡的錯誤不單純指系統 Error,甚至包含邏輯處理的錯誤(例如時間算錯、金錢算錯等等),都應該是開發過程中要極力避免的。但很可惜,在永遠不足夠的開發時間與無止盡的新需求下,我們很難有機會完整測試自己或團隊成員所寫的每一行 code 正不正確。此時正確的觀念可以幫助我們避開很多陷阱。

這篇文章對以下幾種人有特別高的重要性:

  • 你或你的團隊不是長年維護一套系統,而是必須常常快速佈署新系統,無法對每行 code 一改再改。
  • 你或你的團隊開發的軟體常常要在不同的環境中運行,面臨不同的 OS、語言版本。
  • 你或你的團隊開發的軟體,要面對多國語言、環境、文化等各方面要求,但不是每個客群都用一套獨立系統面對。
  • 你或你的團隊開發的軟體,會被用在各種配置不同、狀況極端且無法預測的環境(如虛擬主機)
  • 你或你的團隊開發的軟體,要處理不可輕易出錯的行為(如金流或精確的時間差)
  • 你或你的團隊正在開發市售軟體,要支援各種電腦環境或作業系統。
  • 你或你的團隊正在開發 Opensource、Framework、CMS 或各式各樣免費軟體。

這篇文章對以下幾種人可能不是有很大幫助

  • 你的團隊數十年如一日維護一套系統,建設新環境都有完整的 image 可以複製(bug慢慢解即可,壓力別那麼大)
  • 你的團隊其他成員都是閉著眼推 commit 讓正式機炸掉的 (我認真的,你處在這個環境就別看這篇文章了,去找如何讓生活更開心的文章看吧XD)
  • 閉著眼睛寫也超健壯的神人(我先拜)

好啦,喇賽完就進入正題吧。

防禦性程式寫法

這裡探討的是介面設計。團隊開發,尤其是負責寫核心功能與共用函式庫的,或者你本身就是在開發框架的時候,我們要僅記一個原則,絕對不要相信使用者送進來的參數值。運氣好的一開始就錯誤被發現到,運氣差的常常會等到上線時才發現。另一方面,動態語言的回傳參數常常也不確定類別,有些時候我們要讓 function 可以吃各種類型然後強制轉成我們要的類型。

有幾種方法可以面對不確定的錯誤數值(尤其在 php 這種動態語言中,做好檢查非常重要)

Type Hint 類別檢查

在 function 的參數前宣告類別,讓使用者一旦丟入錯誤變數就直接跳 Error,以免上線後才出問題:

public function __construct(array $bar, SomeObject $object = null)
{
    // Do some stuff
}

通常可以搭配預設值,要記住,有宣告 class 的參數只能允許 null 為預設值,所以當 null 送進來時,我們可以在 method 開頭做預設處理:

public function __construct(array $bar, SomeObject $object = null)
{
    // 這裡只有兩種可能, SomeObject 的子類別或 null
    $object = $object ? : new SomeObject;

    // Do some stuff
}

內部檢查

碰到 string 或 int 這種不能再參數區宣告的,我們就得在 function 開頭做好檢查:

public function __construct($string, $int, $array)
{
    // 最基本的檢查,型別不對就丟錯
    if (!is_string($string))
    {
        throw new InvalidArgumentException('Argument 1 should be string.');
    }

    // 這個檢查比較鬆一點,只要是數字都可以過,不一定要 int 型態
    if (!is_numeric($int))
    {
        throw new InvalidArgumentException('Argument 2 should be a number.');
    }

    // 這個檢查比較特別,如果是 Iterator 物件也能夠接受,因為同樣可以 foreach
    if (!is_array($array) && !($array instanceof Traversable))
    {
        throw new InvalidArgumentException('Argument 3 should be Traversable.');
    }

    // Do some stuff
}

自動轉換

自動轉換通常用在公開類別或方法,例如框架或函式庫,因為可能的使用情境太多變了,會盡量讓介面可以吃下各種內容做轉換,但要注意總有漏網之魚的。

public function foo($array)
{
    // 強制先轉成 array
    foreach ((array) $array as $val)
    {
        // Do some stuff
    }
}

例如上面無差別轉換 array,所以 string, int, object 進來後都可以正常 foreach。但要注意,string與int會被轉成陣列的第一個元素,而 object 則會取出所有的 properties(在某些 scope 下連 protected 都會被取出來),在極少數的情況下是有風險的。下面的寫法解決了這個問題:

public function foo($array)
{
    if (is_object($array))
    {
        // 用正確的方法取得 properties
        $array = get_object_vars($array);

        // 或者也可以轉成第一個元素,行為不一樣
        $array = array($array);
    }

    foreach ((array) $array as $val)
    {
        // Do some stuff
    }
}

還有一些寫法是運用特殊物件來處理能變動的陣列,例如如下的 function 就很危險:

public function foo(array $config)
{
    if ($config['driver'] == 'mysql')
    {
        // Do some stuff
    }
}

雖然 $config 限制是陣列,但不一定會有 driver 這個 index,一旦缺少就會跳 warning,我們可以用專用的 Config 物件來解決:

public function foo($config = array())
{
    // 如果不是 Config 物件,丟給 Config 物件做預處理
    if (!($config instanceof Config))
    {
        $config = new Config($config);
    }

    // 讓 config 的 getter 幫你取值
    if ($config->get('driver') == 'mysql')
    {
        // Do some stuff
    }
}

如上,假設 Config 的 get() 被設計會自動判斷 index 是否存在,就能夠避免錯誤發生。這裡有個很推薦的 Registry 物件專門用來處理這類 Config。

Fallback 與預設值

有時候,也許不是參數的型別錯誤,但照著這個參數的執行結果就是有問題,我們可以做一些還原機制:

public function getUser($userId = null)
{
    // 沒有值的時候,考慮從其他可能取得的地方拿出預先存好的值(這要看你的系統如何設定)
    if (!$userId)
    {
        $userId = Session::getUserId();
    }

    // 強制轉換成 int,移除不合法字元,除非你們用 UUID
    $userId = (int) $userId;

    // 假設我們這個方法是要從 Session 中拿暫存的 user 資料
    $user = Session::getUser($userId);

    if (!$user)
    {
        // 結果發現 Session 沒有,表示可能 Session 過期了,那就從DB中拿正確的user出來
        $user = Database::getOne('SELECT * FROM user WHERE id = ' . $userId);

        if (!$user)
        {
            throw new Exception;
        }

        // User 確定拿到了,我們把它存回 Session 中
        Session::setUser($userId, $user);

        // 再從 Session 拿出來一次
        $user = Session::getUser($userId);
    }

    return $user;
}

上面這個範例較為複雜,不過明確說明了我們的 function 如何隱藏實作細節,使用者並不知道 user 是從哪裡拿出來的,反正 Session 拿不到就跟 DB 要,有簡單的自我還原機制,也有點 Lazy loading 的風格。

這種錯誤我們稱作 Runtime Error,也就是無關乎程式設計的外部錯誤,例如外連的 SQL 是否正常運作?使用者會不會關閉 Cookie 讓 Session 也不能運作?剛安裝起來的程式會不會因為目錄權限不足無法寫入 log?因為我們無法在寫程式的時候預期這些狀況,都是執行期才會知道的,統稱 Runtime Error,這類錯誤都要盡可能先做一兩次修復機制,例如目錄權限不夠應該先嘗試偵測並打開權限:

$path = '/foo/bar.txt';

$dir = dirname($path);

// 取出原本的權限
$tmpP = Filesystem::getPermission($dir);

// 第一次判斷是否可寫
if (!Filesystem::isWritable($dir))
{
    // 不能就嘗試開權限,你不知道強制開權限系統會不會報錯,最保險是加 @ 來暫時隱沒
    @Filesystem::setPermission($dir, 755);
}

// 還是不能只好丟錯了
if (!Filesystem::isWritable($dir))
{
    // Reset 權限
    Filesystem::setPermission($dir, $tmpP);

    throw new RuntimeException($dir . ' not writable.');
}

// 寫入檔案
Filesystem::write($path, $data);

// Reset 權限
Filesystem::setPermission($dir, $tmpP);

真的還原失敗才報錯。這裡要注意,Reset 權限這個動作極為重要,你不知道你操作的這個目錄被誰使用?有多少安全顧慮? 所有對環境的操作都要還原,否則一樣會在你想休假時炸給你看,等著用休假時間疑惑的在茫茫網海找原因吧。這個關於環境的操作會留在之後的章節說明。

黑洞

黑洞泛指 NullObject 模式或者任何無行為的物件與回傳值。有時候我們的回傳值可能是空,此時要考慮該回傳什麼數值來給不知名的使用者,例如以下範例:

public function getItems()
{
    $items = Model::getItems();

    if (!$items)
    {
        return false;
    }

    return $items;
}

我們取得一個複數的資料,但內容為空的時候回傳了 false,這會造成不知情的使用者直接送進 foreach 然後報錯,所以這樣是比較安全的:

public function getItems()
{
    $items = Model::getItems();

    if (!$items)
    {
        return array();
    }

    return $items;
}

回傳一個空陣列,送進 foreach 不會錯誤,empty() 判斷也會是 true。但如果是物件的時候就麻煩了,因為空物件不是 empty,此時我們可以採用 NullObject pattern 來解決問題。

// 這是一個無論怎麼操作都不會報錯的 object
class NullObject
{
    public function __get($name)
    {
        return null;
    }

    public function __set($name)
    {
    }

    public function __call($name, $args)
    {
        return null;
    }

    public function isNull()
    {
        return true;
    }
}

public function getObject()
{
    $result = Model::getItem();

    if (!$result)
    {
        return new NullObject;
    }

    return $result;
}

$obj = getObject();

$obj->foo;
$obj->bar = '123';
$obj->yoo();

如此,NullObject不管怎麼被操作,都是永遠安靜的。很適合作為欺騙系統正常運行的手段。

下面這是一個連送進 foreach 都不會炸掉的 NullObject。

class NullObject implement IteratorAggregate
{
    public function __get($name)
    {
        return null;
    }

    public function __set($name)
    {
    }

    public function __call($name, $args)
    {
        return null;
    }

    public function isNull()
    {
        return true;
    }

    public function getItrator()
    {
        return new ArrayIterator(array());
    }
}

小結

防禦性程式寫法先寫到這裡,我相信還有無限多種組合與可能,無法一一列舉出來。不過應該可以發現,大多數的寫法強調如何避免錯誤,讓系統自我還原。這也是我這篇的主題「健壯性」所重視的。至於錯誤應該怎麼處理,Exception 應該怎麼接,則留給專門討論錯誤處理的文章吧。

要注意的是,防禦型寫法大多用在不確定環境的公開介面上,如果是私有函數,或者團隊內有約定的設計模式時,則應該要盡量讓錯誤的參數提早報錯,在第一時間就發現並修正。然後讓錯誤的回傳值盡量多一兩層修復機制,不要讓使用者察覺。如何拿捏就是一門藝術了。

這只是第一篇關於介面設計的章節,後續還會帶到版本的管理、環境的依賴,目的都是一樣的,讓系統不要輕易出錯,在最極端的環境都還是能正常運行。

感謝大家。

參見

Control Tools

WS-logo