Знакомство с 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 файла


<?xml version="1.0" encoding="utf-8"?>
<Students>
  <Student Id="0">
    <FirstName>Robert</FirstName>
    <Last>Jarman</Last>
    <Age>21</Age>
    <Gender>0</Gender>
  </Student>
  ...
</Students>

то заметим, что 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<string> или Some<Student>/Ok<Student> в зависимости от результата проверки.


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 [<Literal>] Sample = """
<Students>
  <Student Id="0">
    <FirstName>Robert</FirstName>
    <Last>Jarman</Last>
    <Age>21</Age>
    <Gender>0</Gender>
  </Student>
  <Student Id="2">
    <FirstName>Leona</FirstName>
    <Last>Menders</Last>
    <Age>20</Age>
    <Gender>1</Gender>
  </Student>
</Students>"""

type Students = XmlProvider<Sample>

В используемом образце 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<Student list,_,_>) request : UIFactory<Student list,_,_> =
    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<Student list, CollectionNav, AppMessages>, а тип studentComponent соответственно IComponent<Student, CollectionNav, Student>.


Последний аргумент (msgMapper) это функция обратного преобразования для сообщений. Компонент studentComponent вернет студента, поэтому здесь нам остается только передать наверх правильное сообщение.


Можно переходить к завершающей части — рассмотрению самих компонентов.


За привязки данных в Gjallarhorn.Bindable.WPF отвечает модуль Bind, который в свою очередь разбит на несколько подмодулей.


Основное (корневое) API (прим. было добавлено начиная с первой версии) более безопасно, но иногда и более громоздкое и второе — явное (функции из модуля Explicit).


Здесь, чтобы показать оба подхода, используется Explicit для получения информации о студенте и Implicit для основного.


Заметим, что оба компонента независимы друг от друга.


Начнем с главного — appComponent


Для использования нового API нужно объявить промежуточный тип, который должен содержать все выставляемые свойства и команды.


type AppViewModel =
    {
        Students : Student list
        Add : VmCmd<CollectionNav>
        Edit : VmCmd<CollectionNav>
        Remove : VmCmd<AppMessages>
        RemoveAll : VmCmd<AppMessages>
        Save : VmCmd<AppMessages>
    }

Команды задаются с помощью специального типа 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<Student list, CollectionNav, AppMessages> [
        <@ 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<CollectionNav>()

и с помощью него в функции отправляем запросы при получении сообщений


|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# сообщества в чьих рядах состою и я.


До новых встреч!

  • +20
  • 2,5k
  • 2
Поделиться публикацией

Похожие публикации

Комментарии 2
    +1
    Интересно! Спасибо
      +1

      На всякий случай оставлю полезные ссылки.
      Сайт русскоязычного F# сообщества


      Чаты по F# на русском языке:


      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое