Не прошло и недели с момента «безумного успеха» (тут мнения немного расходятся, конечно...) первой части нашего повествования, как пришло время выпустить вторую.
Сегодня мы продолжаем путешествие в бездонную глубину библиотеки runn/core будущего фреймворка «Runn Me!». Под катом нам встретятся следующие обитатели бездны:
- Концепция «мультиисключения» и ее реализация в библиотеке
- Понятие объекта с внутренней валидацией и эталонная реализация такого объекта
- Немного заглянем в мир валидаторов и санитайзеров (подробный рассказ о них будет позже)
- Рассмотрим реализацию объекта с обязательными полями
- Фирменное наименование: Runn Me!
- Вендор: Runn
- GitHub: github.com/RunnMe
- Composer: packagist.org/packages/runn
Предыдущие серии:
Мультиисключение
В начале был интерфейс Throwable.
Хитрая штука. Напрямую реализовать нельзя, как и многие интерфейсы из стандартной библиотеки PHP, зато любой объект, реализующий этот интерфейс, получает особую суперспособность: его можно «выбросить» (throw). Впрочем, слово «любой» тут явное преувеличение: всё, что нам доступно — это унаследовать собственный класс от библиотечного же \Exception.
Именно с такого наследования и начинается работа с исключениями в «Runn Me!»:
namespace Runn\Core;
class Exception
extends \Exception
implements \JsonSerializable
{
public function jsonSerialize()
{
return ['code' => $this->getCode(), 'message' => $this->getMessage()];
}
}
Имплементация интерфейса JsonSerializable неспроста добавлена сразу же в Runn\Core\Exception: это явный задел на будущее, на то светлое будущее, когда мы с вами научимся исключения ловить в наших middleware, упаковывать в JSON и отдавать в ответе клиенту.
Однако «Runn Me!» не так скучен, как может показаться при перечитывании листингов. Совсем рядом с классом Exception в пространстве имён Runn\Core притаился еще один, почти незаметный класс Runn\Core\Exceptions. Что же это такое?
Это мультиисключение:
- Типизированная коллекция (см. предыдущую статью)
- Тип элементов этой коллекции — Throwable
- И при этом она сама является Throwable!
Давайте посмотрим на несложном примере, как такой конструкцией можно пользоваться:
$exceptions = new Exceptions;
$exceptions->add(new Exception('First'));
$exceptions->add(new Exception('Second'));
assert(2 === count($exceptions));
assert('First' === $exceptions[0]->getMessage());
assert('Second' === $exceptions[1]->getMessage());
if (!$exceptions->empty()) {
throw $exceptions;
}
Где применяются мультиисключения?
Самое, пожалуй, «яркое» применение этого паттерна — механизм валидации, предусмотренный в стандартных объектах «Runn Me!».
Авторы библиотек прекрасно осознают, что даже намёк на то, что ошибка валидации может быть исключением, способен вызывать жжение пониже спины у многих программистов. Однако мы решили, что возможный профит от такого подхода во много раз превышает минусы, которые можно от него ожидать. Поэтому не пылайте зря — у нас ошибки валидации реализуются исключениями и мультиисключениями. Это решение принято и обжалованию не подлежит.
Рассмотрим подробно, шаг за шагом, как же это устроено.
Методы подключения валидации
Внутри стандартного объекта (а точнее — любого наследника HasInnerValidationInterface, если вы не слишком отступили от эталонной реализации, описанной в StdGetSetWValidateSanitizeTrait) вы можете определить методы подключения валидации.
Название такого метода должно начинаться со слова «validate», за которым следует имя ключа, значение для которого мы будем валидировать, с первой большой буквы. Например:
class ValidationExampleClass extends Std
{
protected function validateFoo($val)
{
return true;
}
protected function validateBar($val)
{
return true;
}
}
$obj = new ValidationExampleClass;
$obj->foo = 42;
$obj->bar = 'baz';
В данном коде показано, что мы создали два метода для подключения валидации validateFoo() и validateBar(). Первый из них будет автоматически вызван ДО присваивания какого-либо значения свойству $obj->foo, второй же, соответственно, до фактического изменения $obj->baz.
Возможны пять вариантов «поведения» метода подключения валидации:
- Метод возвращает false
- Метод выбрасывает единичное исключение
- Метод выбрасывает мультиисключение
- Метод является генератором исключений
- И, наконец, метод просто вернул что-то, что не является false
Самые простые — это варианты №№ 1 и 5.
В первом случае просто ничего не происходит, присваивание «молча» отменяется, свойство не получает новое значение.
В пятом случае присваивание нового значения свойству также «молча» происходит, не вызывая никаких побочных эффектов.
Чуть сложнее варианты №№ 2, 3 и 4. Все они ведут к отмене присваивания. Но чтобы точно понять, для чего они предназначены, мы с вами пойдем дальше.
Методы массового присваивания (заполнения)
С целью наиболее полно использовать потенциал методов подключения валидации в трейте StdGetSetWValidateSanitizeTrait переопределен важный метод merge() (который, в том числе, используется и в конструкторе класса Std).
Показать его работу лучше всего на примере:
class ValidationExampleClass extends Std
{
// Случай номер 2: единичное исключение
protected function validateFoo($val)
{
if (empty($val)) {
throw new Exception('foo is empty');
}
}
// Случай номер 3: мультиисключение
protected function validateBar($val)
{
$errors = new ValidationErrors; // этот класс, разумеется, наследуется от Exceptions
if (strlen($val) < 6) {
$errors[] = new Exception('bar is too short');
}
if (preg_match('~\d~', $val)) {
$errors[] = new Exception('bar contains digits');
}
if (!$errors->empty()) {
throw $errors;
}
}
// Случай номер 4: генератор исключений
protected function validateBaz($val)
{
if (strlen($val) > 6) {
yield new Exception('baz is too long');
}
if (preg_match('~[a-z]~', $val)) {
yield new Exception('baz contains letters');
}
}
}
Теперь, когда мы с вами определили все возможные правила валидации всеми возможными способами, давайте попробуем создать объект, который намеренно нарушит все эти правила:
try {
$obj = new ValidationExampleClass([
'foo' => '', // нарушаем правило "непустой"
'bar' => '123', // две ошибки - "слишком коротко" и "содержит цифры",
'baz' => 'abcdefgh', // две ошибки - "слишком длинно" и "содержит буквы",
]);
} catch (Exceptions $errors) {
foreach ($errors as $error) {
// Прекрасно! Мы получили все пять ошибок валидации!
echo $error->getMessage();
}
}
Что же произошло?
Для начала метод merge() готовит пустую коллекцию класса Exceptions для будущих ошибок валидации. Затем для каждого ключа вызывается, если он существует, метод подключения валидации.
2. Метод подключения валидации выбросил одиночное исключение: оно добавляется в коллекцию.
3. Метод выбросил мультиисключение: оно объединяется с коллекцией.
4. Метод является генератором: всё, что он сгенерирует, являющееся Throwable, будет добавлено в коллекцию.
Если после всех этих операций коллекция ошибок валидации оказалась непустой — она выбрасывается. Дальше уже вам надлежит ее где-то поймать и решить, что делать со всеми этими ошибками.
Методы подключения санитайзинга
Ну, тут рассказ будет не таким захватывающим, как про валидацию. Всё просто:
class SanitizationExampleClass extends Std
{
protected function sanitizePhone($val)
{
return preg_replace('~\D~', '', $val);
}
}
$obj = new SanitizationExampleClass;
$obj->phone = '+7 (900) 123-45-67';
assert('79001234567' === $obj->phone);
Определили метод, он получает на вход то значение, которое вы намереваетесь присвоить свойству, то, что возвращает — будет действительно присвоено. Банально, но полезно.
Небольшой анонс
Разумеется, «магическими» методами в стандарных объектах тема валидации и санитации не исчерпывается. Одна из следующих статей будет целиком посвящена библиотеке runn/validation, которая сейчас готовится к публикации.
И, наконец, обещанные обязательные поля
Важнейшая вещь, скажу я вам. Особенно когда мы перейдем к теме комплексных (очень хочется произнести это слово с ударением на «е»: «комплЕксных») объектов. Но и без них можно всё понять:
class testClassWithRequired extends Std {
protected static $required = ['foo', 'bar'];
// если вам не хватит гибкости, можете переопределить метод getRequiredKeys() по своему вкусу
}
try {
$obj = new testClassWithRequired();
} catch (Exceptions $errors) {
assert(2 == count($errors));
assert('Required property "foo" is missing' === $errors[0]->getMessage());
assert('Required property "bar" is missing' === $errors[1]->getMessage());
}
Как вы видите, здесь используется всё тот же уже знакомый механизм мультиисключения для оповещения нас о том, что некоторые обязательные поля оказались неустановленными в конструкторе объекта. Кстати, если там возникнут ошибки валидации — мы их тоже увидим! Всё в той же коллекции $errors.
На сегодня всё. Следите за следующими статьями!
P.S. Детального плана со сроками выхода фреймворка в целом у нас нет, как нет и желания успеть к какой-то очередной дате. Поэтому не спрашивайте «когда». По мере готовности отдельных библиотек будут выходить статьи о них.
P.P.S. С благодарностью приму сведения об ошибках или опечатках в личные сообщения.
Хороших выходных всем!
Комментарии (22)
BoShurik
05.05.2017 18:00+1Судя по коду, правильнее так
try { // ... } catch (Exceptions $errors) { foreach ($errors as $error) { echo $error->getMessage(); } } catch (Exception $error) { echo $error->getMessage(); }
Т.к.
Exceptions
не наследуется отException
И все-таки не понятно, зачем ошибки валидации делать исключениями, вы ведь их не выбрасываете (только во втором случае). В итоге возникает желание их поймать, а не получится.
ellrion
05.05.2017 18:12+4зачем ошибки валидации делать исключениями
у автора уже была статья где с ним холиварили на эту тему.
AlexLeonov
05.05.2017 18:43Ветка
} catch (Exception $error) {
в данном конкретном случае не нужна, поскольку конструктор стандартного класса все без исключения Throwable заворачивает в Exceptions: https://github.com/RunnMe/Core/blob/master/src/Core/Std.php#L53
Впрочем, если вы в своём классе переопределите конструктор, тогда возможно всё.
И все-таки не понятно, зачем ошибки валидации делать исключениями
Как уже ниже написали, я в свое время писал даже отдельную статью на эту тему. Вкратце:
1. Инкапсулировать всю информацию об ошибке в один объект
2. Выстраивать иерархию классов таких объектов
3. Иметь возможность разделить возникновение ошибки валидации (throw) и ее обработки (catch) любым количеством уровней кода, не заботясь о цепочке возвратов.
Всё это нас приводит к исключениям. Другого подобного механизма в PHP нет.BoShurik
05.05.2017 19:04в данном конкретном случае не нужна, поскольку конструктор стандартного класса все без исключения Throwable заворачивает в Exceptions: https://github.com/RunnMe/Core/blob/master/src/Core/Std.php#L53
Т.е. конкретные ошибки валидации мы поймать не можем, нам всегда надо ловить
Exceptions
и работать уже с ним? А наследуются они отException
, чтобы просто была возможность их выбросить при обработкеExceptions
?AlexLeonov
05.05.2017 19:09В этом конкретном примере (валидация в конструкторе объекта класса Std) вы всегда из конструктора будете ловить именно объект класса Exceptions — коллекцию ВСЕХ исключений, которые были выброшены внутри конструктора различными валидаторами. Даже если в этой коллекции будет всего одно исключение.
Используя разные инструменты фреймворка вы можете переопределить это поведение на нужное вам. Например — можете прекращать валидацию полей объекта после получения первой же ошибки валидации. Наследуйтесь, переопределяйте метод innerSet().
hlogeon
05.05.2017 18:40+2Однако мы решили, что возможный профит от такого подхода во много раз превышает минусы, которые можно от него ожидать.
— про исключения при валидации.
Так а в чем профит-то? Конкретно можно, пожалуйста, что вам это дает? Какое преимущество перед использованием обычного message bag?AlexLeonov
05.05.2017 18:44Была отдельная статья на эту тему: https://habrahabr.ru/post/279501/
Если после ее прочтения у вас останутся вопросы — задавайте, с удовольствием на них отвечу.Fesor
06.05.2017 00:06+1Вы в той статье смешиваете понятие валидации входящих данных и проверку инвариантов и бизнес правил.
Fesor
05.05.2017 19:50+5tl;dr Я так и не понял, вы вроде бы за type safety (коллекции, нул объекты) и вроде как и нет (неявное возвращение значений при помощи throw вместо явного).
получает особую суперспособность: его можно «выбросить» (throw).
Это в свою очередь порождает сайд эффекты. С великой силой приходит великая ответственность.
Имплементация интерфейса JsonSerializable неспроста добавлена сразу же в Runn\Core\Exception: это явный задел на будущее
Что делать если я захочу в debug режиме добавлять помимо кода ошибки и описания еще и стэктрэйс? Налицо нарушение open/close принципа. Да, это просто, но намного проще будет сделать так:
class JsonExceptionFormatter implements ExceptionFormatter { private $shouldIncludeDebugInfo; public function __construct(bool $debug) { $this->shouldIncludeDebugInfo = $debug; } public function format(Throwable $e) { return new JsonResponse(array_merge( [ 'code' => $e->getCode(), 'message' ], $this->debugInfo(), ) } private function debugInfo(\Throwable $exception): array { if (!$this->shouldIncludeDebugInfo) return []; return [ 'trace' => $e->getTrace(), ]; } }
Профит:
- разделение ответственности. Мы не помещаем логику форматирования данных в данные.
- Универсально. Мы можем сделать кучу разных форматтеров и выбирать их исходя из настроек или вообще через content negotiation.
Это мультиисключение:
механизм валидации, предусмотренный в стандартных объектах «Runn Me!»."исключения", это объект, описывающий исключительную ситуацию. Это такие штуки которых чем меньше — тем лучше. Они обладают суперспособностями (например они запоминают место где их создали а так же весь стэк вызовов до этого места). Они умеют неявно выходить из функций всплывая по стэку вверх. И самое главное — их надо обрабатывать.
Сравним запись:
try { $validator->validate($data); } catch (Exceptions $validationErrors) { $this->handleValidationErrors($validationErrors); } // vs $errors = $validator->validate($data, $rules); if ($errors->count() !== 0) $this->handleValidationErrors();
профит:
- валидатор становится чистым. Он получает данные на вход и возвращает результат. Отсутствуют сайд эффекты.
- такая же типизированная коллекция ошибок как и у вас, но без оверхэда и которая представляет собой дерево ошибок, в соответствии с иерархии валидируемых данных.
- даже ваш выше приведенный пример с JsonSerializable уже не так удобен. Моим клиентам удобен такой формат:
{ "status_code": 422, "message": "Please check your data", "violations": [ {"path": "/foo/bar", "message": "Value should not be blank"}, {"path": "/foo/email", "message": "Invalid email address"} ] }
С вашим подходом сделать это можно только забыв про пункт с JsonSerializable у исключений либо надо менять код
Exceptions
.
Однако мы решили, что возможный профит от такого подхода во много раз превышает минусы, которые можно от него ожидать.
На самом деле мне кажется я понимаю почему вы решили кидать исключениями ошибки валидации. Вы таким образом обходите ограничение что один метод может возвращать только штуки одного типа. Хотя это намного лучше делать возвращая всегда явно коллекцию ошибок. Без магии и сайд эффектов. Более того, таким образом мы делаем контракт объекта проще с точки зрения клиентского кода.
Определили метод, он получает на вход то значение, которое вы намереваетесь присвоить свойству
Санитайзинг надо делать при выводе информации, а не при записи в базу. Не очень хорошо карраптить данные пользователя и заменять их на свои. Да и такой подход дает больше гибкости. Мы всегда можем подправить санитайзинг и обрабатывать данные по другому.
перейдем к теме комплексных (очень хочется произнести это слово с ударением на «е»: «комплЕксных») объектов.
Вы выкинули в мусорку инкапсуляцию, о каких объектах вы говорите? Это структуры данных, property bags. Тупые и бесполезные.
оповещения нас о том, что некоторые обязательные поля оказались неустановленными в конструкторе объекта.
Поздравляю. Вы переизобрели Design by Contract/AOP. Можно просто повесить на класс описание инвариантов и трекать все при помощи какого-нибудь goaop. Выйдет тоже что и у вас но за счет прокси объектов а не наследования.
AlexLeonov
05.05.2017 19:56-3Благодарю за ваше внимание к коду библиотеки. Но, если честно, я не нашел, что вам ответить. Наверное потому, что вы не задали ни одного вопроса, а утверждения, которые вы делаете, ответов не требуют.
В любом случае внимательно перечитаю ваш текст еще раз. Спасибо.Fesor
05.05.2017 20:08+3вы не задали ни одного вопроса
это не требуется для поддержания дискуссии. Я привел вам свои аргументы и доводы и хотелось бы получить вашу точку зрения по каждому из пунктов.
В целом же я скорее хочу не вам что-то доказать а уберечь наивных читателей от мыслей что описанные идеи это что-то хорошее. Существует масса схожих подходов реализованных на порядок лучше.
AlexLeonov
05.05.2017 20:55-3Так и дискуссии-то нет, уважаемый коллега. О чем мы дискутируем?
Вот пример:
намного лучше делать возвращая всегда явно коллекцию ошибок
Что мне тут ответить в порядке дискуссии? Я во всех своих примерах в статье явно выкидываю коллекцию ошибок. Настолько явно, что «явнее» быть не может.
Вы считаете, что валидатор должен возвращать или «успех» или коллекцию ошибок именно оператором return. Для меня это странная идея, поэтому я разделяю возврат информации об успешности/неуспешности валидации (return boolean) и выброс коллекции ошибок валидации (throw Exceptions). Для меня это нормальная практика, проверенная годами и тоннами промышленного кода. Но не повод для дискуссии. Если вы считаете, что так делать плохо — ОК, я уважаю ваше мнение, но не вижу причин для спора и тем более не вижу возможности вам что-то доказать.
Ровно тоже самое про интерфейс JsonSerializable.
Для меня это стандартный библиотечный интерфейс, очень удобный, я его реализую на своих исключениях потому что точно знаю, что он мне нужен и именно в таком виде. Вы против — ОК, я вас услышал, спасибо.
И так далее.hlogeon
05.05.2017 22:08+4Я сколько слежу за Вашими ответами от поста к посту и Вы постоянно апеллируете к каким-то аргументам наподобие: «проверено временем», какие-то «тонны промышленных проектов» и т.п. При этом, судя по всему, даже не понимаете, что когда вам аргументируют, почему то, что вы делаете — плохо с точки зрения дизайна, люди руководствуются т.н «лучшими практиками», которые проверены не одним человеком(Вами), а огромным сообществом. И масштаб промышленных проектов, использующих те практики, о которых говорит уважаемый Fesor и их качество значительно выше даже самых смелых Ваших фантазий. Люди, наподобие Эрика Эванса, например, положили годы на анализ и формализацию накопленного СООБЩЕСТВОМ опыта и его формализацию. Вы же просто игнорируете все, что вам говорят и считаете себя самым умным, при этом, совершенно не гнушаясь, откровенным и беспочвенным обсиранием этих самых практик(хороший пример — ваша статья про валидацию. Опытным программистам, при взгляде на ваши подходы, проблемы, которые могут возникнуть при использовании вашего кода очевидны, а вот новички действительно могут повестись на все это и начать отстреливать себе ноги.
А нам потом работай с Вашими продуктами и, не дай Бог, еще и исправляй.AlexLeonov
05.05.2017 22:13-1Вы же просто игнорируете все, что вам говорят
Неправда. Я благодарен за каждый комментарий.
Даже за ваш говорю вам «большое спасибо», хотя он и не содержит ничего практически интересного — одни эмоции. Но это тоже важно, эмоции тоже нужны. Значит вам действительно важно, раз вы так эмоционально реагируете.
Осталось понять — что вы хотите предложить. И тогда, вероятно, мы сможем услышать друг друга.hlogeon
05.05.2017 22:19+1С конкретными предложениями по вопросу, непосредственно касающемуся статьи уже высказались выше, мне добавить нечего. А свою эмоциональную реакцию я обосновал этим
А нам потом работай с Вашими продуктами и, не дай Бог, еще и исправляй.
и вот этим
а вот новички действительно могут повестись на все это и начать отстреливать себе ноги.
хотя он и не содержит ничего практически интересного — одни эмоции
Помимо эмоций оно содержит критику Ваших аргументов, конкретный пример использования похожего подхода к проектированию и критику в адрес Вашего игнорирования лучших практик. Быть может, самое интересное, что вы можете вынести из этого сообщения — мысль о том, что может все-таки стоит учиться не только на своих ошибках, но и обратиться к опыту сообщества, который вы так тщательно игнорируете(по причинам, которые вы наверное знаете лучше меня).AlexLeonov
05.05.2017 22:21-1мысль о том, что может все-таки стоит учиться не только на своих ошибках, но и обратиться к опыту сообщества
Банально, но полезно. Спасибо. Я обязательно учту ваше мнение.
Fesor
05.05.2017 23:59+4Я во всех своих примерах в статье явно выкидываю коллекцию ошибок. Настолько явно, что «явнее» быть не может.
посмотрим на интерфейс вашего валидатора:
/** * @param mixed $value * @return bool */ abstract public function validate($value): bool;
где тут "явно"? Ответьте пожалуйста. Ни намека что этот метод возвращает хоть что-то кроме bool. Есть такая штука — правило наименьшего удивления при проектировании интерфейсов. Ваш компонент — это ваш продукт. Им должно быть удобно пользоваться. И под удобно это значит что все должно происходить явно, без необходимости на каждый чих читать доку.
Вы считаете, что валидатор должен возвращать или «успех» или коллекцию ошибок
нет, валидатор должен возвращать ТОЛЬКО коллекцию ошибок:
public function validate($data, $rules, $groups): ConstraintViolationsList;
тут все явно. А отсутствие ошибок будет говорить нам что данные валидны. Я вам уже писал подробно об этом в комментариях к предыдущему вашему посту.
я разделяю возврат информации об успешности/неуспешности валидации (return boolean) и выброс коллекции ошибок валидации (throw Exceptions).
вот только в вашем варианте результатом работы функции всеравно будет либо
true
либо исключение. Никто и никогда не увидит false. Более того, за счет того что выбрасывается исключение даже в этом самомtrue
смысла никакого нет. Нам не нужны условия так как мы вместо них используем обработку исключений. А стало быть мы можем выкинутьbool
и заменить его на простой список ошибок, без выброса исключений. А если вам лень сделать проверку — не вопрос, делаем простенький адаптер с методомvalidateAndFailOnErrors
. Все явно, можно менять поведение под нужны задачи… И избавляемся от кастыля под названиемExceptions
.
Прокомментируйте.
Для меня это нормальная практика, проверенная годами и тоннами промышленного кода.
для того что бы фразы "проверенная годами" и "тоннами промышленного кода" имела хоть какой-то вес, ваш собеседник должен знать:
- Ваш уровень и опыт работы
- Квалификацию, ибо 10 лет опыта могут быть как 10 лет опыта так и 1 год опыта повторенный 10 раз.
- Уровень проектов. Весьма субъективная метрика, можно начать с обсуждения человеко-лет изначальной разработки, перешли ли проекты на поддержку или все так же активно развиваются (ибо качество решений постигается не в период начальной разработки а проверяется потоком изменений в течении жизни проекта). Так же интересует динамика количества багов.
Ровно тоже самое про интерфейс JsonSerializable.
тогда ответьте на мой вопрос.
Что делать если я захочу в debug режиме добавлять помимо кода ошибки и описания еще и стэктрэйс?
Вы против — ОК, я вас услышал, спасибо.выводов вы для себя не сделали.
p.s. глянул тесты. Откройте для себя провайдеры данных.
oxidmod
06.05.2017 00:06+2Исключения не должны использоваться как goto
Это может быть неочевидность вначале, но потом больно бьёт по лбу.
Сериализация. Вы говорите, что точно знаете что вам нужно. Но вы же делаете фреймворк. Он не только для вас. Я вот хочу более детально. Или хочу на другом языке меседж. Что мне делать?
sspat
06.05.2017 01:21+6Насколько у вас большой опыт командной разработки? Не со своими падаванами, где ваш подход априори не будет подвергаться сомнению, а с вашего или выше уровня разработчиками? Я просто с трудом представляю работу в команде над чем-то серьезным, если даже один член команды будет пользоваться не общепринятыми знакомыми всем практиками а своей поваренной книгой заклинаний.
unSeen
Yoda Conditions
justboris
Так Star Wars Day вчера (4 мая) же был
AlexLeonov
У меня студенты на первом уроке первого курса по PHP учатся писать условия, как завещал великий мастер :))