среда, 8 сентября 2010 г.

Организация Модели: Валидация данных.

Введение

Эта статья является продолжением рассуждений на тему  организации модели и посвящена организации процесса валидации данных. И не смотря на то, что практическая часть выполнена на Zend Framework, приведенные рассуждения в равной степени могут быть применены к любому фреймворку и даже платформе.

Большинство данных поступает в модель из внешнего мира, через html-формы, различные сервисы, почту и т.д. При этом, всегда необходимо производить валидацию и фильтрацию этих данных, т.к. они не надежные.

Например, при поступлении данных из html-форм  в Zend Framework'е удобно использовать компонент Zend_Form. Это мощный компонент, позволяющий структурировать данные, производить гибкую фильтрацию и валидацию данных, а так же имеет механизмы вывода, включая вывод ошибок валидации.

С другой стороны, данные в модели так же должны быть всегда в валидном состоянии и не зависеть от источника поступления. Поддержание модели в корректном состоянии это обязанность как раз самой модели. На лицо дублирование функциональности.

Избежать дублирования можно, если перенести всю проверку данных в Модель. Таким образом, мы всегда сможем гарантировать, что Модель корректна, даже если данные поступают не из пользовательских форм.


Организация валидации данных
Валидация - это процесс проверки данных на соответствие неким, заранее известным правилам (формату, требованиям).
Что бы лучше осветить этот вопрос надо понять какие данные могут циркулировать в системе.

Можно выделить следующие типы данных:
  1. Внешние - данные, пришедшие в систему из вне. Этим данным нельзя доверять.
  2. Данные прошедшие первичную проверку - это внешние данные, которые прошли первичную проверку. Например данные прошедшие тест CAPTCHA, или проверка совпадения полей "Пароль" и "Повтор пароля" и т.д. Так же нельзя доверять этим данным.
  3. Данные конкретной сущности - непротиворечивые данные, по отношению к конкретной сущности. Например, поле login у сущности "User" должно быть от 4 до 16 латинских символов, поле email должно быть корректным e-mail адресом и т.д.
  4. Данные в Доменной Модели - непротиворечивые данные, с точки зрения бизнес-логики. По сравнению с предыдущем уровнем, например, не допускается что бы две разных сущности "User" имели одинаковый логин или email.
  5. Данные на выходе из Модели - данные отправляемые клиентам, либо данные отдаваемые другим слоям системы.

Первичная валидация

В Zend Framework первичную валидацию удобно осуществлять через компонент Zend_Validate. А если данные поступают из html-форм (наверняка 80% данных именно так и поступают), то удобно использовать компонент Zend_Form, который опирается на компоненты по фильтрации (Zend_Filter) и валидации данных (Zend_Validate). Если данные не валидны, то необходимо прервать текущую операцию и каким-либо образом сообщить клиенту об этом.

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

Валидация сущности

Предположим данные достигли доменной сущности, каким образом организовать валидацию?

Валидация сущности это не просто проверка, отдельных свойств, тут уже должна быть задействована бизнес-логика. Так значение одного свойства может зависеть от значения другого. Простой пример, есть сущность "Человек", если возраст этого человека больше или равен 14 годам, то должно быть заполнено поле с номером паспорта.

Можно выделить два подхода:
  1. Строгий - когда данные валидируются каждый раз при изменении свойства. С учетом замечаний выше, необходимо валидировать всю сущность.
  2. Мягкий - когда данные валидируются по требованию (запросу). В этом случае валидируется вся сущность, хотя можно организовать валидацию отдельных свойств.
Очевидно оба способа имеют недостатки. При строгом, возможно валидация избыточна, т.к с учетом замечаний выше, необходимо валидировать всю сущность сразу каждый раз.

При мягком, есть вероятность что произойдет логический сбой. Т.к. "кто-то" воспользуется нашей сущностью, пока она не в валидном состоянии.

На данный момент мы остановимся на мягкой валидации. Если, изменяется какое-либо свойство через мутатор, то помечать всю сущность как "грязную", после успешной валидаци помечать всю сущность как "чистую". Ответственность за использование "грязной" сущности возложить на сервисный слой.

Валидация в Доменной Модели

Валидация на этом уровне схожа с предыдущим уровнем, здесь так же задействована бизнес-логика приложения, разница лишь в контексте. Не противоречива ли эта сущность по отношению к другим сущностям системы? Здесь важно взаимодействие между сущностями.

Например, как уже упоминалось выше, не допускается что бы в системе были две разные сущности с одинаковым логином или e-mail.

Каким образом организовать такую валидацию? Тут сложно однозначно ответить, т.к. такая валидация это элементы бизнес-логики в чистом ее проявлении. 

Скорей всего часть валидации можно сделать в виде надстройки над валидацией с предыдущего уровня. Например, проверка незанятости логина отлично впишется в эту концепцию. 

Также, в DDD есть такое понятие, как Агрегирование. Когда выделяется корневая сущность, которая ограничивает вложенные сущности. Существование вложенных сущностей без корневой - невозможно. Поэтому, валидацию такого агрегата можно производить посредством корневой сущности.

Остальные случаи могут быть слишком специфичными, главное не допускать работы с инвалидными сущностями, тем более попадание их в Источник Данных.

Данные на выходе из модели

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

Связь валидации данных на различных уровнях


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

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

Назревают следующие вопросы:
  1. Каким образом осуществлять валидацию данных без дублирования?
  2. Каким образом сообщить Zend_Form что часть валидации производит модель?
  3. Каким образом осуществлять валидацию доменного уровня?

Валидатор для Модели

Если в системе фигурируют различные алгоритмы, которые часто могут использоваться повторно в других частях, слоях приложения, то можно инкапсулировать каждый из них в отдельную сущность, параметризовать и запускать их там, где понадобится, не дублируя, таким образом, код. По сути это назначение паттерна проектирования Стратегия.
Стратегия - определяет семейство алгоритмов, инкапсулирует каждый из них и делает их взаимозаменяемыми. И далее – позволяет изменять алгоритмы независимо от клиентов, которые ими пользуются.
Мы можем вынести проверку из модели, т.е. делегировать проверку другому классу. Для этого необходимо объявить интерфейс, который будут понимать все клиенты.

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

Как мы договорились выше, валидацию модели мы вынесем в отдельный класс - Валидатор. А для того, что бы не зависеть от этого класса мы объявим интерфейс валидатора.

Наш интерфейс расширяет интерфейс Zend_Validate_Interface и добавляет операции работы с валидаторами конкретных свойств (полей) модели.

Валидаторы для  свойств модели так же представляют собой отдельные классы, т.о. мы можем многократно использовать одни и те же валидаторы к разным свойствам, для этого объявим интерфейс валидатора элемента.
Этот интерфейс расширяет интерфесы Zend_Validate_Intreface и Zend_Filter_Interface, и включает операции по добавлению других валидаторов и фильтров, совместимых с Zend_Validate и Zend_Filter тем самым образуя цепочки.

Так же объявим интерфейс для самой модели, поддержка которого, говорит о том, что валидация модели осуществляется внешним валидатором.
Посредством этого интерфейса будет осуществляться работа по валидации модели. Общая картина взаимодействия интерфейсов приведена на UML-диаграмме ниже.


Медиатор между формой и моделью

Если взглянуть на общую картину валидации в модели рассмотреную выше, то можно увидеть много общего с внутренним устройством Zend_Form. Так, кокретный доменный объект можно отождествить с конкретной формой Zend_Form, конкретный валидатор для свойства модели,  с Zend_Form_Element, аккумулирующим всю информацию об одном элементе. Ну и самое главное, оба эти компонента в качестве рабочих лошадок используют  объекты, реализующие Zend_Validate_Interface и Zend_Filter_Interface.

Необходимо выстроить валидацию как формы, так и модели в рамках единого процесса. И этим механизмом будет Медиатор.
Медиатор - разделяет множество объектов так, что бы они не знали о существовании друг друга, но были осведомлены об наличии объекта медиатор.
Медиатор знает как связать например форму и модель, при этом ни форма, ни модель друг о друге ничего не знают. Объявим интерфейс медиатора.
Медиатор в нашем случае, будет получать на вход готовые объекты формы и модели. Затем, по запросу производить сначала валидацию формы, потом используя валидатор из модели - валидацию модели. В случае успешной валидации вставлять данные в модель. Таким образом, мы можем запросить у медиатора готовую модель с валидными данными.

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


Валидация доменного уровня

Как уже рассматривалось выше, валидация доменного уровня отвечает за непротиворечивость конкретной сущности среди других сущностей.


Надо признаться этот вопрос я до конца не проработал. Так, для валидации уникальности полей доменного объекта среди подобных, мне хватило добавления еще одного валидатора в цепочку валидаторов сущности. Возможно, такой способ покроет большинство вариантов использования. Для остальных случаев, можно расширить медиатор так, что бы он работал еще с одним типом -  валидатором доменного уровня, наряду с валидатором форм и сущности.

Дирижер

Всем процессом валидации необходимо управлять. Как было рассмотрено в предыдущей заметке, в качестве дирижера выступает Сервисный Слой. Именно здесь мы решаем, для какой бизнес операции какую форму использовать, с каким набором валидаторов. Какой доменный объект(ы) задействовать, с каким набором валидаторов.

Очевидно для кажой сущности должен быть свой сервисный слой, но у них много общего. Выделим общие черты в отдельный интерфейс.

Общая картина процесса валидации данных
На рисунке ниже представлено общее видение процесса валидации данных в модели.

Практическая часть

Все эти рассуждения я постарался реализовать в фреймворке на базе Zend Framework, разработкой которого я сейчас занимаюсь. Следить за его неспешным развитием можно на гитхабе.

В этом разделе я рассмотрю лишь использование этой реализации.

Начнем с модели предметной области.
namespace Core\Model\Domain;
/**
 * @Entity
 * @Table(name="users")
 */
class User extends \Xboom\Model\Domain\AbstractObject
{
    // domain properties...

    /**
     * @Id @Column(type="integer")
     * @GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /** @Column(type="string", length=50) */
    protected $name;

    /** @Column(type="string", length=32) */
    protected $login;

    /** @Column(type="string", length=32) */
    protected $password;

    // domain operations...
    public function register()
    {
        ...
    }
}
Как видно, модель очень проста, весь функционал связанный с реализацией интерфейса ValidateInterface вынесен в абстрактный доменный объект. Тем самым позволяя сосредоточится на домене. В комментариях к полям описывается отображение (маппинг) этих полей на реляционную БД, посредством Doctrine 2, об интеграции которой можно прочитать здесь.

Валидатор для модели может выглядить так.
namespace Core\Model\Domain\Validator;
use \Xboom\Model\Validate\AbstractValidator,
    \Xboom\Model\Validate\Element\BaseValidator as BaseElementValidator;

class UserValidator extends AbstractValidator
{
    public function init()
    {
        // name
        $nameValidator = new BaseElementValidator();
        $nameValidator->addValidator(new \Zend_Validate_StringLength(
                              array('min' => 1, 'max' => 50)))
                      ->addFilter(new \Zend_Filter_StringTrim());
        $this->addPropertyValidator('name', $nameValidator);

        // login
        $loginValidator = new BaseElementValidator();
        $loginValidator->addValidator(new \Zend_Validate_StringLength(
                              array('min' => 4, 'max' => 32)))
                       ->addValidator(new \Zend_Validate_Alnum())
                       ->addValidator(new UniqueField(
                              array(
                               'em' => $this->getEntityManager(),
                               'entity' => $this->getEntityClass(),
                               'field' => 'login')
                       ))
                       ->addFilter(new \Zend_Filter_StringTrim())
                       ->addFilter(new \Zend_Filter_StringToLower('UTF-8'));
        $this->addPropertyValidator('login', $loginValidator);

        // password
        $passwordValidator = new BaseElementValidator();
        $passwordValidator->addValidator(new \Zend_Validate_StringLength(
                              array('min' => 4, 'max' => 32)))
                          ->addFilter(new \Zend_Filter_StringTrim())
                          ->setObscureValue(true);
        $this->addPropertyValidator('password', $passwordValidator);

    }
}
Как видно, для валидации конкретных свойств модели мы используем стандартные валидаторы и фильтры Zend. Так же, мы можем спокойно расширить этот набор, взяв готовые валидаторы (фильтры) из своей библиотеки или библиотек сторонних разработчиков. Обратите внимание на валидатор UniqueField - это самописный валидатор доменного уровня.

Поддерживаются цепочки фильтров и валидаторов.

Теперь опишем форму, для регистрации нового User'а
namespace Core\Model\Form;
class RegisterUserForm extends \Zend_Form
{

    public function init()
    {
        $this->setName('registerUser');

        $this->addElement('text', 'name', array(
            'label' => 'Username'
        ));

        $this->addElement('text', 'login', array(
            'label' => 'Login'
        ));

        $this->addElement('password', 'password', array(
            'label' => 'Password'
        ));

        $this->addElement('password', 'confirm_password', array(
            'label' => 'Confirm password',
            'required' => true,
            'validators' => array( array('Identical', false, 'password'))
        ));

        $this->addElement('captcha', 'captcha', array(
            'label' => 'Captcha',
            'captcha' => array(
                'captcha' => 'Image',
                'wordLen' => 6,
                'timeout' => 300,
                'font' => \APPLICATION_PATH . '/configs/fonts/Glasten_Bold.ttf',
            ),
        ));

        $this->addElement('submit', 'register', array(
            'label' => 'Register'
        ));
    }
}
Как видно обычная форма на базе Zend_Form, отличие лишь в том, что здесь нет валидаторов для доменных полей. Здесь заключена только логика валидации самой формы, т.е. проверка совпадения полей или проверка теста CAPTCHA и т.д.

Ну и наконец, как может выглядеть операция по регистрации нового пользователя в сервисном слое.
    /**
     * Register new user.
     *
     * @param array $data
     * @param boolean $flush If true then flush EntityManager
     * @return object User
     * @throws \Xboom\Model\Service\Exception If can't create new user
     */
    public function registerUser(array $data, $flush = true)
    {

        $formToModelMediator = $this->getFormToModelMediator('RegisterUser');
        $breakValidation = false;
        if ($formToModelMediator->isValid($data, $breakValidation))
        {
            $user = $formToModelMediator->getModel();

            $user->register();

            $this->_em->persist($user);

            if ($flush)
            {
                $this->_em->flush();
            }
            
            return $user;
        }

        throw new ServiceException('Can\'t create new user.');
    }
Сначала мы получаем медиатор, в который передаем строку с названием формы, это же название используется для валидатора модели. Т.о. служебный метод getFormToModelMediator('RegisterUser') создаст для нас форму с указанным именем, создаст пустую Модель, создаст валидатор с указанным именем и установит этот валидатор в Модель. Для того, что бы найти Форму, Модель и Валидатор - необходимо указать префиксы, где искать эти объекты. Префиксы задаются при создании сервиса, например так.
    protected function _initService()
    {
        $this->setModelClassPrefix('\\Core\\Model\\Domain')
             ->setModelShortName('User')
             ->setValidatorClassPrefix('\\Core\\Model\\Domain\\Validator')
             ->setFormClassPrefix('\\Core\\Model\\Form');
    } 
Параметр $breakValidation = false говорит медиатору, что если даже форма не валидна, не прерывать процесс валидации, что бы сразу получить все возможные ошибки.

Далее мы, используя Медиатор, валидируем внешние данные, и если валидация успешна, получаем готовую модель с уже установленными данными. Выполняем доменные операции, и сохраняем модель в источнике данных (посредством Doctrine 2).

Если валидация не проходит то выбрасывается исключение. Т.о. клиент использующий этот сервис информируется о сбое и может принять соответствующие меры, например повторно запросить форму с уже установленными ошибками.
    public function addAction()
    {
        $userService = new Core\Model\Service\UserService($this->em);

        if ($this->getRequest()->isPost())
        {
            try
            {
                $result = $userService->registerUser($_POST);
                // Добавить сообщение что все ок
                // И например переправить пользователя на индексную страницу
            }
            catch (\Xboom\Exception $e)
            {
                // Сообщить что во время регистрации произошла ошибка и
                // и следует повторно заполнить форму
            }
        }

        $form = $userService->getForm('RegisterUser');
        echo $form;
    }
P.S. Это только набросок, как может выглядеть акшн добавления пользователя в UserController

Заключение

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

Так же я посмотрел как валидацию осуществляют в других фреймворках. Везде прослеживается тенденция к переносу валидации в модель. Передовиками тут конечно же стоит считать законодателей из мира Java и .NET, со своим декларативным описанием валидации, посредством аннотаций. Или как в Ruby On Rails 3.0 отдельные модули валидации для модели.

В мире PHP передовиком наверно можно назвать Symfony 2.0, в котором валидация позаимствована из мира Java. Так в Symfony 2.0 форма является легкой надстройкой! над моделью.  Форма черпает всю необходимую информацию непосредственно из модели. Так же используется декларативный вариант описания валидаторов. Т.е. поддерживаются аннотации, описание валидаторов в XML, Yaml или в  нативном PHP. Единственно что я не нашел в документации как добавлять свои валидаторы, хотя думаю такая возможность имеется.

На этом фоне печально выглядит картина в Zend Framework. Каждый придумывает велосипеды (я и себя имею ввиду :)). Так, например в своей заметке Matthew Weier O'Phinney Using Zend_Form in Your Models предложил засунуть форму прямо в модель, что вызвало бурную дискуссию, и в принципе я согласен с теми кто считает, что это "пачкает" модель.

Так же не могу не упомянуть статью Benjamin Eberlei Zend_Form and the Model: Yet another perspective using a Mediator. Этот способ куда приятнее, правда не раскрывает много механизмов, таких как отображение ошибок в стиле Zend_Form и вообще сам процесс валидации модели. Там только описывается способ как закинуть данные из формы в модель. А ошибки оформляются в виде исключений, т.е. нет гибкости.

Подводя итоги, на данный момент в Zend Framework имеется настолько мощный компонент Zend_Form, что он сам частенько выступает в роли модели данных, что естественно не верно. Кто-то с этим мирится, кто-то изобретает велосипеды, но пока готового решения с полки нету. Надеюсь в ZF 2.0 появится готовый инструмент для организации прозрачной, гибкой валидации данных в модели. 

7 комментариев:

  1. Спасибо за статью! Подталкнуло к интересным размышлениям.

    ОтветитьУдалить
  2. однозначно в избранное, титанический труд, спасибо

    ОтветитьУдалить
  3. Класс, молодец. Думаю многие из нас проводили подобные исследования, но только будучи опубликованными они постепенно обеспечивают прогресс.

    Немного жаль, что ПХП в этом плане уготована роль "догоняющего" и копирующего лучшие техники.

    ОтветитьУдалить
  4. Статья хороша!!! Жду других интересных статей.

    ОтветитьУдалить
  5. Отличный блог, отличные статьи о ZF, даже не ожидал, честно говоря) Вы тут сослались на мою статью (с torqueo.net), вот, так и нашел.

    ОтветитьУдалить
  6. Этот комментарий был удален автором.

    ОтветитьУдалить