一次 HTTP 请求、一条 Kafka 消息、一次缓存读写,边界上都有一次 JSON 编解码。它的成本被低估得很厉害:一个中等规模的微服务,序列化与反序列化能占到 CPU 时间的两位数百分比,而 ClassCastException、字段名拼错、可选字段缺失这类事故几乎全部发生在这个环节。选型和写法因此不只是风格问题。
Scala 生态的两条主线是 circe(Typelevel 栈,代数数据类型 + 类型类派生)和 jsoniter-scala(编译期宏生成无反射编解码器)。前者胜在模型清晰、组合自由、生态齐备;后者胜在吞吐与分配。本文把两者的模型、代价和适用边界讲清楚,并给出可复现的性能实测方法。
前置:序列化协议选型与演进 、Web 与 HTTP 应用 。
1. JSON 库的选型维度
选 JSON 库不是「谁快选谁」,而是几个维度的加权:模型是否可组合、是否支持编译期检查、性能是否满足边界要求、错误信息是否可诊断、生态集成是否顺手。
| 库 | 模型 | 性能 | 反射 | 生态 |
|---|---|---|---|---|
| circe | Json ADT + 类型类 | 中 | 编译期派生(无运行时反射) | Typelevel 全家桶 |
| jsoniter-scala | 宏生成 codec | 高 | 无 | 需显式声明 codec |
| uPickle | 类型类 + 宏 | 中 | 无 | 轻量、跨平台 |
| Play-JSON | JsValue + 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」。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。