Pull to refresh

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

Reading time 8 min
Views 9.2K
Несколько месяцев назад я начал осваивать популярный 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
Tags:
Hubs:
+4
Comments 4
Comments Comments 4

Articles