Vraag Hoe een ADT met circe te decoderen zonder objecten te disambigueren


Stel dat ik zo'n ADT heb:

sealed trait Event

case class Foo(i: Int) extends Event
case class Bar(s: String) extends Event
case class Baz(c: Char) extends Event
case class Qux(values: List[String]) extends Event

De standaard generieke afleiding voor een Decoder[Event] bijvoorbeeld in Circe verwacht dat de ingevoerde JSON een wrapper-object bevat dat aangeeft welke casusklasse wordt weergegeven:

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

scala> decode[Event]("""{ "i": 1000 }""")
res0: Either[io.circe.Error,Event] = Left(DecodingFailure(CNil, List()))

scala> decode[Event]("""{ "Foo": { "i": 1000 }}""")
res1: Either[io.circe.Error,Event] = Right(Foo(1000))

scala> (Foo(100): Event).asJson.noSpaces
res2: String = {"Foo":{"i":100}}

Dit gedrag betekent dat we ons nooit zorgen hoeven te maken over dubbelzinnigheden als twee of meer hoofdletters dezelfde namen hebben, maar dat is niet altijd wat we willen. Soms weten we dat de niet-omwikkelde codering ondubbelzinnig is, of willen we disambigueren door de bestelling te specificeren. elke casusklasse moet worden geprobeerd of het maakt ons gewoon niet uit.

Hoe kan ik mijn coderen en decoderen Event ADT zonder het omhulsel (bij voorkeur zonder dat ik mijn encoders en decoders helemaal opnieuw hoef te schrijven)?

(Deze vraag komt vrij vaak naar voren-zie b.v. deze discussie met Igor Mazor op Gitter vanochtend.)


23
2018-02-10 17:38


oorsprong


antwoorden:


Opsomming van de ADT-constructeurs

De meest eenvoudige manier om de gewenste representatie te krijgen is om generieke afleiding te gebruiken voor de casusklassen, maar expliciet gedefinieerde instanties voor het ADT-type:

import cats.syntax.functor._
import io.circe.{ Decoder, Encoder }, io.circe.generic.auto._
import io.circe.syntax._

sealed trait Event

case class Foo(i: Int) extends Event
case class Bar(s: String) extends Event
case class Baz(c: Char) extends Event
case class Qux(values: List[String]) extends Event

object Event {
  implicit val encodeEvent: Encoder[Event] = Encoder.instance {
    case foo @ Foo(_) => foo.asJson
    case bar @ Bar(_) => bar.asJson
    case baz @ Baz(_) => baz.asJson
    case qux @ Qux(_) => qux.asJson
  }

  implicit val decodeEvent: Decoder[Event] =
    List[Decoder[Event]](
      Decoder[Foo].widen,
      Decoder[Bar].widen,
      Decoder[Baz].widen,
      Decoder[Qux].widen
    ).reduceLeft(_ or _)
}

Merk op dat we moeten bellen widen (die wordt geleverd door Cats's Functor syntaxis, die we met de eerste import in scope brengen) op de decoders omdat de Decoder typeklasse is niet covariant. De invariantie van circe's typeklassen is een kwestie van wat controverse (Argonaut is bijvoorbeeld gegaan van invariant naar covariant en terug), maar het heeft genoeg voordelen die het waarschijnlijk niet zal veranderen, wat betekent dat we workalounds zoals deze af en toe nodig hebben.

Het is ook vermeldenswaard dat onze expliciete Encoder en Decoder instanties hebben voorrang op de generieke afgeleide instanties die we anders zouden krijgen van de io.circe.generic.auto._ importeren (zie mijn dia's hier voor enige discussie over hoe deze prioritering werkt).

We kunnen deze instanties als volgt gebruiken:

scala> import io.circe.parser.decode
import io.circe.parser.decode

scala> decode[Event]("""{ "i": 1000 }""")
res0: Either[io.circe.Error,Event] = Right(Foo(1000))

scala> (Foo(100): Event).asJson.noSpaces
res1: String = {"i":100}

Dit werkt, en als je de bestelling van de ADT-constructeurs moet kunnen specificeren, is dit momenteel de beste oplossing. Het is natuurlijk niet ideaal om de constructors op deze manier op te sommen, zelfs als we de case class-instanties gratis krijgen.

Een meer generieke oplossing

Zoals ik opmerk op Gitter, we kunnen het gedoe vermijden om alle cases weg te schrijven door de circe-shapes-module te gebruiken:

import io.circe.{ Decoder, Encoder }, io.circe.generic.auto._
import io.circe.shapes
import shapeless.{ Coproduct, Generic }

implicit def encodeAdtNoDiscr[A, Repr <: Coproduct](implicit
  gen: Generic.Aux[A, Repr],
  encodeRepr: Encoder[Repr]
): Encoder[A] = encodeRepr.contramap(gen.to)

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

sealed trait Event

case class Foo(i: Int) extends Event
case class Bar(s: String) extends Event
case class Baz(c: Char) extends Event
case class Qux(values: List[String]) extends Event

En dan:

scala> import io.circe.parser.decode, io.circe.syntax._
import io.circe.parser.decode
import io.circe.syntax._

scala> decode[Event]("""{ "i": 1000 }""")
res0: Either[io.circe.Error,Event] = Right(Foo(1000))

scala> (Foo(100): Event).asJson.noSpaces
res1: String = {"i":100}

Dit werkt voor elke ADT waar dan ook encodeAdtNoDiscr en decodeAdtNoDiscr zijn in omvang. Als we wilden dat het beperkter was, konden we de generieke vervangen A met onze ADT-typen in die definities, of we zouden de definities niet-impliciet kunnen maken en impliciete instances expliciet definiëren voor de ADT's die we op deze manier willen coderen.

Het belangrijkste nadeel van deze benadering (afgezien van de extra afhankelijkheid van circe-shapes) is dat de constructeurs in alfabetische volgorde zullen worden berecht, wat niet is wat we willen als we dubbelzinnige case-classes hebben (waarbij de namen en typen van leden identiek zijn) ).

De toekomst

De generieke extra's module biedt wat meer configureerbaarheid in dit opzicht. We kunnen het volgende schrijven, bijvoorbeeld:

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

implicit val genDevConfig: Configuration =
  Configuration.default.withDiscriminator("what_am_i")

sealed trait Event

case class Foo(i: Int) extends Event
case class Bar(s: String) extends Event
case class Baz(c: Char) extends Event
case class Qux(values: List[String]) extends Event

En dan:

scala> import io.circe.parser.decode, io.circe.syntax._
import io.circe.parser.decode
import io.circe.syntax._

scala> (Foo(100): Event).asJson.noSpaces
res0: String = {"i":100,"what_am_i":"Foo"}

scala> decode[Event]("""{ "i": 1000, "what_am_i": "Foo" }""")
res1: Either[io.circe.Error,Event] = Right(Foo(1000))

In plaats van een wrapper-object in de JSON hebben we een extra veld dat de constructor aangeeft. Dit is niet het standaardgedrag, omdat het vreemde hoeken heeft (bijvoorbeeld als een van onze hoofdcategorieën een lid heeft met de naam what_am_i), maar in veel gevallen is het redelijk en wordt het ondersteund in generieke extra's sinds die module werd geïntroduceerd.

Dit geeft ons nog steeds niet precies wat we willen, maar het is dichterbij dan het standaardgedrag. Ik heb ook overwogen om te veranderen withDiscriminator om een ​​te nemen Option[String] inplaats van een String, met None geeft aan dat we geen extra veld willen dat de constructor aangeeft, en geeft ons hetzelfde gedrag als onze instanties met circe-shapes in de vorige sectie.

Als u geïnteresseerd bent om dit te zien gebeuren, open dan alstublieft een probleem, of (nog beter) a trek aanvraag. :)


30
2018-02-10 17:38