Хабрахабр

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

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

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

Я приведу пример консольного приложения написанного по этой архитектуре, на его примере будет понятно как использовать такой подход, а потом разберемся в архитектуре 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 = 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>

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

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

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

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

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

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

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

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

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

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

Program для тестирования отличается, функция createProgram принимает список сообщений, которые бы инициировал пользователь и в SetState они подменяют обычное нажатие:

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

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

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

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть