Symfony2 и KnockoutJS — валидация форм

от автора

Несколько месяцев назад я начал осваивать популярный PHP фреймворк Symfony2. Недавно передо мной встала задача проверки корректность заполнения формы на стороне клиента с применением библиотеки KnockoutJS. При этом правила валидации, дабы не заниматься дублированием кода, желательно брать из класса сущности Symfony.
Существует over 10.000 плагинов, библиотек и бандлов охватывающих какую-либо одну сторону проблемы. Комплексного решения мне найти так и не удалось. Оценив трудоёмкость объединения двух наиболее популярных решения (Knockout-Validation и APYJsFormValidationBundle) для первой и второй части задачи я решил написать всё с нуля. Подробности под катом.

Валидация в Symfony2

В моём случае, правила валидации задаются в аннотациях. Для освежения памяти приведу листинг:

/**  * Acme\UsersBundle\Entity\User  */ class User implements JsonSerializable {     /**      * @var string $name Имя.      *      * @ORM\Column(name="name", type="string", length=255, unique = true, nullable=false)      *      * @Assert\NotBlank(message="Заполните поле")      * @Assert\MinLength(limit=3, message="Слишком короткое значение")      * @Assert\MaxLength(limit=15, message="Слишком длинное значение")      * @Assert\Regex(pattern="/^[A-z0-9_-]+$/ui", match=true, message="Значение содержит недопустимые символы")      */     private $name;      // .... } 

Первое что нужно сделать это распарсить эти комментарии. Конечно, это уже делает сам фреймворк. Результаты парсинга хранятся в кэше по адресу «app/cache/dev/annotations/» или «app/cache/prod/annotations/», в зависимости от окружения. Немного подумав, я написал небольшой метод:

/**  * Читает аннотации классов сущностей.  *  * @param string $bundle Имя бандла без "Bundle".  * @param string $entity Имя класса сущности без префикса в виде имени бандла.  * @param string $env Сервер ("dev" или "prod").  * @param string $namespace Пространство имён (По умолчанию "Acme").  * @return array Аннотации.  */ private function readEntityAnnotations($bundle, $entity, $env = 'prod', $namespace = 'Acme') {     $result = array();      $files = glob($_SERVER['DOCUMENT_ROOT'] . '/../app/cache/' . $env . '/annotations/' . $namespace         . '-' . $bundle .'Bundle-Entity-' . $bundle . $entity .'$*.php');     foreach ($files as $path) {         // Имя члена класса к которой относятся аннотации         preg_match('/\\$(.*?)\\./', $path, $matches);         // Чтение аннотаций         foreach (include $path as $annotation) {             // Сохраяем только относящиеся к валидации аннотации             if (get_parent_class($annotation) === 'Symfony\\Component\\Validator\\Constraint') {                 $type = preg_replace('/^.*\\\/', '', get_class($annotation));                 $annotation = (array)$annotation;                 unset($annotation['charset']);                 $result[$matches[1]][$type] = (array)$annotation;             }         }     }     return $result; } 

Вероятно такой код — плохой пример для подражания, однако со своей задачей он справляется. Перепишу его потом.

В результате на клиенте мы можем получить нечто подобное:
аннотации

Валидация и KnockoutJS

После того как правила валидации известны можно приступать к написанию клиентского кода. Идея реализации была позаимствована у Knockout Validation. Приведу пример задания правил валидации в этом плагине:

var myComplexValue = ko.observable()  myComplexValue.extend({ required: true })               .extend({ minLength: 42 })               .extend({ pattern: {                    message: 'Hey this doesnt match my pattern',                    params: '^[A-Z0-9].$'               }}); 

То есть, суть в использовании extender’ов появившихся со второй ветки Knockout. Extender’ы позволяет изменять или дополнять поведение любых видов observables. Рассмотрим пример:

var name = ko.observable('habrahabr').extend({MinLength: 42});

При обновлении наблюдаемого свойства name нокаут попытается найти extender c именем MinLength и, в случае успеха, вызовет его. В качестве параметров extender’у будет передано само наблюдаемое свойство и число 42.

Теперь реализуем сам extender:

ko.extenders.MinLength = function(observavle, params) {     // .... }; 

Идея ясна, перейдём к реализации. Возьмём для примера следующую модель:

var AppViewModel = new (function () {     var self  = this;                   // Ссылка на текущий контекст     this.name = ko.observable('');      // Имя пользователя     this.mail = ko.observable('');      // E-mail      // Инициализация валидатора     ko.validation.init(self, _ANNOTATIONS_);      // Обработчик отправки формы     this.submit = function () {         if (self.isValid()) {             alert('Модель валидна');         } else {             alert('Модель НЕ валидна');         }     }; })(); 

За исключением ko.validation.init и self.isValid тут должно быть всё понятно. ko.validation.init — это функция инициализации валидатора, принимающая в качестве аргументов модель и объект содержащий информацию об аннотациях полученный из Symfony. Метод isValid будет добавляться к модели в момент инициализации валидатора.

<form action="#">     <p>         <label for="">Имя</label>         <input type="text" data-bind="value: name, valueUpdate: 'keyup'">         <span data-bind="visible: name.isError, text: name.message"></span>     </p>     <p>         <label for="">E-mail</label>         <input type="text" data-bind="value: mail, valueUpdate: 'keyup'">         <span data-bind="visible: mail.isError, text: mail.message"></span>     </p>     <button data-bind="click: submit">Отправить</button> </form> 

Свойства isError и message это флаг наличия ошибки и сообщение об ошибке соответственно. Оба эти свойства являются наблюдаемыми и добавляются к основному свойству в момент инициализации.

AppViewModel.name.isError   = ko.observable();  // Флаг наличия ошибки AppViewModel.name.message   = ko.observable();  // Сообщение об ошибке AppViewModel.name.typeError = '';               // Валидатор установивший ошибку 

Для целевой аудитории поста это не должно быть проблемой, но на всякий случай поясню: в JavaScript всё является объектами, вернее сказать для, каждого типа существует объектная обёртка. Преобразования происходят автоматически по мере необходимости. Это же справедливо и для функций. По этому, ни что не мешает нам, добавить несколько свойств к свойству AppViewModel.name, являющемуся, по сути, функцией.

Алгоритм валидации формы будет выглядеть следующим образом:
— ни чего не делаем до тех пор, пока пользователь не пытается отправить форму
— после первой, неудачной по причине не валидности, отправки проверяем поля при каждом их обновлении (keyup и change).

Теперь я приведу код целиком, а затем разберу его подробно:

ko.validation = new (function () {     /**      * Функция валидации моделей.      * @return {Boolean}      */     var isValid = function () {         this.validate(true);         // Цикл по наблюдаемым свойствам модели         for (var opt in this) if (ko.isObservable(this[opt])) {             // Если поле содержит ошибку             if (this[opt].isError !== undefined && this[opt].isError() === true) {                 return false;             }         }         return true;     };      return {         /**          * Инициализация валидатора.          * @param {object} AppViewModel Модель приложения.          * @param {object} annotations Аннотации полей сущности.          */         init: function (AppViewModel, annotations) {             var asserts, options;              AppViewModel.validate = ko.observable(false);              // Цикл по полям для которых есть ограничения             for (var field in annotations) if (annotations.hasOwnProperty(field)) {                 asserts = annotations[field];                  // Если в модели(AppViewModel) существует нужное свойство и оно является наблюдаемым                 if (AppViewModel[field] !== undefined && ko.isObservable(AppViewModel[field])) {                     AppViewModel[field].isError = ko.observable();  // Флаг наличия ошибки                     AppViewModel[field].message = ko.observable();  // Сообщение об ошибке                      // Цикл по ограничениям для поля                     for (var i in asserts) if (asserts.hasOwnProperty(i)) {                         options = {};                         options[i] = asserts[i];                    // Опции валидатора                         options[i]['asserts'] = asserts;            // Ссылка на ограничения                         options[i]['AppViewModel'] = AppViewModel;  // Ссылка на модель                         // Раширение наблюдаемого значения методами валидации                         AppViewModel[field].extend(options);                     }                 }             }              // Примешать к модели функцию валидации             AppViewModel.isValid = isValid;         },          /**          * Регистрирует новый метод валидации.          * @param name Имя ограничения.          * @param validate Фаункция валидации.          * @param checkAsserts          */         addAssert: function (name, validate, checkAsserts) {             // Регистрация extender'а             ko.extenders[name] = function(target, option) {                 // Вычислять в зависимости от "AppViewModel.validate"                 ko.computed(function () {                     // Если поле не валидно и для модели запрошена валидация                     if  (validate(target, option) === false && option.AppViewModel.validate()) {                         checkAsserts = checkAsserts || new Function('t,o', 'return false');                         // Если нет других ограничений                         if (checkAsserts(target, option) === false) {                             target.isError(true);               // Флаг наличия ошибки                             target.message(option.message);     // Сообщение об ошибке                             target.typeError = name;            // Тип ошибки                         }                         return;                     }                     // Снять флаг ошибки может только метод валидации установивший его                     if (target.isError.peek() === true && target.typeError === name) {                         target.isError(false);                     }                 });                 return target;             };         }     } })(); 

Добавим сразу пару методов валидации:

// NotBlank ko.validation.addAssert('NotBlank', function (target, option) {     return (target().length > 0); });  // MaxLength ko.validation.addAssert('MaxLength', function (target, option) {     return (target().length <= option.limit); }); 

Общее устройство

Код организован в соответствии с паттерном проектирования, названным Стефаном Стояновым «Модуль», в его книге «Javascript patterns». Т.е. анонимная немедленно вызываемая функция, возвращает объект с двумя методами: init и addAssert. Внутри замыкания определён метод isValid.

Метод isValid. Валидация модели

Проверяет валидность модели. Метод вызывается в контексте модели, т.е. this внутри метода isValid это AppViewModel. Первым делом он устанавливает наблюдаемое свойство модели validate в true. Это сигнализирует о попытке отправить форму. Само свойство validate добавляется к модели в процессе инициализации методом init.
Далее метод пробегает по всем наблюдаемым свойствам модели и проверяет их флаги ошибки.

Метод init. Инициализация валидации

Сначала метод добавляет к модели выше упомянутое наблюдаемое свойство validate и метод isValid. За тем циклом проходит по полям, для которых указаны ограничения и для которых существуют одноимённые наблюдаемые свойства, в модели добавляя последним: isError и message. Второй, вложенный цикл обходит ограничения и пытается расширить поле соответствующим extender’ом. В качестве параметра extender’у передаётся объект с параметрами ограничения, полученными из кэша Symfony, с добавленными к нему ссылками на модель (AppViewModel) и списком всех ограничений для этого поля.

Метод addAssert. Регистрация нового метода валидации

Метод принимает три параметра: name — имя нового метода валидации, validate — функция валидации, checkAsserts — функция подтверждающая выставление ошибки. Последний параметр рассмотрим немного позже.
Тело метода extender’а заворачивается в вычисляемое (computed) свойство чтобы обеспечить перезапуск валидации при обновлении AppViewModel.validate.

Метод checkAsserts

Это опциональный параметр метода addAssert. Он нужен, чтобы проверять, не выставит ли какой-либо другой валидатор ошибку. Например, при проверке длины строки введённой в поле. В случае если поле пусто я хочу сказать «заполните поле», а если его длинна меньше 3х символов — «имя должно содержать не менее 3х символов» и т.п. Но нет никакой гарантии что проверка «MinLength» произойдёт позднее «NotBlank». Вот пример метода валидации (extender’а) MinLengt:

// MinLength ko.validation.addAssert(      'MinLength',      function (target, option) {         return (target().length >= option.limit);     },      function (target, option) {         // В случае истины ошибку установит валидатор "NotBlank"         return (target().length === 0 && option.asserts.NotBlank !== undefined);     } ); 

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

Использование

Правила валидации применяются ко всем observable свойствам, имеющим одноимённые поля в сущностном классе Symfony с описанными ограничениями. Соответственно, если какое-либо поле не нужно проверять на стороне клиента решение очевидно — дать ему другое имя или удалить свойство из js-объекта, полученного при чтении аннотаций.

Есть небольшая демка на codepen: codepen.io/alexismaster/pen/LAaqc

На последок обещанные улучшения метода readEntityAnnotations. Получить аннотации можно через сервис валидации:

// Получим информацию об ограничениях свойства "name" сущностного класса "User" $validator = $this->get('validator'); $metadata  = $validator->getMetadataFactory()->getClassMetadata("Acme\\UsersBundle\\Entity\\User"); var_dump($metadata->properties['name']->constraints); 

Ссылки:
github.com/Abhoryo/APYJsFormValidationBundle — Symfony-бандл генерирующий JS код для валидации
github.com/Knockout-Contrib/Knockout-Validation
habrahabr.ru/post/136782/ — Интересный пост о KnockoutJS и Extenders
phalcon-docs-ru.readthedocs.org/ru/latest/reference/annotations.html — Парсер аннотаций
habrahabr.ru/post/133270/ — Кастомные аннотации в Symfony 2

ссылка на оригинал статьи http://habrahabr.ru/post/188720/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *