Продолжим говорить о Elm 0.18.
Elm. Удобный и неловкий
Elm. Удобный и неловкий. Json.Encoder и Json.Decoder
Elm. Удобный и неловкий. Http, Task
В этой статье рассмотрим вопросы архитектуры Elm приложения и возможные варианты реализации компонентного подхода разработки.
В качестве задачи рассмотрим реализацию выпадающего окна, которое позволяет зарегистрированному пользователю добавить вопрос. В случае анонимного пользователя предлагает сначала авторизоваться или зарегистрироваться.
Так же предположим, что впоследствии может потребоваться реализовать прием других типов пользовательского контента, но логика работы с авторизованными и анонимными пользователями останется прежней.
Неловкая композиция
Исходный код наивной реализации. В рамках этой реализации будем все хранить в одной модели.
Все данные необходимые для авторизации и опроса пользователя лежат в модели на одном уровне. Такая же ситуация и с сообщениями (Msg).
type alias Model =
{ user: User
, ui: Maybe Ui -- Popup is not open is value equals Nothing
, login: String
, password: String
, question: String
, message: String
}
type Msg
= OpenPopup
| LoginTyped String
| PasswordTyped String
| Login
| QuestionTyped String
| SendQuestion
Тип интерфейса описан в виде union type Ui, который используется с типом Maybe.
type Ui
= LoginUi -- Popup shown with authentication form
| QuestionUi -- Popup shown with textarea to leave user question
Таким образом ui = Nothing описывает отсутствие выпадающего окна, а Just — попап открыт с конкретным интерфейсом.
В функции update происходит сопоставление пары, сообщение и данные пользователя. В зависимости от этой пары выполняются различные действия.
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case (msg, model.user) of
Допустим при клике на кнопку “Open popup” генерируется сообщение OpenPopup. Сообщение OpenPopup в функции update обрабатывается различным образом. Для анонимного пользователя генерируется форма авторизации, а для авторизованного — форма, в которой можно оставить вопрос.
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case (msg, model.user) of
-- Anonymous user message handling section
(OpenPopup, Anonymous) ->
( { model | ui = Just LoginUi, message = "" }, Cmd.none)
-- Authenticated user message handling section
(OpenPopup, User userName) ->
( { model | ui = Just QuestionUi, message = "" }, Cmd.none)
Очевидно, у данного подхода возможны проблемы с ростом функций приложения:
- отсутствует группировка данных в модели и сообщений. Все лежит в одной плоскости. Таким образом отсутствуют границы компонентов, изменение логики одной части вероятнее всего затронет остальные;
- повторное использование кода возможно по принципу copy-paste со всеми вытекающими последствиями.
Удобная композиция
Исходный код удобной реализации. В рамках этой реализации попробуем разделить проект на самостоятельные компоненты. Допустимы зависимости между компонентами.
Структура проекта:
- в папке Type объявлены пользовательские типы;
- в папке Component объявлены пользовательские компонента;
- файл Main.elm входная точка проекта;
- файлы login.json и questions.json используются в качестве тестовых данных ответа сервера на авторизацию и сохранение информации о вопросе соответственно.
Пользовательские компоненты
Каждый компонент, исходя из архитектуры языка, должен содержать:
- модель (Model);
- сообщения (Msg);
- результат выполнения (Return);
- функцию инициализации (init);
- функция мутации (update);
- функцию представления (view).
Каждый компонент может содержать подписку (subscription) в случае необходимости.
Рис. 1. Диаграмма активности компонента
Инициализация
Каждый компонент должен быть инициирован, т.е. должны быть получены:
- модель;
- команда или список команд, которые должны инициализировать состояние компонента;
- результат выполнения. Результат выполнения в момент инициализации может понадобиться допустим для проверки авторизации пользователя, как в примерах к данной статье.
Перечень аргументов функции инициализации (init) зависит от логики работы компонента и может быть произвольным. Функций инициализации может быть несколько. Допустим, для компонента авторизации может быть предусмотрено два варианта инициализации: с токеном сессии и с данными пользователя.
Код, использующий компонент, после инициализации должен передать команды в elm runtime при помощи функции Cmd.map.
Мутация
Функция компонента update должна быть вызвана для каждого сообщения компонента. В качестве результата выполнения функция возвращает тройку:
- новую модель или новое состояние (Model);
- команду или список команд для Elm runtime (Cmd Msg). В качестве команд могут быть команды на выполнение HTTP-запросов, взаимодействие с портами и прочее;
- результат выполнения (Maybe Return). Тип Maybe имеет два состояния Nothing и Just a. В нашем случае, Nothing — результата отсутствует, Just a — результат имеется. Например, для авторизации результатом может быть Just (Authenticated UserData) — пользователь авторизован с данными UserData.
Код, использующий компонент, после мутации должен обновить модель компонента и передать команды в Elm runtime при помощи функции Cmd.map.
Обязательные аргументы функции update, в соответствии с архитектурой Elm приложений:
- сообщение (Msg);
- модель (Model).
При необходимости перечень аргументов можно дополнить.
Представление
Функция представления (view) вызывается в момент, когда необходимо в общее представление приложения вставить представление компонента.
Обязательным аргументом функции view должна быть модель компонента. При необходимости перечень аргументов можно дополнить.
Результат выполнения функции view должен быть передан в функцию Html.map.
Интеграция в приложение
В примере описано два компонента: Auth и Question. Компоненты описанным выше принципам. Рассмотрим каким образом они могут быть интегрированы в приложение.
Для начала определим то, как наше приложение должно работать. На экране имеется кнопка, при нажатию на которую:
- для неавторизованного пользователя отображается форма авторизации, после авторизации — форма размещения вопроса;
- для авторизованного пользователя отображается форма размещения вопроса.
Для описания приложения необходимы:
- модель (Model);
- сообщения (Msg);
- точку старта приложения (main);
- функция инициализации (init);
- функция мутации;
- функция представления;
- функция подписки.
Модель
type alias Model =
{ user: User
, ui: Maybe Ui
}
type Ui
= AuthUi Component.Auth.Model
| QuestionUi Component.Question.Model
Модель содержит информацию о пользователе (user) и типе текущего интерфейса (ui). Интерфейс может быть либо в состоянии по умолчанию (Nothing), либо одним из компонентов Just a.
Для описания компонентов мы используем тип Ui, который связывает (тегирует) каждую модель компонента с конкретным вариантом из множества типа. Например, тег AuthUi связывает модель авторизации (Component.Auth.Model) с моделью приложения.
Сообщения
type Msg
= OpenPopup
| AuthMsg Component.Auth.Msg
| QuestionMsg Component.Question.Msg
В сообщениях необходимо тегировать все сообщения компонентов и включить их в сообщения приложения. Тег AuthMsg и QuestionMsg связывают сообщения компонента авторизации и задания вопроса пользователем соответственно.
Сообщение OpenPopup необходимо для обработки запроса на открытие интерфейса.
Функция main
main : Program Never Model Msg
main =
Html.program
{ init = init
, update = update
, subscriptions = subscriptions
, view = view
}
Входная точка приложения описана типично для Elm-приложения.
Функция инициализации
init : ( Model, Cmd Msg )
init =
( initModel, Cmd.none )
initModel : Model
initModel =
{ user = Anonymous
, ui = Nothing
}
Функция инициализации создает стартовую модель и не требует выполнения команд.
Функция мутации
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case (msg, model.ui) of
(OpenPopup, Nothing) ->
case Component.Auth.init model.user of
(authModel, commands, Just (Component.Auth.Authenticated userData)) ->
let
(questionModel, questionCommands, _) = Component.Question.init userData
in
( { model | ui = Just <| QuestionUi questionModel, user = User userData }, Cmd.batch [Cmd.map AuthMsg commands, Cmd.map QuestionMsg questionCommands] )
(authModel, commands, _) ->
( { model | ui = Just <| AuthUi authModel }, Cmd.map AuthMsg commands )
(AuthMsg authMsg, Just (AuthUi authModel)) ->
case Component.Auth.update authMsg authModel of
(_, commands, Just (Component.Auth.Authenticated userData)) ->
let
(questionModel, questionCommands, _) = Component.Question.init userData
in
( { model | ui = Just <| QuestionUi questionModel, user = User userData }, Cmd.batch [Cmd.map AuthMsg commands, Cmd.map QuestionMsg questionCommands] )
(newAuthModel, commands, _) ->
( { model | ui = Just <| AuthUi newAuthModel }, Cmd.map AuthMsg commands )
(QuestionMsg questionMsg, Just (QuestionUi questionModel)) ->
case Component.Question.update questionMsg questionModel of
(_, commands, Just (Component.Question.Saved record)) ->
( { model | ui = Nothing }, Cmd.map QuestionMsg commands )
(newQuestionModel, commands, _) ->
( { model | ui = Just <| QuestionUi newQuestionModel }, Cmd.map QuestionMsg commands )
_ ->
( model, Cmd.none )
Т.к. модель и сообщения приложению связаны, будем обрабатывать пару сообщение (Msg) и тип интерфейса (model.ui: Ui).
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case (msg, model.ui) of
Логика работы
Если получено сообщение OpenPopup и в модели указан интерфейс по умолчанию (model.ui = Nothing), то инициализируем компонент Auth. Если компонент Auth сообщает, что пользователь авторизован — инициализируем компонент Question сохраняем в модель приложения. Иначе, сохраняем в модель приложения модель компонента.
(OpenPopup, Nothing) ->
case Component.Auth.init model.user of
(authModel, commands, Just (Component.Auth.Authenticated userData)) ->
let
(questionModel, questionCommands, _) = Component.Question.init userData
in
( { model | ui = Just <| QuestionUi questionModel, user = User userData }, Cmd.batch [Cmd.map AuthMsg commands, Cmd.map QuestionMsg questionCommands] )
(authModel, commands, _) ->
( { model | ui = Just <| AuthUi authModel }, Cmd.map AuthMsg commands )
Если получено сообщение с тегом AuthMsg a и в модели указан интерфейс авторизации (model.ui = Just (AuthUi authModel)), то передаем сообщение компонента и модель компонента в функцию Auth.update. В результате получим новую модель компонента, команды и результат.
Если пользователь авторизован инициализируем компонент Question, иначе обновляем данные об интерфейса в модели приложения.
(AuthMsg authMsg, Just (AuthUi authModel)) ->
case Component.Auth.update authMsg authModel of
(_, commands, Just (Component.Auth.Authenticated userData)) ->
let
(questionModel, questionCommands, _) = Component.Question.init userData
in
( { model | ui = Just <| QuestionUi questionModel, user = User userData }, Cmd.batch [Cmd.map AuthMsg commands, Cmd.map QuestionMsg questionCommands] )
(newAuthModel, commands, _) ->
( { model | ui = Just <| AuthUi newAuthModel }, Cmd.map AuthMsg commands )
Аналогичным компоненту Auth образом обрабатываются сообщения для компонента Question. В случае успешного размещения вопроса, интерфейс меняется на по умолчанию (model.ui = Nothing).
(QuestionMsg questionMsg, Just (QuestionUi questionModel)) ->
case Component.Question.update questionMsg questionModel of
(_, commands, Just (Component.Question.Saved record)) ->
( { model | ui = Nothing }, Cmd.map QuestionMsg commands )
(newQuestionModel, commands, _) ->
( { model | ui = Just <| QuestionUi newQuestionModel }, Cmd.map QuestionMsg commands )
Все остальные случаи игнорируются.
_ ->
( model, Cmd.none )
Функция представления
view : Model -> Html Msg
view model =
case model.ui of
Nothing ->
div []
[ div []
[ button
[ Events.onClick OpenPopup ]
[ text "Open popup" ]
]
]
Just (AuthUi authModel) ->
Component.Auth.view authModel
|> Html.map AuthMsg
Just (QuestionUi questionModel) ->
Component.Question.view questionModel
|> Html.map QuestionMsg
Функция представления в зависимости от типа интерфейса (model.ui) генерирует либо интерфейс по умолчанию, либо вызывает функцию представления компонента и отображает тип сообщения компонента в тип сообщения приложения (Html.map).
Функция подписки
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
Подписка отсутствует.
Далее
Данный пример хоть и чуть удобнее, но достаточно наивный. Чего не хватает:
- блокировка взаимодействия с приложением в процессе загрузки;
- валидация данных. Требует отдельного разговора;
- действительно выпадающее окно с возможностью закрыть.