Elm. Удобный и неловкий. Json.Encoder и Json.Decoder

habr.png

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

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

В этой статье рассмотрим вопросы энкодеров/декодеров.

Декодеры/энкодеры используются для:


  1. преобразование ответов от сторонних ресурсов (Http, WebSocket и прочее);
  2. взаимодействия через порты. Подробнее про порты и нативный код расскажу в следующих статьях.

Как было описано ранее, Elm требует от нас обязательного преобразования внешних данных во внутренние типы приложения. За данный процесс отвечает модуль Json.Decode. Обратный процесс — Json.Encode.

Тип определяющий правила декодирования — Json.Decode.Decoder a. Данный тип параметризуется пользовательским типом и определяет каким образом из JSON объекта получить пользовательский тип a.

Для энкодера определен только тип результата — Json.Encode.Value.

Рассмотрим примеры для типа UserData.

type alias User =
  { id: Int
  , name: String
  , email: String
  }

Декодер для получения данных от пользователя:

decodeUserData : Json.Decode.Decoder UserData
decodeUserData =
  Json.Decode.map3 UserData
    (Json.Decode.field "id” Json.Decode.int)
    (Json.Decode.field "name” Json.Decode.string)
    (Json.Decode.field "email” Json.Decode.string)

encodeUserData : UserData -> Json.Encode.Value
encodeUserData userData =
  Json.Encode.object
    [ ( "id”, Json.Encode.int userData.id)
    , ( "name”, Json.Encode.string userData.name)
    , ( "email”, Json.Encode.string userData.email)
    ]

Функция Json.Decode.map3 принимает конструктора типа UserData. Далее передаются три декодера типа в соответствии с порядок их объявления в пользовательском типе UserData.

Функция decodeUserData может быть использована совместно с функциями Json.Decode.decodeString или Json.Decode.decodeValue. Пример использования из предыдущих статей.

Функция encodeUserData производит кодирование пользовательского типа в тип Json.Encode.Value, который может быть отправлен наружу. По простому, Json.Encode.Value соответствует JSON объекту.

Простые варианты описаны в документации, их можно изучить без особых трудностей. Давайте рассмотрим жизненные случаи, которые требуют некоторой ловкости пальцев.


Декодеры Union типов или дискриминаторы типов

Предположим, у нас есть каталог товаров. И каждый товар может иметь произвольное количество атрибутов, каждый из которых имеет тип один из множества:


  1. целое число;
  2. строка;
  3. перечислимое. Предполагает выбор одного из допустимых значений.

JSON объект допустим следующего вида:

{
  "id”: 1,
  "name”: "Product name”,
  "price”: 1000,
  "attributes”: [
    {
      "id”: 1,
      "name”: "Length”,
      "unit”: "meters”,
      "value”: 100
    }, 
    {
      "id”: 1,
      "name”: "Color”,
      "unit”: "”,
      "value”: {
        "id”: 1,
        "label”: "red”
      }
    },...
  ]
}

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

type alias Product = 
  { id: Int
  , name: String
  , price: Int
  , attributes: Attributes
  }

type alias Attributes = List Attribute

type alias Attribute = 
  { id: Int
  , name: String
  , unit: String
  , value: AttributeValue
  }

type AttributeValue
  = IntValue Int
  | StringValue String
  | EnumValue Enum

type alias Enum = 
  { id: Int
  , label: String
  }

Слегка обсудим описанные типы. Есть товар (Product), который содержит список атрибутов/характеристик (Attributes). Каждый атрибут (Attribute) содержит идентификатор, наименование, размерность и значение. Значение атрибута описано как union type, по одному элементу для каждого типа значения характеристики. Тип Enum описывает одно значение из допустимого множества и содержит: идентификатор и человеко читаемое значение.

Описание декодера, префикс Json.Decode опустим для краткости:

decodeProduct : Decoder Product
decodeProduct =
  map4 Product
    (field "id” int)
    (field "name” string)
    (field "price” int)
    (field "attributes” decodeAttributes)

decodeAttributes : Decoder Attributes
decodeAttributes =
  list decodeAttribute

decodeAttribute : Decoder Attribute
decodeAttribute = 
  map4 Attribute
   (field "id” int)
   (field "name” string)
   (field "unit” string)
   (field "value” decodeAttributeValue)

decodeAttributeValue : Decoder AttributeValue
decodeAttributeValue =
  oneOf 
    [ map IntValue int
    , map StringValue string
    , map EnumValue decodeEnumValue
    ]

decodeEnumValue : Decoder Enum
decodeEnumValue = 
  map2 Enum
    (field "id” int)
    (field "label” string)

Весь трюк содержится в функции decodeAttributeValue. При помощи функции Json.Decode.oneOf перебираются все допустимые декодеры для значения атрибута. В случае успешной распаковки одним из декодоров, значение тегируется соответствующим тегом из типа AttributeValue.

Кодирование типа Product, может быть выполнено при помощи функции Json.Encode.object, в которую будут переданы закодированные атрибуты типа. Стоит уделить внимание кодированию типа AttributeValue. В соответствии с описанным ранее JSON объектом, энкодер может быть описан как, префикс Json.Encode опустим для краткости:

encodeAttributeValue : AttributeValue -> Value
encodeAttributeValue attributeValue = 
  case attributeValue of
    IntValue value -> 
      int value

    StringValue value -> 
      string value

    EnumValue value ->
      object
        [ ("id”, int value.id)
        , ("id”, string value.label)
        ]

Как видно, сопоставляем варианты типа и используем соответствующие энкодеры.

Изменим описание атрибутов и определим их с использование дискриминатора типа. JSON объект атрибута, в этом случае, имел бы следующий вид:

{
   "id”: 1,
   "name”: "Attribute name”,
   "type”: "int”,
   "value_int”: 1,
   "value_string”: null,
   "value_enum_id”: null,
   "value_enum_label”: null
}

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

decodeAttribute2 : Decoder Attribute
decodeAttribute2 =
 field "type" string
  |> andThen decodeAttributeValueType
  |> andThen (\attributeValue ->
     map4 Attribute
        (field "id" int)
        (field "name" string)
        (field "unit" string)
        (succeed attributeValue)
  )

decodeAttributeValueType : String -> Decoder AttributeValue
decodeAttributeValueType valueType =
 case valueType of
   "int" ->
     field "value_int" int
       |> Json.Decode.map IntValue

   "string" ->
     field "value_string" string
       |> Json.Decode.map StringValue

   "enum" ->
     map2 Enum
       (field "value_enum_id" int)
       (field "value_enum_label" string)
       |> Json.Decode.map EnumValue

   _ ->
     Json.Decode.fail "Unknown attribute type"

В функции decodeAttribute2 сначала декодируем дискриминатор, в случае успеха — декодируем значение атрибута. Далее декодируем оставшиеся поля типа Attribute, а в качестве значения поля value указываем ранее полученное значение.

Исходный код декодера.


Частичное обновление типа

Встречаются случаи, когда API возвращает не весь объект, а лишь его часть. Например, при регистрации просмотра или смене статуса объекта. В этом случае в сообщении удобнее сразу получать обновленный объект, а все манипуляции скрыть за декодером.

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

type alias Product = 
  { id: Int
  , name: String
  , price: Int
  , attributes: Attributes
  , status: Int
  }

decodeUpdateStatus : Product -> Decoder Product
decodeUpdateStatus product = 
  field "status” int
    |> andThen (\newStatus ->
      succeed { product | status = newStatus}
    )

Или можно использовать функцию Json.Decode.map.

decodeUpdateStatus : Product -> Decoder Product
decodeUpdateStatus product = 
  field "status” int
    |> map (\newStatus ->
      { product | status = newStatus}
    )


Дата и время

Будем использовать функцию Date.fromString, которая реализована при помощи конструктора типа Date.

decodeDateFromString : Decoder Date.Date
decodeDateFromString = 
  string
    |> andThen (\stringDate ->
      case Date.fromString stringDate of
        Ok date -> Json.Decode.succeed date
        Err reason -> Json.Decode.fail reason
    )

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

decodeDateFromTimestamp : Decoder Date.Date
decodeDateFromTimestamp = 
  oneOf
    [ int 
        |> Json.Decode.map toFloat
    , float  ]
    |> Json.Decode.map Date.fromTime

© Habrahabr.ru