本文的主題是怎么組織php的異常?在大型項目中異常往往被我們忽略,但是如果前期沒有很好的規(guī)劃好,越到項目后期,重構(gòu)的成本會越大。
在實際工作中,對于錯誤的處理,我們一幫都是直接返回錯誤號,然后從最內(nèi)層一層一層往外面?zhèn)?,最后將錯誤返回給用戶,很少使用異常,可能是因為公司里最初寫代碼比較早,13,14年開始使用php,當時第一批使用者是從C轉(zhuǎn)過來的,從而沒有使用異常,導致現(xiàn)在都16年了,php都出7了,我們在實際代碼中還是沒有使用異常,我前不久在項目中引入了異常,但也是簡單的使用try catch,沒有很多的經(jīng)驗,網(wǎng)上搜索也只是簡單的一些使用例子,沒有說在大型項目中怎么去使用,最近也是在讀The Clean Architecture in php,深知代碼組織的重要性,如果前期沒有很好的組織好,后期的維護,重構(gòu)代價都會很大,今天看到兩篇文章:
A Crash Course of Changes to Exception Handling in PHP 7
所以就有了本文。寫這篇文章的目的是探討一些在實際中怎么使用異常的方式,也希望得到大家的反饋,大家平時在開發(fā)中是怎么使用異常的?如何組織的。
為什么還使用異常?
在討論使用異常之前,我們得統(tǒng)一認識:使用異常對項目是有益的。我們看看沒有異常的時候,我們的處理方式。
返回錯誤號
function foo($arrInput) {
if ($arrInput['user_id']<0){
return -1; // 參數(shù)錯誤
}
// something else
}
當程序遇到錯誤時返回一個錯誤碼,使用這種方式的好處是:我們每次在調(diào)用完函數(shù)后,都會檢查返回值,當出現(xiàn)錯誤的時候,馬上進行處理。
但是壞處也很明顯:錯誤的處理和正常的業(yè)務邏輯耦合在了一起,我們平時開發(fā)中一個很惱人的感觸就是:寫一個業(yè)務邏輯,可能異常錯誤處理就占了2/3的代碼,愁人啊,于是有人就發(fā)明了異常。
在php中對錯誤的處理有兩種,一種是error和warnings,另一種是異常。
errors & warnings
php中的errors和warnings來源于過程式的代碼,在過程式代碼中,我們按照既定的步驟一步一步執(zhí)行,此時如果出現(xiàn)了錯誤,我們必須要將程序的控制權(quán)接管過來,在PHP中是通過 set_error_handler 方法來設(shè)置處理函數(shù)的,但是這種方式?jīng)]能提供一種有效的錯誤恢復手段,你可能除了打印下錯誤信息后,沒有足夠的錯誤發(fā)生時的上下文信息讓你來恢復錯誤了。
exceptions
一般我們使用異常的代碼如下:
try {
find_slash(string);
} catch(AnException& e) {
//Handle exception
}
這樣做的好處是:程序邏輯和錯誤處理分離了。你可以看到函數(shù)是如何工作的,同時也可以看到失敗時候是怎么處理的。另外,現(xiàn)在可以提供更多的異常發(fā)生的上下信息,幫助你從發(fā)生的異常中恢復出來。
舉個例子:當從數(shù)據(jù)庫中獲取一條記錄的時候發(fā)生了異常,我們可以根據(jù)異常的不同類型,采取不同的結(jié)果。如果異常時由于沒有我們想要的id記錄,我們可能返回一個NullObject 是更好的方式,但如果異常是由于數(shù)據(jù)庫連接的斷開,我們可能會繼續(xù)拋出異常,讓異常被更上層的函數(shù)看到,因為這個異常在此處我們已經(jīng)沒有能夠恢復的方法了。
通過SPL來構(gòu)建異常
Standard PHP Library (SPL) 標準庫中提供了一些predefined set of exceptions,我們可以基于這些預定于的異常進行擴展,得到滿足我們自己需求的代碼。這樣子做的好處是,我們能夠很方便的捕獲(catch)這些異常。
此處提供一個組織異常的方案:standard set of exception groupings 是一些預定義的異常,每次在使用的使用,通過composer引入。通過引入這一抽象層的目的是:讓我能更好的區(qū)分想要捕獲異常的粒度。
在standard set of exception groupings 中的每個異常,都extend了SPL中的異常,而且實現(xiàn)了BrightNucleus\Exception\ExceptionInterface 接口,這么做可以方便我只捕獲框架相關(guān)的異常,通過只捕獲實現(xiàn)了接口的異常。
下面列舉了捕獲不同粒度的異常的方法:
- Catch all exceptions
catch( Exception $exception ) {} - Catch all exceptions thrown by a Bright Nucleus library
catch( BrightNucleus\Exception\ExceptionInterface $exception ) {} - Catch a specific SPL exception (BrightNucleus or not)
catch( LogicException $exception ) - Catch a specific SPL exception thrown by a Bright Nucleus library
catch( BrightNucleus\Exception\LogicException $exception ) {}
命名規(guī)范
目前命名的一個原則是:
- 該異常如果代表一個具體的錯誤,則使用一個過去時態(tài)的語句表明錯誤發(fā)生的原因
- 如果異常是一個基類,需要別的類進行擴展,則統(tǒng)一后綴
Exception
看一個具體的例子:
假設(shè)我們有一個功能是從文件中讀取內(nèi)容,可能會有3種錯誤發(fā)生:
- 文件名不合法
- 文件不存在
- 文件不可讀
此時會有3種錯誤:
FileNameWasNotValid extends InvalidArgumentException
FileWasNotFound extends InvalidArgumentException
FileWasNotReadable extends RuntimeException
此時具體的錯誤都是過去式的句子,而基類都是帶有統(tǒng)一后綴的。
通過構(gòu)造函數(shù)捕獲異常邏輯
我們一般在實例化異常的時候,都是直接在使用的時候才去new出來,但是這種方式導致異常的代碼可能會比正常的業(yè)務邏輯還負雜,非常不適合閱讀,而且將相同的實例化邏輯放的到處都是,也不符合代碼重用的原則,我們舉個例子:
public function render( $view ) {
if ( ! $this->views->has( $view ) ) {
$message = sprintf(
"The View "%s" does not exist.",
json_encode( $view )
);
throw new ViewWasNotFound( $message );
}
echo $this->views->get( $view )->render();
}
上面的代碼中異常的處理邏輯比正常的業(yè)務邏輯還多,我們重構(gòu)下,將異常的構(gòu)建封裝起來:
class ViewWasNotFound extends InvalidArgumentException {
public static function fromView( $view, $code = null, Exception $previous = null ) {
$message = sprintf(
"The View "%s" does not exist.",
json_encode( $view )
);
return new static( $message, $code, $previous );
}
}
我們可能會有多個構(gòu)造函數(shù),每個構(gòu)造函數(shù)有不同的應用場景,此時我們再來寫我們的render函數(shù):
public function render( $view ) {
if ( ! $this->views->has( $view ) ) {
throw ViewWasNotFound::fromView( $view );
}
echo $this->views->get( $view )->render();
}
現(xiàn)在代碼就非常簡潔了。
異常捕獲
問:我們需要捕獲什么異常?
答:只捕獲當前上下文下能夠處理的異常。
如果當前操作返回NullObject也ok,那在最外層套一個catch( Exception $exception ) {}就完全ok。但是如果當前操作只有正確才能保證后續(xù)操作繼續(xù),那你可能就需要捕獲那些你當前能恢復的異常,那些不能恢復的異常,則讓它往更上層去。
在SPL中,我們定義了兩大類異常:
-
Logic exceptions
邏輯異常是那些由于開發(fā)者的錯誤而導致的異常。你可能在請求一些不存在的值,或者調(diào)用傳遞的參數(shù)不對等等。這些異常在開發(fā)中都需要我們馬上處理掉的。在理想情況下,這些邏輯異常在實際生產(chǎn)系統(tǒng)中是不應該出現(xiàn)的。
-
runtime exception
運行時異常是一些在開發(fā)中不能控制的異常,如:數(shù)據(jù)庫鏈接的異常斷開,文件的讀寫權(quán)限不對等等。這些錯誤是無法避免的,我們不可能開發(fā)一個沒有錯誤的系統(tǒng),我們能做得只是當這些錯誤發(fā)生的時候,盡快的去通知系統(tǒng)管理員,而不是代碼出現(xiàn)fatal。
這就是為什么我們在開發(fā)中需要在某一軟件層捕獲運行時錯誤,而對于邏輯錯誤,我們盡可能讓它在開發(fā)時就讓他們暴露出來,好讓我們在開發(fā)時就解決它。
中心化的Error處理函數(shù)
我們將邏輯異常都pass through,沒有去捕獲,那么作為一個web應用,我們不能讓用戶無響應啊,因此我們需要通過一個中心化的處理函數(shù)來捕獲所有我們沒有處理的異常。
捕獲后,我們一般的工作是:記錄這些異常,記錄調(diào)用棧,方便我們?nèi)シ治鼋鉀Q這些問題。
對于這個工作,我推薦使用 BooBoo 來做。
總結(jié)
此處總結(jié)下我們的原則:
- 對于運行時異常,我們盡量捕獲然后進行處理,重要的上報錯誤,讓管理員知道系統(tǒng)異常,而對于邏輯異常我們則是將其盡可能詳細的記錄下來,因為這些錯誤理論上是不應該出現(xiàn)在生產(chǎn)環(huán)境中。
- 我們在捕獲異常的時候,只捕獲在該層級能處理的異常,對于不能處理的則讓它到上一層上去。
- 我們需要一個全局的異常處理函數(shù),處理如返回html,json這種格式問題,以及處理錯誤信息的轉(zhuǎn)換(隱藏系統(tǒng)內(nèi)部錯誤信息),錯誤的記錄,現(xiàn)場環(huán)境的保存等公共邏輯。
一個示例
講了這么多,還是那句話
talk is cheap, show me the code
我們基于的一個基本代碼是:
$user = $this->usersGateway->fetchOneById($userId);
if (!$user) {
throw new Exception('User with the ID: ' . $userId . ' does not exist');
}
用戶定義異常
上面針對找不到user的情況,我們只是簡單的拋出了異常。但是上面的問題是:僅僅拋出異常不足以幫助我們定位問題,單一的異常類型,不能讓我們針對不同的類型做出不同行為,因此解決方法是自定義異常。
class UserNotFoundException extends RuntimeException
{
}
//...
throw new UserNotFoundException('User with the ID: ' . $userId . ' does not exist');
格式化異常
現(xiàn)在我們已經(jīng)有了異常類,并且異常的生成和異常消息都是異常類本身的職責,因此我們根據(jù)單一職責(SRP)將其組織到異常類中:
class UserNotFoundException extends RuntimeException
{
public static function forUserId(string $userId) : self
{
return new self(sprintf(
'User with the ID: %s does not exist',
$userId
));
}
}
在使用異常的地方我們簡單的調(diào)用下面的代碼:
throw UserNotFoundException::forUserId($userId);
聚合異常
根據(jù)單一職責(SRP)我們將相同異常放到一起,不同的功能拆分出來,看例子:
class UserException extends Exception
{
public static function forEmptyEmail() : self
{
return new self("User's email must not be empty");
}
public static function forInvalidEmail(string $email) : self
{
return new self(sprintf(
'%s is not a valid email address',
$email
));
}
public static function forNonexistentUser(string $userId) : self
{
return new self(sprintf(
'User with the ID: %s does not exist',
$userId
));
}
}
在上面的例子中,異常類UserException有兩個功能,第一個負責User的驗證異常,另一個則是沒有用戶的異常,因此我們應該拆分為兩個:
class InvalidUserException extends DomainException
{
public static function forEmptyEmail() : self
{
return new self("User's email address must not be empty");
}
public static function forInvalidEmail(string $email) : self
{
return new self(sprintf(
'%s is not a valid email address',
$email
));
}
}
class UserNotFoundException extends RuntimeException
{
public static function forUserId(string $userId) : self
{
return new self(sprintf(
'User with the ID: %s does not exist',
$userId
));
}
}
此時我們就能針對不同的異常類采取不同的措施,可能我們會根據(jù)異常類返回合適的 HTTP status codes。
異常代碼
異常的構(gòu)造函數(shù)接受code作為第二個參數(shù),所以我們可以通過不同的錯誤碼來標志不同的錯誤。
class UserNotFoundException extends RuntimeException
{
public static function forUserId(string $userId) : self
{
return new self(
sprintf(
'User with the ID: %s does not exist',
$userId
),
ErrorCodes::ERROR_USER_NOT_FOUND
);
}
}
我們會將所有的錯誤碼都放到一個文件中,方便管理。
組件級別的異常
當我們提供一個庫給別人使用的時候,我們可能希望能夠捕獲我們庫級別的異常,這通過一個模式Marker Interface可以實現(xiàn):
namespace App\Domain\Exception;
interface ExceptionInterface
{
}
class UserNotFoundException extends RuntimeException impements ExceptionInterface
{
public static function forUserId(string $userId) : self
{
return new self(
sprintf(
'User with the ID: %s does not exist',
$userId
),
ErrorCodes::ERROR_USER_NOT_FOUND
);
}
}
我們通過在每個命名空間都聲明一個ExceptionInterface類來實現(xiàn),這樣我們就可以通過代碼try{}catch(ExceptionInterface $e){}來捕獲所有本庫的錯誤。
錯誤處理
上代碼:
class UserController extends BaseController
{
public function viewUserAction(RequestInterface $request)
{
try {
$user = $this->userService->get($request->get('id'));
return new JsonResponse($user->toArray());
} catch (\Exception $ex) {
return new JsonResponse([
'error' => $ex->getCode(),
'message' => $ex->getMessage(),
], 500);
}
}
}
上面的處理中,我們在controller中通過一個最外層的try{}catch{}捕獲了所有異常,但是我們針對不同的需求可能會有不同的返回格式的要求,可能我們需要針對參數(shù)的不同返回html或者json格式,另外我們也不希望底層的錯誤信息,如:數(shù)據(jù)庫連接失敗,這樣子的錯誤信息直接返回給調(diào)用方,那怎么解決呢?
這就要用到PHP的全局異常處理函數(shù)了,通過set_exception_handler來設(shè)置,另外推薦除了 BooBoo 另外一個開源庫:Whoops,能很好的解決這個問題。
你的觀點
相信你在實際工作中肯定也遇到過好多類似的困擾,你在實際工作中也有你自己的一套解決方案,期待你的分享,讓更多的人知道好的優(yōu)秀的方案,所以期待你在評論區(qū)寫下你的方案。
這是 php異常系列 的第一篇,你的鼓勵是我繼續(xù)寫下去的動力,期待我們共同進步。