Elm. Удобный и неловкий. Композиция
- четверг, 27 сентября 2018 г. в 00:18:48
Продолжим говорить о Elm 0.18.
Elm. Удобный и неловкий
Elm. Удобный и неловкий. Json.Encoder и Json.Decoder
В этой статье рассмотрим вопросы архитектуры 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)
Очевидно, у данного подхода возможны проблемы с ростом функций приложения:
Исходный код удобной реализации. В рамках этой реализации попробуем разделить проект на самостоятельные компоненты. Допустимы зависимости между компонентами.
Структура проекта:
Каждый компонент, исходя из архитектуры языка, должен содержать:
Каждый компонент может содержать подписку (subscription) в случае необходимости.
Рис. 1. Диаграмма активности компонента
Каждый компонент должен быть инициирован, т.е. должны быть получены:
Перечень аргументов функции инициализации (init) зависит от логики работы компонента и может быть произвольным. Функций инициализации может быть несколько. Допустим, для компонента авторизации может быть предусмотрено два варианта инициализации: с токеном сессии и с данными пользователя.
Код, использующий компонент, после инициализации должен передать команды в elm runtime при помощи функции Cmd.map.
Функция компонента update должна быть вызвана для каждого сообщения компонента. В качестве результата выполнения функция возвращает тройку:
Код, использующий компонент, после мутации должен обновить модель компонента и передать команды в Elm runtime при помощи функции Cmd.map.
Обязательные аргументы функции update, в соответствии с архитектурой Elm приложений:
При необходимости перечень аргументов можно дополнить.
Функция представления (view) вызывается в момент, когда необходимо в общее представление приложения вставить представление компонента.
Обязательным аргументом функции view должна быть модель компонента. При необходимости перечень аргументов можно дополнить.
Результат выполнения функции view должен быть передан в функцию Html.map.
В примере описано два компонента: Auth и Question. Компоненты описанным выше принципам. Рассмотрим каким образом они могут быть интегрированы в приложение.
Для начала определим то, как наше приложение должно работать. На экране имеется кнопка, при нажатию на которую:
Для описания приложения необходимы:
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 : 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
Подписка отсутствует.
Данный пример хоть и чуть удобнее, но достаточно наивный. Чего не хватает: