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

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

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


    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
      0

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

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

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое