Пример 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, если вас заинтересовала это тема, советую заглянуть на их сайт.

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


ссылка на оригинал статьи https://habr.com/ru/post/459870/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *