вторник, 4 марта 2008 г.

Business objects validation and UI integration

Случилось мне писать WPF Wizard. И было это сложно и интересно и много проблем порешал я.


Вот одна из них довольно интересная - валидация бизнес-объектов и управление состоянием UI в зависимости от результата валидации.

В прошлых своих постах я писал про паттерн проектирования и разработки клиентских UI приложений DM-V-VM. В моей реализации UI никак не следит за валидностью нижележащих данных, т.к. в архитектуре системы заложено, что редактированием бизнес-объектов занимается специально предназначенный для этого UI - wizard, а все остальные представления занимаются лишь отображением данных без возможности редактирования.

Если есть необходимость редактировать данные вне wizard'а, то здесь описан подход, как это можно реализовать.

Итак, что же нам нужно и что мы имеем?

Нужно:
  • автоматическое отслеживание представлениями состояния валидности объекта (кнопки Next, Save и т.п. должны быть отключены, если редактируемый объект в невалидном состоянии, и наоборот. Проще говоря, wizard не должен дать вам совершить ошибку);
  • прозрачное для разработчика валидирование - это не должна быть его забота и он не должен писать в code-behind никакой логики валидирования;

Что есть:

  • В WPF binding engine есть валидация. Она реализуется при помощи объектов классов наследников ValidationRule и статического класса Validation. Почему бы не использовать их и не угомониться на этом? Да потому что это валидация данных, лежащих в UI контролах, а не валидация бизнес объектов. Нужен унифицированный механизм, не завязанный на UI технологию, т.к. валидровать объект можно не только в UI. Но эту технологию можно использовать как точку расширения и подключения дополнительной логики в процессе валидирования данных wizard.
  • В Enterprise library (EL) есть Validation Application Block (VAB), который предназначен для решения задачи валидирования бизнес-объектов;
  • В предыдущих постах я писал об использовании комманд WPF, управляющих состоянием UI.
Итак, кажется все, что нужно есть, тогда приступим.

Для начала сценарий: wizard редактирует сущность посетитель/пользователь (как угодно).

  1. на первой странице заполняются ФИО, причем имя или фамилия поля обязательные.
  2. на второй странице заполняются настройки доступа - логин, пароль, подтверждение пароля
  3. на третьей странице заполняется и так далее... =)
В разработанном мною wizard'e редактируемая сущность "вешается" wizard'у на DataContext, таким образом все его визуальное дерево (страницы с контролами в них) наследует это свойство.

Каждому UI контролу, выполняющему какое-нибудь действие присваивается wpf команда. Каждая wpf команда, обернута уже знакомым нам классом CommandModel. Эти классы реализуют логику принятия решения о состоянии команды и логику выполнения команды. Делегируем этим классам валидирование бизнес-объекта. Но как она реализована?

Предоставим бизнес-объектам самим реализовывать свою логику валидирования. Для этого используем EL VAB. У David'а Hayden'а есть отличная серия статей по VAB. Валидация при помощи VAB настраивается 2мя путями, либо программно (при помощи аттрибутов), либо административно (при помощи конфигурационного файла). В нашем случае мы будем использовать аттрибуты, т.к. наши ограничения не меняются и заранее известны. Управлять подмножеством свойств для валидации можно при помощи ValidationRuleSet - идентификатора подмножества валидаторов. Так реализуется частичное валидирование. Т.к. страницы wizard'а редактируют только части объекта, то очевидно страница wizard'а - наилучшее место для размещения свойства validationRuleSet.

Теперь по ограничениям, т.к. наше приложение service ориентировано, то бизнес-объекты будут поставляться WCF сервисами и следовательно должны быть помечены атрибутами [DataContract] или хотя бы [Serializable] для того, чтобы сервис мог передать их через канал и десереализовать. Валидирование реализуем при помощи паттерна Template method (метод DoValidate() ), реализацию которого возложим на класс-наследник.


Диаграмма иерархии наследования классов домена.

На диаграмме приведена иерархия классов домена:


  1. ObservableObject - уже встречавшийся нам класс, реализующий InotifyPropertyChanged.
  2. DomainObject - базовый класс домена. Содержит шаблонный метод DoValidate, в котором наследниками должна будет реализовываться логика валидирования. Не является data contract, но помечен аттрибутом [Serializable]. Такое решение принято чтобы не вводить зависимость домена от технологии создания сервисов (WCF).
  3. DomainDTO - базовый класс для всех дата контрактов. Знает обо всех своих наследниках (при помощи аттрибута [KnownType]).
  4. DomainDTO, где Т - тип класса наследника. Реализует логику валидирования объекта.

Базовый класс домена.

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

Базовый дата контракт, реализующий шаблоный метод.

DomainDTO в статическом конструкторе собирает аттрибуты-валидаторы и сохраняет их. Точно также как базовый класс домена не включает в data contract список валидаторов, т.к. нет нужды передавать их через границу сервиса. Data contract'ы на стороне сервиса и клиента используются одни и те же, так что нет нужды импортировать wsdl сервиса и генерить классы контрактов, а потом вручную добавлять в них ту же логику.



Когда DomainDTO просят - валидирует себя по указанному подмножеству валидаторов (ruleSet).

Здесь он просит фасад VAB фабрики объектов создать композитный валидатор по указанному ruleSet подмножеству, а затем передает себя валидатору, который собирает, комбинирует и возвращает ответ от всех валидаторов, входящих в указанный validationRuleSet.

Наш класс посетителя мог бы выглядеть вот так:


В нем свойство Name помечено валидатором длины строки (от 1 до 200) с идентификатором подмножества CommanRuleSet, так что у страницы, редактирующей это же свойство ValidationRuleSet должно быть присвоено CommanRuleSet.

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

В wizard'е есть следующие команды:


  • NextPage - переход на следующую страницу;
  • BackPage - переход на предыдущую страницу;
  • GoToPage - переход на указаную страницу;
  • Save - сохранить редактируемый объект;
  • Cancel - отменить редактирование объекта;
  • Help - показать файл справки;
Решение проиллюстрируем при помощи команды NextPage. В OnQueryEnabled команда проверяет валиден ли объект по подмножеству validationRuleSet, заданном для текущей страницы wizard'а, не является ли текущая страница последней и не помечена ли текущая страница классом Validation как невалидная (именно это является точкой расширения, т.к. разработчик конкретного wizard'а в UI может добавить логику валидирования и сообщить команде, что по его мнению редактируемые данные невалидны):


Эта логика будет автоматически вызываться WPF framework и состояние редактируемого объекта будет управлять состоянием кнопки Next.

Extensibility point.

Для демонстрации этой возможности представьте себе следующий сценарий:

на странице wizard'а редактируется объект пользовательских данных для разграничения доступа (логин, пароль). На странице есть 1 TextBox - Login, и 2 PasswordBox'а - Password, PasswordConfirmation. В DataContext страницы wizard'а лежит бизнес-объект (пусть LoginCredential). Поле Text textbox'а Login привязано (binding) к свойству LoginCredential.Login, поле Text passwordbox'а Password в codebehind привязно к LoginCredential.Password. Для валидации данных, необходимо, чтобы пользователь повторил пароль, но в объекте LoginCredential нет поля повтор пароля. Если в passwordbox PasswordConfirmation не будет введен идентичный первому пароль все навигационные кнопки не должны быть активными. Как быть? Мы по-прежнему не хотим писать spaghetti-code.

Здесь и вступает в игру binding validation, ведь теперь мы валидируем не бизнес объект, а данные, которые лежат в UI, но сильно влияют на принятие решения о валидности объекта.

Для облегчения интеграции внешней логики в процесс валидации я написал класс ValidationHelper.



У класса есть attached-свойство Sink, которым можно пометить любой визуальный объект, наследованный от FrameworkElement и у которого задано свойство Name. В своем дефолтном стиле wizardPage присваивает привязывает ValidationHelper.Sink к себе самой, тем самым помечая себя для валидации.


Также у класса есть attached-свойство Bag, которое будет очень полезно объектам у которых нет dependency-свойств, чтобы привязывать какие-либо данные через WPF binding.

Далее, в нашем сценарии, разработчику необходимо привязать ValidationHelper.Bag у первого объекта PasswordBox к LoginCredential.Password, т.к. свойство Password PasswordBox'а не является dependency-свойством (странное решение).


Здесь вы видите в binding в коллекцию ValidationRules добавляется PasswordValidationRule, которое и является ключевым элементом интеграции. Из code-behind'а (именно, опять же из-за того что Password свойство обычное, а не dependency), при изменении пароля, получаем объект BindingExpression и просим его обновить Source, тем самым вызывая валидацию.


Сложным моментом здесь является передача объекту ValidationRule значений из UI, ведь ValidationRule не наследуется от DependencyObject и не может использовать binding. Решение этой проблемы нашел и описал наш любимы rock-star Josh Smith здесь. В кратце опишу суть - поскольку мы не можем унаследоваться от DependencyObject создадим суррогатный объект наследник DependencyObject, с единственным Dependency-свойством Value. Нужное нам значение из визуального дерева мы пересылаем в ресурсы объекту, который Josh очень лаконично назвал DataContextBridge, при помощи особого вида binding - OneWayToSource. А потом привязываем Value суррогатного объекта к DataContextBridge при помощи обычного binding. В нашей реализации таким образом объекту PasswordValidationRule передаются пароль и ссылка на страницу, в которой происходит редактирование.

Далее обязанность нашего правила сравнить пароли и пометить соответствующим образом страницу:



Логика очень проста - если пароли различны, то при помощи вспомогательного метода ValidationHelper.MarkInvalid страница помечается невалидной и в OnQueryEnabled будет принято решение о невалидности данных,т.к. страница невалидна. Если же введенные пароли идентичны, то страница помечается валидной вспомогательным методом ValidationHelper.ClearInvalid.
ValidationHelper.MarkInvalid и ValidationHelper.ClearInvalid являются всего лишь обертками методов класса System.Windows.Controls.Validation MarkInvalid и ClearInvalid, передавая им BindingExpression объекта на свойство Validation.Sink.
Совсем непростая реализация, но позволяет добиться ожидаемого поведения.
Также wizard предлагает более простую модель - реакция на события смены страниц, но это решение не обеспечивает нужного поведения UI элементов.

Удачи.
PS: перечитал пост и понял, что в последней части можно было бы обойтись и без столь сложного кода с ValidationRule - просто в code-behind сравнивать строки и помечать страницу валидной либо невалидной. подумал но исправлять ничего не стал - пусть послужит демонстрацией продвинутых возможностей WPF. Возможно где-нибудь в другом более сложном сценарии вам пригодится этот код. И обратите внимание на VirtualBranch прием от Josh'а.
PPS: а потом еще раз подумал и решил, что все правильно и так и надо делать, ибо при валидации в ValidationRule WPF автоматически отобразит результаты валидации в UI, надо лишь задать ErrorTemplate в классе Validation.

3 комментария:

Анонимный комментирует...

есть source code для wizard framework?

Анонимный комментирует...

есть source code для вашей wizard framework?

RobertT комментирует...

я бы не называл это framework, потому что стиль в xaml надо немного доработать для того, чтобы он стал легкокастомизуемым, так что можете взять код и немного поправить для себя, либо использовать как есть. ссылка: http://www.megaupload.com/?d=OEWPQ15D