Мне никогда не нравилась в фреймворках на PHP работа с ошибками. И даже употребление этого слова не нравилось. Чтобы сразу уточнить — я не про фатальные ошибки, не про error_reporting, я про то, что называют ошибками валидации. То в моделях, то в формах — это уж от фреймворка зависит.
Вы только гляньте. Вот например Yii и Yii2, получение ошибок валидации модели:
Symfony, ошибки формы:
Активно рекламирующийся Pixie (давненько про него ничего не было):
Что тут не так?
Да всё. Всё не так. Весь этот код очень дурно пахнет, он пахнет временами PHP4, спагетти-архитектурой и диким смешением понятий.
Что же делать?
Начать разбираться. С самого начала.
1. Валидность — это ответ на вопрос «является ли значение допустимым, иначе говоря валидным, в данном контексте». Контекст может быть разным, это и поле в форме, и свойство объекта. Интересно, что ответ «да» на вопрос о валидности не предполагает никакой дополнительной информации, а вот ответ «нет» требует пояснения. Например: пароль невалиден ПОТОМУ ЧТО его длина менее 6 символов.
2. Валидация — процесс проверки валидности. У нас есть с вами некое значение и есть контекст. Валидатор (процесс, осуществляющий валидацию), должен однозначно ответить, валидно ли значение в данном контексте, и если нет — то почему.
3. «Почему» из предыдущего пункта как раз и называют "ошибкой валидации". Ошибки валидации — детальная информация о том, что конкретно вызвало ответ false на вопрос о валидности данных, то есть причина непрохождения валидации. Фактически это не ошибки в смысле «шеф, всё пропало!», а просто некий отчет валидатора, однако слово «ошибка» уже прижилось в среде разработчиков.
4. Правила валидации — функции, принимающие на вход контекст и значение, и возвращающие ответ о валидности. Ответ должен включать в себя и true/false и отчет о валидации, то есть набор ошибок, если такие есть.
С валидацией довольно часто (особенно в некоторых фреймворках, которые до сих пор поддерживают PHP 5.2, не будем показывать на них пальцем) путают sanitize (или по-русски «очистку») значений. Не стоит путать понятия «валидация» и «очистка» (или приведение к каноническому виду), это два совершенно разных процесса.
Хороший пример, который мне нравится: ввод российского телефонного номера. Для валидации достаточно (в общем случае), чтобы в введенной строке было 11 цифр, причем первая из них 7, при произвольном количестве и позициях иных символов. Если это не так — валидация не пройдена. Задача же санитайзера — удалить из этого значения всё, кроме цифр, чтобы мы могли сохранить в БД стандартизованный msisdn.
Почитайте, чтобы окончательно понять разницу: php.net/manual/ru/filter.filters.php
То, что коллекция ошибок валидации не является исключением.
Все вот эти замечательные
Попробуем поставить задачу. Что бы хотелось видеть в коде? Ну, скажем, примерно вот такое:
Где-то в активном коде:
Где-то в шаблоне:
Суть предлагаемого архитектурного шаблона можно выразить очень кратко: Мультиисключение. Исключение, являющееся коллекцией других исключений.
Как этого добиться? К счастью, современный PHP позволяет нам и не такие трюки.
В модели создаются правила валидации. Они выбрасывают исключения каждый раз, когда значение не проходит валидацию при присваивании его полю модели. Например:
Создаем магический сеттер, который будет автоматически вызывать валидатор для поля. И заодно преобразовывать выброшенное исключение к другому типу, содержащему в себе не просто сообщение об ошибке валидации, но еще и имя поля:
Создаем метод fill($data), который попытается заполнить модель данными и аккуратно соберет в одно целое все ошибки валидации по отдельным полям:
Собственно, всё. Можно применять. Куча плюсов:
Вот это всё, за небольшими нюансами, вполне себе боевой код, который я давно применяю, и даже учу своих студентов применять. Удивлен, что раньше нигде не видел такой простой концепции. Если вдруг я первый — передаю идею и код, приведенный в этой статье, в общественное достояние. Если не первый — простите автору его ошибки (с)
UPD по итогам комментариев
Благодарю всех комментаторов за ценные мысли и мнения.
Суть статьи — не в валидации. Вообще. Валидация — просто неудачный холиворный пример, просто мне не удалось придумать лучшего.
Суть очень проста. В PHP может существовать объект, являющийся и исключением, и коллекцией других исключений одновременно. И это бывает удобно.
Вы только гляньте. Вот например 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()
не исключения. Следовательно мы лишены множества преимуществ:- Исключения типизированы. В фреймворках же, подобных вышеупомянутым, я не могу создать иерархию FormException --> FormFieldException --> FormPasswordFieldException --> FormPasswordFieldNotSameException. Это очень важно, особенно с выходом PHP 7, который делает тайп-хинтинги наконец-то нормой и стандартом
- Исключения инкапсулируют в себе много нужного. Это же ООП! Например: на какой странице (URL) возникла ошибка валидации? Кто пользователь? Какое конкретно поле формы? Какое правило валидации сработало? Наконец «а дай-ка перевод этого сообщения на эстонский». Может ли это всё сделать простой массив сообщений об ошибках? Конечно же нет. (Кстати, достаточно реализовать метод __toString() и исключение в шаблоне продолжит вести себя как простое сообщение об ошибке)
- Исключения управляют потоком. Я могу его бросить. Оно всплывает. Я могу его поймать, а могу поймать и бросить дальше. Массив $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 может существовать объект, являющийся и исключением, и коллекцией других исключений одновременно. И это бывает удобно.