[Из песочницы] Знакомство с Gjallarhorn.Bindable.WPF (F#) на примере выполнения тестового задания
В статьях на русском языке теме использования F#
совместно с WPF
уделяют немного внимания.
Сегодня я попробую познакомить вас с одной из F#
библиотек, которая значительно упрощает такую разработку.
В качестве демонстрационного примера возьмем одно из тестовых заданий по WPF
, которые дают соискателям на должность Junior-разработчика для проверки их знаний.
Само задание звучит так
Необходимо разработать приложение с использованием данных, представленных в файле Students.xml.Указанный файл содержит следующие сведения о студентах: фамилия, имя, возраст, пол.
Конечно, есть дополнительные рекомендации и ограничения на реализацию, но не будем копировать их целиком. Основные части будут приведены в тексте при необходимости, а полная версия доступна здесь
Для начала создадим пустой консольный проект для .NET Framework в Visual Studio (или любой другой предпочитаемой IDE). Если не хотите видеть отладочную консоль, то нужно будет поменять тип выходных данных в настройках проекта.
Работу над нашим простым приложением начнем с основного шага — определения главных типов данных (тех, которые не зависят от пользовательского интерфейса).
Воспользуемся F# — типами которые (пока) не имеют своих аналогов в C# — записи (Record
) и размеченные объединения (Discriminated Union
).
type Gender = |Male |Female
type Student = {FirstName : string; LastName : string; Age : int; Gender : Gender}
Здесь, пожалуй, стоит остановиться. Есть еще один момент, на котором в задании не сделали акцент — уникальности записи. В теории может существовать студенты, у которых будут совпадать все перечисленные поля.
Но, если посмотрим на образец xml файла
Robert
Jarman
21
0
...
то заметим, что ID задан в виде атрибута, поэтому мы можем просто добавить еще одно поле:
type Student = {ID:int; FirstName : string; LastName : string; Age : int; Gender : Gender; }
У такого объявления есть один существенный недостаток — наличие ID не гарантирует уникальности записи.
То есть в теории можно добавить сколько угодно записей с одинаковым идентификатором.
F# не разрешает назначать модификаторы доступа для отдельных полей, но позволяет это делать для типов.
Если бы мы хотели себя обезопасить, то могли бы поставить явное указание о том, что хотим видеть тип Student
приватным:
type Student = private {ID:int; FirstName : string; LastName : string; Age : int; Gender : Gender; }
и написать для создания объекта вспомогательную функцию
let create firstname lastname age gender =
let id = getNextId()
{
ID = id
FirstName = firstname
...
}
Рассмотрим требования на ограничение допустимых значений полей:
поля с именем, фамилией и полом обязательны для заполнения;
возраст не может быть отрицательным и должен находиться в диапазоне [16, 100].
Сразу же возникает закономерный вопрос — где проводить проверку корректности вводимых параметров?
Если бы тип Student
был защищен, то можно было бы написать функцию tryCreate
, которая будет возвращатьNone
/ Error
или Some
/Ok
в зависимости от результата проверки.
Result
удобно использовать в том случае, если нужно не только сигнализировать о том, что попытка оформить студента была неуспешной, но и для того, чтобы конкретно указать где возникла проблема.
За очевидностью реализации, не станем добавлять в статью код этой функции.
Запомним описанный выше подход, но обязанность контроля за данными отведем на связующее звено между View и Model.
Прежде чем перейти к части отвечающей за представление, закроем тему основных возможностей приложения
- создание нового элемента и добавление в список;
- редактирование любой записи в списке;
- удаление одной и более записей из списка.
С созданием уже разобрались, добавим еще несколько функций
//добавление в список;
let add xs student = student :: xs
//удаление из списка
let remove students = List.filter (fun student -> Seq.contains student students |> not)
//редактирование любой записи в списке;
let editFirstName firstname student = { student with FirstName = firstname }
let editLastName lastname student = { student with LastName = lastname }
let editAge age student = { student with Age = age}
let editGender gender student = { student with Gender = gender }
let editId student id = {student with ID = id}
let edit student = List.map (fun st -> if st.ID = student.ID then student else st)
и переходим к следующему этапу.
Чтение и запись в XML
Для работы с данными в F# есть превосходный механизм под названием поставщик типов (иногда его еще называют провайдер (для) данных, но по причине большей распространенности в дальнейшем будем использовать именно первый вариант).
Существует множество реализаций для удобной работы с тем или иным форматом.
В этой части нам нужен только XmlProvider (из библиотеки FSharp.Data
).
Добавим этот пакет в проект:
Install-Package FSharp.Data
Отметим, что внутри XmlProvider
используется тип XDocument
, поэтому нам еще будет нужна ссылка наSystem.Xml.Linq
.
open FSharp.Data
let [] Sample = """
Robert
Jarman
21
0
Leona
Menders
20
1
"""
type Students = XmlProvider
В используемом образце Id
указаны как {0, 2}
, а не {0, 1}
как в файле затем, чтобы тип был определен как int
, а не bool
.
В общем случае может потребоваться сложная логика для того, чтобы преобразовать типы из формата источника данных в типы, принятые в приложении. Однако так как у нас эти структуры данных практически полностью совпадают, то понадобится всего одна дополнительная функция для установки соответствия значения типа bool
и размеченного объединения.
let fromBool = function | true -> Female | false -> Male
Запись function | true -> Female | false -> Male
означает ровно то же самое что иmatch x with
, но только в более коротком виде. Такую форму удобно использовать при тривиальном сопоставлении с образцом.
Следующая часть тоже проблем не вызывает — все просто и понятно.
let toCoreStudent (student:Students.Student) =
student.Gender
|> fromBool
|> create student.Id student.FirstName student.Last student.Age
let readFromFile (path : string) =
Students.Load path
|> fun x -> x.Students
|> Seq.map toCoreStudent
Но это еще не все, нужно учесть, что пользователь может добавлять данные в список, следовательно нужно уметь не только извлекать данные из файла, но и записывать.
Код будет абсолютно аналогичным, за исключением того, что преобразование будет идти в другом направлении
let toBool = function | Male -> false | Female -> true
let fromCoreStudent (student:Student) =
Students.Student(student.ID, student.FirstName, student.LastName, student.Age, toBool student.Gender)
let toXmlStudents data =
data
|> Seq.map fromCoreStudent
|> Seq.toArray
|> Students.Students
let writeToFile (path : string) data =
let students = data |> toXmlStudents
students.XElement.Save path
Подчеркнем, что все, что пока рассматривалось не имеет зависимости от WPF и при возможном переносе (например на другой тип интерфейса) изменений в этой части не будет.
В обычной ситуации имеет смысл вынести такой код в библиотеку классов, но так как функциональная часть не связанная с конкретным форматом (.xml) слишком мала, то отдельный проект для создания полностью отдельного модуля использован не был.
Пользовательский интерфейс
Наша цель написать проект полностью на F#, поэтому в вопросе интерфейса мы прибегнем к помощи FsXAML
.
Нет ничего плохого в том, чтобы написать часть на C#, но, согласитесь, что это было бы не так интересно.
FsXAML
это поставщик типов, который позволяет нам удобным образом использовать xaml
файлы. Добавить его в проект можно через NuGet.
Install-Package FsXaml.Wpf
О преимуществах над XamlReader
можно прочитать в отдельном ответе на StackOverFlow (на английском)
Одним из его недостатков является отсутствие документации, поэтому далеко не сразу можно узнать о том, что там есть конвертеры, а также удобная обертка для написания своих собственных.
Здесь нам как раз понадобится конвертер для правильного отображения возраста и ошибок валидации.
type AgeToStringConverter() =
inherit ConverterBase
(fun value _ _ _ ->
match value with
| :? int ->
value
|> unbox
|> AgeToStringConverter.ageToStr
|> box
| _ -> null )
static member ageToStr age =
...
где ConverterBase
— базовый класс из FsXAML
для создания конвертеров.
Повторим основные требования к приложению, но теперь посмотрим на них с точки зрения внешнего вида.
- отображение списка уже существующих элементов;
- создание нового элемента и добавление в список;
- редактирование любой записи в списке;
- удаление одной и более записей из списка.
Для того, чтобы отобразить список элементов удобно будет использовать ListView
.
Кроме таблицы студентов в главном окне будут находится еще и управляющие кнопки.
Все вместе образует UserControl
который представляет основную «страницу» приложения.
Других страниц не предвидится, поэтому использование навигации может показаться избыточным решением.
Но для демонстрации простые примеры подходят как нельзя лучше.
Редактирование и добавление информации о студенте будем производить в диалоговом окне.
После создания xaml
файлов нужно создать для них типы
type App = XAML<"App.xaml">
type MainWin = XAML<"MainWindow.xaml">
type StudentsControl = XAML<"StudentsControl.xaml">
type StudentDialogBase = XAML<"StudentDialog.xaml">
type StudentDialog() =
inherit StudentDialogBase()
override this.CloseClick (_sender, _e) = this.Close()
Начиная с третьей версии в FsXAML
была добавлена базовая поддержка для обработки событий. В примере выше окно закрывается после нажатия на подтверждающую кнопку.
Gjallarhorn.Bindable
Для связи нашей модели с представлением будем использовать новую, но очень перспективную, библиотеку Gjallarhorn.Bindable
Install-Package Gjallarhorn.Bindable.Wpf -Version 1.0.0-beta5
Последний доступный релиз, которой все еще находится в бета версии.
Основная концепция — своеобразное переложение Elm
-архитектуры с учетом wpf
специфики поверх главной библиотеки Gjallarhorn. Кроме wpf
-версии также есть пакет для XamarinForms
.
Для создания приложения удобно использовать функцию application
из модуля Framework
:
Framework.application model update appComponent nav
которая соединяет отдельные части (модель, функция для ее обновления, компонент для связи с представлением и навигатор)
model
— модель приложения (модель верхнего уровня) — основные данные, с которыми предстоит дальнейшая работа.update:('message -> 'model -> 'model)
функция которая обрабатывает модель (model
) в зависимости от получаемого сообщения (message
) и возвращает новое значение.
Исходя из поставленной задачи наше приложение должно уметь добавлять, редактировать и удалять элементы из списка.
На каждое действие удобно заводить отдельное сообщение, представляемое в виде именованного варианта размеченного объединения
type AppMessages =
|Add of Student
|Edit of Student
|Remove of Student seq
|Save
К уже перечисленным возможностями добавили еще функцию для перезаписи файла.
При добавлении новой записи в список нужно учесть приращение (ID
) для уникального идентификатора.
Для этого можно написать вспомогательную функцию getId
, которая возвращает следующий порядковый номер после максимального из тех, что находится в списке.
Других подводных камней в функции обновления нет, поэтому в итоге она принимает следующий вид
let update message model =
match message with
|Add student ->
model
|> getId
|> editId student
|> add model
|Edit newValue ->
model
|> edit newValue
|Remove students ->
model
|> remove students
|Save ->
XmlWorker.writeToFile path model
model
Для определения навигационных состояний также используем размеченное объединение
type CollectionNav =
| ViewStudents
| AddStudent
| EditStudent of Student
Все, каркас для навигации готов и можно переходить к связыванию навигационных сообщений с сообщениями для приложения.
По аналогии с обновлением модели обновление состояния тоже реализуется в функции update
let updateNavigation (_ : ApplicationCore) request : UIFactory =
match request with
|ViewStudents ->
Navigation.Page.fromComponent StudentsControl id appComponent id
|AddStudent ->
Navigation.Page.dialog StudentDialog (fun _ -> defaultStudent) studentComponent Add
|EditStudent x ->
Navigation.Page.dialog StudentDialog (fun _ -> x) studentComponent Edit
Здесь используются две функции предоставляемых библиотекой
Navigation.Page.fromComponent
fromComponent : (makeElement : unit -> 'UIElement)
-> (modelMapper : 'Model -> 'Submodel)
-> (comp : IComponent<'Submodel, 'Nav, 'Submsg>)
-> (msgMapper : 'Submsg -> 'Message)
-> UIFactory<_,_,_>
и
Navigation.Page.dialog
dialog : (makeElement : unit -> 'Win)
-> (modelMapper : 'Model -> 'Submodel)
-> (comp : IComponent<'Submodel, 'Nav, 'Submsg>)
-> (msgMapper : 'Submsg -> 'Message) =
-> UIFactory<_,_,_>
Между собой они очень похожи, поэтому не будем приводить их описания по отдельности.
Первым аргументом выступает функция (makeElement
), которая задает отображаемый элемент (окно (Window
) или элемент управления (UIElement
)).
Конструктор по своей сути это та-же функция, поэтому в большинстве случаев нам достаточно передать нужный тип.
Второй аргумент (modelMapper
) это функция преобразования из модели верхнего уровня в модель уровнем ниже.
В нашем случае с редактированием получаем интересующий нас объект (Student
) в качестве параметра, поэтому мы можем просто передать его дальше. Для добавления передаем значение по умолчанию.
Для основного состояния ViewStudents
модель главного компонента будет моделью приложения, поэтому никаких изменений делать не нужно и можно применять
стандартную F# функцию id
Дальше идет компонент (comp
), который содержит все необходимые привязки для взаимодействия с интерфейсом.
Компонент appComponent
имеет тип IComponent
, а тип studentComponent
соответственно IComponent
.
Последний аргумент (msgMapper
) это функция обратного преобразования для сообщений. Компонент studentComponent
вернет студента, поэтому здесь нам остается только передать наверх правильное сообщение.
Можно переходить к завершающей части — рассмотрению самих компонентов.
За привязки данных в Gjallarhorn.Bindable.WPF
отвечает модуль Bind
, который в свою очередь разбит на несколько подмодулей.
Основное (корневое) API (прим. было добавлено начиная с первой версии) более безопасно, но иногда и более громоздкое и второе — явное (функции из модуля Explicit
).
Здесь, чтобы показать оба подхода, используется Explicit
для получения информации о студенте и Implicit
для основного.
Заметим, что оба компонента независимы друг от друга.
Начнем с главного — appComponent
Для использования нового API нужно объявить промежуточный тип, который должен содержать все выставляемые свойства и команды.
type AppViewModel =
{
Students : Student list
Add : VmCmd
Edit : VmCmd
Remove : VmCmd
RemoveAll : VmCmd
Save : VmCmd
}
Команды задаются с помощью специального типа VmCmd
который просто хранит сообщение.
Это приводит к тому, что названия для команд получаются с помощью квотирования (иногда называют цитирование).
Таким образом мы избегаем «магических строк», что позволяет уменьшить риск появления ошибок из-за несовпадающих названий вызванных опечатками.
До того как оформлять компонент, нам понадобиться еще создать базовый экземпляр (значение по умолчанию) типа VM
let appvd = {
Students = []
Edit = Vm.cmd (CollectionNav.EditStudent defaultStudent)
Add = Vm.cmd CollectionNav.AddStudent
Remove = Vm.cmd (AppMessages.Remove [defaultStudent])
RemoveAll = Vm.cmd (AppMessages.Remove [defaultStudent])
Save = Vm.cmd AppMessages.Save
}
Для начала нужно учесть блокировку некоторых кнопок, если список пуст, поэтому определяем функцию которая содержит информацию о наличии элементов:
let hasStudents = List.isEmpty >> not
(в принципе можно было использовать триггер данных (DataTrigger
) как это было сделано для смены шаблона ListView
).
Затем создаем компонент передавая список всех привязок в функцию Component.create
как показано ниже
let appComponent =
let hasStudents = List.isEmpty >> not
Component.create [
<@ appvd.Students @> |> Bind.oneWay id
<@ appvd.Edit @> |> Bind.cmdParam EditStudent |> Bind.toNav
<@ appvd.Add @> |> Bind.cmd |> Bind.toNav
<@ appvd.Save @> |> Bind.cmd
<@ appvd.Remove @> |> Bind.cmdParamIf hasStudents (Seq.singleton >> Remove)
<@ appvd.RemoveAll @> |> Bind.cmdParamIf hasStudents (Seq.cast >> Remove)
]
Bind.oneWay
предназначена для создания однонаправленной привязки.
Bind.cmd
, Bind.cmdParam
и Bind.cmdParamIf
создают соответственно команду, команды с параметром и команду с дополнительной проверкой на возможность выполнения.
Обратим внимание на некоторые моменты — чтобы не заводить два отдельных сообщения (для удаления одного и нескольких элементов) переданный объект образует последовательность единичной длины.
<@ appvd.Remove @> |> Bind.cmdParamIf hasStudents (Seq.singleton >> Remove)
Так как SelectedItems
, к сожалению, не является обобщенной коллекцией, то тут приходится применять дополнительное преобразование
<@ appvd.RemoveAll @> |> Bind.cmdParamIf hasStudents (Seq.cast >> Remove)
Отправление навигационных сообщений происходит с помощью Bind.toNav
.
Здесь стоит заметить, что вместо этого можно использовать другой подход, который оставляет компоненты «чистыми» (без побочных эффектов навигации).
Его суть состоит в том, чтобы проводить не только все изменения в функциях update
, но также и сами запросы на изменения.
В нашем случае ими являются запросы на добавление и редактирование информации о студенте.
Другими словами нужно убрать в компоненте вызовы Bind.toNav
или прямые отправки через диспетчер (если используется явное API).
Давайте рассмотрим этот способ на примере.
Добавляем сообщения AddRequest
и EditRequest
к типу AppMessages
которые отражают необходимые запросы:
type AppMessages =
|Add of Student
|Edit of Student
|Remove of Student seq
|Save
|AddRequest
|EditRequest of Student
затем перепишем тип AppViewModel
таким образом, чтобы за добавление и редактирования в компоненте отвечала следующая часть
<@ appvd.Edit @> |> Bind.cmdParam EditRequest
<@ appvd.Add @> |> Bind.cmd
Дальше перед функцией update
создаем диспетчер
let disp = Dispatcher()
и с помощью него в функции отправляем запросы при получении сообщений
|AddRequest ->
AddStudent |> disp.Dispatch
model
|EditRequest st ->
EditStudent st |> disp.Dispatch
model
Чтобы подключить диспетчер (в данном случае отвечающий за управление навигацией) применим функцию Framework.withNavigation
let app =
Framework.application model update appComponent nav.Navigate
|> Framework.withNavigation disp
Да, в этом случае код занимает больше места, но зато компонент получается «чистым».
Теперь переходим к studentComponent
, который не будем приводить тут целиком, оставим только основные части
type StudentUpdate =
|FirstName of string
|LastName of string
|Age of int
|Gender of Gender
let studentBind _ source model =
let mstudent = model |> Signal.get |> Mutable.create
[Female; Male]
|> Signal.constant
|> Bind.Explicit.oneWay source "Genders"
let first =
mstudent
|> Signal.map (fun student -> student.FirstName)
|> Bind.Explicit.twoWayValidated source "FirstName"
(Validators.notNullOrWhitespace >> Validators.noSpaces)
|> Observable.toMessage FirstName
// привязка других свойств
let upd msg =
match msg with
| FirstName name -> Mutable.step (editFirstName name) mstudent
| LastName name -> Mutable.step (editLastName name) mstudent
| Age age -> Mutable.step (editAge age) mstudent
| Gender gender -> Mutable.step (editGender gender) mstudent
[last; age; gender]
|> List.fold Observable.merge first
|> Observable.subscribe upd
|> source.AddDisposable
[
Bind.Explicit.createCommandChecked "SaveCommand" source.Valid source
|> Observable.map(fun _ -> mstudent.Value)
]
let studentComponent : IComponent<_,CollectionNav,_> = Component.fromExplicit studentBind
Здесь, при связывании данных проводится еще и проверка корректности (валидация) с помощью возможностей библиотеки Gjallarhorn
.
Для отслеживания состояния допустимости параметров отвечает сигнал source.Valid
.
Модуль Validators
содержит несколько вспомогательных функций, которые можно легко комбинировать друг с другом.
Например, мы хотим чтобы поле для ввода имени не было пустой строкой или не содержало пробельных символов.
Для этого просто совместим обе функции
Validators.notNullOrWhitespace >> Validators.noSpaces
Если стандартных функций недостаточно всегда можно написать свою и добавить её в цепочку проверок.
О том, как это сделать, а также другие подробности о валидации данных с Gjallarhorn
можно прочитать в документации.
Функция Validators.noValidation
пригодиться в тех случаях, когда никаких проверок делать не нужно.
В результате диалоговое окно для добавления студента будет выглядеть следующим образом:
Показанный подход
mstudent
|> Signal.map (fun student -> student.FirstName)
|> Bind.Explicit.twoWayValidated source "FirstName"
(Validators.notNullOrWhitespace >> Validators.noSpaces)
вероятно кому-то покажется излишне многословным. Но выход есть — использовать функцию Bind.Explicit.memberToFromView
, которая позволяет запись то же самое немного короче.
На этом мы подходим к завершению, отмечу только несколько моментов :
- Подтверждение действий при удалении
- Перевод сообщений об ошибках на русский язык
- Добавление возможности указать путь для файла с данными
- …
которые было бы хорошо реализовать тем, кому попадется подобное тестовое задание. Надеюсь вы сделаете его на F# ;)
Исходный код расположен здесь.
На последок хочу выразить благодарность всему F# сообществу, которое очень дружелюбно к начинающим (и не только). Вы можете убедиться в этом сами присоединившись к F# Slack (для получения инвайта нужно будет зарегистрироваться в F# Software Foundation)
Отдельное спасибо Reed Copsey за ответы на мои многочисленные вопросы (особенно в то время когда я только начинал свое знакомство с F#), за предложение добавить пример использования нового API и другие советы без которого обзор был бы не таким полным.
И, конечно, этот материал никогда бы не увидел свет без поддержки со стороны русскоязычного F# сообщества в чьих рядах состою и я.
До новых встреч!