🥰

Circe の機能で様々な形態のJSONを列挙型(enum/sealed trait)として扱う

2023/09/01に公開

Scala では複数の型のどれか一つになる列挙型を sealed traitenum を使って定義できます. JSON には列挙型の概念が存在しないので、列挙型はさまざまな方法でシリアライズ・デシリアライズされることがあります.

このような enum のシリアライズ・デシリアライズをする際に Rust の serde の ADT のシリアライズ・デシリアライはとても便利です.

https://igaguri.hatenablog.com/entry/2018/12/28/120500

実は Scala の circe でも似たようなことができるんですよ? 😊

なお、以降のコードは次の環境で実行されている.

//> using scala "3.3.0"
//> using dep "io.circe::circe-core:0.14.5"
//> using dep "io.circe::circe-generic:0.14.5"
import io.circe.*
import io.circe.syntax.*

enum Member:
  case Permanent(id: Long, name: String, nickname: Option[String])
  case SingleChannel(channel_id: Long, name: String, nickname: Option[String])

完全自動導出(デフォルト)

自動導出は次に説明する Externally Tagged として Enum のシリアライズ・デシリアライズを扱う.

import io.circe.generic.auto.*
import Member.*

@main
def program() =

  val m = Permanent(1L, "foo", Some("bar"))
  val j = m.asJson // Externally Tagged な JSON にシリアライズされる.
  println(j)
  //
  // {
  //   "Permanent" : {
  //     "id" : 1,
  //     "name" : "foo",
  //     "nickname" : "bar"
  //   }
  // }
  //
  val Right(n) = j.as[Member]: @unchecked
  println(n)

Externally Tagged

circe のデフォルト.

Memberの定義(再掲)
enum Member derives Encoder.AsObject, Decoder:
  case Permanent(
    id: Long,
    name: String,
    nickname: Option[String]
  )
  case SingleChannel(
    channel_id: Long,
    name: String,
    nickname: Option[String]
  )
val m: Member = Permanent(1L, "foo", Some("bar"))
println(m.asJson)

例えば、Member.Permanent は以下のように JSON にマッピングされる. 外側に variant の識別子が来るので Externally Tagged と呼ばれる.

{
  "Permanent" : {
    "id" : 1,
    "name" : "foo",
    "nickname" : "bar"
  }
}

一般的には以下のような形式になる.

  "<enum tag>" : {
    enum variant
  }

Externally Tagged に限らず、JSON から Member 型に戻すには as[Member] を使う.

val Right(n) = m.asJson.as[Member] : @unchecked

Internally Tagged

オブジェクトのフィールドに variant を区別する discriminator を付与してシリアライズ・デシリアライズする. デフォルト以外のシリアライズ・デシリアライズをする(例えば、descriminator として利用するフィールドを指定する)場合は、io.circe.derivation モジュールを利用する.

import io.circe.derivation.Configuration
import io.circe.derivation.ConfiguredEncoder
import io.circe.derivation.ConfiguredDecoder

// ここに Configuration をおくと他の ConfiguredDecoder, ConfiguredEncoder の自動導出にも影響を与える.
// アプリケーション全体で同じカスタマイズをするならグローバルに Configuration を設定する方が楽.
// given Configuration = Configuration.default.withDiscriminator("_type")

object Member:
  // Configuration を Member のコンパニオンオブジェクトにおいているので他の Encoder・Decoder の自動導出には影響を与えない.
  // 型によってシリアライズ・デシリアライズ方式が異なる場合はこのほうが安全.
  given Configuration = Configuration.default.withDiscriminator("_type")
enum Member derives ConfiguredDecoder, ConfiguredEncoder:
  case Permanent(id: Long, name: String, nickname: Option[String])
  case SingleChannel(channel_id: Long, name: String, nickname: Option[String])
val m: Member = Permanent(1L, "foo", Some("bar"))
println(m.asJson)

シリアライズされる JSON は以下のようになる.

{
  "id" : 1,
  "name" : "foo",
  "nickname" : "bar",
  "_type" : "Permanent"
}

一般的には以下のような形式になる.

{
  ...entity fields,
  "<discriminator>" : "<variant name>"
}

Untagged

楽をするなら Member 型に対する導出をやめ、個別の型について自動導出すればいい.
generic.semiauto マクロの方が無難.

// enum(sealed trait) に対しては導出しない.
enum Member:
  case Permanent(id: Long, name: String, nickname: Option[String])
  case SingleChannel(channel_id: Long, name: String, nickname: Option[String])

import io.circe.generic.auto.*
// Member 型でないことに注意.
// 型アノテーションがないと挙動が変わるので、`generic.auto.*` ではなく `generic.semiauto.*` をそれぞれの型に使う方が無難.
val m: Permanent = Permanent(1L, "foo", Some("bar"))
val j = m.asJson
// val j = Permanent(1L,"fo",Some("bar")).asJson(using Encoder[Permanent])
// としてもいい. Encoder[Member] ではなく Encoder[Permanent] を使うのがポイント
println(j)
{
  "id" : 1,
  "name" : "foo",
  "nickname" : "bar"
}

untagged な JSON からモデルへのデコーディングは以下のように定義する.

import cats.syntax.all.*
implicit val dec: Decoder[Member] = 
  Decoder[Permanent].widen <+> Decoder[SingleChannel].widen
val model = j.as[Member]

フォールバックを定義する場合

enum Member:
  case Permanent(id: Long, name: String, nickname: Option[String])
  case SingleChannel(channel_id: Long, name: String, nickname: Option[String])
  // Other variant を追加
  case Other
import cats.syntax.all.*
implicit val dec: Decoder[Member] = 
  Decoder[Permanent].widen 
  <+> Decoder[SingleChannel].widen
  <+> Decoder.const(Other).widen

Adjacently tagged

そんなものはない...😢(はず. あったら教えてください.)

カスタマイズ

設定値の渡し方

全てデフォルト.

enum Member derives Encoder.AsObject, Decoder:
  ...

暗黙の引数で渡す.

object Member:
  given Configuration = ???
  ...
enum Member derives ConfiguredEncoder, ConfiguredDecoder:
  ...

明示的に渡す.

object Member:
  val: encodeConfig: Configuration = ???
  val: decodeConfig: Configuration = ???
  given Encoder[Member] = ConfiguredEncoder.derived(encodeConfig)
  given Decoder[Member] = ConfiguredDecoder.derived(decodeConfig)
  ...
enum Member:
  ...

Encode と Decode で異なるシリアライズ形式を使う

コンパニオンオブジェクトに Configuration を配置することで、その設定を考慮して Codec を導出することができる. 以下の例はシリアライズには Internally Tagged を、デシリアライズには Externally Tagged を利用する例.

object Member:
    given Configuration = Configuration.default.withDiscriminator("_type")
enum Member derives Decoder, ConfiguredEncoder:
  case Permanent(id: Long, name: String, nickname: Option[String])
  case SingleChannel(channel_id: Long, name: String, nickname: Option[String])

フィールド名の変換

ConfigurationwithTransformMemberNames で変換できる.

object Member:
    given Configuration = Configuration.default.withTransformMemberNames{
        case "id" => "_id"
        case other  => other
    }
    //given Encoder[Member] = ConfiguredEncoder.derived
enum Member derives ConfiguredDecoder, ConfiguredEncoder:
  ...

他にもフィールド名をパスカルケースやケバブケースなどにまとめて変換する設定項目が Configuration に生えている.

参考

https://github.com/circe/circe/pull/1800

Discussion