開發日記 — 重構結帳流程 & 統一錯誤處理格式

J米的學習日記
Parenting 數位研發
17 min readAug 17, 2023
Alert Error Message 作者:freepik 圖片來源

前言

久久沒紀錄,這次要記錄的是重構專案內的所有錯誤代碼,內容很無趣沒錯,但又非常重要,一髮不可牽,牽一髮動全身。

身為工程師,都會遇到盤查 Bug,但遇到錯誤訊息散落各地,東噴一個、西噴一個,錯誤訊息沒有鑑別性,發生錯誤時,每次都是嶄新的開始,讓你體驗完整的名偵探柯南,上述當然是反諷。

身為工程師,當然希望程式碼乾淨無瑕,潔白剔透,但有時候人在江湖,身不由己,總會遇到技術債,因此我就問一句,江湖上傳言:「 窮則獨善其身,達者兼善天下 」,此時的你,該如何處置?

A. 眼不見為淨,寫好自己的程式碼

B. 大刀闊斧,建立一套新制度,讓錯誤訊息與代碼,按照一定的邏輯與順序去通知給對的人

此時的我,不是選 A 或 B,筆者選擇 C,筆者想兼善天下但心臟比較小,因此選擇小範圍的變動作為一個簡單的 Best Practice。

步驟一:團隊共識

筆者所在技術團隊,有折價券與會員權益兩項會員可使用的優惠,但購物車不可以同時使用,只能選擇其一,前端本身會有防呆阻擋,後端是最後一道守門員,也會盡全力阻擋錯誤的使用行為,但過去錯誤的資訊,筆者團隊是顯示系統錯誤,一些詳細資訊送至 ELK,也比較沒有紀錄發生錯誤的原因,在盤查問題時,會花比較多時間追到 Log,並重新去看整個商業邏輯。

// 原始長相
{
'code': 400,
'message': '系統錯誤',
'data': []
}
// 方案一
{
'code': 400,
'message': {
'errorCode': '1001',
'errorMsg': '結帳同時使用權益與優惠券',
},
'data': []
}
// 方案二
{
'code': 400,
'message': '結帳同時使用權益與優惠券',
'data': {
'errorCode': '1001',
}
}
// 方案三
{
'errorCode': '1001',
'errorMsg': '結帳同時使用權益與優惠券',
'data': []
}

最後決定為方案二,方便前端串接與後續修改維護,主要也是考量到現有系統是否能正常運行。

步驟二:集中處理錯誤代碼與回傳格式

  1. 定義前後端錯誤的訊息代碼,後續如果要維護與新增錯誤代碼,可以有快速新增與盤查。
<?php

namespace App\Http;

class FrontendErrorCode
{
const DEFAULT_ERROR = '系統錯誤';
// 公版 Error code and message,可根據使用情境添減,或是客製化 error message
public const CODE_MESSAGE = [
'0000' => '成功',
// prefix 1 權限/token/其他

// prefix 2 系統相關 API route not found/限制 IP/其他
// prefix 3 資料驗證 request 參數相關,包含參數未帶等等
'3001' => self::DEFAULT_ERROR,
// prefix 4 需顯示於前端的商業邏輯錯誤要回吐前端, httpCode 顯示為 200
'4003' => '您的訂單商品已超出海外國家限制重量,請調整商品品項',
'4004' => '海外運送運費計算問題,請洽客服',
// 以下省略
// prefix 5 第三方相關/DB系統/REDIS系統相關
// prefix 6 開頭為商業邏輯非預期錯誤 httpCode 顯示為 400
'6001' => self::DEFAULT_ERROR,
'6002' => self::DEFAULT_ERROR,
// prefix 7 後端商業邏輯錯誤 httpCode 顯示為 400
];
}
<?php

namespace App\Http;

class BackendErrorCode
{
// 公版 Error code and message,可根據使用情境添減,或是客製化 error message
public const CODE_MESSAGE = [
'0000' => '',
// prefix 1 權限/token/其他

// prefix 2 系統相關 API route not found/限制 IP/其他
// prefix 3 資料驗證 request 參數相關,包含參數未帶等等
'3001' => 'validate params error, lack of required data',
// prefix 4 需顯示於前端的商業邏輯錯誤要回吐前端, httpCode 顯示為 200
'4003' => 'abroad shipping error, products are overweight',
'4004' => 'abroad shipping error, country shipping abroad is lack of shipping setting',
// prefix 5 第三方相關/DB系統/REDIS系統相關
// prefix 6 開頭為商業邏輯非預期錯誤 httpCode 顯示為 400
'6001' => 'business layer error',
'6002' => 'database cannot get specific data',
// prefix 7 後端商業邏輯錯誤 httpCode 顯示為 400
];
}

3. 自定義 Exception,有一個固定格式去承接錯誤訊息

觀看完文件後,發現可以繼承 Exception,並客製化團隊的錯誤訊息。

<?php

namespace App\Exceptions;

use Exception;
use App\Http\BackendErrorCode;
use App\Http\FrontendErrorCode;
use Illuminate\Support\Facades\Log;

class CustomErrorException extends Exception
{
private $errorCode;
private $displayMessage;

/**
* 自定義訊息優先於公版 Error Message
* @param int $code
* @param string $errorCode 系統錯誤碼
* @param string $displayMessage 顯示前台訊息
* @param string $message 系統訊息
*/
public function __construct(int $code = 400, string $errorCode = '6001', string $displayMessage = '', string $message = '')
{
$this->errorCode = $errorCode;
if (isset(BackendErrorCode::CODE_MESSAGE[$errorCode]) && empty($message)) {
$message = BackendErrorCode::CODE_MESSAGE[$errorCode];
}
if (!empty($displayMessage)) {
$this->displayMessage = $displayMessage;
} elseif (isset(FrontendErrorCode::CODE_MESSAGE[$errorCode])) {
$this->displayMessage = FrontendErrorCode::CODE_MESSAGE[$errorCode];
}

parent::__construct($message, (int) $code);
}

public function getErrorCode(): string
{
return $this->errorCode;
}

public function getDisplayMessage(): string
{
return $this->displayMessage;
}
}

將結帳錯誤的 Exception,繼承剛剛建立的 CustomErrorException,並客製化團隊要 render 給前端的文字。

<?php

namespace App\Exceptions;

use App\Cart;
use Exception;
use Illuminate\Support\Facades\Log;
use Throwable;
use App\Services\NotificationService;

class PaidFailedRestfulException extends CustomErrorException
{
private $context = [];

/**
* Render an exception into an HTTP response.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function render($request)
{
// 處理傳給前端的文字與邏輯
}

public function addContext(array $context): void
{
$this->context = array_merge($this->context, $context);
}
}

4. 根據不同情境統一回傳格式,筆者先將回傳格式分為 ServiceReturns 與 RestfulReturns,這麼一來筆者此次重構的程式碼都規範要用 ReturnFormat 回傳,就不會讓回傳各自為政。

<?php

namespace App\Entities\ReturnFormat;

use Illuminate\Support\Facades\Log;

class ServiceReturns
{
/**
* @var array
*/
protected $format = [
'status' => true,
'error_code' => '0000',
'message' => '',
'data' => []
];

/**
* @return array
*/
public function getFormat(): array
{
return $this->format;
}

/**
* @param array $format
* @return void
*/
public function setFormat(array $format): void
{
$this->format = $format;
}

/**
* @param bool $status
* @return void
*/
public function setStatus(bool $status): void
{
$this->format['status'] = $status;
}

/**
* @param string $errorCode
* @return void
*/
public function setErrorCode(string $errorCode): void
{
$this->format['error_code'] = $errorCode;
}

/**
* @param string $message
* @return void
*/
public function setMessage(string $message): void
{
$this->format['message'] = $message;
}

public function setData($data): void
{
$this->format['data'] = $data;
}

/**
* @param string $errorCode
* @param string $logErrorContext
* @param $logPayload
* @param string $backendErrorMsg
* @return void
*/
public function setErrorPayload(string $errorCode = '', string $logErrorContext = '', $logPayload = null, string $backendErrorMsg = ''): void
{
$this->setStatus(false);
$this->setErrorCode($errorCode);
$this->setMessage($backendErrorMsg);
if (!empty($logErrorContext)) {
Log::error($logErrorContext, [
'data' => $this->getFormat(),
'customize_payload' => $logPayload
]);
}
}
}
<?php

namespace App\Entities\ReturnFormat;

class RestfulReturns
{
/**
* @var array
*/
protected $format = [
'code' => 200,
'message' => '',
'data' => []
];

/**
* @return array
*/
public function getFormat(): array
{
return $this->format;
}

/**
* @param array $format
* @return void
*/
public function setFormat(array $format): void
{
$this->format = $format;
}

/**
* @param int $code
* @return void
*/
public function setCode(int $code): void
{
$this->format['code'] = $code;
}

/**
* @param string $message
* @return void
*/
public function setMessage(string $message): void
{
$this->format['message'] = $message;
}

/**
* @param array $data
* @return void
*/
public function setData(array $data): void
{
$this->format['data'] = $data;
}

/**
* @return int
*/
public function getHttpCode(): int
{
// 2 開頭為正常
if (preg_match('/^2/', (int) $this->format['code'])) {
return $this->format['code'];
}
if (!isset($this->format['data']['error_code'])) {
return 400;
}
// 後續如遇到需要吐給前端的 API,要規範其顯示為 200
if (preg_match('/^4/', (int) $this->format['data']['error_code'])) {
return 200;
}
return 400;
}
}

步驟三:重構程式碼的商業邏輯

此次重構不會大幅調動原有邏輯,會將程式碼共用的邏輯放在 Service,與資料庫交互的邏輯放在 Repository,並用 try … catch 將 Controller & Service 邏輯包覆起來,把錯誤訊息利用 Exception 承接起來,集中化去回吐錯誤訊息並做處理。

因為結帳的程式比較不容易講解,筆者有重構運費計算的 API,邏輯比較簡單,可以將重點擺在這次錯誤代碼整理。

public function calculateShippingFee(Request $request): \Illuminate\Http\JsonResponse
{
try {
$products = $request->post('products') ?? [];
$countryId = $request->post('country_id') ?? 0;

$response = new RestfulReturns();
// 1. 缺少參數,回吐 3001 缺乏參數錯誤
if (empty($products) || empty($countryId)) {
throw new ProductErrorRestfulException(400, '3001', '運費計算錯誤');
}
// 2. 計算運費
$shippingResult = app(ProductService::class)->calculateShippingFee($products, $countryId)->getFormat();
if (!$shippingResult['status']) {
throw new ProductErrorRestfulException(400, $shippingResult['error_code'], '', $shippingResult['message']);
}
$response->setData($shippingResult['data']);
return response()->json($response->getFormat(), $response->getHttpCode());
} catch (\Throwable $e) {
$response->setFormat([
'code' => $e->getCode(),
'message' => method_exists($e, 'getDisplayMessage') ? $e->getDisplayMessage() : '運費計算錯誤',
'data' => [
'error_code' => method_exists($e, 'getErrorCode') ? $e->getErrorCode() : '4001',
]
]);
Log::error(__CLASS__ . '::' . __FUNCTION__ . 'Error:', $response->getFormat());

return response()->json($response->getFormat(), $response->getHttpCode());
}
}
  1. 當參數缺少時,會發生錯誤,catch 會接到錯誤,筆者定義好 RestfulReturns 派上用場,會有統一格式去傳送給前端。
  2. 當參數正常,進入到 ProductService 計算 calculateShippingFee 運費,calculateShippingFee 會有許多檢查與計算,會定義 ServiceReturns 去將資料或是錯誤承接回 Controller。
    當 ServiceReturns 定義的 status 為 false,表示發生錯誤,catch 會接到錯誤,傳給前端。
    當 ServiceReturns 定義的 status 為 true,表示正常,可以把計算好的運費利用 RestfulReturns,傳給前端。

後記

先講成果,結帳與計算運費的主程式碼行數都減少 50 % 以上,程式碼的可讀性提升,這次 PM 要求的回傳錯誤代碼也如期完成目標,由後端統一邏輯控制與處理。

測試!測試!測試!這一點在上面都沒提到,所有修改,都需要將每個情境列出,並做測試,留作紀錄,不要有僥倖心態,常常墨菲定律就直接出現在你眼前,讓你哭笑不得。

說個但書,本文章的處理方式不見得適用每個公司的專案,還需要與團隊多加討論,擬定一個共識,讓團隊朝共識方向前進,希望文章提出的想法與概念可以提供一些參考與想法,如有任何問題可以 Email 與我聯絡,謝謝!。

本次重構購物車與計算運費商業邏輯,算是一個新的挑戰,筆者繼承接先人的功力(債?),並將錯誤處理有一個統一的格式與回拋的流程,挑戰成功,繼續往下一個大專案邁進!

--

--