Кому-то не нравился Redux в React из-за его имплементации на JS?
Мне он не нравился корявыми switch-case в reducer'ах, есть языки с более удобным pattern matching, и типы лучше моделирующие события и модель. Например, F#.
Эта статья — разъяснение устройства обмена сообщениями в Elmish.
Я приведу пример консольного приложения написанного по этой архитектуре, на его примере будет понятно как использовать такой подход, а потом разберемся в архитектуре Elmish.
Я написал простое консольное приложение для чтения стихотворений, в seed'e есть несколько стихотворений по одному на каждого автора, которые выводятся на консоль.
Окно вмещает только 4 строки текста, по нажатию кнопок "Up" и "Down" можно листать стихотворение, цифровые кнопки меняют цвет текста, а кнопки влево и вправо позволяют перемещаться по истории действий, например пользователь читал стихотворение Пушкина, переключился на стихотворение Есенина, сменил цвет текста, а потом подумал, что цвет не очень и Есенин ему не нравится, нажал дважды на стрелку влево и вернулся к месту на котором закончил читать Пушкина.
Это чудо выглядит так :
Рассмотрим реализацию.
Если продумать все варианты, понятно, что все, что может делать пользователь это нажимать кнопку, по ее нажатию, можно определить, что хочет пользователь, а он может желать:
- Поменять автора
- Поменять цвет
- Пролистать (наверх/вниз)
- Пройти на предыдущую/последующую версию
Поскольку пользователь должен иметь возможность возвращаться на версию назад, нужно фиксировать его действия и запоминать модель, в итоге все возможные сообщения, описываются так:
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, если вас заинтересовала это тема, советую заглянуть на их сайт.
Спасибо за внимание!
Автор: Евгений