2018年12月21日

PHP例外處理在商業邏輯上的應用(一)使用繼承

PHP exception handling in business logic - using Inheritance

前言

開發人員開發時經常會遇到一個狀況是,某支程式隨著需求不斷變更與增加,條件判斷越來越多、越來越複雜,程式的流程越來越混亂,閱讀程式時必須跳來跳去,程式漸漸變得難以理解,令人頭痛不已。

這種時候例外處理就可以派上用場了!

簡介

所謂的例外處理(Exception Handling),不外乎就是專門處理例外情況的發生,異常、錯誤等例外,這是現代程式語言常見的功能,隨著PHP版本的更新,PHP對例外處理的支援度及彈性也是越來越高。對開發人員來說,我們可以在程式中藉由妥善地利用例外處理機制,來增加程式的可讀性以及可維護性。

這邊是例外處理的基本形式:

try {
  throw new Exception('here is a exception!');
} catch(Exception $e) {
  echo $e->getMessage();
}

Output:

here is a exception!

本篇接著會舉一個簡單的案例,來說明如何在商業邏輯上套用PHP的例外處理功能,以及如何應用繼承的技巧,達成更好的例外處理方式。

案例

情境說明

小明到了線上購物平台上購買了一樣商品,在確認完資料後,他點擊了「送出訂單」,等待畫面上的圓圈轉了幾圈後,原以為購買完成了,卻跳出「訂單成立失敗」的訊息⋯⋯

雖然看到這樣的訊息令人失望,但小明會看到這樣的錯誤訊息,正式因為程式有好好的檢查資料正確性的緣故。

即便使用者從畫面上按下了「送出訂單」,但總不可能就這樣讓他毫無節制的成立訂單吧!除了程式、資料會出錯以外,還經常會造成一些交易糾紛,所以稱職的後端工程師必須在訂單真正成立之前,經過一連串嚴密的檢查,來避免這些事情的發生。

處理 - 使用if-else

為了要處理那一系列的判斷,一開始工程師可能會採用if-else來針對每項檢查來做處理。就訂單成立前要做的檢查而言,包括比如說商品是否已經下架不能購買了、買家所購買的數量不足⋯⋯等等。

class Order 
{
  public function check() 
  {
    // 檢查是否可以被購買
    if (!$this->isGoodsCanBePurchased()) {
        return '無法購買此商品';
    }

    // 檢查庫存數量是否足夠
    if (!$this->isInventoryEnough()) {
      return '庫存不足';
    }

    return '';
  }

  private function isGoodsCanBePurchased() { /* omitted */ }    
  private function isInventoryEnough() { /* omitted */ }    

  public function create() { /* omitted */ }    
}

在成立訂單的Controller中會有這樣的邏輯:

$order = new Order($orderData);
$result = $order->check();
if ($result != '') {
  echo $result;
} else {
  $order->create();
}

成立訂單時,先判斷$order->check()的回傳結果是否正確,如果沒有錯誤,才繼續往下執行成立訂單的動作。這樣的程式正確性沒有問題,但是閱讀程式途中總是有點不順暢的感覺,比較難直接清楚地知道下一步的行為是什麼,可讀性還有改善的空間。

處理 - 使用Exception

為了改善可讀性,我們可以將$order->check()中的行為改寫成當訂單檢查沒有通過時,直接往外拋Exception,並且在Controller中,使用try-catch來捕捉Exception後做對應處理,來讓主流程的程式行為更明顯。

class Order 
{
  public function check() 
  {
    // 檢查是否可以被購買
    if (!$this->isGoodsCanBePurchased()) {
      throw new Exception('無法購買此商品');
    }

    // 檢查庫存數量是否足夠
    if (!$this->isInventoryEnough()) {
      throw new Exception('庫存不足');
    }
  }

  private function isGoodsCanBePurchased() { return/* omitted */ }    
  private function isInventoryEnough()  { /* omitted */ }    

  public function create() { /* omitted */ } 
}

Controller:

try {
  $order = new Order($orderData);
  $order->check();
  $order->create();
} catch (Exception $e) {
  echo $e->getMessage();    
}

從範例中,我們可以比較清晰地看出程式流程:做完檢查$order->check()後,成立訂單$order->create()。訂單檢查有發現錯誤時,會直接拋出Exception來中斷主流程的進行,不會往下執行成立訂單,而是直接跳到catch區塊做錯誤處理,這樣一來也可以確保程式的的正確性沒有問題,不會執行不該執行的步驟。

處理 - 使用Exception搭配繼承

但是需求總是會不斷變更的,某天PM突然提出新需求,要求工程師除了要阻擋錯誤的訂單成立以外,還要搭配不同的錯誤,進行不同的額外處理,比如說,轉址到不同頁面、紀錄錯誤log或在DB中寫下一些資訊,這種時候繼承就可以幫上忙了!

首先我們利用繼承定義一系列訂單檢查相關的Exception:

\\訂單檢查主要的Exception
class OrderCheckException extends Exception{} 

\\商品無法購買的Exception
class GoodsCannotBePurchasedException extends OrderCheckException{} 

\\庫存不足的Exception
class StockNotEnoughException extends OrderCheckException{} 

接著在Class中改用新定義的Exception,當發生對應的狀況時,就丟出對應的例外類別。

class Order 
{
  public function check() 
  {
    // 檢查是否可以被購買
    if (!$this->isGoodsCanBePurchased()) {
      throw new GoodsCannotBePurchasedException();
    }

    // 檢查庫存數量是否足夠
    if (!$this->isInventoryEnough()) {
      throw new StockNotEnoughException();
    }
  }
}

接著在Controller中改寫成針對不同例外做不同處理。

try {
  $order = new Order($orderData);
  $order->check();
  $order->create();
} catch (GoodsCannotBePurchasedException $e) {
  // 商品不能被購買 -> 重新導向首頁
  redirectToHomePage();
} catch (StockNotEnoughException $e) {
  // 庫存不足 -> 重新導向回商品頁
  redirectToGoodsPage();   
} catch (OrderCheckException $e) {
  writeOrderErrorLog($e->getMessage());
}

一樣,主流程很清楚,不用在主流程中費力處理例外狀況。而例外狀況會在每個catch區塊中處理,針對商品不能購買時候錯誤,可以重新導向首頁,庫存不足時,則可以導向回商品頁,讓用戶重新選庫存。

而最後一個catch中所捕捉的例外OrderCheckException,並且也可以捕捉所有繼承OrderCheckException的類別,則是利用物件導向多型的技巧。

$order->check()中有其他判斷,但是並不需要特殊處理時,我們可以直接拋出OrderCheckException或是其他繼承OrderCheckException的例外類別,讓最後一個catch可以判別是order檢查時拋出的錯誤,來記錄訂單的錯誤log,以供備查。

結語

本篇使用的例子是非常簡化過的,實際上可能會有更複雜的狀況,大家可以試著在程式中套用例外處理來改善程式流程~

本篇程式碼執行環境

OS: Mac OS Mojave 10.14.2
PHP: PHP 7.3.0