Общий вывод для 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 HList
s, которые используют представление массива, подобное тому, которое мы хотим для наших 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 и HList
s. Мы можем объединить эти два, чтобы получить общие экземпляры, которые мы хотим для 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.