Несколько месяцев назад я начал осваивать популярный PHP фреймворк Symfony2. Недавно передо мной встала задача проверки корректность заполнения формы на стороне клиента с применением библиотеки KnockoutJS. При этом правила валидации, дабы не заниматься дублированием кода, желательно брать из класса сущности Symfony.
Существует over 10.000 плагинов, библиотек и бандлов охватывающих какую-либо одну сторону проблемы. Комплексного решения мне найти так и не удалось. Оценив трудоёмкость объединения двух наиболее популярных решения (Knockout-Validation и APYJsFormValidationBundle) для первой и второй части задачи я решил написать всё с нуля. Подробности под катом.
Валидация в Symfony2
В моём случае, правила валидации задаются в аннотациях. Для освежения памяти приведу листинг:
/**
* AcmeUsersBundleEntityUser
*/
class User implements JsonSerializable
{
/**
* @var string $name Имя.
*
* @ORMColumn(name="name", type="string", length=255, unique = true, nullable=false)
*
* @AssertNotBlank(message="Заполните поле")
* @AssertMinLength(limit=3, message="Слишком короткое значение")
* @AssertMaxLength(limit=15, message="Слишком длинное значение")
* @AssertRegex(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
Автор: alexismaster