這幾年無論在帶團隊寫系統,還是自己開發 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 應該怎麼接,則留給專門討論錯誤處理的文章吧。
要注意的是,防禦型寫法大多用在不確定環境的公開介面上,如果是私有函數,或者團隊內有約定的設計模式時,則應該要盡量讓錯誤的參數提早報錯,在第一時間就發現並修正。然後讓錯誤的回傳值盡量多一兩層修復機制,不要讓使用者察覺。如何拿捏就是一門藝術了。
這只是第一篇關於介面設計的章節,後續還會帶到版本的管理、環境的依賴,目的都是一樣的,讓系統不要輕易出錯,在最極端的環境都還是能正常運行。
感謝大家。
from : http://asika.windspeaker.co/post/3502-strong-php-1-defensive-programming
沒有留言:
張貼留言