Pull to refresh

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

PHP *Symfony *Zend Framework *Yii *Laravel *
Мне никогда не нравилась в фреймворках на 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()!


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


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

UPD по итогам комментариев
Благодарю всех комментаторов за ценные мысли и мнения.

Суть статьи — не в валидации. Вообще. Валидация — просто неудачный холиворный пример, просто мне не удалось придумать лучшего.

Суть очень проста. В PHP может существовать объект, являющийся и исключением, и коллекцией других исключений одновременно. И это бывает удобно.
Tags:
Hubs:
Total votes 23: ↑13 and ↓10 +3
Views 14K
Comments Comments 127