在这篇文章中,我想制定一个详细的实验步骤来说明每种解决方案的利弊,来证明为什么抛出异常比返回错误代码更好。此外,小伙伴们还将看到SOLID原理如何应用于这种场景,来改善我们的软件设计,避免出现大量if和switch语句之类的代码,看上去更加简洁美观。
关注我们的Github开源项目来获得更多的技术:https://github.com/ShowFL
1.基本方案–返回错误代码和切换语句
我们来使用一个登录验证服务的例子:
private function checkLogin() {
// ...
// Some validation to check if the credentials are valid
// ...
if ($hasNotValidCredentials) {
return -1;
}
// ...
// Some validation to check if the user has attempted too many times to login
// ...
if ($hasTooManyLoginAttempts) {
return -2;
}
// If all is OK, return "the successful code" 1
return 1;
}
现在我们来使用这个方法,它是这样子的:
switch($this->checkLogin()) {
case -1:
// Invalid credentials case, log it into the error logs
$this->errorLogger->log("Invalid credentials");
break;
// Invalid credentials case, log it into the error logs
case -2:
// Too many attempts case, log it into the error logs
$this->errorLogger->log("Too many login attempts");
break;
default:
// Successful scenario, log in the user
break;
}
值得思考的问题:
- 首先代码的语义。如果不阅读整个代码,就很难理解到底发生了什么。为了知道返回值“1”到底是什么意思,我们必须要查看内部实现的代码。
- 更改错误代码值怎么样?那么我必须检查checkLogin方法的所有用法,才可以敢大胆的去改错误返回值!
2. 用枚举常量来替换数值
这里你有另一种方法来解决之前描述的两个主要问题:
private function checkLogin() {
// ...
// Some validation to check if the credentials are valid
// ...
if ($hasNotValidCredentials) {
return self::INVALID_LOGIN_CREDENTIALS;
}
// ...
// Some validation to check if the user has attempted too many times to login
// ...
if ($hasTooManyLoginAttempts) {
return self::TOO_MANY_LOGIN_ATTEMPTS;
}
return self::LOGIN_SUCCESSFUL;
}
和前面的步骤一样,这里你可以看到这个方法的一个可能的用途:
switch($this->checkLogin()) {
case self::INVALID_LOGIN_CREDENTIALS:
$this->errorLogger->log("Invalid credentials");
break;
case self::TOO_MANY_LOGIN_ATTEMPTS:
$this->errorLogger->log("Too many login attempts");
break;
default:
// Successful scenario, log in the user
break;
}
比前一步的改进:
- 我们已经用常量符号重构了应用替换错误数值,正如我们前面所说的,这改进了代码的语义,因此现在我们有了更可读的代码。
- 如果我们想编辑一个错误代码值,我们只需要编辑常量的值,这样对容错率降低了很多。
思考的问题:
- 如果我们想在checkLogin方法中进行一些扩展或者提取方法重构,那么问题来了:首先我们必须逐层上报checkLogin方法中的错误代码,直到捕获并处理错误的switch-case语句为止。在整个执行过程中我都对这种方法感到良心不安,看上去是那么的臃肿。我们可能需要更多的灵活性,以便在应用程序的外层委托处理这种异常情况的逻辑。
3. 用抛出异常来替换错误代码
考虑之前描述的问题,我们可以使用抛出异常的方式来巧妙的解决这个问题,这是非常容易的,我们可以很轻松的来重构我们的代码:
private function checkLogin() {
// ...
// Some validation to check if the credentials are valid
// ...
if ($hasNotValidCredentials) {
throw new RuntimeException("Invalid credentials", self::INVALID_LOGIN_CREDENTIALS);
}
// ...
// Some validation to check if the user has attempted too many times to login
// ...
if ($hasTooManyLoginAttempts) {
throw new RuntimeException("Too many login attempts", self::TOO_MANY_LOGIN_ATTEMPTS);
}
}
下面是我们处理异常的方法:
try {
$this->checkLogin();
}
catch (RuntimeException $loginException) {
switch($loginException->getCode()) {
case self::INVALID_LOGIN_CREDENTIALS:
$this->errorLogger->log("Invalid credentials");
break;
case self::TOO_MANY_LOGIN_ATTEMPTS:
$this->errorLogger->log("Too many login attempts");
break;
}
}
// Continue with the user login
比前一步的改进:
- 通过这种方法,我们解决了在整个应用程序中包含错误代码逐层上报的问题:从错误触发点到捕获错误点。我们可以简单地在应用程序的某一点上不考虑这个异常,这使得外部点不得不处理它。
思考的问题:
- 我们捕获了发生在checkLogin方法执行流下面的所有类型的RuntimeException。问题很明显:如果checkLogin方法进行一些DB查询来验证某些东西,而DB服务器关闭了怎么办?
- 另外,这里我们复制了错误字符串。就像我们对神奇数字所做的那样,但是由于在本例的这一点上它是一个特定的情况,所以我更倾向于关注前面的点问题,而不是用额外的不必要的代码修改来分散您的注意力。
3.1. 传播其他类型的异常
我们可以做一个变通的异常处理,像这样:
try {
$this->checkLogin();
}
catch (RuntimeException $loginException) {
switch($loginException->getCode()) {
case self::INVALID_LOGIN_CREDENTIALS:
$this->errorLogger->log("Invalid credentials");
break;
case self::TOO_MANY_LOGIN_ATTEMPTS:
$this->errorLogger->log("Too many login attempts");
break;
default:
throw $loginException;
break;
}
}
// Continue with the user login
比前一步的改进:
- 我们不必处理与登录相关代码不同的异常。我们将这些其他类型的异常上报到应用程序的外层。
值得探讨的问题:
- 那么在错误码数方面的冲突呢?如果另一种错误与登录相关的错误代码相同,我们将捕获它并处理它,就好像它是登录相关的错误一样。
4. 创建特定的RuntimeException子类
所以,最后,我们可以用一个特定的例外来结束每一个例外的情况,比如:
class InvalidLoginCredentialsException extends RuntimeException
{
}
class TooManyLoginAttemptsException extends \RuntimeException
{
}
private function checkLogin()
{
// ...
// Some validation to check if the credentials are valid
// ...
if ($hasNotValidCredentials) {
throw new InvalidLoginCredentialsException();
}
// ...
// Some validation to check if the user has attempted too many times to login
// ...
if ($hasTooManyLoginAttempts) {
throw new TooManyLoginAttemptsException();
}
}
使用这种方法,我们可以处理异常,你可以在这里看到:
try {
$this->checkLogin();
}
catch (InvalidLoginCredentialsException $tooManyLoginAttemptsException) {
$this->errorLogger->log("Invalid credentials");
}
catch (TooManyLoginAttemptsException $tooManyLoginAttemptsException) {
$this->errorLogger->log("Too many login attempts");
}
比前一步的改进:
- 现在我们解决了冲突的可能性,因为我们«使用异常类作为它的标识符»。
- 另外,作为附带效果,我们的代码具有更多的语义。
值得探讨的问题:
即使在这种情况下,我们也没有解决问题的根源:我们违反了开放/封闭原则。我们仍然必须修改«隐藏的开关情况»,我们将其表示为不同的catch子句。这种切换子句被认为是一种代码气味(即使它们不是纯粹的«切换»子句,例如catch语句的意列或elseif的意列)。解决思路:
- 现在,如果我们必须添加另一个业务规则来指定引发登录错误(例如被禁止的用户),那么我们必须添加另一个catch子句。
- 如果我们在应用程序中调用checkLogin方法,我们还必须编辑那些其他try-catch。
- 此外,我们还在重复代码。在我们的具体例子中,我们必须用同样的方法来处理这两个错误。我们可以抽象出这种行为。
5. 创建中间抽象异常类
最后,由于登录用例异常有一个抽象的异常类,我们可以对这种情况进行抽象,在异常方面有如下的类结构:
abstract class InvalidLoginException extends RuntimeException
{
}
class InvalidLoginCredentialsException extends InvalidLoginException
{
protected $message = 'Invalid credentials';
protected $code = 2052;
}
class TooManyLoginAttemptsException extends InvalidLoginException
{
protected $message = 'Too many login attempts';
protected $code = 2051;
}
我们将继续使用它们,正如我们在前面的步骤中所做的:
private function checkLogin()
{
// ...
// Some validation to check if the credentials are valid
// ...
if ($hasNotValidCredentials) {
throw new InvalidLoginCredentialsException();
}
// ...
// Some validation to check if the user has attempted too many times to login
// ...
if ($hasTooManyLoginAttempts) {
throw new TooManyLoginAttemptsException();
}
}
但是为了能够在同一个捕获中捕获它们,我们将捕获抽象的一个,并使用getCode和getMessage异常类方法:
try {
$this->checkLogin();
}
catch (InvalidLoginException $invalidLoginException) {
$this->errorLogger->log('[Error ' . $invalidLoginException->getCode() . '] ' . $invalidLoginException->getMessage());
}
比前一步的改进:
- 这种变化可能是由于取代类型代码完成与子类和替换与多态重构条件,并使用这种方法,如果我们在登录过程中实现另一种错误(例如禁止用户),我们只需要添加另一个异常类,而无需修改处理这个新的错误,因为它是不可知的特定类型的错误。这使得我们的代码对更改更加宽容,并且完全符合OCP。这里你有一个添加这个新案例的例子:
- 新的异常类必须从前面描述的InvalidLoginException抽象类扩展,以便能够捕获它:
class BannedUserLoginException extends InvalidLoginException
{
protected $message = 'Banned user tried to login';
protected $code = 2053;
}
我们可以像之前一样抛出:
private function checkLogin()
{
// ...
// Some validation to check if the credentials are valid
// ...
if ($hasNotValidCredentials) {
throw new InvalidLoginCredentialsException();
}
// ...
// Some validation to check if the user has attempted too many times to login
// ...
if ($hasTooManyLoginAttempts) {
throw new TooManyLoginAttemptsException();
}
// ...
// Some validation to check if the user has been banned in this moment
// ...
if ($hasBeenBanned) {
throw new BannedUserLoginException();
}
}
可以想象,由于之前的原因(从InvalidLoginException类扩展而来),try-catch块没有任何修改:
try {
$this->checkLogin();
}
catch (InvalidLoginException $invalidLoginException) {
$this->errorLogger->log('[Error ' . $invalidLoginException->getCode() . '] ' . $invalidLoginException->getMessage());
}
值得思考的问题:
- 现在,我们在checkLogin方法中有太多的逻辑。很容易假设它有太多的代码行,使得它更难以阅读。
- 我们将无法使用某些验证。
5.1. 封装登录验证
此时,我们可以假设checkLogin函数中实现的业务逻辑太长,所以我们可以利用Extract Method重构,以留下更可读和可重用的代码:
private function checkLogin()
{
$this->checkLoginCredentials();
$this->checkLoginAttempts();
$this->checkBannedUser();
}
外部逻辑将如下:
try {
$this->checkLogin();
}
catch (InvalidLoginException $invalidLoginException) {
$this->errorLogger->log('[Error ' . $invalidLoginException->getCode() . '] ' . $invalidLoginException->getMessage());
}
结论:
一步一步实验过来,现在我们的代码有了一种足够好的感觉,但是仔细看看最后的checkLogin方法,我们可以看到一些通用的行为。我的意思是,我们可以用我们的解决方案进行第六次迭代,以实现一个观察者设计模式,用所有的验证器来组合userValidator服务。但它可能会让我们的类结构有点技术含量,但这不是我们在这里探讨的,请关注下一篇文章。