Elm. Удобный и неловкий. Http, Task

habr.png

Продолжим говорить о Elm 0.18.

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

В этой статье рассмотрим вопросы взаимодействия с серверной частью.


Выполнение запросов

Примеры простых запросов можно найти в описании к пакету Http.

Тип запроса — Http.Request a.
Тип результата запроса — Result Http.Error a.
Оба типа параметризуется пользовательским типом, декодер которого должен быть указан при формировании запроса.

Выполнить запрос можно при помощи функций:


  1. Http.send;
  2. Http.toTask.

Http.send позволять выполнить запрос и по его завершению передает сообщение в функцию update указанное в первом аргументе. Сообщение несет с собой данные о результате запроса.

Http.toTask позволяет из запроса создать Task, который можно выполнить. Использование функции Http.toTask, на мой взгляд, является наиболее удобной, так как экземпляры Task можно объединяться между собой при помощи различных функций, например Task.map2.

Рассмотрим на примере. Допустим, для сохранения данных пользователя необходимо выполнить два последовательных зависимых запроса. Пусть это будет создание поста от пользователя и сохранение фотографий к нему (используется некий CDN).

Сначала рассмотрим реализацию для случая Http.Send. Для этого нам понадобятся две функции:

save : UserData -> Request Http.Error UserData
save userData =
  Http.post "/some/url” (Http.jsonBody (encodeUserData userData)) decodeUserData

saveImages : Int -> Images -> Request Http.Error CDNData
saveImages id images =
  Http.post ("/some/cdn/for/” ++ (toString id)) (imagesBody images) decodedCDNData

Типы UserData и CDNData описывать не будет, для примера не важны. Функция encodeUserData является энкодером. saveImages принимает идентификатор пользовательских данных, который используется при формировании адреса, и список фотографий. Функция imagesBody формирует тело запроса типа multipart/form-data. Функции decodeUserData и decodedCDNData декодируют ответ сервера для пользовательских данных и результат запроса к CDN соответственно.

Далее нам понадобятся два сообщения, результаты запроса:

type Msg
  = DataSaved (Result Http.Error UserData)
  | ImagesSaved (Result Http.Error CDNData)

Предположим, где-то в реализации функции update существует участок кода, который выполняет сохранение данных. Например, это может выглядеть так:

update : Msg -> Model -> (Model, Cmd Msg)
update msg model
  case Msg of

    ClickedSomething ->
      (model, Http.send DataSaved (save model.userData))

В данном случае создается запрос и помечается сообщением DataSaved. Далее это сообщение принимается:

update : Msg -> Model -> (Model, Cmd Msg)
update msg model
  case Msg of

    DataSaved (Ok userData) ->
      ( {model | userData = userData}, Http.send  ImagesSaved (saveImages userData.id model.images))

    DataSaved (Err reason) ->
      (model, Cmd.None)

В случае успешного сохранения, обновляем данные в модели и вызываем запрос на сохранение фотографий куда передаем полученный идентификатор пользовательских данных. Обработка сообщения ImagesSaved будет аналогична DataSaved, необходимо будет обработать успешный и провальный случаи.

Теперь рассмотрим вариант с использование функции Http.toTask. Используя описанные функции определим новую функцию:

saveAll : UserData -> Images -> Task Http.Error (UserData, CDNData)
saveAll : userData images =
  save model.userData
    |> Http.toTask
    |> Task.andThen (\newUserData ->
      saveImages usersData.id images 
        |> Http.toTask
        |> Task.map (\newImages -> 
           (userData, newImages)
        }
    )

Теперь используя возможность комбинировать задачи, можем получить все данные в виде одного сообщения:

type Msg
  = Saved (Result Http.Error (UserData, CDNData))

update : Msg -> Model -> (Model, Cmd Msg)
update msg model
  case Msg of

    ClickedSomething ->
      (model, Task.attempt Saved (saveAll model.userData model.images))

   DataSaved (Ok (userData, images)) ->
      ( {model | userData = userData, images = images}, Cmd.none)

    DataSaved (Err reason) ->
      (model, Cmd.None)

Для выполнения запросов используем функцию Task.attempt, которая позволяет выполнить задачу. Не стоит путать с функцией Task.perform. Task.perform — позволяет выполнить задачи, которые не могут провалиться. Task.attempt — выполняет задачи, которые могут провалиться.

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

В своих проектах, в приложениях и компонентах часто создаю модуль Commands.elm, в котором описываю функции взаимодействия с серверной частью с типом … → Task Http.Error a.


Состояние выполнения запросов

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


  1. запрос не выполнен;
  2. запрос выполняется;
  3. запрос выполнен успешно;
  4. запрос провален.

Для подобного описания существует пакет RemoteData. По началу активно его использовал, но со временем наличие дополнительного типа WebData стало излишним, а работа с ним утомительной. Вместо этого пакета появились следующие правила:


  1. все данные от сервера объявлять типом Maybe. В этом случае Nothing, обозначает отсутствие данных;
  2. объявлять в модели приложения или компоненты атрибут loading типа Int. Параметр хранит количество выполняемых запросов. Единственное неудобство этого подхода, необходимость инкрементировать и декрементировать атрибут в начале запроса и по завершению соответственно;
  3. объявлять в модели приложения или компоненты атрибут errors типа List String. Данный атрибут используется для хранения данных об ошибке.

Описанная схема не сильно лучше варианта с пакетом RemoteData, как показывает практика. Если у кого-то есть иные варианты, поделитесь в комментариях.

К состоянию выполнения запроса стоит отнести прогресс загрузки из пакета Http.Progress.


Последовательность задач

Рассмотрим варианты последовательностей задач, которые часто встречаются в разработке:


  1. последовательные зависимые задачи;
  2. последовательные независимые задачи;
  3. параллельные независимые задачи.

Последовательные зависимые задачи уже рассматривался выше, приведу в этом разделе общее описание и подходы к реализации.

Последовательность задач прерывается при первом провале и возвращается ошибка. В случае успеха возвращается некоторая комбинация результатов:

someTaskA
  |> Task.andThen (\resultA ->
    someTaskB 
      |> Task.map (\resultB ->
        (resultA, resultB)
      )
  )

Данный код создает задачу типа Task error (a, b), которая может быть выполнена позже.

Функция Task.andThen позволяет передать новую задачу на выполнение в случае успешного завершения предыдущей. Функция Task.map позволяет преобразовать дынные результата выполения в случае успеха.

Возможны варианты, когда успешного выполнения задаче будет недостаточно и необходимо проверить согласованность данных. Допустим, что идентификаторы пользователей совпадают:

someTaskA
  |> Task.andThen (\resultA ->
    someTaskB 
      |> Task.andThen (\resultB ->
        case resultA.userId == resultB.userId of
          True -> 
            Task.succeed (resultA, resultB)

          False -> 
            Task.fail "User is not the same”
      )
  )

Стоит отметить, что вместо функции Task.map используется функция Task.andThen и успешность выполнения второй задачи мы определяем самостоятельно при помощи функций Task.succeed и Task.fail.

Если одна из задач может провалиться и это приемлемо, то необходимо использовать функцию Task.onError для указания значения в случае ошибки:

someTaskA
  |> Task.onError (\msg -> Task,succeed defaultValue)
  |> Task.andThen (\resultA ->
    someTaskB 
      |> Task.map (\resultB ->
        (resultA, resultB)
      )
  )

Вызов функции Task.onError должен быть объявлен непосредственно после объявления задачи.

Последовательные независимые запросы можно выполнять при помощи функций Task.mapN. Которые позволяют объединить несколько результатов задач в один. Первая упавшая задача прерывает выполнение всей цепочки, поэтому для значений по умолчанию используйте функцию Task.onError. Также ознакомьтесь с функцией Task.sequence, она позволяет выполнить серию однотипных задач.

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

© Habrahabr.ru