Статьи

Валидация и обработка исключений: от пользовательского интерфейса до серверной части

Рано или поздно в вашей карьере программиста вы столкнетесь с дилеммой проверки и обработки исключений. Так было и со мной, и с моей командой. Примерно пару лет назад мы достигли того момента, когда нам пришлось предпринять архитектурные действия, чтобы учесть все исключительные случаи, которые требовалось выполнить нашему довольно крупному программному проекту. Ниже приведен список практик, которые мы оценили и применили, когда дело доходит до проверки и обработки исключений.


Когда мы начали обсуждать нашу проблему, одна вещь появилась очень быстро. Что такое проверка и что такое обработка исключений? Например, в форме регистрации пользователя у нас есть некоторые правила для пароля (он должен содержать как цифры, так и буквы). Если пользователь вводит только буквы, это проблема проверки или исключения. Должен ли пользовательский интерфейс проверить это или просто передать его бэкэнду и поймать какие-либо исключения, которые могут быть выброшены?

Мы пришли к общему выводу, что валидация относится к правилам, определенным системой и сверенным с данными, предоставленными пользователем. Проверка не должна заботиться о том, как работает бизнес-логика или как работает система в этом отношении. Например, наша операционная система может ожидать, без каких-либо протестов, пароль, состоящий из простых букв. Однако мы хотим применить комбинацию букв и цифр. Это случай для проверки, правило, которое мы хотим навязать.

С другой стороны, исключения — это случаи, когда наша система может функционировать непредсказуемым образом, неправильно или вообще не работать, если некоторые конкретные данные предоставлены в неправильном формате. Например, в приведенном выше примере, если имя пользователя уже существует в системе, это исключение. Наша бизнес-логика должна быть в состоянии генерировать соответствующее исключение, а пользовательский интерфейс перехватывает и обрабатывает его, чтобы пользователь мог видеть приятное сообщение.


Теперь, когда мы ясно дали понять, каковы наши цели, давайте посмотрим несколько примеров, основанных на той же идее формы регистрации пользователя.

Для большинства современных браузеров JavaScript — это вторая натура. В ней почти нет веб-страницы без некоторой степени JavaScript. Хорошей практикой является проверка некоторых основных вещей в JavaScript.

Допустим, у нас есть простая форма регистрации пользователя в index.php , как описано ниже.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!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-код.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
<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.

01
02
03
04
05
06
07
08
09
10
<script>
    function validatePasswords(form) {
        if (form.password.value !== form.confirm.value) {
            alert(«Passwords do not match»);
            return false;
        }
        return true;
    }
 
</script>

Единственное, что мы здесь делаем, — это сравниваем значения двух полей ввода с именами « password » и « confirm ». Мы можем ссылаться на форму по параметру, который мы посылаем при вызове функции. Мы использовали « this » в onsubmit формы, поэтому сама форма отправляется в функцию.

Если значения совпадают, будет возвращено значение true и форма будет отправлена, в противном случае будет показано предупреждающее сообщение о том, что пароли не совпадают.

PasswordDoNotMatchAlert

Хотя мы можем использовать JavaScript для проверки большинства наших входных данных, в некоторых случаях мы хотим пойти по более простому пути. Некоторая степень проверки ввода доступна в HTML5, и большинство браузеров рады применить их. Использование проверки HTML5 проще в некоторых случаях, хотя и предлагает меньшую гибкость.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<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 были установлены на три поля.

  • Имя username ввода текста просто необходимо. Он будет проверяться с любой строкой длиннее нуля символов.
  • Поле адреса электронной почты имеет тип « email », и когда мы указываем атрибут « required », браузеры применяют проверку к полю.
  • Наконец, поле сайта имеет тип » url «. Мы также указали атрибут « pattern », где вы можете написать свои регулярные выражения, которые проверяют обязательные поля.

Чтобы информировать пользователя о состоянии полей, мы также использовали немного CSS, чтобы закрасить границы входных данных красным или зеленым, в зависимости от состояния требуемой проверки.

HTMLValidations

Проблема с проверками HTML заключается в том, что разные браузеры ведут себя по-разному, когда вы пытаетесь отправить форму. Некоторые браузеры просто применяют CSS для информирования пользователей, другие вообще запрещают отправку формы. Я рекомендую вам тщательно протестировать HTML-проверки в разных браузерах и, при необходимости, также предоставить запасной вариант JavaScript для тех браузеров, которые недостаточно умны.


В настоящее время многие знают о предложении Роберта С. Мартина о чистой архитектуре, в котором инфраструктура MVC предназначена только для представления, а не для бизнес-логики.

HighLevelDesign

По сути, ваша бизнес-логика должна находиться в отдельном, хорошо изолированном месте, организованном так, чтобы отражать архитектуру вашего приложения, в то время как представления и контроллеры платформы должны контролировать доставку контента пользователю, а модели могут быть отброшены полностью или, если необходимо, , используется только для выполнения операций, связанных с доставкой. Одной из таких операций является проверка. Большинство фреймворков имеют отличные возможности проверки. Было бы стыдно не использовать ваши модели на работе и провести там небольшую проверку.

Мы не будем устанавливать несколько веб-платформ MVC, чтобы продемонстрировать, как проверять наши предыдущие формы, но вот два приблизительных решения в Laravel и CakePHP.

Laravel разработан таким образом, чтобы у вас было больше доступа к проверке в контроллере, где у вас также есть прямой доступ к вводу от пользователя. Встроенный валидатор предпочитает использовать там. Однако в Интернете есть предположения, что проверка в моделях все еще является хорошей вещью в Laravel. Полный пример и решение Джеффри Уэй можно найти в его хранилище Github .

Если вы предпочитаете написать собственное решение, вы можете сделать что-то похожее на модель ниже.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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 и UserACL validate. Вероятно, в этой модели у вас также будет метод « register », и register просто делегирует уже проверенные данные вашей бизнес-логике.

CakePHP также поддерживает валидацию в моделях. Он имеет обширную функциональность проверки на уровне модели. Вот как будет выглядеть проверка для нашей формы в CakePHP.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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.
        ]
    ];
 
    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 очень легко. Давайте создадим наш класс управления доступом пользователей и продемонстрируем, как реализовать функциональность добавления пользователей.

1
2
3
4
5
class UserControlTest extends PHPUnit_Framework_TestCase {
    function testBehavior() {
        $this->assertTrue(true);
    }
}

Мне всегда нравится начинать с чего-то простого, что заставляет меня двигаться вперед. Создание глупого теста — отличный способ сделать это. Это также заставляет меня думать о том, что я хочу реализовать. Тест с именем UserControlTest означает, что я подумал, что мне понадобится класс UserControl для реализации моего метода.

01
02
03
04
05
06
07
08
09
10
11
12
13
require_once __DIR__ .
class UserControlTest extends PHPUnit_Framework_TestCase {
 
    /**
     * @expectedException Exception
     * @expectedExceptionMessage User can not be empty
     */
    function testEmptyUsernameWillThrowException() {
        $userControl = new UserControl();
        $userControl->add(»);
    }
 
}

Следующий тест для написания — это дегенеративный случай. Мы не будем проверять конкретную длину пользователя, но мы хотим убедиться, что мы не хотим добавлять пустого пользователя. Иногда легко потерять содержимое переменной из вида в бизнес на всех этих уровнях нашего приложения. Этот код явно потерпит неудачу, потому что у нас еще нет класса.

1
2
3
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

Давайте создадим класс и запустим наши тесты. Теперь у нас есть другая проблема.

1
PHP Fatal error: Call to undefined method UserControl::add()

Но мы также можем это исправить всего за пару секунд.

1
2
3
4
5
6
7
class UserControl {
 
    public function add($username) {
 
    }
 
}

Теперь у нас может быть хороший провал теста, рассказывающий нам всю историю нашего кода.

1
2
1) UserControlTest::testEmptyUsernameWillThrowException
Failed asserting that exception of type «Exception» is thrown.

Наконец мы можем сделать некоторое реальное кодирование.

1
2
3
4
5
public function add($username) {
    if(!$username) {
        throw new Exception();
    }
}

Это делает ожидание прохождения исключения, но без указания сообщения, тест все равно не пройден.

1
2
1) UserControlTest::testEmptyUsernameWillThrowException
Failed asserting that exception message » contains ‘User can not be empty’.

Время написать сообщение об исключении

1
2
3
4
5
public function add($username) {
    if(!$username) {
        throw new Exception(‘User can not be empty!’);
    }
}

Это делает наш тест успешным. Как вы можете заметить, PHPUnit проверяет, что ожидаемое сообщение об исключении содержится в фактически выданном исключении. Это полезно, потому что позволяет нам динамически создавать сообщения и проверять только стабильную часть. Типичным примером является случай, когда вы выдаваете ошибку с базовым текстом и в конце указываете причину этого исключения. Причины обычно предоставляются сторонними библиотеками или приложениями.

01
02
03
04
05
06
07
08
09
10
11
/**
 * @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 для внутреннего использования.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
class UserControl {
 
    private $systemCommand;
 
    public function __construct(SystemCommand $systemCommand = null) {
        $this->systemCommand = $systemCommand ?
    }
 
    public function add($username) {
        if (!$username) {
            throw new Exception(‘User can not be empty!’);
        }
    }
 
}
 
class SystemCommand {
 
}

SystemCommand экземпляр SystemCommand было довольно легко. Мы также создали класс SystemCommand внутри нашего теста, чтобы избежать синтаксических проблем. Мы не будем это реализовывать. Его объем превышает тему этого урока. Тем не менее, у нас есть еще одно сообщение об ошибке теста.

1
2
1) UserControlTest::testWillNotAddAnAlreadyExistingUser
Failed asserting that exception of type «Exception» is thrown.

Ага. Мы не бросаем никаких исключений. Логика вызова системной команды и попытки добавления пользователя отсутствует.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
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() могут помочь. Мы пытаемся выполнить нашу команду в системе, несмотря ни на что, и если система говорит, что не может добавить пользователя по какой-либо причине, мы выдаем исключение. Сообщение об этом исключении будет частично жестко запрограммировано, с присоединенным именем пользователя, а затем в конце будет объединена причина из системной команды. Как видите, этот код проходит наш тест.

Бросать исключения в разные сообщения в большинстве случаев достаточно. Однако, когда у вас более сложная система, вам также нужно перехватывать эти исключения и предпринимать различные действия на их основе. Анализ сообщения об исключении и принятие мер исключительно для этого может привести к некоторым раздражающим проблемам. Во-первых, строки являются частью пользовательского интерфейса, презентации и имеют изменчивый характер. Логика на основе постоянно меняющихся строк приведет к кошмару управления зависимостями. Во-вторых, каждый раз вызов метода getMessage() для getMessage() исключения также является странным способом решить, что делать дальше.

Учитывая все это, создание наших собственных исключений является следующим логическим шагом.

01
02
03
04
05
06
07
08
09
10
11
/**
 * @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 . Остальная часть теста не изменилась.

01
02
03
04
05
06
07
08
09
10
class ExceptionCannotAddUser extends Exception {
 
    public function __construct($userName, $reason) {
        $message = sprintf(
            ‘Cannot add user %s.
            $userName, $reason
        );
        parent::__construct($message, 13, null);
    }
}

Класс, который реализует наше пользовательское исключение, похож на любой другой класс, но он должен расширять Exception . Использование пользовательских исключений также дает нам прекрасное место для выполнения всех манипуляций со строками, связанных с презентацией. Переместив конкатенацию сюда, мы также исключили представление из бизнес-логики и соблюдали принцип единой ответственности.

1
2
3
4
5
6
7
8
9
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 на новую и отправки двух параметров вместо составления сообщения. Конечно все тесты проходят.

1
2
3
4
5
6
7
8
9
PHPUnit 3.7.28 by Sebastian Bergmann.
 
..
 
Time: 18 ms, Memory: 3.00Mb
 
OK (2 tests, 4 assertions)
 
Done.

Исключения должны быть обнаружены в какой-то момент, если вы не хотите, чтобы ваш пользователь видел их такими, какие они есть. Если вы используете инфраструктуру MVC, вы, вероятно, захотите перехватить исключения в контроллере или модели. После обнаружения исключения оно преобразуется в сообщение для пользователя и отображается внутри вашего представления. Распространенный способ добиться этого — создать tryAction($action) в базовом контроллере или модели вашего приложения и всегда вызывать его с текущим действием. В этом методе вы можете использовать ловкую логику и генерацию сообщений в соответствии с вашими требованиями.

Если вы не используете веб-фреймворк или веб-интерфейс в этом отношении, ваш уровень представления должен позаботиться о перехвате и преобразовании этих исключений.

Если вы разрабатываете библиотеку, за ваши исключения будут отвечать ваши клиенты.


Вот и все. Мы прошли все слои нашего приложения. Мы проверили в JavaScript, HTML и в наших моделях. Мы выбросили и уловили исключения из нашей бизнес-логики и даже создали наши собственные пользовательские исключения. Этот подход к проверке и обработке исключений может применяться от небольших к большим проектам без каких-либо серьезных проблем. Однако, если ваша логика проверки становится очень сложной, и разные части вашего проекта используют перекрывающиеся части логики, вы можете рассмотреть возможность извлечения всех проверок, которые могут быть выполнены на определенном уровне, для службы проверки или поставщика проверки. Эти уровни могут включать, но не ограничиваться ими, валидатор JavaScript, бэкэнд-валидатор PHP, сторонний валидатор связи и так далее.

Спасибо за чтение. Хорошего дня.