Собственные валидации полей для Rules в одном классе

Валидация входных данных заслуженно является одним из важнейших правил во всей сфере IT. Если сузить сферу деятельности до разработки веб-сайтов, речь пойдет в основном про валидацию данных из форм.

image

Я не думаю, что многие разработчики любят проверять входные данные и делают это достаточно тщательно, поэтому в современных фреймворках, таких как 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' => 'Выберите поставщика' ] ,
        ];
    }


Конечно можно все замыкания заменить на

callback функции
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' => 'Выберите поставщика' ] ,
        ];
    }


Этот пример казалось бы лучше предыдущего. Да, мы не захламляем Модель методами валидации, однако мы захламляем какую-либо из папок проекта

дополнительными файлами
image

Само по себе «захламление» папок не столь критично на первый взгляд, но работать с ними неудобно… Эти классы имеют лишь 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 допустимо)

Лично мне кажется это решение более изящным, чем те, которые есть в документации:
  1. Мы не меняем движок -> не будет проблем с обновлением (ведь можно было просто добавить нужные методы в сам класс Model (но такого мы конечно никогда не делаем)
  2. Можно менять все именования ошибок и реализацию в одном файле
  3. Используя префикс trait для методов мы сразу даем понять разработчику, о чем идет речь
  4. Можно вообще пойти во все тяжкие и использовать методы rules() через трейт, тем самым — единственное, что нужно изменить в моделях — добавить use CustomTrait; и убрать базовый метод rules, а в самом трейте определять какие правила использовать

Послесловие


Разумеется я не навязываю свое мнение, и я более чем уверен, что могу ошибаться во многих моментах, поэтому мой первый опыт публикации на Хабре подскажет мне в любом случае, где я прав, а где нет, а комментарии помогут более подробно разобраться в причинах тех или иных последствий.
Поделиться публикацией

Комментарии 11

    +2
    Если я все верно понимаю, ваш PriceValidator вынесен в отдельный метод только для того что бы написать пару логов. Без логов можно было бы использовать match. Также в Yii2 целая куча встроенных валидаторов.

    Кстати не уверен что логировать в валидаторе — хорошее решение. Хотя если не в нем, то где? Опытные Yii'сты поправьте меня.
      0
      Изначально идея вынести все кастомные валидации в отдельный файл у меня появилась из-за того, что клиент просит практически для каждого поля свой текст ошибки. Не просто «Некорректный ввод» или «Не должно быть пустым», а «ИНН должен состоять из 10 цифр», «Ваш телефон не подходит для нашего региона», при этом клиент очень щепетильно относится и к вопросу несоответствия информации на разных формах сайта.

      Например при регистрации пишется «Некорректный номер телефона» (регистрацию делал 2 месяца назад), а при изменении номера в личном кабинете «Номер набран неверно» (делал пару недель назад и благополучно не обратил внимание, что валидация регистрации у меня проходила через модель-посредник). Таким образом я и решил запихнуть все кастомные валидации в одно место, чтобы их было удобней поддерживать/изменять, а не приходилось бегать по десяткам моделей и для каждой менять rules и при этом не совершать элементарных опечаток.
        0

        Не нужно бояться "захламления" папок валидаторами. Это самый правильный подход. Каждый класс должен выполнять то что ему положено.


          public function traitPrice($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);
        }
        

        А тут у вас смешение логики, и валидация и какое-то добавление… возможно в базу.
        Тут лучше кинуть событие о том что левый регион, потому как тут явно логика может поменяться. И почему функция traitPrice обрабатывает номер телефона?

        0

        если для бизнеса важно отлавливать некорректные валидации, для анализа взаимодействия с ui например, то кидать event, который слушать и обрабатывать

          0
          Event, точно! Спасибо, совсем забыл о них. Тогда можно создать директорию под события. Потому что, как demimurych говорил ниже:
          разбросать все нужные мне классы так как хочется

          Главное PSR (вроде бы 4й) соблюдать, именование, неймспейсы, а дальше, к примеру, создать директорию ./events/signup, а в ней PhoneMismatchEvent, PotentialClientEvent и т.д.
        0
        Простите если вдруг я что-то забыл, но насколько я помню, Yii2 никогда не накладывал жестких ограничений на размещение чего-либо где-либо. Иначе говоря я всегда мог разбросать все нужные мне классы так как хочется мне, а не держать контроллеры в папке контроллеры модели в папке моделс и так далее. Не исключаю что с валидаторами ровно тоже самое.
          0
          можно вызывать его сразу в родителе моделей \yii\db\ActiveRecord (имхо такое внедрение в базовый код Yii допустимо)


          Нет, не допустимо. Если вы обновите Yii, вы будете каждый раз править исходник?
          Лучше тогда уж создать наследника от \yii\db\ActiveRecord в своем проекте и туда импортировать ваш трейт.
            0
            Несколько десяткой файлов в каталоге Вам кажется много (хотя никто не запрещает их группировать в подкаталоги).
            А как тогда несколько сотен/тысяч строк в файле с трейтом (который нужно постоянно поддерживать), удобно так работать?
            А если нужно сообщения об ошибках вынести в одно место, то достаточно использовать Yii::t().

            P.S.: на истину также не претендую.
              0

              Не претендую на истинность высказываний, но Ваше решение очень плохо пахнет… Yii в качестве валидатора может принимать любой callable (ведь так?). Поэтому я бы предпочел либо сделать класс с набором статических методов-валидаторов [CustomValidators::class, 'phoneValidator'], либо создавать экземпляр класса и указывать метод [new CustomValidators(), 'phoneValidator'], либо можно вообще наполнить статическое свойство yii\validators\Validator::$builtInValidators конфигами часто используемых валидаторов и писать так: ['propertyToValidate', 'phone']. Но использовать для этого трейт и пихать все нужные и ненужные методы в класс… Почему-то мне кажется, это не самое лучшее решение…
              Я предпочитаю для часто используемых валидаторов использовать последний метод (для номеров телефонов, например), а в остальных случаях создавать новый класс и явно его указывать.
              // Все выше сказанное является абсолютным ИМХО

                0
                Вроде должен, но, по моему, в документации используется только string т.е. если вы напишете в массиве с правилами
                ['myValidatedField', 'validationMethod']
                , то надо объявить в текущем классе метод validationMethod с модификатором доступа public. Вы, вероятнее всего, это знаете, поэтому пишу на всякий случай для тех, кто не знает ;-)
                А на счет любых callable не проверял, но недавно пробовал сделать что-то вроде
                [$this, 'validationMethod']
                , по типу как вы писали, и у меня било ошибку.
                  0

                  Вы, к сожалению, правы на счет последнего. Обычный callable Yii2 в качестве валидатора не принимает (хотя очень хотелось бы). Достаточно посмотреть в код \yii\validators\Validator::createValidator


                  Зато он принимает Closure! И тогда можно использовать одну из фич PHP7 Closure::fromCallable:


                  \Closure::fromCallable([$this, 'validationMethod'])

                  // Опять же, я не проверял. В теории, должно работать. Но идея с трейтами мне до сих пор не нравится.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое