在你的编程生涯中,迟早你会面临验证和异常处理的困境。我和我的团队也是如此。大约几年前,我们达到了这样一个阶段:我们必须采取架构措施来适应我们相当大的软件项目需要处理的所有特殊情况。以下是我们在验证和异常处理方面重视并应用的实践列表。
当我们开始讨论我们的问题时,一件事很快就浮出水面。什么是验证,什么是异常处理?例如在用户注册表中,我们对密码有一些规则(它必须同时包含数字和字母)。如果用户仅输入字母,则表明存在验证问题或异常。 UI 应该验证这一点,还是只是将其传递到后端并捕获我抛出的任何异常?
我们得出了一个共同的结论,即验证是指系统定义的规则并根据用户提供的数据进行验证。验证不应该关心业务逻辑如何工作,或者系统如何工作。例如,我们的操作系统可能会期望一个由普通字母组成的密码,而不会出现任何抗议。然而,我们希望强制使用字母和数字的组合。这是一个验证案例,是我们想要实施的规则。
另一方面,如果某些特定数据以错误的格式提供,我们的系统可能会以不可预测的方式运行、错误运行或根本无法运行,这是例外情况。例如,在上面的例子中,如果系统中已经存在该用户名,则属于异常情况。我们的业务逻辑应该能够抛出适当的异常,并且 UI 捕获并处理它,以便用户看到一条不错的消息。
现在我们已经明确了我们的目标,让我们看一些基于相同用户注册表单想法的示例。
对于当今大多数浏览器来说,JavaScript 是第二天性。几乎所有网页都包含一定程度的 JavaScript。一种好的做法是验证 JavaScript 中的一些基本内容。
假设我们在 index.php
中有一个简单的用户注册表单,如下所述。
<!DOCTYPE html> <html> <head> <title>User Registration</title> <meta charset="UTF-8"> </head> <body> <h3>Register new account</h3> <form> Username: <br/> <input type="text" /> <br/> Password: <br/> <input type="password" /> <br/> Confirm: <br/> <input type="password" /> <br/> <input type="submit" name="register" value="Register"> </form> </body> </html>
这将输出类似于下图的内容:
每个此类表单都应验证两个密码字段中输入的文本是否相同。显然,这是为了确保用户在输入密码时不会犯错误。使用 JavaScript,进行验证非常简单。
首先我们需要更新一些 HTML 代码。
<form onsubmit="return validatePasswords(this);"> Username: <br/> <input type="text" /> <br/> Password: <br/> <input type="password" name="password"/> <br/> Confirm: <br/> <input type="password" name="confirm"/> <br/> <input type="submit" name="register" value="Register"> </form>
我们在密码输入字段中添加了名称,以便我们可以识别它们。然后我们指定提交表单时应返回名为 validatePasswords()
的函数的结果。这个函数就是我们要编写的 JavaScript。像这样的简单脚本可以保存在 HTML 文件中,其他更复杂的脚本应该保存在自己的 JavaScript 文件中。
<script> function validatePasswords(form) { if (form.password.value !== form.confirm.value) { alert("Passwords do not match"); return false; } return true; } </script>
我们在这里唯一做的就是比较名为“password
”和“confirm
”的两个输入字段的值。我们可以通过调用函数时传入的参数来引用表单。我们在表单的 onsubmit
属性中使用了“this
”,因此表单本身被发送到该函数。
当值相同时,将返回 true
并提交表单,否则将显示一条警告消息,告诉用户密码不匹配。
虽然我们可以使用 JavaScript 来验证大多数输入,但在某些情况下我们希望采用更简单的方法。 HTML5 中提供了某种程度的输入验证,并且大多数浏览器都乐意应用它们。在某些情况下,使用 HTML5 验证更简单,但灵活性较差。
<head> <title>User Registration</title> <meta charset="UTF-8"> <style> input { width: 200px; } input:required:valid { border-color: mediumspringgreen; } input:required:invalid { border-color: lightcoral; } </style> </head> <body> <h3>Register new account</h3> <form onsubmit="return validatePasswords(this);"> Username: <br/> <input type="text" name="userName" required/> <br/> Password: <br/> <input type="password" name="password"/> <br/> Confirm: <br/> <input type="password" name="confirm"/> <br/> Email Address: <br/> <input type="email" name="email" required placeholder="A Valid Email Address"/> <br/> Website: <br/> <input type="url" name="website" required pattern="https?://.+"/> <br/> <input type="submit" name="register" value="Register"> </form> </body>
为了演示几个验证案例,我们稍微扩展了我们的表单。我们还添加了电子邮件地址和网站。 HTML 验证在三个字段上设置。
用户名
。它将验证任何长度超过零个字符的字符串。
email
”,当我们指定“required
”属性时,浏览器将对该字段应用验证。
url
”。我们还指定了“pattern
”属性,您可以在其中编写验证所需字段的正则表达式。
为了让用户了解字段的状态,我们还使用了一些 CSS 将输入的边框着色为红色或绿色,具体取决于所需验证的状态。
HTML 验证的问题在于,当您尝试提交表单时,不同的浏览器会有不同的行为。有些浏览器只会应用 CSS 来通知用户,而另一些浏览器会完全阻止表单的提交。我建议您在不同的浏览器中彻底测试 HTML 验证,如果需要,还可以为那些不够智能的浏览器提供 JavaScript 后备。
现在很多人都知道Robert C. Martin的干净架构建议,其中MVC框架仅用于表示而不用于业务逻辑。
本质上,您的业务逻辑应该驻留在一个单独的、隔离良好的位置,组织起来以反映应用程序的架构,而框架的视图和控制器应该控制向用户交付内容,并且可以完全删除或删除模型,如果需要,仅用于执行与交付相关的操作。其中一种操作就是验证。大多数框架都有很好的验证功能。如果不将您的模型投入使用并进行一些验证,那将是一种耻辱。
我们不会安装多个 MVC Web 框架来演示如何验证之前的表单,但这里有 Laravel 和 CakePHP 中的两个近似解决方案。
Laravel 的设计使您可以更多地访问控制器中的验证,您还可以直接访问用户的输入。内置验证器更喜欢在那里使用。然而,互联网上有建议认为,在 Laravel 中进行模型验证仍然是一件好事。 Jeffrey Way 的完整示例和解决方案可以在他的 Github 存储库中找到。
如果您更喜欢编写自己的解决方案,您可以执行类似于以下模型的操作。
class UserACL extends Eloquent { private $rules = array( 'userName' => 'required|alpha|min:5', 'password' => 'required|min:6', 'confirm' => 'required|min:6', 'email' => 'required|email', 'website' => 'url' ); private $errors; public function validate($data) { $validator = Validator::make($data, $this->rules); if ($validator->fails()) { $this->errors = $validator->errors; return false; } return true; } public function errors() { return $this->errors; } }
您可以通过简单地创建 UserACL
对象并对其调用验证来从控制器中使用它。您可能还会在此模型上使用“register
”方法,并且 register
只会将已验证的数据委托给您的业务逻辑。
CakePHP 还促进模型中的验证。它在模型级别具有广泛的验证功能。下面介绍了 CakePHP 中表单验证的样子。
class UserACL extends AppModel { public $validate = [ 'userName' => [ 'rule' => ['minLength', 5], 'required' => true, 'allowEmpty' => false, 'on' => 'create', 'message' => 'User name must be at least 5 characters long.' ], 'password' => [ 'rule' => ['equalsTo', 'confirm'], 'message' => 'The two passwords do not match. Please re-enter them.' ] ]; public function equalsTo($checkedField, $otherField = null) { $value = $this->getFieldValue($checkedField); return $value === $this->data[$this->name][$otherField]; } private function getFieldValue($fieldName) { return array_values($otherField)[0]; } }
我们仅举例说明了部分规则。这足以凸显模型中验证的力量。 CakePHP 在这方面尤其擅长。它有大量的内置验证函数,例如示例中的“minLength
”,以及向用户提供反馈的各种方式。更重要的是,像“required
”或“allowEmpty
”这样的概念实际上并不是验证规则。 Cake 在生成视图时会查看这些,并将 HTML 验证也放在标有这些参数的字段上。然而,规则很棒,并且可以通过简单地在模型类上创建方法来轻松扩展,就像我们比较两个密码字段一样。最后,您始终可以指定在验证失败时要发送到视图的消息。有关 CakePHP 验证的更多信息,请参阅说明书。
一般来说,模型级别的验证有其优点。每个框架都提供对输入字段的轻松访问,并创建在验证失败时通知用户的机制。无需 try-catch 语句或任何其他复杂的步骤。无论如何,服务器端的验证还可以确保数据得到验证。用户不能再像使用 HTML 或 JavaScript 那样欺骗我们的软件。当然,每次服务器端验证都会带来网络往返成本和提供商端而不是客户端的计算能力。
在将数据提交到系统之前检查数据的最后一步是在我们的业务逻辑级别。到达系统这部分的信息应该经过足够的净化才能使用。业务逻辑应该只检查对其至关重要的情况。例如,添加一个已经存在的用户就是我们抛出异常的情况。在此级别不应检查用户的长度至少为五个字符。我们可以有把握地假设此类限制是在更高级别执行的。
另一方面,比较两个密码是一个值得讨论的问题。例如,如果我们只是加密并将密码保存在数据库中用户附近,我们可以放弃检查并假设前面的层确保密码相同。但是,如果我们使用 API 或 CLI 工具在操作系统上创建实际需要用户名、密码和密码确认的真实用户,我们可能还想获取第二个条目并将其发送到 CLI 工具。让它重新验证密码是否匹配,如果不匹配则准备抛出异常。通过这种方式,我们对业务逻辑进行了建模,以匹配真实操作系统的行为方式。
从 PHP 中抛出异常非常容易。让我们创建用户访问控制类,并演示如何实现用户添加功能。
class UserControlTest extends PHPUnit_Framework_TestCase { function testBehavior() { $this->assertTrue(true); } }
我总是喜欢从简单的事情开始,让我继续前进。创建一个愚蠢的测试是一个很好的方法。它还迫使我思考我想要实现什么。名为 UserControlTest
的测试意味着我认为我需要一个 UserControl
类来实现我的方法。
require_once __DIR__ . '/../UserControl.php'; class UserControlTest extends PHPUnit_Framework_TestCase { /** * @expectedException Exception * @expectedExceptionMessage User can not be empty */ function testEmptyUsernameWillThrowException() { $userControl = new UserControl(); $userControl->add(''); } }
下一个要编写的测试是退化案例。我们不会测试特定的用户长度,但我们想确保不想添加空用户。有时很容易在应用程序的所有这些层中丢失从视图到业务的变量内容。这段代码显然会失败,因为我们还没有类。
PHP Warning: require_once([long-path-here]/Test/../UserControl.php): failed to open stream: No such file or directory in [long-path-here]/Test/UserControlTest.php on line 2
让我们创建该类并运行我们的测试。现在我们遇到了另一个问题。
PHP Fatal error: Call to undefined method UserControl::add()
但我们也可以在几秒钟内解决这个问题。
class UserControl { public function add($username) { } }
现在我们可以通过一次很好的测试失败来告诉我们代码的整个故事。
1) UserControlTest::testEmptyUsernameWillThrowException Failed asserting that exception of type "Exception" is thrown.
最后我们可以进行一些实际的编码。
public function add($username) { if(!$username) { throw new Exception(); } }
这使得异常的预期通过,但如果没有指定消息,测试仍然会失败。
1) UserControlTest::testEmptyUsernameWillThrowException Failed asserting that exception message '' contains 'User can not be empty'.
是时候编写异常消息了
public function add($username) { if(!$username) { throw new Exception('User can not be empty!'); } }
现在,我们的测试就通过了。正如您所观察到的,PHPUnit 验证实际抛出的异常中是否包含预期的异常消息。这很有用,因为它允许我们动态构造消息并仅检查稳定部分。一个常见的示例是,当您使用基本文本引发错误并在最后指定该异常的原因时。原因通常由第三方库或应用程序提供。
/** * @expectedException Exception * @expectedExceptionMessage Cannot add user George */ function testWillNotAddAnAlreadyExistingUser() { $command = Mockery::mock('SystemCommand'); $command->shouldReceive('execute')->once()->with('adduser George')->andReturn(false); $command->shouldReceive('getFailureMessage')->once()->andReturn('User already exists on the system.'); $userControl = new UserControl($command); $userControl->add('George'); }
对重复用户抛出错误将使我们能够进一步探索此消息构造。上面的测试创建了一个模拟,它将模拟系统命令,它将失败,并且根据请求,它将返回一条不错的失败消息。我们将此命令注入到 UserControl
类中供内部使用。
class UserControl { private $systemCommand; public function __construct(SystemCommand $systemCommand = null) { $this->systemCommand = $systemCommand ? : new SystemCommand(); } public function add($username) { if (!$username) { throw new Exception('User can not be empty!'); } } } class SystemCommand { }
注入 SystemCommand
实例非常容易。我们还在测试中创建了一个 SystemCommand
类,以避免语法问题。我们不会实施它。它的范围超出了本教程的主题。但是,我们还有另一条测试失败消息。
1) UserControlTest::testWillNotAddAnAlreadyExistingUser Failed asserting that exception of type "Exception" is thrown.
是的。我们不会抛出任何异常。缺少调用系统命令并尝试添加用户的逻辑。
public function add($username) { if (!$username) { throw new Exception('User can not be empty!'); } if(!$this->systemCommand->execute(sprintf('adduser %s', $username))) { throw new Exception( sprintf('Cannot add user %s. Reason: %s', $username, $this->systemCommand->getFailureMessage() ) ); } }
现在,对 add()
方法的修改就可以解决问题。无论如何,我们尝试在系统上执行我们的命令,如果系统说它无法添加用户,无论出于何种原因,我们都会抛出异常。此异常的消息将部分硬编码,附加用户名,然后在末尾连接系统命令的原因。正如您所看到的,这段代码使我们的测试通过了。
在大多数情况下,使用不同的消息引发异常就足够了。但是,当您拥有更复杂的系统时,您还需要捕获这些异常并根据它们采取不同的操作。分析异常消息并仅对其采取行动可能会导致一些恼人的问题。首先,字符串是 UI、表示的一部分,并且它们具有易变性。基于不断变化的字符串的逻辑将导致依赖管理噩梦。其次,每次在捕获的异常上调用 getMessage()
方法也是一种奇怪的方式来决定下一步做什么。
考虑到所有这些,创建我们自己的异常是下一个合乎逻辑的步骤。
/** * @expectedException ExceptionCannotAddUser * @expectedExceptionMessage Cannot add user George */ function testWillNotAddAnAlreadyExistingUser() { $command = Mockery::mock('SystemCommand'); $command->shouldReceive('execute')->once()->with('adduser George')->andReturn(false); $command->shouldReceive('getFailureMessage')->once()->andReturn('User already exists on the system.'); $userControl = new UserControl($command); $userControl->add('George'); }
我们修改了测试以期望我们自己的自定义异常 ExceptionCannotAddUser
。其余测试不变。
class ExceptionCannotAddUser extends Exception { public function __construct($userName, $reason) { $message = sprintf( 'Cannot add user %s. Reason: %s', $userName, $reason ); parent::__construct($message, 13, null); } }
实现自定义异常的类与任何其他类一样,但它必须扩展 Exception
。使用自定义异常还为我们提供了一个执行所有与演示相关的字符串操作的好地方。将串联移到此处,我们还消除了业务逻辑中的表示并尊重单一职责原则。
public function add($username) { if (!$username) { throw new Exception('User can not be empty!'); } if(!$this->systemCommand->execute(sprintf('adduser %s', $username))) { throw new ExceptionCannotAddUser($username, $this->systemCommand->getFailureMessage()); } }
抛出我们自己的异常只需将旧的“throw
”命令更改为新命令并发送两个参数,而不是在此处编写消息。当然,所有测试都通过了。
PHPUnit 3.7.28 by Sebastian Bergmann. .. Time: 18 ms, Memory: 3.00Mb OK (2 tests, 4 assertions) Done.
必须在某个时刻捕获异常,除非您希望用户看到它们的本来面目。如果您使用 MVC 框架,您可能希望捕获控制器或模型中的异常。捕获异常后,会将其转换为发送给用户的消息并在视图中呈现。实现此目的的常见方法是在应用程序的基本控制器或模型中创建“tryAction($action)
”方法,并始终使用当前操作调用它。在该方法中,您可以执行捕获逻辑和良好的消息生成以适合您的框架。
如果您不使用 Web 框架或 Web 界面,那么您的表示层应该负责捕获和转换这些异常。
如果您开发一个库,捕获异常将是您的客户的责任。
就是这样。我们遍历了应用程序的所有层。我们在 JavaScript、HTML 和我们的模型中进行了验证。我们从业务逻辑中抛出并捕获异常,甚至创建了自己的自定义异常。这种验证和异常处理方法可以应用于从小到大的项目,而不会出现任何严重问题。但是,如果您的验证逻辑变得非常复杂,并且项目的不同部分使用逻辑的重叠部分,您可以考虑将可以在特定级别完成的所有验证提取到验证服务或验证提供程序。这些级别可以包括但不限于JavaScript验证器、后端PHP验证器、第三方通信验证器等。
感谢您的阅读。祝你有美好的一天。