Общий вывод для ADT в Scala с настраиваемым представлением

Я перефразирую вопрос канала Цирце Гиттер здесь.

Предположим, у меня есть запечатанная иерархия черт (или ADT) Scala, подобная этой:

sealed trait Item
case class Cake(flavor: String, height: Int) extends Item
case class Hat(shape: String, material: String, color: String) extends Item

… И я хочу иметь возможность выполнять сопоставление между этим ADT и представлением JSON, как показано ниже:

{ "tag": "Cake", "contents": ["cherry", 100] }
{ "tag": "Hat", "contents": ["cowboy", "felt", "black"] }

По умолчанию общий вывод circe использует другое представление:

scala> val item1: Item = Cake("cherry", 100)
item1: Item = Cake(cherry,100)

scala> val item2: Item = Hat("cowboy", "felt", "brown")
item2: Item = Hat(cowboy,felt,brown)

scala> import io.circe.generic.auto._, io.circe.syntax._
import io.circe.generic.auto._
import io.circe.syntax._

scala> item1.asJson.noSpaces
res0: String = {"Cake":{"flavor":"cherry","height":100}}

scala> item2.asJson.noSpaces
res1: String = {"Hat":{"shape":"cowboy","material":"felt","color":"brown"}}

Мы можем немного приблизиться с помощью circe-generic-extras:

import io.circe.generic.extras.Configuration
import io.circe.generic.extras.auto._

implicit val configuration: Configuration =
   Configuration.default.withDiscriminator("tag")

А потом:

scala> item1.asJson.noSpaces
res2: String = {"flavor":"cherry","height":100,"tag":"Cake"}

scala> item2.asJson.noSpaces
res3: String = {"shape":"cowboy","material":"felt","color":"brown","tag":"Hat"}

… Но это все еще не то, что мы хотим.

Как лучше всего использовать circe для создания подобных экземпляров в общем для ADT в Scala?


person Travis Brown    schedule 31.08.2018    source источник


Ответы (1)


Представление классов case в виде массивов JSON

Первое, что следует отметить, это то, что модуль circe-shape предоставляет экземпляры для HLists Shapeless, которые используют представление массива, подобное тому, которое мы хотим для наших классов case. Например:

scala> import io.circe.shapes._
import io.circe.shapes._

scala> import shapeless._
import shapeless._

scala> ("foo" :: 1 :: List(true, false) :: HNil).asJson.noSpaces
res4: String = ["foo",1,[true,false]]

… И сам Shapeless обеспечивает общее сопоставление между классами case и HLists. Мы можем объединить эти два, чтобы получить общие экземпляры, которые нам нужны для классов case:

import io.circe.{ Decoder, Encoder }
import io.circe.shapes.HListInstances
import shapeless.{ Generic, HList }

trait FlatCaseClassCodecs extends HListInstances {
  implicit def encodeCaseClassFlat[A, Repr <: HList](implicit
    gen: Generic.Aux[A, Repr],
    encodeRepr: Encoder[Repr]
  ): Encoder[A] = encodeRepr.contramap(gen.to)

  implicit def decodeCaseClassFlat[A, Repr <: HList](implicit
    gen: Generic.Aux[A, Repr],
    decodeRepr: Decoder[Repr]
  ): Decoder[A] = decodeRepr.map(gen.from)
}

object FlatCaseClassCodecs extends FlatCaseClassCodecs

А потом:

scala> import FlatCaseClassCodecs._
import FlatCaseClassCodecs._

scala> Cake("cherry", 100).asJson.noSpaces
res5: String = ["cherry",100]

scala> Hat("cowboy", "felt", "brown").asJson.noSpaces
res6: String = ["cowboy","felt","brown"]

Обратите внимание, что я использую io.circe.shapes.HListInstances, чтобы связать только нужные нам экземпляры из Circe-shape вместе с нашими экземплярами пользовательского класса case, чтобы минимизировать количество вещей, которые наши пользователи должны импортировать (как с точки зрения эргономики, так и с точки зрения эргономики). ради сокращения времени компиляции).

Кодирование общего представления наших ADT

Это хороший первый шаг, но он не дает нам того представления, которое мы хотим для самого Item. Для этого нам понадобится более сложное оборудование:

import io.circe.{ JsonObject, ObjectEncoder }
import shapeless.{ :+:, CNil, Coproduct, Inl, Inr, Witness }
import shapeless.labelled.FieldType

trait ReprEncoder[C <: Coproduct] extends ObjectEncoder[C]

object ReprEncoder {
  def wrap[A <: Coproduct](encodeA: ObjectEncoder[A]): ReprEncoder[A] =
    new ReprEncoder[A] {
      def encodeObject(a: A): JsonObject = encodeA.encodeObject(a)
    }

  implicit val encodeCNil: ReprEncoder[CNil] = wrap(
    ObjectEncoder.instance[CNil](_ => sys.error("Cannot encode CNil"))
  )

  implicit def encodeCCons[K <: Symbol, L, R <: Coproduct](implicit
    witK: Witness.Aux[K],
    encodeL: Encoder[L],
    encodeR: ReprEncoder[R]
  ): ReprEncoder[FieldType[K, L] :+: R] = wrap[FieldType[K, L] :+: R](
    ObjectEncoder.instance {
      case Inl(l) => JsonObject("tag" := witK.value.name, "contents" := (l: L))
      case Inr(r) => encodeR.encodeObject(r)
    }
  )
}

Это говорит нам, как кодировать экземпляры Coproduct, которые Shapeless использует как общее представление запечатанных иерархий признаков в Scala. Поначалу код может показаться пугающим, но это очень распространенный шаблон, и если вы потратите много времени на работу с Shapeless, вы поймете, что 90% этого кода, по сути, является шаблоном, который вы видите каждый раз, когда создаете экземпляры, как это индуктивно.

Расшифровка этих копродуктов

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

import io.circe.{ DecodingFailure, HCursor }
import shapeless.labelled.field

trait ReprDecoder[C <: Coproduct] extends Decoder[C]

object ReprDecoder {
  def wrap[A <: Coproduct](decodeA: Decoder[A]): ReprDecoder[A] =
    new ReprDecoder[A] {
      def apply(c: HCursor): Decoder.Result[A] = decodeA(c)
    }

  implicit val decodeCNil: ReprDecoder[CNil] = wrap(
    Decoder.failed(DecodingFailure("CNil", Nil))
  )

  implicit def decodeCCons[K <: Symbol, L, R <: Coproduct](implicit
    witK: Witness.Aux[K],
    decodeL: Decoder[L],
    decodeR: ReprDecoder[R]
  ): ReprDecoder[FieldType[K, L] :+: R] = wrap(
    decodeL.prepare(_.downField("contents")).validate(
      _.downField("tag").focus
        .flatMap(_.as[String].right.toOption)
        .contains(witK.value.name),
      witK.value.name
    )
    .map(l => Inl[FieldType[K, L], R](field[K](l)))
    .or(decodeR.map[FieldType[K, L] :+: R](Inr(_)))
  )
}

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

Наше представительство ADT

Теперь мы можем все это объединить:

import shapeless.{ LabelledGeneric, Lazy }

object Derivation extends FlatCaseClassCodecs {
  implicit def encodeAdt[A, Repr <: Coproduct](implicit
    gen: LabelledGeneric.Aux[A, Repr],
    encodeRepr: Lazy[ReprEncoder[Repr]]
  ): ObjectEncoder[A] = encodeRepr.value.contramapObject(gen.to)

  implicit def decodeAdt[A, Repr <: Coproduct](implicit
    gen: LabelledGeneric.Aux[A, Repr],
    decodeRepr: Lazy[ReprDecoder[Repr]]
  ): Decoder[A] = decodeRepr.value.map(gen.from)
}

Это очень похоже на определения в нашем FlatCaseClassCodecs выше, и идея та же: мы определяем экземпляры для нашего типа данных (либо классы case, либо ADT), опираясь на экземпляры для общих представлений этих типов данных. Обратите внимание, что я расширяю FlatCaseClassCodecs, чтобы минимизировать импорт для пользователя.

в действии

Теперь мы можем использовать эти экземпляры следующим образом:

scala> import Derivation._
import Derivation._

scala> item1.asJson.noSpaces
res7: String = {"tag":"Cake","contents":["cherry",100]}

scala> item2.asJson.noSpaces
res8: String = {"tag":"Hat","contents":["cowboy","felt","brown"]}

… Что именно то, что мы хотели. И самое приятное то, что это будет работать для любой запечатанной иерархии черт в Scala, независимо от того, сколько в ней классов case или сколько членов имеют эти классы case (хотя время компиляции начнет ухудшаться, когда вы попадете в десятки из них). ), предполагая, что все типы членов имеют представления JSON.

person Travis Brown    schedule 31.08.2018
comment
будет ли это работать, если в классах случаев есть одно или несколько необязательных полей (или пар ключ / значение)? - person Andriy Plokhotnyuk; 07.09.2018
comment
@AndriyPlokhotnyuk К сожалению, вам понадобится пара изменений, чтобы убедиться, что вы используете стандартный Option кодировщик и декодер вместо получения одного с помощью этого механизма (поскольку Option - это просто запечатанная иерархия черт, это именно то, что мы пытаемся получить экземпляры для). Я скоро обновлю ответ, чтобы показать, как вы это сделаете. - person Travis Brown; 07.09.2018