Мультиисключение или Хочу поделиться одним интересным архитектурным приемом

от автора

Мне никогда не нравилась в фреймворках на PHP работа с ошибками. И даже употребление этого слова не нравилось. Чтобы сразу уточнить — я не про фатальные ошибки, не про error_reporting, я про то, что называют ошибками валидации. То в моделях, то в формах — это уж от фреймворка зависит.

Вы только гляньте. Вот например Yii и Yii2, получение ошибок валидации модели:

$errors = $model->getErrors(); 

Symfony, ошибки формы:

$errors = $form->getErrors(); 

Активно рекламирующийся Pixie (давненько про него ничего не было):

$result = $validator->validate($data); $errors = $result->errors(); 

Что тут не так?
Да всё. Всё не так. Весь этот код очень дурно пахнет, он пахнет временами PHP4, спагетти-архитектурой и диким смешением понятий.

Что же делать?


Начать разбираться. С самого начала.

Определим важные понятия.

1. Валидность — это ответ на вопрос «является ли значение допустимым, иначе говоря валидным, в данном контексте». Контекст может быть разным, это и поле в форме, и свойство объекта. Интересно, что ответ «да» на вопрос о валидности не предполагает никакой дополнительной информации, а вот ответ «нет» требует пояснения. Например: пароль невалиден ПОТОМУ ЧТО его длина менее 6 символов.

2. Валидация — процесс проверки валидности. У нас есть с вами некое значение и есть контекст. Валидатор (процесс, осуществляющий валидацию), должен однозначно ответить, валидно ли значение в данном контексте, и если нет — то почему.

3. «Почему» из предыдущего пункта как раз и называют "ошибкой валидации". Ошибки валидации — детальная информация о том, что конкретно вызвало ответ false на вопрос о валидности данных, то есть причина непрохождения валидации. Фактически это не ошибки в смысле «шеф, всё пропало!», а просто некий отчет валидатора, однако слово «ошибка» уже прижилось в среде разработчиков.

4. Правила валидации — функции, принимающие на вход контекст и значение, и возвращающие ответ о валидности. Ответ должен включать в себя и true/false и отчет о валидации, то есть набор ошибок, если такие есть.

С валидацией довольно часто (особенно в некоторых фреймворках, которые до сих пор поддерживают PHP 5.2, не будем показывать на них пальцем) путают sanitize (или по-русски «очистку») значений. Не стоит путать понятия «валидация» и «очистка» (или приведение к каноническому виду), это два совершенно разных процесса.
Хороший пример, который мне нравится: ввод российского телефонного номера. Для валидации достаточно (в общем случае), чтобы в введенной строке было 11 цифр, причем первая из них 7, при произвольном количестве и позициях иных символов. Если это не так — валидация не пройдена. Задача же санитайзера — удалить из этого значения всё, кроме цифр, чтобы мы могли сохранить в БД стандартизованный msisdn.
Почитайте, чтобы окончательно понять разницу: php.net/manual/ru/filter.filters.php

Ну хорошо, а что всё-таки не так?

То, что коллекция ошибок валидации не является исключением.

Все вот эти замечательные

->getErrors() 

не исключения. Следовательно мы лишены множества преимуществ:

  1. Исключения типизированы. В фреймворках же, подобных вышеупомянутым, я не могу создать иерархию FormException —> FormFieldException —> FormPasswordFieldException —> FormPasswordFieldNotSameException. Это очень важно, особенно с выходом PHP 7, который делает тайп-хинтинги наконец-то нормой и стандартом
  2. Исключения инкапсулируют в себе много нужного. Это же ООП! Например: на какой странице (URL) возникла ошибка валидации? Кто пользователь? Какое конкретно поле формы? Какое правило валидации сработало? Наконец «а дай-ка перевод этого сообщения на эстонский». Может ли это всё сделать простой массив сообщений об ошибках? Конечно же нет. (Кстати, достаточно реализовать метод __toString() и исключение в шаблоне продолжит вести себя как простое сообщение об ошибке)
  3. Исключения управляют потоком. Я могу его бросить. Оно всплывает. Я могу его поймать, а могу поймать и бросить дальше. Массив $errors лишен права управлять потоком кода, поэтому очень неудобен. Как мне с помощью $errors эскалировать обработку ошибок валидации из модели выше, например в контроллер или компонент приложения?

И что же делать?

Попробуем поставить задачу. Что бы хотелось видеть в коде? Ну, скажем, примерно вот такое:

Где-то в активном коде:

try {   $user = new User;   $user->fill($_POST);   $user->save();   redirect('hello.php'); catch (ValidationErrors $e) {   $this->view->assign('errors', $e); } 

Где-то в шаблоне:

<?php foreach ($errors as $error): ?>   <div class="alert alert-danger"><?php echo $error->getMessage(); ?></div> <?php endforeach; ?> 

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

Как этого добиться? К счастью, современный PHP позволяет нам и не такие трюки.

Превращаем исключение в коллекцию

Всё самое интересное — здесь!

Интерфейс, который наследует все полезные для нас интерфейсы для превращения объекта в массив:

interface IArrayAccess     extends \ArrayAccess, \Countable, \IteratorAggregate, \Serializable { } 

Трейт, который реализует этот интерфейс:

 trait TArrayAccess {     protected $storage = [];      protected function innerIsset($offset)     {         return array_key_exists($offset, $this->storage);     }      protected function innerGet($offset)     {         return isset($this->storage[$offset]) ? $this->storage[$offset] : null;     }      protected function innerSet($offset, $value)     {         if ('' == $offset) {             if (empty($this->storage)) {                 $offset = 0;             } else {                 $offset = max(array_keys($this->storage))+1;             }         }         $this->storage[$offset] = $value;     }      protected function innerUnset($offset)     {         unset($this->storage[$offset]);     }      public function offsetExists($offset)     {         return $this->innerIsset($offset);     }      public function offsetGet($offset)     {         return $this->innerGet($offset);     }      public function offsetSet($offset, $value)     {         $this->innerSet($offset, $value);     }      public function offsetUnset($offset)     {         $this->innerUnset($offset);     }      public function count()     {         return count($this->storage);     }      public function isEmpty()     {         return empty($this->storage);     } }  // И так далее. Аккуратно реализуем каждый интерфейс из состава IArrayAccess  // Здесь я позволяю себе только одну вольность по сравнению с ванильными массивами - обратите внимание на метод innerIsset(), он вернет true, если элемент коллекции существует, но равен null. Имхо, это более верное поведение. 

Я лично добавляю еще один полезный интерфейс и его реализацию трейтом, но он, конечно же, совсем необязателен:

interface ICollection {     public function add($value);     public function prepend($value);     public function append($value);     public function slice($offset, $length=null);     public function existsElement(array $attributes);     public function findAllByAttributes(array $attributes);     public function findByAttributes(array $attributes);     public function asort();     public function ksort();     public function uasort(callable $callback);     public function uksort(callable $callback);     public function natsort();     public function natcasesort();     public function sort(callable $callback);     public function map(callable $callback);     public function filter(callable $callback);     public function reduce($start, callable $callback);     public function collect($what);     public function group($by);     public function __call($method, array $params = []); } 

и, наконец, собираем всё воедино:

class MultiException     extends \Exception     implements IArrayAccess {     use TArrayAccess; } 

Простой пример применения

Метод заполнения модели данными.

В модели создаются правила валидации. Они выбрасывают исключения каждый раз, когда значение не проходит валидацию при присваивании его полю модели. Например:

protected function validatePassword($value) {   if (strlen($value) < 3) {     throw new Exception('Недостаточная длина пароля');   }    ...    return true; } 

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

public function __set($key, $val) {   $validator = 'validate' . ucfirst($key);   if (method_exists($this, $validator)) {     try {       if ($this->$validator($value)) {         parent::__set($key, $val);       }     } catch (Exception $e) {       throw new ModelColumnException($key, $e->getMessage());     }   } } 

Создаем метод fill($data), который попытается заполнить модель данными и аккуратно соберет в одно целое все ошибки валидации по отдельным полям:

public function fill($data) {   $errors = new Multiexception;   foreach ($data as $key => $val) {     try {       $this->$key = $val;     } catch (ModelColumnException $e) {       $errors[] = $e;     }   }   if (!$errors->isEmpty()) {     throw $errors;   } } 

Собственно, всё. Можно применять. Куча плюсов:

  • Это исключение, значит его можно поймать в нужном месте
  • Это массив исключений, так что мы можем в любой момент добавить в него новое исключение или удалить уже обработанное
  • Это исключение, поэтому его после некой фазы обработки можно кинуть дальше
  • Это объект, поэтому мы можем его легко передать куда угодно
  • Это класс, поэтому мы выстраиваем свою иерархию классов
  • И, наконец, это все еще исключение, а значит нам доступны все его стандартные свойства и методы. Да-да, и даже getTrace()!

Вместо заключения

Собственно, это всё, за небольшими изменениями для читаемости, вполне себе боевой код, который я давно применяю. Удивлен, что раньше нигде не видел такой простой концепции. Если вдруг я первый — передаю идею и код, приведенный в этой статье, в общественное достояние. Если не первый — простите автору его ошибки (с)

ссылка на оригинал статьи https://habrahabr.ru/post/279501/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *