Circe の機能で様々な形態のJSONを列挙型(enum/sealed trait)として扱う
Scala では複数の型のどれか一つになる列挙型を sealed trait
や enum
を使って定義できます. JSON には列挙型の概念が存在しないので、列挙型はさまざまな方法でシリアライズ・デシリアライズされることがあります.
このような enum のシリアライズ・デシリアライズをする際に Rust の serde の ADT のシリアライズ・デシリアライはとても便利です.
実は 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 のデフォルト.
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])
フィールド名の変換
Configuration
の withTransformMemberNames
で変換できる.
object Member:
given Configuration = Configuration.default.withTransformMemberNames{
case "id" => "_id"
case other => other
}
//given Encoder[Member] = ConfiguredEncoder.derived
enum Member derives ConfiguredDecoder, ConfiguredEncoder:
...
他にもフィールド名をパスカルケースやケバブケースなどにまとめて変換する設定項目が Configuration
に生えている.
参考
Discussion