Я не думаю, что многие разработчики любят проверять входные данные и делают это достаточно тщательно, поэтому в современных фреймворках, таких как Yii 2, предусмотрены функции rules() для моделей и классы-Валидаторы, которые хоть и не избавляют от этой рутины, но, как минимум, делают этот процесс менее нудным.
В современной документации Yii 2 и других источниках я не нашел живой пример, как сделать так, чтобы все собственные правила валидации хранились в одном месте и их было удобно использовать, если Вы заинтересованы в решении этой проблемы, добро пожаловать под кат.
Немного о себе
Я не могу назвать себя искушенным в ООП программистом, более того я далек от формальных планок Middle developer и сейчас нахожусь скорее на стадии Junior. Я начал свой путь веб-разработчика в 2007 (тогда мне было 15 лет), все делал на коленке, поглощая тонны литературы, но в 2010 благополучно «слился», поступив в университет на специальность, которая недостаточно пересекалась с разработкой и программированием в целом, а вернулся в сферу лишь полгода назад. Чтобы более точно выразить степень своего опыта, каждый раз, когда я смотрю на свой код неделю спустя, я думаю «Что за хрень написал этот программист?» Поэтому не исключена ситуация, что Вам покажется эта статья бессмысленной или слишком поверхностной, или, что более печально, некорректной.
Суть проблемы
Для повседневных нужд и стандартных задач правил «из коробки» Yii 2.0* вполне хватает, однако когда речь идет о более щепетильной работе валидаторов и удобстве их использования мы столкнемся с некоторыми трудностями, которые противоречат различным принципам, в том числе DRY, да и в целом, они могут выглядеть
public function rules() {
return [
[ [ 'product_id' , 'currency_id' , 'unit_id' , 'quantity' , 'price', 'phone' ] , 'required' ] ,
[['phone'], function ($attribute, $params, $validator) {
$pattern = "/^[8|+7]922\d{7}$/uism";
if (preg_match($pattern, $this->$attribute) == 0) {
$this->addError($attribute, 'Принимаются только номера мегафона в Перми!');
$region = Yii::$app->newRegions->addRegionByPhone( $this->$attribute );
Yii::$app->log->write("Потенциальный клиент из другого региона: " . $region);
}
}],
[['price'], function ($attribute, $params, $validator) {
if (!is_numeric($this->$attribute) || (float) $this->$attribute <= 0)
$this->addError($attribute, 'Неверное значение цены');
}],
[['quantity'], function ($attribute, $params, $validator) {
if ((int) $this->$attribute < 0)
$this->addError($attribute, 'Количество может быть меньше нуля');
}],
[ [ 'vendor_code' ] , 'string' , 'max' => 255, 'message' => 'Артикул должен содержать от 25 до 255 символов.' ] ,
[ [ 'currency_id' ] , 'exist' , 'skipOnError' => true , 'targetClass' => Currencies::className() , 'targetAttribute' => [ 'currency_id' => 'id' ], 'message' => 'Выберите валюту' ] ,
[ [ 'product_id' ] , 'exist' , 'skipOnError' => true , 'targetClass' => Products::className() , 'targetAttribute' => [ 'product_id' => 'id' ] ], 'message' => 'Выберите товар' ,
[ [ 'unit_id' ] , 'exist' , 'skipOnError' => true , 'targetClass' => Units::className() , 'targetAttribute' => [ 'unit_id' => 'id' ], 'message' => 'Выберите единицу измерения' ] ,
[ [ 'user_id' ] , 'exist' , 'skipOnError' => true , 'targetClass' => User::className() , 'targetAttribute' => [ 'user_id' => 'id' ], 'message' => 'Выберите поставщика' ] ,
];
}
Конечно можно все замыкания заменить на
public function rules() {
return [
[ [ 'product_id' , 'currency_id' , 'unit_id' , 'quantity' , 'price', 'phone' ] , 'required' ] ,
[['phone'], "phoneValidator"],
[['price'], "priceValidator"],
[['quantity'], "quantityValidator"],
[ [ 'vendor_code' ] , 'string' , 'max' => 255, 'message' => 'Артикул должен содержать от 25 до 255 символов.' ] ,
[ [ 'currency_id' ] , 'exist' , 'skipOnError' => true , 'targetClass' => Currencies::className() , 'targetAttribute' => [ 'currency_id' => 'id' ], 'message' => 'Выберите валюту' ] ,
[ [ 'product_id' ] , 'exist' , 'skipOnError' => true , 'targetClass' => Products::className() , 'targetAttribute' => [ 'product_id' => 'id' ] ], 'message' => 'Выберите товар' ,
[ [ 'unit_id' ] , 'exist' , 'skipOnError' => true , 'targetClass' => Units::className() , 'targetAttribute' => [ 'unit_id' => 'id' ], 'message' => 'Выберите единицу измерения' ] ,
[ [ 'user_id' ] , 'exist' , 'skipOnError' => true , 'targetClass' => User::className() , 'targetAttribute' => [ 'user_id' => 'id' ], 'message' => 'Выберите поставщика' ] ,
];
}
function phoneValidator ($attribute, $params, $validator) {
$pattern = "/^[8|+7]922\d{7}$/uism";
if (preg_match($pattern, $this->$attribute) == 0) {
$this->addError($attribute, 'Принимаются только номера мегафона в Перми!');
$region = Yii::$app->newRegions->addRegionByPhone( $this->$attribute );
Yii::$app->log->write("Потенциальный клиент из другого региона: " . $region);
}
}
...
Метод rules будет выглядеть чище, но это все равно захламляет код модели дополнительными методами валидации. Для этого случая разработчики Yii 2.0* позволяют нам добавлять классы-Валидаторы,
public function rules() {
return [
[ [ 'product_id' , 'currency_id' , 'unit_id' , 'quantity' , 'price', 'phone' ] , 'required' ] ,
[['phone'], PhoneValidator::className()],
[['price'], PriceValidator::className()],
[['quantity'], QuantityValidator::className()],
[ [ 'vendor_code' ] , 'string' , 'max' => 255, 'message' => 'Артикул должен содержать от 25 до 255 символов.' ] ,
[ [ 'currency_id' ] , 'exist' , 'skipOnError' => true , 'targetClass' => Currencies::className() , 'targetAttribute' => [ 'currency_id' => 'id' ], 'message' => 'Выберите валюту' ] ,
[ [ 'product_id' ] , 'exist' , 'skipOnError' => true , 'targetClass' => Products::className() , 'targetAttribute' => [ 'product_id' => 'id' ] ], 'message' => 'Выберите товар' ,
[ [ 'unit_id' ] , 'exist' , 'skipOnError' => true , 'targetClass' => Units::className() , 'targetAttribute' => [ 'unit_id' => 'id' ], 'message' => 'Выберите единицу измерения' ] ,
[ [ 'user_id' ] , 'exist' , 'skipOnError' => true , 'targetClass' => User::className() , 'targetAttribute' => [ 'user_id' => 'id' ], 'message' => 'Выберите поставщика' ] ,
];
}
Этот пример казалось бы лучше предыдущего. Да, мы не захламляем Модель методами валидации, однако мы захламляем какую-либо из папок проекта
Само по себе «захламление» папок не столь критично на первый взгляд, но работать с ними неудобно… Эти классы имеют лишь 3 метода: validateValue, ClientValidateAttribute, getClientOptions, последние 2 можно адекватно использовать, только если вы собираетесь пользоваться лишь «коробочным» функционалом. Но ведь хотелось бы, чтобы у меня был удобный способ обновлять\поддерживать валидацию десятка моделей, не прыгая по десяткам (а может и сотням) файлов.
Оба вышеперечисленных примера можно найти в официальной документации Yii и сотнях других источников. Однако я нигде не нашел примера, как можно организовать валидацию иначе.
Какое-никакое, но все же решение
Более подробно я начал изучать ООП пример 2 месяца назад, когда примерно на середине книги Стива я понял, что ничерта не понимаю в ООП и нужно реабилитироваться, я стал изучать все, что попадется под руку. Казалось бы, я знаю много, но в то же время ничерта, тем не менее каждая следующая неделя открывала мне глаза на то, что я изучал в предыдущую.
По такому же принципу я познакомился с Трейтами. Когда-то я прочитал документацию на официальном сайте PHP. Вроде бы понял, о чем идет речь. Но, как оказалось, не понял, как, где и зачем их применять. Лишь, когда я столкнулся с проблемой «комфорта» над текущим проектом, я начал искать варианты решения и вспомнил о тех самых «классах, которые я непонимаю как использовать».
CustomValidator.php
namespace common\traits;
use Yii;
trait CustomValidator {
public function traitPhone($attribute, $params, $validator ) {
$pattern = "/^[8|+7]922\d{7}$/uism";
if (preg_match($pattern, $this->$attribute) == 0) {
$this->addError($attribute, 'Принимаются только номера мегафона в Перми!');
$region = Yii::$app->newRegions->addRegionByPhone( $this->$attribute );
Yii::$app->log->write("Потенциальный клиент из другого региона: " . $region);
}
}
}
ProductOffers.php
namespace common\models;
use common\traits\CustomValidator;
class ProductOffers extends \yii\db\ActiveRecord {
use CustomValidator;
public function rules() {
return [
....
[['phone'], 'traitPhone'],
....
];
}
Иными словами, все методы собственной валидации находятся в одном единственном Trait'e, и в самих моделях мы используем именно эти методы. Чтобы избежать постоянного дублирования use CustomValidator; можно вызывать его сразу в родителе моделей \yii\db\ActiveRecord (имхо такое внедрение в базовый код Yii допустимо)
Лично мне кажется это решение более изящным, чем те, которые есть в документации:
- Мы не меняем движок -> не будет проблем с обновлением (ведь можно было просто добавить нужные методы в сам класс Model (но такого мы конечно никогда не делаем)
- Можно менять все именования ошибок и реализацию в одном файле
- Используя префикс trait для методов мы сразу даем понять разработчику, о чем идет речь
- Можно вообще пойти во все тяжкие и использовать методы rules() через трейт, тем самым — единственное, что нужно изменить в моделях — добавить use CustomTrait; и убрать базовый метод rules, а в самом трейте определять какие правила использовать
Послесловие
Разумеется я не навязываю свое мнение, и я более чем уверен, что могу ошибаться во многих моментах, поэтому мой первый опыт публикации на Хабре подскажет мне в любом случае, где я прав, а где нет, а комментарии помогут более подробно разобраться в причинах тех или иных последствий.
Комментарии (9)
demimurych
12.04.2018 14:27Простите если вдруг я что-то забыл, но насколько я помню, Yii2 никогда не накладывал жестких ограничений на размещение чего-либо где-либо. Иначе говоря я всегда мог разбросать все нужные мне классы так как хочется мне, а не держать контроллеры в папке контроллеры модели в папке моделс и так далее. Не исключаю что с валидаторами ровно тоже самое.
Sergo96
13.04.2018 08:10можно вызывать его сразу в родителе моделей \yii\db\ActiveRecord (имхо такое внедрение в базовый код Yii допустимо)
Нет, не допустимо. Если вы обновите Yii, вы будете каждый раз править исходник?
Лучше тогда уж создать наследника от \yii\db\ActiveRecord в своем проекте и туда импортировать ваш трейт.
sergiobelya
13.04.2018 08:10Несколько десяткой файлов в каталоге Вам кажется много (хотя никто не запрещает их группировать в подкаталоги).
А как тогда несколько сотен/тысяч строк в файле с трейтом (который нужно постоянно поддерживать), удобно так работать?
А если нужно сообщения об ошибках вынести в одно место, то достаточно использоватьYii::t()
.
P.S.: на истину также не претендую.
vtvz_ru
13.04.2018 15:53Не претендую на истинность высказываний, но Ваше решение очень плохо пахнет… Yii в качестве валидатора может принимать любой callable (ведь так?). Поэтому я бы предпочел либо сделать класс с набором статических методов-валидаторов [CustomValidators::class, 'phoneValidator'], либо создавать экземпляр класса и указывать метод [new CustomValidators(), 'phoneValidator'], либо можно вообще наполнить статическое свойство yii\validators\Validator::$builtInValidators конфигами часто используемых валидаторов и писать так: ['propertyToValidate', 'phone']. Но использовать для этого трейт и пихать все нужные и ненужные методы в класс… Почему-то мне кажется, это не самое лучшее решение…
Я предпочитаю для часто используемых валидаторов использовать последний метод (для номеров телефонов, например), а в остальных случаях создавать новый класс и явно его указывать.
// Все выше сказанное является абсолютным ИМХО
MihaOo
Если я все верно понимаю, ваш PriceValidator вынесен в отдельный метод только для того что бы написать пару логов. Без логов можно было бы использовать match. Также в Yii2 целая куча встроенных валидаторов.
Кстати не уверен что логировать в валидаторе — хорошее решение. Хотя если не в нем, то где? Опытные Yii'сты поправьте меня.
peresada Автор
Изначально идея вынести все кастомные валидации в отдельный файл у меня появилась из-за того, что клиент просит практически для каждого поля свой текст ошибки. Не просто «Некорректный ввод» или «Не должно быть пустым», а «ИНН должен состоять из 10 цифр», «Ваш телефон не подходит для нашего региона», при этом клиент очень щепетильно относится и к вопросу несоответствия информации на разных формах сайта.
Например при регистрации пишется «Некорректный номер телефона» (регистрацию делал 2 месяца назад), а при изменении номера в личном кабинете «Номер набран неверно» (делал пару недель назад и благополучно не обратил внимание, что валидация регистрации у меня проходила через модель-посредник). Таким образом я и решил запихнуть все кастомные валидации в одно место, чтобы их было удобней поддерживать/изменять, а не приходилось бегать по десяткам моделей и для каждой менять rules и при этом не совершать элементарных опечаток.
Samouvazhektra
Не нужно бояться "захламления" папок валидаторами. Это самый правильный подход. Каждый класс должен выполнять то что ему положено.
А тут у вас смешение логики, и валидация и какое-то добавление… возможно в базу.
Тут лучше кинуть событие о том что левый регион, потому как тут явно логика может поменяться. И почему функция traitPrice обрабатывает номер телефона?
Samouvazhektra
если для бизнеса важно отлавливать некорректные валидации, для анализа взаимодействия с ui например, то кидать event, который слушать и обрабатывать
MihaOo
Event, точно! Спасибо, совсем забыл о них. Тогда можно создать директорию под события. Потому что, как demimurych говорил ниже:
Главное PSR (вроде бы 4й) соблюдать, именование, неймспейсы, а дальше, к примеру, создать директорию ./events/signup, а в ней PhoneMismatchEvent, PotentialClientEvent и т.д.