Достаточно часто в пользовательском интерфейсе нужно отображать, подходящие данные ввёл пользователь или нет. В зависимости от ситуации подсвечивать зелёным или красным поля или показывать около них подсказки. Существует много плагинов/библиотек для множества фреймворков, но какого-либо особого, в меру простого, стандарта, похоже нет. То есть, может стоит изобрести ещё один велосипед, но попробовать сделать его поудобнее.
Есть JSR-303 (о нём на хабре, и ещё немного на английском), он предназначен для валидации java-бинов с помощью аннотаций, похожа на него (и одновременно на мою версию) и библиотека gwt-validation для GWT - эти вещи попроще чем обычно. Предлагаемый мной вариант ориентирован больше на UI-компоненты, чем данные с которыми они работают, на разных страницах может потребоваться валидация разной строгости и разное оформление (+i18n
), да и управлять ограничениями формы по-моему удобнее в самой форме.
Кстати, тут наверное множество UI-профессионалов, поэтому я только за, чтобы отмечаться в комментариях.
Постараюсь описать максимально независимо от языка программирования, но примеры придётся приводить на Java :).
Концепция
- Один метод
validate()
для композитного компонента (формы) который возвращает первый тип ограничения, не прошедший валидацию илиnull
, если валидация пройдена. Этот метод может использоваться для проверок при нажатии на кнопки типа “Сохранить” или “Отправить”, когда важен только первый не прошедший тест. - Этот же метод
validate()
можно вызвать для любого UI-компонента в форме и он изменит своё визуальное состояние в соответствии с введённым в него значением. А также при вызове этого метода у формы в целом - каждый компонент на ней также обновит своё состояние. - Валидирующий код может иметь возможность бросить исключение о валидации, но не обязан.
- В общем случае все компоненты реагируют на корректные/некорректные значения одинаково.
- Не более трёх основных классов/интерфейсов.
Диаграмма
Сама диаграмма охватывает все описанные в статье классы, поэтому выглядит довольно (мягко говоря) эпично, но к самому паттерну, как я считаю, следует относить только верхний левый пакет [Core]
.
Основные классы
Ограничение
Отправной точкой будет самый весомый класс - базовый Constraint
- какое-либо ограничение. В моём случае тип ограничения задан простым enum
-ом, поскольку возможные виды ограничений обычно вполне исчислимы. Вместо enum
-а может быть и просто какое-либо абстрактное уникальное число, которое передаётся из наследников ValidationConstraint
, тогда о типе ограничения будут знать только они и i18n
-модуль.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | public class ValidationConstraint {
public enum ConstraintType { INVALID_FORMAT, // Значение в поле не соответствует регулярному выражению
ILLEGAL_CHARACTERS, // Частный случай первого, в поле введены недопустимые символы
INVALID_VALUE, // Частный случай первого, вместо числа введена строка или подобные ограничения
REQUIRED_VALUE, // Поле требуется к заполнению
BOTH_OR_NONE_REQUIRED, // Требуется указать оба поля или ни одно из них
MUST_BE_GREATER_THAN, // Значение в поле должно быть больше чем...
MORE_ITEMS_THAN_ALLOWED, // Выбрано больше элементов, чем требуется
. . .
};
private final ConstraintType type;
private final String subject;
private final String expectedValue;
private final String failedValue;
private static final ValidationMessages messages = /* Получить локализованные сообщения */;
public ValidationConstraint(ConstraintType type, String subject, String expectedValue, String failedValue) {
this.type = type;
this.subject = subject;
this.expectedValue = expectedValue;
this.failedValue = failedValue;
}
public ConstraintType getType() { return type; }
public String getSubject() { return subject; }
public String getExpectedValue() { return expectedValue; }
public String getFailedValue() { return failedValue; }
public String getLocalizedDescription() {
switch (getType()) {
case INVALID_CHARACTERS: return messages.invalidCharacters(subject, expectedValue, failedValue);
case INVALID_FORMAT: return messages.invalidFormat(subject, expectedValue, failedValue);
case INVALID_VALUE: return messages.invalidValue(subject, expectedValue, failedValue);
case REQUIRED_VALUE: return messages.requiredValue(subject);
case MORE_ITEMS_THAN_ALLOWED: return messages.moreThanAllowed(subject, expectedValue, failedValue);
case BOTH_OR_NONE_REQUIRED: return messages.bothOrNoneRequired(subject);
case MUST_BE_GREATER_THAN: return messages.mustBeGreaterThan(subject, expectedValue, failedValue);
. . .
default: return messages.unknownConstraint();
}
};
}
|
Объект, который можно проверить
Любой объект, который может быть проверен на соответствие ограничениям, должен имплементировать интерфейс IValidatable
. Метод validate()
можно вызвать, если нужно получить [первый] свалившийся Constraint
или нужно обновить состояние компонента (когда не важно возвращаемое значение). Метод isValid
можно вызвать если требуется просто проверить, прошло ограничения (ограничения) или нет - в подавляющем большинстве случаев isValid()
равноценно (validate() == null)
.
1 2 3 4 5 6 | public interface IValidatable {
public ValidationConstraint validate();
public boolean isValid() throws ValidationException;
}
|
Также isValid()
может бросать исключение, содержащее тип ограничения, которое не прошло:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | public class ValidationException extends Exception {
private final ValidationConstraint constraint;
public ValidationException(ValidationConstraint constraint) {
super(constraint.getFailedValue());
this.constraint = constraint;
}
@Override
public String getLocalizedMessage() {
return constraint.getLocalizedDescription()/* + " (" + constraint.getType() + ")"*/;
}
}
|
Объект, содержащий несколько ограничений
Таким объектом может стать, например, форма или страница с полями для заполнения или какой-либо бин. Этот объект должен имплементировать интерфейс HasConstraints
. Метод initContraints()
можно вызывать в конструкторе имплементирующего класса или в каком-либо другом методе, выполняющемся один раз перед использованием объекта. addConstraint(...)
добавляет новое ограничение, за которым следит объект. Также он наследует метод validate()
, который перебирает все ограничения и возвращает первое упавшее. В этот объект можно встроить возможность удаления ограничений, тогда он будет действовать примерно как Observer
.
1 2 3 4 5 6 | public interface HasConstraints extends IValidatable {
public void initConstraints();
public void addConstraint(IValidatable validatable);
}
|
Как использовать снаружи
То, что доступно конечному разработчику в результате - любые формы и страницы, которые могут переопределить метод initConstraints
и вызвать поочерёдно для каждого ограничения на какое-либо поле метод addConstraint(...)
. Метод addConstraint(...)
принимает параметром любой объект, который умеет себя валидировать (имплементирует IValidatable
) или, для ограничений, экзепляр из уже готового набора ограничений (которые, в свою очередь, тоже имплементируют тот самый IValidatable
). Перед сохранением/отправкой формы разработчик может вызывать у этих страниц/форм метод validate()
или isValid()
, чтобы узнать что именно упало или перехватить/передать исключение валидации. Все ограничения автоматически проверяются при изменении значений в этих полях.
Ниже я рассмотрю дополнения и примеры, которые никоим образом не изменяют это утверждение.
Дополнения
Обновляющий состояние объект
Если какой-либо объект содержит значение, то он может сам проверять своё состояние на основе ограничений. Такой объект может имплементировать интерфейс Validator
. Методы whenValueInvalid(...)
и whenValueValid(...)
могут вызываться напрямую при проверке из имплементируемого validate()
, тогда вызов validate()
всегда будет обновлять состояние объекта.
1 2 3 4 5 6 7 8 | public interface Validator<V> extends IValidatable {
public V getValue();
public void whenValueInvalid(V value, ValidationConstraint constraint);
public void whenValueValid(V value);
}
|
Делегирование объекта, обновляющего состояние
Чаще удобнее делегировать такой объект, потому что он может быть уже готовым компонентом, цепочку наследования которого нельзя изменять. Будем называть делегируемый объект целью - Target
. Ожидаемое поведение здесь такое же как и в интерфейсе Validator
.
1 2 3 4 5 6 7 8 9 | public interface TargetValidator<V, T> extends IValidatable {
public V getValue();
public T getTarget();
public void whenValueInvalid(T target, V value, ValidationConstraint constraint);
public void whenValueValid(T target, V value);
}
|
Впрочем, могут понадобиться несколько слушателей, реагирующих на изменение значения. Поэтому я создал интерфейс ValueChangeReactor
и изменил TargetValidator
, чтобы он расширял этот интерфейс (хотя это необязательно). В примерах я буду придерживаться этого варианта.
1 2 3 4 5 6 7 8 9 10 11 12 13 | public interface ValueChangeReactor<V, T> {
public void whenValueInvalid(T target, V value, ValidationConstraint constraint);
public void whenValueValid(T target, V value);
}
public interface TargetValidator<V, T> extends IValidatable, ValueChangeReactor<V, T> {
public V getValue();
public T getTarget();
}
|
Теперь можно создавать объекты, которые содержат слушателей на изменения значений. Допустим, один из слушателей добавляет к объекту CSS-класс, другой - подсказку.
1 2 3 4 5 | public interface HasValueReactors<V, T> {
public void addReactor(ValueChangeReactor<V, T> reactor);
}
|
Без примеров статья была бы неполной…
Примеры
Базовая “коробка проверяемых объектов”
Вот класс, от которого может наследоваться любой объект (например, та самая форма или страница), который содержит в себе другие проверяемые объекты (в том числе ограничения) и собственно проверяет их при вызове validate()
. Дочерние классы должны иметь метод initConstraints()
, который будет добавлять все неоходимые для проверки объекты.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | public abstract class ValidationSupport implements HasConstraints {
private final Set<IValidatable> validatables = new LinkedHashSet<IValidatable>();
@Override
public ValidationConstraint validate() {
for (IValidatable validatable: validatables) {
final ValidationConstraint constraint = validatable.validate();
if (constraint != null) return constraint;
}
return null;
};
@Override
public void addConstraint(IValidatable validatable) {
validatables.add(validatable);
}
public boolean isValid() {
return (validate() == null);
}
}
|
Однако, если нельзя нарушать цепочку наследования, удобнее делегировать объект этого класса, переопределив initConstraints
на вызов initContraints
у оборачивающего объекта.
Обратите внимание на то, что у наследуемого или делегирующего объекта
initConstraints
нужно вызывать вручную, например после подготовки и создания всех компонентов формы. В большинстве случаев, однако, подойдёт и просто вызов в конструкторе.
Базовое ограничение
От этого класса могут наследоваться все конкретные ограничения. Он позволяет передать валидируемый компонент (target
), тип ограничения (constraintType
), “название” компонента (subject
) и ожидаемое значение (expectation
). Собственно, он и выполняет описанные выше ожидания от TargetValidator
. Метод passes()
наследника должен проверять, соответствует ли текущее значение типу ограничения.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | public abstract class BaseValidator<V, T> implements TargetValidator<V, T>, HasValueReactors<V, T> {
private final T target;
private final String subject;
private final String expectation;
private final ConstraintType constraintType;
private final Set<ValueChangeReactor<V, T>> reactors = new LinkedHashSet<ValueChangeReactor<V, T>>();
public BaseValidator(T target, ConstraintType constraintType, String subject, String expectation) {
this.target = target;
this.subject = subject;
this.expectation = expectation;
this.constraintType = constraintType;
}
protected BaseValidator(T target, ConstraintType constraintType, String subject) {
this(target, constraintType, subject, null);
}
protected abstract boolean passes(V value);
@Override
public T getTarget() { return target; }
@Override
public final ValidationConstraint validate() {
final V value = getValue();
final boolean passes = passes(value);
ValidationConstraint constraint = null;
if (passes) {
whenValueValid(target, value);
} else {
constraint = new ValidationConstraint(constraintType, subject, expectation, (value != null) ? value.toString() : "");
whenValueInvalid(target, value, constraint);
}
return constraint;
}
public boolean isValid() {
return (validate() == null);
}
/* Либо:
public boolean isValid() throws ValidationException {
ValidationConstraint constraint = validate();
if (constraint != null) throw new ValidationException(constraint);
return (constraint == null);
} */
@Override
public void whenValueInvalid(T target, V value, ValidationConstraint constraint) {
for (ValueChangeReactor<V, T> reactor: reactors) {
reactor.whenValueInvalid(target, value, constraint);
}
}
@Override
public void whenValueValid(T target, V value) {
for (ValueChangeReactor<V, T> reactor: reactors) {
reactor.whenValueValid(target, value);
}
}
@Override
public void addReactor(ValueChangeReactor<V, T> reactor) {
reactors.add(reactor);
}
}
|
Практика
Практика: Валидирование UI-компонентов
Допустим, в нашем UI-фреймворке у нас чётко выделяются компоненты, которые имеют какое-то значение и имеют хэндлеры, которые вызываются при его изменении - то есть имплементируют некий интерфейс HasValue
(см., например, HasValue в GWT). Можно создать валидатор, который будет автоматически следить за изменениями значения таких объектов (событие изменения вызывается, к примеру, при потере фокуса у текстового поля) и сразу же валидировать значение (вызывая validate()
).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | public abstract class ValueContainerValidator<V, T extends HasValue<V>> extends BaseValidator<V, T> {
public ValueContainerValidator(T target, ConstraintType constraintType, String fieldName, String expectation) {
super(target, constraintType, fieldName, expectation);
addValidationHandlers(target);
}
public ValueContainerValidator(T target, ConstraintType constraintType, String fieldName) {
this(target, constraintType, fieldName, "");
}
protected void addValidationHandlers(T target) {
target.addValueChangeHandler(new ValueChangeHandler<V>() {
@Override public void onValueChange(ValueChangeEvent<V> event) {
validate();
}
});
/* if (target instanceof HasKeyUpHandlers) {
((HasKeyUpHandlers)target).addKeyUpHandler(new KeyUpHandler() {
@Override
public void onKeyUp(KeyUpEvent event) {
validate();
}
});
} */
}
@Override
public V getValue() {
return getTarget().getValue();
}
}
|
В комментарии показано, что вы можете проверить и другие интерфейсы объекта и, допустим обновлять состояние не только при потере фокуса, но и при нажатии клавиши и т.п.
И наконец, вот несколько часто используемых ограничений:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | public class RegexConstraint<T extends HasValue<String>> extends ValueContainerValidator<String, T> {
private final String regex;
public RegexConstraint(T target, String fieldName, String regex, String regexDescription) {
super(target, ConstraintType.INVALID_FORMAT, fieldName, regexDescription);
this.regex = regex;
}
@Override
protected boolean passes(String value) {
return value.isEmpty() || value.matches(regex);
}
}
public class RequiredFieldConstraint<T extends HasValue<String>> extends ValueContainerValidator<String, T> {
public RequiredFieldConstraint(T target, String fieldName) {
super(target, ConstraintType.REQUIRED_VALUE, fieldName);
}
@Override
protected boolean passes(String value) {
return (value != null) && !value.isEmpty();
}
}
public class MinimumLengthConstraint<T extends HasValue<String>> extends ValueContainerValidator<String, T> {
private final int minLength;
public MinimumLengthConstraint(T target, String fieldName, int minLength) {
super(target, ConstraintType.LESS_ITEMS_THAN_REQUIRED, fieldName, String.valueOf(minLength));
this.minLength = minLength;
}
@Override
protected boolean passes(String value) {
return value.isEmpty() || (value.length() >= minLength);
}
}
|
Иногда требуется проверить несколько полей в совокупности. Например, для двух полей требуется заполнить либо оба, либо ни одного. Вот пример базового класса для ограничений на два поля:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | public abstract class TwoTargetsConstraint<T extends HasValue<String>> extends ValueContainerValidator<String, T> {
private final T targetTwo;
public TwoTargetsConstraint(T targetOne, T targetTwo, ConstraintType constraintType, String fieldName, String expectation) {
super(targetOne, constraintType, fieldName, expectation);
this.targetTwo = targetTwo;
addValidationHandlers(targetTwo);
}
public TwoTargetsConstraint(T targetOne, T targetTwo, ConstraintType constraintType, String fieldName) {
this(targetOne, targetTwo, constraintType, fieldName, "");
}
@Override
public void whenValueInvalid(T target, String value, ValidationConstraint constraint) {
super.whenValueInvalid(target, value, constraint);
super.whenValueInvalid(targetTwo, value, constraint);
}
@Override
public void whenValueValid(T target, String value) {
super.whenValueValid(target, value);
super.whenValueValid(targetTwo, value);
}
@Override
protected final boolean passes(String value) {
return passes(value, targetTwo.getValue());
}
protected abstract boolean passes(String valueOne, String valueTwo);
}
|
А вот реализация, которая собственно и удостоверяется, что заполнено либо оба поля, либо ни одного:
1 2 3 4 5 6 7 8 9 10 11 12 13 | public class BothOrNoneRequiredConstraint<T extends HasValue<String>> extends TwoTargetsConstraint<T> {
public BothOrNoneRequiredConstraint(T targetOne, T targetTwo, String fieldName) {
super(targetOne, targetTwo, ConstraintType.BOTH_OR_NONE_REQUIRED, fieldName);
}
@Override
protected boolean passes(String valueOne, String valueTwo) {
return (valueOne.isEmpty() && valueTwo.isEmpty()) ||
(!valueOne.isEmpty() && !valueTwo.isEmpty());
}
}
|
В GWT основная часть компонентов наследуется от класса UIObject
, для такого элемента можно добавлять и убирать CSS-стили. Учитывая это можно сделать StylingReactor
, который при изменении значения добавляет нужный CSS-стиль к объекту:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | public class StylingReactor<V, T extends UIObject> implements ValueChangeReactor<V, T> {
public StylingReactor() { }
@Override
public void whenValueInvalid(T target, V value, ValidationConstraint constraint) {
target.addStyleName("b-invalid-value");
}
@Override
public void whenValueValid(T target, V value) {
target.removeStyleName("b-invalid-value");
}
}
|
Формы, панели и страницы наследуются в GWT от класса Composite
. Сделаем базовый CompositeWithConstraints
, от которого смогут наследоваться такие формы и страницы. По сути он просто делегирует ValidationSupport
, но кроме этого автоматически добавляет всем внутренним ограничениям, которые вешаются на UIObject
-компоненты StylingReactor
(при жуткой необходимости его можно переиспользовать).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | public abstract class CompositeWithConstraints extends Composite implements HasConstraints {
private final ValidationSupport validationSupport = new ValidationSupport() {
public void initConstraints() {
CompositeWithConstraints.this.initConstraints();
};
};
protected CompositeWithConstraints() {
}
@Override
public void addConstraint(IValidatable validatable) {
validationSupport.addConstraint(validatable);
}
public <V, T extends UIObject> void addConstraint(BaseValidator<V, T> validator) {
validator.addReactor(new StylingReactor<V, T>());
validationSupport.addConstraint(validator);
}
@Override
public boolean isValid() throws ValidationException {
return validationSupport.isValid();
}
@Override
public ValidationConstraint validate() {
return validationSupport.validate();
}
}
|
Ещё раз обратите внимание на то, что у наследуемого или делегирующего объекта
initConstraints
нужно вызывать вручную, например после подготовки и создания всех компонентов формы. В большинстве случаев, однако, подойдёт и просто вызов в конструкторе.
Пример использования
Допустим FormWithValidation
наследуется от класса CompositeWithConstraints
, а TextBox
, TextArea
имплементируют интерфейс HasValue
(так и есть в штатных компонентах GWT):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | public class ProfileEditForm extends FormWithValidation implements View {
. . .
@Override
public void initConstraints() {
addConstraint(new RequiredFieldConstraint<TextBox>(nameField, "Name"));
addConstraint(new RequiredFieldConstraint<TextArea>(aboutMe, "AboutMe"));
addConstraint(new MinimumLengthConstraint<TextArea>(aboutMe, "AboutMe", ProfileBean.MIN_ABOUT_LENGTH));
addConstraint(new RegexConstraint<TextBox>(academyStartField, "Academy start", StringUtils.DATE_REGEX, "NN-NN-NNNN"));
addConstraint(new RegexConstraint<TextBox>(academyFinishField, "Academy finish", StringUtils.DATE_REGEX, "NN-NN-NNNN"));
addConstraint(new BothOrNoneRequiredConstraint<TextBox>(academyStartField, academyFinishField, "Academy"));
addConstraint(new FirstLessThanSecondConstraint<TextBox>(academyStartField, academyFinishField, "Academy"));
}
public HasClickHandlers getSavingButton() { ... }
. . .
}
|
Теперь эти поля автоматически валидируются при изменении их значений. Для того чтобы проверить соответствие ограничениям перед сохранением формы, достаточно вызвать validate
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | public class ProfileEditPresenter implements Presenter {
. . .
public void assignSaveHandler() {
view.getSavingButton().addClickHandler(new ClickHandler() {
@Override public void onClick(ClickEvent event) {
final ValidationConstraint constraint = view.validate();
if (constraint == null) {
final ProfileBean profile = view.gatherFields();
updateProfile(profile);
} else {
eventBus.displayMessage(MessageType.VALIDATION_ERROR, constraint.getLocalizedDescription());
}
}
});
}
. . .
}
|
Резюме
Мне хотелось вывести какой-то общий, в меру простой, паттерн, который поместился бы на одной (хоть и большой) диаграмме классов и был понятен с первого взгляда. Надеюсь это получилось.