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

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


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


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


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


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


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



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


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


    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 выглядит так:



    Как вы видите, разные сообщения вызывают разные 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 они подменяют обычное нажатие:


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


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


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

    • +14
    • 3.3k
    • 5
    Share post

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 5

      0

      Что сразу бросается в глаза — использование camelCase для полей записей. Хотя, на данный момент, нет строгой рекомендации по использованию того или иного подхода, намного более часто встречается именно PascalCase.


      Ишшуй на гитхабе:


      Discussion: PascalCase vs camelCase in record fields #600


      Чем обоснован выбор типа для "автора"? Если заходите расширить приложение, то могут быть проблемы когда авторов станет много ;)


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

      Вот тут не совсем понятно. Код писался на все случаи жизни, но в вашем конкретном случае не подошел? Посмотрел исходный код, получается, вы просто скопировали куски кода которые были нужны. Почему бы не использовать библиотеку в виде пакета?

        +2
        Я разбирался как работает elmish, изучил исходный код, убрал из него все, что было излишним для моих задач и написал приложение, чтобы закрепить понимание, как там все устроено, разумеется в разработке я использую elmish, а не мою облегченную модификацию
          +2
          Насчет структуры хранения авторов, согласен, еще проблемы возникнут, когда авторы будут начинаться на одну букву, но моей целью не было создание приложения для чтения стихотворений в консоли, спасибо за развернутый комментарий
          +2

          Спасибо за статью.
          Очень интересно, что для ознакомления с оригинальным elmish для веба/xamarin/etc. Вы сделали elmish для консоли. Показывает насколько эта концепция гибка.
          Так же интересующимся очень советую посмотреть на safe stack
          https://safe-stack.github.io/docs/intro/

            0
            Спасибо, до него я еще не добрался)

          Only users with full accounts can post comments. Log in, please.