Elm. Удобный и неловкий. Композиция +8


Продолжим говорить о 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)

Очевидно, у данного подхода возможны проблемы с ростом функций приложения:


  1. отсутствует группировка данных в модели и сообщений. Все лежит в одной плоскости. Таким образом отсутствуют границы компонентов, изменение логики одной части вероятнее всего затронет остальные;
  2. повторное использование кода возможно по принципу copy-paste со всеми вытекающими последствиями.

Удобная композиция


Исходный код удобной реализации. В рамках этой реализации попробуем разделить проект на самостоятельные компоненты. Допустимы зависимости между компонентами.


Структура проекта:


  1. в папке Type объявлены пользовательские типы;
  2. в папке Component объявлены пользовательские компонента;
  3. файл Main.elm входная точка проекта;
  4. файлы login.json и questions.json используются в качестве тестовых данных ответа сервера на авторизацию и сохранение информации о вопросе соответственно.

Пользовательские компоненты


Каждый компонент, исходя из архитектуры языка, должен содержать:


  1. модель (Model);
  2. сообщения (Msg);
  3. результат выполнения (Return);
  4. функцию инициализации (init);
  5. функция мутации (update);
  6. функцию представления (view).

Каждый компонент может содержать подписку (subscription) в случае необходимости.


image
Рис. 1. Диаграмма активности компонента


Инициализация


Каждый компонент должен быть инициирован, т.е. должны быть получены:


  1. модель;
  2. команда или список команд, которые должны инициализировать состояние компонента;
  3. результат выполнения. Результат выполнения в момент инициализации может понадобиться допустим для проверки авторизации пользователя, как в примерах к данной статье.

Перечень аргументов функции инициализации (init) зависит от логики работы компонента и может быть произвольным. Функций инициализации может быть несколько. Допустим, для компонента авторизации может быть предусмотрено два варианта инициализации: с токеном сессии и с данными пользователя.


Код, использующий компонент, после инициализации должен передать команды в elm runtime при помощи функции Cmd.map.


Мутация


Функция компонента update должна быть вызвана для каждого сообщения компонента. В качестве результата выполнения функция возвращает тройку:


  1. новую модель или новое состояние (Model);
  2. команду или список команд для Elm runtime (Cmd Msg). В качестве команд могут быть команды на выполнение HTTP-запросов, взаимодействие с портами и прочее;
  3. результат выполнения (Maybe Return). Тип Maybe имеет два состояния Nothing и Just a. В нашем случае, Nothing — результата отсутствует, Just a — результат имеется. Например, для авторизации результатом может быть Just (Authenticated UserData) — пользователь авторизован с данными UserData.

Код, использующий компонент, после мутации должен обновить модель компонента и передать команды в Elm runtime при помощи функции Cmd.map.


Обязательные аргументы функции update, в соответствии с архитектурой Elm приложений:


  1. сообщение (Msg);
  2. модель (Model).

При необходимости перечень аргументов можно дополнить.


Представление


Функция представления (view) вызывается в момент, когда необходимо в общее представление приложения вставить представление компонента.


Обязательным аргументом функции view должна быть модель компонента. При необходимости перечень аргументов можно дополнить.


Результат выполнения функции view должен быть передан в функцию Html.map.


Интеграция в приложение


В примере описано два компонента: Auth и Question. Компоненты описанным выше принципам. Рассмотрим каким образом они могут быть интегрированы в приложение.


Для начала определим то, как наше приложение должно работать. На экране имеется кнопка, при нажатию на которую:


  1. для неавторизованного пользователя отображается форма авторизации, после авторизации — форма размещения вопроса;
  2. для авторизованного пользователя отображается форма размещения вопроса.

Для описания приложения необходимы:


  1. модель (Model);
  2. сообщения (Msg);
  3. точку старта приложения (main);
  4. функция инициализации (init);
  5. функция мутации;
  6. функция представления;
  7. функция подписки.

Модель


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

Подписка отсутствует.


Далее


Данный пример хоть и чуть удобнее, но достаточно наивный. Чего не хватает:


  1. блокировка взаимодействия с приложением в процессе загрузки;
  2. валидация данных. Требует отдельного разговора;
  3. действительно выпадающее окно с возможностью закрыть.

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (3):

  1. easimonenko
    /#19155965

    Стоит где-то в начале статьи написать, что речь идёт об Elm 0.18.

  2. Ilgrim
    /#19156555

    Правда, спасибо!
    Довно ждал статей по Elm'у :)