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

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

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

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?

1 ответ

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

Первое, на что стоит обратить внимание, это то, что модуль circe-shape предоставляет экземпляры для Shapeless HLists, которые используют представление массива, подобное тому, которое мы хотим для наших 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 связывать только нужные нам экземпляры из круговых форм вместе с нашими пользовательскими экземплярами класса 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 выше, и идея та же: мы определяем экземпляры для нашего типа данных (классы дел или 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, независимо от того, сколько у нее классов дел и сколько у них членов этих классов дел (хотя время компиляции начнет ухудшаться, как только вы попадете в десятки), предполагая, что все типы членов имеют представления JSON.

Другие вопросы по тегам