Пример Model-View-Update архитектуры на F#

в 12:35, , рубрики: .net, elm, Elmish, F#, redux, Анализ и проектирование систем, Проектирование и рефакторинг

Кому-то не нравился Redux в React из-за его имплементации на JS?

Мне он не нравился корявыми switch-case в reducer'ах, есть языки с более удобным pattern matching, и типы лучше моделирующие события и модель. Например, F#.
Эта статья — разъяснение устройства обмена сообщениями в Elmish.

Я приведу пример консольного приложения написанного по этой архитектуре, на его примере будет понятно как использовать такой подход, а потом разберемся в архитектуре Elmish.

Я написал простое консольное приложение для чтения стихотворений, в seed'e есть несколько стихотворений по одному на каждого автора, которые выводятся на консоль.

Окно вмещает только 4 строки текста, по нажатию кнопок "Up" и "Down" можно листать стихотворение, цифровые кнопки меняют цвет текста, а кнопки влево и вправо позволяют перемещаться по истории действий, например пользователь читал стихотворение Пушкина, переключился на стихотворение Есенина, сменил цвет текста, а потом подумал, что цвет не очень и Есенин ему не нравится, нажал дважды на стрелку влево и вернулся к месту на котором закончил читать Пушкина.

Это чудо выглядит так :

Пример Model-View-Update архитектуры на F# - 1

Рассмотрим реализацию.

Если продумать все варианты, понятно, что все, что может делать пользователь это нажимать кнопку, по ее нажатию, можно определить, что хочет пользователь, а он может желать:

  1. Поменять автора
  2. Поменять цвет
  3. Пролистать (наверх/вниз)
  4. Пройти на предыдущую/последующую версию

Поскольку пользователь должен иметь возможность возвращаться на версию назад, нужно фиксировать его действия и запоминать модель, в итоге все возможные сообщения, описываются так:

type Msg =
        | ConsoleEvent of ConsoleKey
        | ChangeAuthor of Author
        | ChangeColor of ConsoleColor
        | ChangePosition of ChangePosition
        | ChangeVersion of ChangeVersion
        | RememberModel
        | WaitUserAction
        | Exit

type ChangeVersion =
        | Back
        | Forward

type ChangePosition =
        | Up
        | Down

type Author =
        | Pushkin
        | Lermontov
        | Blok
        | Esenin

type Poem = Poem of string

В модели нужно хранить информацию о тексте, который сейчас в консоли, историю действий пользователя и количество действий на которые пользователь будет откатываться, чтобы знать какую именно модель нужно показать.

type Model =
        {
            viewTextInfo: ViewTextInfo
            countVersionBack: int
            history: ViewTextInfo list
        }

type ViewTextInfo =
        {
            text: string;
            formatText: string;
            countLines: int;
            positionY: int;
            color: ConsoleColor
        }

Архитектура Elmish — model-view-update, модель уже рассмотрели, перейдем к view:

let SnowAndUserActionView (model: Model) (dispatch: Msg -> unit) =
       let { formatText = ft; color = clr } = model.viewTextInfo;
       clearConsoleAndPrintTextWithColor ft clr
       let key = Console.ReadKey().Key;
       Msg.ConsoleEvent key |> dispatch

let clearConsoleAndPrintTextWithColor (text: string) (color: ConsoleColor) =
       Console.Clear();
       Console.WriteLine()
       Console.ForegroundColor <- color
       Console.WriteLine(text)

Это одно из представлений, оно отрисовывается на основе viewTextInfo, ждет реакцию пользователя, и отправляет это сообщение в функцию update.
Позже подробно рассмотрим, что именно происходит при вызове dispatch, и что это вообще за функция.

Update:

let update (msg: Msg) (model: Model) =
        match msg with
        | ConsoleEvent key -> model, updateConsoleEvent key
        | ChangeAuthor author -> updateChangeAuthor model author
        | ChangeColor color -> updateChangeColor model color
        | ChangePosition position -> updateChangePosition model position
        | ChangeVersion version -> updateChangeVersion model version
        | RememberModel -> updateAddEvent model
        | WaitUserAction -> model, []

В зависимости от типа msg выбирается какая функция будет обрабатывать сообщение.

Это update на действие пользователя, сопоставление кнопки с сообщением, последний кейс — возвращает событие WaitUserAction — игнорируем нажатие и ждем дальнейших действий пользователя.

let updateConsoleEvent (key: ConsoleKey) =
       let msg =
        match key with
        | ConsoleKey.D1 -> ChangeColor ConsoleColor.Red
        | ConsoleKey.D2 -> ChangeColor ConsoleColor.Green
        | ConsoleKey.D3 -> ChangeColor ConsoleColor.Blue
        | ConsoleKey.D4 -> ChangeColor ConsoleColor.Black
        | ConsoleKey.D5 -> ChangeColor ConsoleColor.Cyan

        | ConsoleKey.LeftArrow -> ChangeVersion Back
        | ConsoleKey.RightArrow -> ChangeVersion Forward

        | ConsoleKey.P -> ChangeAuthor Author.Pushkin
        | ConsoleKey.E -> ChangeAuthor Author.Esenin
        | ConsoleKey.B -> ChangeAuthor Author.Blok
        | ConsoleKey.L -> ChangeAuthor Author.Lermontov

        | ConsoleKey.UpArrow -> ChangePosition Up
        | ConsoleKey.DownArrow -> ChangePosition Down

        | ConsoleKey.X -> Exit

        | _ -> WaitUserAction
       msg |> Cmd.ofMsg

Меняем автора, обратите внимание, что countVersionBack сразу сбрасывается на 0, это значит, что если пользователь откатывался по своей истории назад, а потом захотел сменить цвет, это действие будет трактоваться как новое и будет добавлено в history.

let updateChangeAuthor (model: Model) (author: Author) =
        let (Poem updatedText) = seed.[author]
        let updatedFormatText = getlines updatedText 0 3
        let updatedCountLines = (splitStr updatedText).Length
        let updatedViewTextInfo =
            {model.viewTextInfo
             with text = updatedText;
              formatText = updatedFormatText;
              countLines = updatedCountLines }

        { model
          with viewTextInfo = updatedViewTextInfo;
           countVersionBack = 0 },
        Cmd.ofMsg RememberModel

Также мы отправляем сообщение RememberModel, обработчик которого, обновляет history, добавляя текущую модель.

let updateModelHistory model =
        { model with history = model.history @ [ model.viewTextInfo ] },
        Cmd.ofMsg WaitUserAction

Остальные update'ы можно посмотреть тут, они похожи на рассмотренные.

Чтобы проверить работоспособность программы, я приведу тесты на несколько сценариев:

Тесты

Метод run принимает структуру в которой хранится список Messages и возвращает модель после того, как они будут обработаны

[<Property(Verbose=true)>]
let ``Автор равен последнему переданному автору`` (authors: Author list) =
    let state = (createProgram (authors |> List.map ChangeAuthor) |> run)
    match (authors |> List.tryLast) with
    | Some s ->
        let (Poem text) = seed.[s]
        state.viewTextInfo.text = text
    | None -> true

[<Property(Verbose=true)>]
let ``Цвет равен последнему переданному цвету`` changeColorMsg =
    let state = (createProgram (changeColorMsg|>List.map ChangeColor)|> run)
    match (changeColorMsg |> List.tryLast) with
    | Some s -> state.viewTextInfo.color = s
    | None -> true

[<Property(Verbose=true,Arbitrary=[|typeof<ChangeColorAuthorPosition>|])>]
let ``Вызов случайных цепочек команд смены цвета и автора корректен`` msgs =
      let tryLastSomeList list = list |> List.filter (Option.isSome)
                                      |> List.map (Option.get)
                                      |> List.tryLast
      let lastAuthor = msgs
                       |> List.map (fun x -> match x with
                                             | ChangeAuthor a -> Some a
                                             | _ -> None)
                       |> tryLastSomeList
      let lastColor = msgs
                       |> List.map (fun x -> match x with
                                            | ChangeColor a -> Some a
                                            | _ -> None)
                       |> tryLastSomeList
      let state = (createProgram msgs |> run)
      let colorTest =
          match lastColor with
          | Some s -> state.viewTextInfo.color = s
          | None -> true
      let authorTest =
          match lastAuthor with
          | Some s ->
              let (Poem t) = seed.[s];
              state.viewTextInfo.text = t
          | None -> true
      authorTest && colorTest

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

Теперь рассмотрим ядро программы, код в Elmish писался на все случаи жизни я упростил его(оригинальный код):

type Dispatch<'msg> = 'msg -> unit

type Sub<'msg> = Dispatch<'msg> -> unit

type Cmd<'msg> = Sub<'msg> list

type Program<'model, 'msg, 'view> =
        {
          init: unit ->'model * Cmd<'msg>
          update: 'msg -> 'model -> ('model * Cmd<'msg>)
          setState: 'model -> 'msg -> Dispatch<'msg> -> unit
         }

let runWith<'arg, 'model, 'msg, 'view> (program: Program<'model, 'msg, 'view>) =
    let (initModel, initCmd) = program.init() //1
    let mutable state = initModel //2
    let mutable reentered = false //3
    let buffer = RingBuffer 10 //4 

    let rec dispatch msg =
        let mutable nextMsg = Some msg; //5
        if reentered  //6
         then buffer.Push msg //7
         else 
             while Option.isSome nextMsg do // 8
                 reentered <- true // 9
                 let (model, cmd) = program.update nextMsg.Value state // 9
                 program.setState model nextMsg.Value dispatch // 10
                 Cmd.exec dispatch cmd |> ignore  //11
                 state <- model; // 12
                 nextMsg <- buffer.Pop() // 13
                 reentered <- false; // 14

    Cmd.exec dispatch initCmd |> ignore // 15
    state //16

let run program = runWith program

Тип Dispath<'msg> именно тот dispatch который используется во view, он принимает Message и возвращает unit
Sub<'msg> — функция подписчик, принимает dispatch и возвращает unit, мы порождаем список Sub, когда используем ofMsg:

let ofMsg<'msg> (msg: 'msg): Cmd<'msg> =
        [ fun (dispatch: Dispatch<'msg>) -> dispatch msg ]

После вызова ofMsg, как, например Cmd.ofMsg RememberModel в конце метода updateChangeAuthor, через некоторое время вызовется подписчик и сообщение попадет в метод update
Cmd<'msg> — Лист Sub<'msg>

Перейдем к типу Program, это generic тип, принимает тип модели, сообщения и view, в консольном приложении нет нужны что-то возвращать из view, но в Elmish.React view возвращает F# структуру DOM дерева.

Поле init — вызывается на старте elmish, эта функция возвращает начальную модель и первое сообщение, в моем случае я возвращаю Cmd.ofMsg RememberModel
Update — главная функция update, вы с ней уже знакомы.

SetState — в стандартном Elmish принимает только модель и dispatch и вызывает view, но мне нужно передавать msg, чтобы подменять view в зависимости от сообщения, я покажу ее реализацию после того, как мы рассмотрим обмен сообщениями.

Функция runWith, получает конфигурацию, далее вызывает init, возвращаются модель и первое сообщение, на строчках 2,3 объявляются два изменяемых объекта, первый — в котором будет храниться state, второй нужен функции dispatch.

На 4 строке объявляется buffer — можно воспринимать его как очередь, первый зашел — первый вышел(на самом деле реализация RingBuffer, очень интересна, я взял ее из библиотеки, советую ознакомиться на github)

Далее идет сама рекурсивная функция dispatch, та же самая, что вызывается во view, при первом вызове мы минуем if на строчке 6 и сразу попадаем в цикл, ставим reented значение true, чтобы последующие рекурсивные вызовы, не заходили снова в этот цикл, а добавляли новое сообщение в buffer.

На строчке 9 выполняем метод update, из которого забираем измененную модель и новое сообщение(в первый раз это сообщение RememberModel)
На строчке 10 отрисовывается модель, метод SetState выглядит так:

Пример Model-View-Update архитектуры на F# - 2

Как вы видите, разные сообщения вызывают разные view
Это необходимая мера, чтобы не блокировать поток, потому что вызов Console.ReadLine блокирует поток программы, и такие события как RememberModel,ChangeColor (которые инициируются внутри программы, а не пользователем) будут каждый раз ждать пока пользователь нажмет на кнопку, хотя просто должны изменить цвет.

В первый раз будет вызвана функция OnlyShowView, которая просто отрисует модель.
Eсли бы вместо RememberModel в метод пришло сообщение WaitUserAction, то вызвалась бы функция ShowAndUserActionView, которая отрисует модель и заблокирует поток, ожидая нажатия кнопки, как только кнопка будет нажата снова вызовется метод dispatch, и сообщение будет запушено в buffer(потому что reenvited= false)

Далее нужно обработать все сообщения, пришедшие из метода update, иначе мы их потеряем, рекурсивные вызовы попадут в цикл только если reented станет false. 11 строчка выглядит сложно, но на самом деле это просто push всех сообщения в buffer:

let exec<'msg> (dispatch: Dispatch<'msg>) (cmd: Cmd<'msg>) =
        cmd |> List.map (fun sub -> sub dispatch)

Для всех подписчиков, возвращенных методом update, будет вызван dispatch, тем самым эти сообщения будут добавлены в buffer.

На 12 строке обновляем модель, достаем новое сообщение и возвращаем значение reented на false, когда buffer не пустой это не нужно, но если там не осталось элементов и dispatch может быть вызван только из view, это имеет смысл. Опять же в нашем случае, когда все синхронно, это не имеет смысла, так как мы ожидаем синхронный вызов dispatch на 10 строчке, но если в коде есть асинхронные вызовы, возможен вызов dispatch из callback'a и нужно иметь возможность продолжить выполнение программы.

Ну вот и все описание функции dispatch, на 15 строке она вызывается и на 16 возвращается state.

В консольном приложении выход происходит, когда buffer становится пустым. В оригинальной версии runWith ничего не возвращает, но без этого тестирование невозможно.

Program для тестирования отличается, функция createProgram принимает список сообщений, которые бы инициировал пользователь и в SetState они подменяют обычное нажатие:
Пример Model-View-Update архитектуры на F# - 3

Еще одно отличие моей измененной версии от оригинальной — сначала вызывается функция update, а потом только setState, в оригинальной версии наоборот, сначала происходит отрисовка, а потом обработка сообщений, я вынужден был на это пойти из-за блокирующего вызова Console.ReadKey (необходимости менять view)

Я надеюсь, мне удалось объяснить как устроен Elmish и подобные системы, за бортом осталось довольно много функционала Elmish, если вас заинтересовала это тема, советую заглянуть на их сайт.

Спасибо за внимание!

Автор: Евгений

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js