JSON 编解码与性能:circe 派生、jsoniter-scala 代码生成与选型

JSON 编解码是服务边界上最频繁的一次数据转换,它的性能与健壮性直接决定接口的吞吐和容错能力。circe 用代数数据类型把解析、派生与错误路径都建模成纯函数,jsoniter-scala 则用编译期宏生成无反射的编解码器换取吞吐。本文对比两者的模型与代价,给出派生与自定义的写法、性能实测方法,以及版本演进与向后兼容的实践路径。

一次 HTTP 请求、一条 Kafka 消息、一次缓存读写,边界上都有一次 JSON 编解码。它的成本被低估得很厉害:一个中等规模的微服务,序列化与反序列化能占到 CPU 时间的两位数百分比,而 ClassCastException、字段名拼错、可选字段缺失这类事故几乎全部发生在这个环节。选型和写法因此不只是风格问题。

Scala 生态的两条主线是 circe(Typelevel 栈,代数数据类型 + 类型类派生)和 jsoniter-scala(编译期宏生成无反射编解码器)。前者胜在模型清晰、组合自由、生态齐备;后者胜在吞吐与分配。本文把两者的模型、代价和适用边界讲清楚,并给出可复现的性能实测方法。

前置:序列化协议选型与演进 、Web 与 HTTP 应用 。

1. JSON 库的选型维度

选 JSON 库不是「谁快选谁」,而是几个维度的加权:模型是否可组合、是否支持编译期检查、性能是否满足边界要求、错误信息是否可诊断、生态集成是否顺手。

库模型性能反射生态
circeJson ADT + 类型类中编译期派生(无运行时反射)Typelevel 全家桶
jsoniter-scala宏生成 codec高无需显式声明 codec
uPickle类型类 + 宏中无轻量、跨平台
Play-JSONJsValue + Reads/Writes中部分运行时Play 生态
Jackson注解 + 反射高有(运行时代码生成)Java 生态通用
选型决策的关键问题:
1. 边界吞吐是否已经接近瓶颈?(先用 jmh 实测,别凭感觉)
2. 数据契约是否由你控制?(能控制就优先编译期检查)
3. 是否需要在纯函数层面对 JSON 做变换?(circe 的 ADT 更顺手)
4. 是否已有大量 Java 生态的 DTO?(Jackson 兼容性更好)
5. 是否需要流式解析超大文档?(两者的流式支持差异明显)

工程要点:先测再选。把生产环境的真实 payload(不是玩具样例)抓下来做基准,很多团队发现「换 jsoniter 提速 3 倍」的结论在真实数据分布下只有 1.3 倍,而代价是每个 DTO 都要手写 codec 声明。

2. circe 的核心模型

circe 把 JSON 建模成一个代数数据类型 Json,共有六个构造子:JNull、JBoolean、JNumber、JString、JArray、JObject。解析与编码都是这个 ADT 与 Scala 类型之间的双向映射,Decoder[A] 与 Encoder[A] 是各自的类型类。

import io.circe.*
import io.circe.parser.*
import io.circe.syntax.*

// Json ADT 的六个构造子
val j1: Json = Json.Null
val j2: Json = Json.fromInt(42)
val j3: Json = Json.obj("name" -> Json.fromString("ada"), "age" -> Json.fromInt(36))
val j4: Json = Json.arr(Json.fromInt(1), Json.fromInt(2))

// 解析:字符串 → Either[ParsingFailure, Json]
val parsed: Either[ParsingFailure, Json] = parse("""{"a":1}""")

// 编码:任意类型 → Json(需要隐式 Encoder)
final case class User(name: String, age: Int)
given Encoder[User] = u => Json.obj("name" -> u.name.asJson, "age" -> u.age.asJson)
val encoded: Json = User("ada", 36).asJson

// 游标:在不解码成 case class 的前提下导航与取值
val cursor: ACursor = j3.hcursor.downField("name")
val name: Decoder.Result[String] = cursor.as[String]
circe 的层次:
□ Json            纯数据 ADT,不含类型类
□ Decoder[A]      Json → Either[DecodingFailure, A]
□ Encoder[A]      A → Json
□ Codec[A]        二者的组合(extends Decoder with Encoder)
□ HCursor/ACursor 导航与局部解码,无需完整 case class
□ Printer         控制输出(缩进、排序、转义、最大深度)

工程要点:ACursor 是处理「半结构化 JSON」的关键——第三方回调、配置片段、日志事件这类形状不固定的数据,用游标逐字段取值比先定义完整 case class 再解码灵活得多,且不会因为多了一个未知字段就整体失败。

3. 派生与自定义

circe 的派生有三档:semiauto(显式调用派生方法)、auto(隐式自动派生)、手写 Decoder/Encoder。生产代码推荐 semiauto——它把「派生点」显式化,编译期开销可控,隐式搜索路径也更可预测。

import io.circe.*
import io.circe.generic.semiauto.*
import io.circe.generic.extras.Configuration
import io.circe.generic.extras.semiauto.*

final case class Address(street: String, city: String, zip: String)
final case class User(id: String, name: String, address: Address, tags: List[String])

object User:
  given Decoder[Address] = deriveDecoder
  given Encoder[Address] = deriveEncoder
  given Decoder[User] = deriveDecoder
  given Encoder[User] = deriveEncoder

// 字段重命名与默认值(extras 模块)
given Configuration = Configuration.default
  .withSnakeCaseMemberNames              // userName → user_name
  .withDefaults                          // 缺失字段用默认值
// 密封特质的派生:判别字段 + 子类型注册
import io.circe.generic.extras.ConfiguredJsonCodec

@ConfiguredJsonCodec sealed trait Event
object Event:
  final case class Created(id: String) extends Event
  final case class Deleted(id: String, reason: Option[String]) extends Event

// 等价于:
// { "type": "Created", "id": "..." }
// { "type": "Deleted", "id": "...", "reason": null }

// 手写 Decoder:需要跨字段校验、自定义解析逻辑时
given Decoder[User] = (c: HCursor) =>
  for
    id   <- c.downField("id").as[String]
    name <- c.downField("name").as[String]
    addr <- c.downField("address").as[Address]
    tags <- c.downField("tags").as[Option[List[String]]].map(_.getOrElse(Nil))
    _    <- Either.cond(id.nonEmpty, (), DecodingFailure("id 不能为空", c.history))
  yield User(id, name, addr, tags)

工程要点:优先 semiauto,慎用 auto。auto 会在任意隐式搜索点触发派生,导致编译时间随代码规模非线性增长,也容易掩盖「同一个类型被派生出两个不兼容实例」的问题。手写 Decoder 只在该字段需要跨字段校验或兼容历史格式时使用。

4. 错误处理与诊断

Decoder.Result[A] 就是 Either[DecodingFailure, A]。DecodingFailure 带有 history(CursorOp 列表),能还原出「在哪个字段、第几层、期望什么类型」——这是它比「抛异常 + 栈」更适合接口边界的原因。

import io.circe.*
import io.circe.parser.decode

val bad = """{"id":"1","name":42,"address":{"city":"SH"}}"""

decode[User](bad) match
  case Right(u)  => println(s"ok: $u")
  case Left(err) =>
    // 打印人类可读的路径
    println(s"失败: ${err.message}")
    println(s"位置: ${err.history.reverse.mkString(".")}")

// 累积错误:默认 fail-fast,需要全部错误时手工收集
def decodeAll(c: HCursor): Decoder.Result[(String, Int)] =
  val a = c.downField("a").as[String]
  val b = c.downField("b").as[Int]
  (a, b) match
    case (Right(x), Right(y)) => Right((x, y))
    case (Left(e1), Left(e2)) =>
      Left(DecodingFailure(s"${e1.message}; ${e2.message}", c.history))
    case (Left(e), _) => Left(e)
    case (_, Left(e)) => Left(e)
错误诊断的实践:
□ 对外:只回 400 + 简短消息,绝不把 DecodingFailure 原文暴露给客户端
□ 对内:日志里打印完整 history 与原始 payload(脱敏后)
□ 契约:把「缺字段」「类型不符」「越界」区分为不同的错误码
□ 测试:为每个可空/可选字段写「缺失」「null」「错误类型」三类用例
□ 监控:按字段统计解码失败率,定位上游契约漂移

工程要点:circe 默认是 fail-fast(遇到第一个错误就返回),这与「一次性返回所有校验错误」的产品预期不同。如果接口需要返回全部字段错误,要么自己收集(如上面的 decodeAll),要么改用 cats.data.Validated 在应用层做二次校验。

5. jsoniter-scala 的宏代码生成

jsoniter-scala 走的是完全不同的路线:用 Scala 宏在编译期为每个类型生成读写器(JsonValueCodec[A]),运行时零反射、零中间 Json ADT,直接把字节流解析成对象。

import com.github.plokhotnyuk.jsoniter_scala.core.*
import com.github.plokhotnyuk.jsoniter_scala.macros.*

final case class Address(street: String, city: String, zip: String)
final case class User(id: String, name: String, address: Address, tags: List[String])

// 编译期生成 codec:给定配置后展开为字节级读写循环
given JsonValueCodec[Address] = JsonCodecMaker.make
given JsonValueCodec[User] = JsonCodecMaker.make(
  CodecMakerConfig
    .withFieldNameMapper(JsonCodecMaker.enforce_snake_case)
    .withDiscriminatorFieldName(Some("type"))
    .withRequireCollectionFields(true)      // 集合字段必须存在
)

// 使用:字节数组直接进出,无中间 JSON 树
val addr = Address("Main St 1", "SH", "200000")
val bytes: Array[Byte] = writeToArray(User("u1", "ada", addr, List("vip")))
val user: User = readFromArray[User](bytes)

// 解析失败抛 JsonReaderException(可被捕获,含 offset 与路径)
try readFromArray[User](Array[Byte]('{', '}'))
catch case e: JsonReaderException => println(s"offset=${e.offset} ${e.getMessage}")
jsoniter-scala 的配置要点:
□ CodecMakerConfig 在编译期生效,改动即全量重编译
□ withDiscriminatorFieldName:密封特质的判别字段名
□ withFieldNameMapper:snake_case / kebab-case 等命名策略
□ withRequireCollectionFields:缺失集合字段是报错还是空集合
□ withTransientEmpty:空集合/空字符串是否省略(影响输出体积)
□ withBigDecimalScaleLimit:限制小数位数,防解析爆炸
□ 不支持运行时反射 → 第三方 DTO 需要手写 codec

工程要点:CodecMakerConfig 的每一处改动都会触发全量重编译,且编译期报错信息比 circe 的隐式错误更难定位。把配置集中在一个对象里(例如 object JsonConfig 统一给出所有 codec),既便于统一策略,也让编译错误的来源唯一。

6. 性能实测方法

性能结论必须来自可复现的基准,而不是博客上的数字。用 JMH 测,用真实 payload 测,同时看吞吐、延迟和分配率。

import org.openjdk.jmh.annotations.*
import java.util.concurrent.TimeUnit

@State(Scope.Benchmark)
@BenchmarkMode(Array(Mode.Throughput, Mode.AverageTime))
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
@Fork(2)
class JsonBench:
  @Param(Array("small", "medium", "large"))
  var size: String = _

  var payload: Array[Byte] = _
  var circePayload: String = _

  @Setup(Level.Trial)
  def setup(): Unit =
    val u = BenchData.user(size)
    payload = writeToArray(u)
    circePayload = u.asJson.noSpaces

  @Benchmark def jsoniterDecode: User = readFromArray[User](payload)
  @Benchmark def circeDecode: Either[io.circe.Error, User] =
    io.circe.parser.decode[User](circePayload)
# 跑基准(sbt-jmh 插件)
sbt "Jmh/run -i 10 -wi 5 -f 2 -t 1 .*JsonBench.*"

# 关键指标
# Throughput (ops/ms)    吞吐:每秒能处理多少次
# AverageTime (ms/op)    延迟:单次平均耗时
# GC profiler            分配率:-prof gc 看 B/op
sbt "Jmh/run -prof gc .*JsonBench.jsoniterDecode"
实测时最容易犯的错:
□ 用玩具 payload(3 个字段)测出「十倍差距」,真实数据只有 1.2 倍
□ 没有预热(JIT 未编译),第一次迭代的数字毫无意义
□ 在同一个 JVM 里混测两个库(互相干扰内联决策)
□ 忽略分配率:吞吐相近但分配率差 5 倍,GC 压力完全不同
□ 只测解码不测编码:生产上写入往往更频繁
□ 忘记关掉日志/断言等干扰

工程要点:-prof gc 给出的 B/op(每次操作分配的字节数)往往比吞吐更能解释线上表现。一个吞吐高 20% 但分配率高 5 倍的库,在 GC 压力大的服务里可能反而更慢。测完两个库后,务必把数字与当前线上 p99 延迟对照,判断序列化是否真是瓶颈。

7. 选型决策与混合策略

大多数系统不需要二选一。合理做法是:边界上性能敏感的热路径用 jsoniter,需要灵活变换与诊断的内部逻辑用 circe,两者通过字节数组或字符串互转。

import io.circe.*
import com.github.plokhotnyuk.jsoniter_scala.core.*

// 混合:jsoniter 负责边界的高吞吐,circe 负责灵活的中间变换
def handle(raw: Array[Byte]): Either[String, Json] =
  try
    val user = readFromArray[User](raw)           // 快:字节 → 对象
    val enriched = user.copy(tags = user.tags :+ "processed")
    Right(enriched.asJson)                        // 灵活:对象 → 任意 JSON
  catch
    case e: JsonReaderException => Left(s"bad payload at ${e.offset}")

// 反向:circe 拼出的 JSON 交给 jsoniter 快速写出
def emit(j: Json): Array[Byte] =
  writeToArray(j.noSpaces, WriterConfig.withIndentionStep(0))
分场景建议:
场景                         推荐            理由
公开 API 入参/出参            circe           生态集成好、错误可诊断
内部高 QPS RPC                jsoniter        吞吐与分配优势明显
Kafka 大消息                  jsoniter        避免中间 Json 树的内存峰值
配置文件/脚本                 circe           灵活、可部分解码
日志与调试输出                circe           Printer 控制能力强
第三方契约(形状不定)        circe 游标       不需要完整模型
已有大量 Java DTO             Jackson         兼容性成本最低

工程要点:混合策略的代价是两套类型类实例,容易在某处忘记声明导致隐式解析失败。把两套 given 放在同一个伴生对象或统一的 JsonInstances 对象里,用命名区分(given circeCodec / given jsoniterCodec),避免隐式冲突。

8. 与 HTTP 与消息队列集成

在 http4s 里,circe 通过 org.http4s.circe._ 提供 EntityDecoder/EntityEncoder;jsoniter 则通常自己实现一层编解码以避免中间 Json。

import cats.effect.*
import org.http4s.*
import org.http4s.dsl.io.*
import org.http4s.circe.CirceEntityCodec.*

val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
  case req @ POST -> Root / "users" =>
    req.decode[User] { u =>                 // 自动反序列化,失败即 400
      Ok(u.copy(tags = u.tags :+ "created"))
    }
  // 直接读原始字节,交给 jsoniter 解析(避免中间 Json 树)
  case req @ POST -> Root / "fast" =>
    req.as[Array[Byte]].flatMap { raw =>
      IO(readFromArray[User](raw)).attempt.flatMap {
        case Right(u) => Ok(u.name)
        case Left(e: JsonReaderException) => BadRequest(e.getMessage)
        case Left(e)  => InternalServerError(e.getMessage)
      }
    }
}
集成的几个坑:
□ http4s 默认对超大 body 无限制 → 用 EntityLimiter 设上限
□ 内容类型不符时返回 415 而不是 400,别让客户端困惑
□ 空 body 与 "null" 的处理不同:前者 400,后者要显式建模 Option
□ 流式解析超大数组:circe 需要 fs2 + jawn 的流式解析器
□ Kafka 侧:反序列化失败不可重试,直接进死信队列

工程要点:接口边界必须设 body 大小上限。EntityLimiter 或网关层的 client_max_body_size 是防「用一个超大 JSON 打爆堆」的最低成本手段——JSON 解析器的内存占用通常与输入大小成正比甚至更高。

9. 版本演进与兼容

JSON 契约的演进规则比二进制协议宽松,但仍有破坏性变更。核心原则是:新增可选字段向后兼容,删除或改类型不兼容。

import io.circe.*
import io.circe.generic.semiauto.*

// 演进策略一:新字段给默认值(向后兼容,老客户端不发也能解)
final case class Order(id: String, amount: BigDecimal, currency: String = "CNY")
given Decoder[Order] = deriveDecoder
given Encoder[Order] = deriveEncoder

// 演进策略二:用 Option 建模「可能缺失」(兼容 null 与缺失两种形态)
final case class Profile(name: String, nickname: Option[String])
// 注意:circe 默认把 None 编码为 null;要省略需自定义 Encoder
given Encoder[Profile] = p => Json.obj(
  "name" -> p.name.asJson,
  "nickname" -> p.nickname.fold(Json.Null)(_.asJson)   // 显式控制输出形态
)

// 演进策略三:判别字段 + 版本号,支持多版本共存
final case class Envelope(v: Int, payload: Json)
兼容性检查清单:
□ 新增字段:给默认值 → 兼容;不给默认值 → 破坏(老数据解不出)
□ 删除字段:老客户端仍在发 → 用 extras 的 withDefaults 忽略未知字段
□ 类型变更(int → string):破坏,需并行发新字段再下线旧字段
□ 枚举新增取值:消费方必须有 unknown 兜底,否则整体失败
□ 命名策略变更:破坏,等价于全字段重命名
□ null 与缺失:语义不同,明确约定并写测试
□ 时间格式:统一用 ISO-8601 带时区,别用本地时间戳

工程要点:给每个密封特质的子类型和枚举取值都留一个 Unknown 兜底分支。上游新增一个事件类型、新增一个订单状态,下游若没有兜底分支,整条消息解析失败——这是跨服务 JSON 契约最常见的事故形态。

10. 速查表与一句话记忆

问题一句话答案
circe 与 jsoniter 的本质区别前者是 ADT + 类型类,后者是编译期宏生成字节级读写
派生用哪档生产用 semiauto,慎用 auto
半结构化数据怎么解用 HCursor/ACursor 逐字段取值
错误怎么诊断DecodingFailure 带 history,可还原字段路径
默认是否累积错误否,fail-fast,需要全量错误得自己收集
性能怎么测JMH + 真实 payload + 预热 + -prof gc 看分配率
什么时候用 jsoniter高 QPS 边界、大消息、分配敏感
什么时候用 circe需要灵活变换、错误可诊断、生态集成
演进的安全区新增字段给默认值,删字段与改类型是破坏性
必须留的兜底密封特质子类型与枚举取值的 Unknown 分支

一句话记忆:JSON 编解码选型 = circe 给模型与诊断(ADT + 类型类 + 游标 + DecodingFailure 路径),jsoniter-scala 给吞吐(宏生成 codec + 零中间树 + 零反射)——先测再选、真实 payload 定胜负、混合策略各取所长;演进上「新增给默认、删除与改类型算破坏、枚举留 Unknown」。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「scala」更多文章

  1. Scala CLI 与工具链现代化:指令声明、打包发布与 CI 集成
  2. 持久化与事件溯源:journal、快照与 CQRS 读模型
  3. fs2 流处理实战:Stream、Pipe 与背压模型