JSON/EDN 序列化与数据格式互操作

Clojure 数据序列化全景:EDN 与 JSON 的语义差异、cheshire 与 jsonista 的性能对比、自定义编码器、关键字与命名风格转换、Transit 跨语言传输、Nippy 二进制序列化,以及大文档流式解析与安全反序列化。

Clojure 有自己原生的数据表示法 EDN(Extensible Data Notation),但对外通信——HTTP API、消息队列、配置文件——几乎都要求 JSON。两种格式看似都是「花括号 + 键值」,语义却差得不小:EDN 有关键字(Keyword)、符号(Symbol)、集合、任意精度的整数,JSON 只有字符串键、数字与布尔。

序列化(Serialization)出问题的场合,往往不是「转不出来」,而是「转出去和转回来不一样」:关键字变成了字符串、nil 与缺字段混淆、大整数丢精度。本文把 JSON 与 EDN 的边界讲清楚,并给出各库的选型与性能数据。

1. EDN 与 JSON 的语义差异

特性EDNJSON
键类型任意(keyword/symbol/string)仅字符串
关键字:name无(用 "name")
符号foo/bar无
集合list/vector/set/maparray/object
数字任意精度整数、BigDecimal双精度浮点
注释; 与 #_无
扩展reader tag(#inst、#uuid)无

关键结论:EDN 是 JSON 的超集式表达——几乎所有 EDN 数据都能映射到 JSON,但反过来不成立。

1.1 关键字问题

(require '[clojure.edn :as edn])

;; EDN 原生支持关键字
(edn/read-string "{:name \"Alice\" :age 30}")
;; => {:name "Alice", :age 30}

JSON 里 :name 必须写成 "name",反序列化时又要决定「转不转关键字」。这个决策贯穿整个 API 层。

1.2 数字精度

;; JSON 用 double 表示数字,大整数会丢精度
(json/parse-string "{\"id\": 9007199254740993}")
;; => {"id" 9007199254740992.0}  ← 精度已丢

;; EDN 保持整数
(edn/read-string "9007199254740993")
;; => 9007199254740993

超过 2^53 的 ID(雪花 ID 常见)用 JSON 传必须转成字符串。

2. JSON 库选型

2.1 三大库对比

库底层速度依赖特点
cheshireJackson中较重生态最广、API 友好
jsonistaJackson + 优化快中关键字处理最优、性能高
clojure.data.json纯 Clojure慢无零依赖、可定制

基准测试(序列化 1 万条记录,单位 ms,量级参考):

库序列化反序列化
cheshire4255
jsonista2836
data.json120150

对大多数服务,cheshire 足够;吞吐敏感的网关或大数据管道,jsonista 值得切换。

2.2 cheshire 用法

(require '[cheshire.core :as json])

;; 序列化:关键字键 -> JSON 字符串
(json/generate-string {:name "Alice" :tags ["a" "b"]})
;; => "{\"name\":\"Alice\",\"tags\":[\"a\",\"b\"]}"

;; 反序列化:默认键为字符串
(json/parse-string "{\"name\":\"Alice\"}")
;; => {"name" "Alice"}

;; 关键字化
(json/parse-string "{\"name\":\"Alice\"}" true)
;; => {:name "Alice"}

;; 关键字化 + 下划线转连字符
(json/parse-string "{\"user_name\":\"Alice\"}" keyword)
;; => {:user_name "Alice"}   ← 注意不会自动转连字符

2.3 jsonista 用法

(require '[jsonista.core :as j])

(def mapper (j/object-mapper {:decode-key-fn true}))   ;; 键转关键字

(j/write-value-as-string {:name "Alice"} mapper)
;; => "{\"name\":\"Alice\"}"

(j/read-value "{\"name\":\"Alice\"}" mapper)
;; => {:name "Alice"}

jsonista 的 object-mapper 复用 Jackson 的 ObjectMapper 实例,避免每次调用重建,这也是它更快的原因之一。

2.4 命名风格转换

Java/JSON 世界用 camelCase 或 snake_case,Clojure 惯用 kebab-case。转换要在序列化边界显式做:

(require '[camel-snake-kebab.core :as csk])

(defn ->json [m]
  (clojure.walk/postwalk
    (fn [x]
      (if (and (map-entry? x) (keyword? (key x)))
        [(csk/->camelCase (name (key x))) (val x)]
        x))
    m))

(->json {:user-name "Alice" :created-at 1700000000})
;; => {"userName" "Alice", "createdAt" 1700000000}

反向则用 ->kebab-case-keyword。建议把这两个函数封在 API 层,业务代码只处理 kebab-case 关键字。

3. EDN 的读写

3.1 基础读写

(require '[clojure.edn :as edn])

(edn/read-string "{:a 1 :b [1 2 3] :c #{:x :y}}")
;; => {:a 1, :b [1 2 3], :c #{:x :y}}

(pr-str {:a 1 :b "x"})
;; => "{:a 1, :b \"x\"}"

pr-str 输出的是 EDN(也是可读的 Clojure 形式),clojure.edn/read-string 则是安全的——它不会执行代码,与 clojure.core/read-string 有本质区别。

3.2 安全反序列化

;; 危险:core/read-string 可执行 reader eval
(read-string "#=(System/exit 1)")     ;; 千万别用于不可信输入

;; 安全:edn/read-string 只解析数据
(edn/read-string "#=(System/exit 1)")  ;; => 抛异常,不会执行

永远用 clojure.edn/read-string 解析外部输入。clojure.core/read-string 只用于读取自己生成的、可信的文件。

3.3 reader tag 扩展

EDN 支持自定义 reader tag,用 :readers 传入处理函数:

(edn/read-string
  {:readers {'my/point (fn [[x y]] {:x x :y y})}}
  "#my/point [3 4]")
;; => {:x 3, :y 4}

标准 tag 有 #inst(时间)与 #uuid:

(edn/read-string "#inst \"2026-10-08T01:00:00Z\"")
;; => #object[java.util.Date ...]

4. 自定义编码

4.1 处理特殊类型

Clojure 的常见类型 JSON 库默认不认识:keyword 值、set、java.time.Instant、UUID。

(require '[cheshire.core :as json]
         '[cheshire.generate :as gen])

(gen/add-encoder java.time.Instant
  (fn [inst ^com.fasterxml.jackson.core.JsonGenerator g]
    (.writeString g (.toString inst))))

(gen/add-encoder clojure.lang.IPersistentSet
  (fn [s ^com.fasterxml.jackson.core.JsonGenerator g]
    (.writeObject g (vec s))))

(json/generate-string {:ts (java.time.Instant/now) :tags #{:a :b}})
;; => "{\"ts\":\"2026-10-08T01:00:00Z\",\"tags\":[\"a\",\"b\"]}"

4.2 关键字值 vs 键

注意 generate-string 默认把值位置的关键字转成字符串,而键位置的关键字也会转字符串。但如果值是 :user/name 这种命名空间关键字,默认会输出 "user/name",有时希望只取 name 部分:

(defn kw->str [k]
  (if (keyword? k) (name k) k))

统一在 postwalk 里处理,避免各处遗漏。

4.3 日期时间格式

Java 8 时间类型用 java.time.format.DateTimeFormatter 明确格式,不要依赖默认:

(def fmt (java.time.format.DateTimeFormatter/ISO_INSTANT))

(defn encode-instant [^java.time.Instant i]
  (.format fmt i))

(encode-instant (java.time.Instant/parse "2026-10-08T01:00:00Z"))
;; => "2026-10-08T01:00:00Z"

统一用 ISO-8601 UTC,避免时区歧义。

5. Transit:跨语言的高保真传输

5.1 为什么用 Transit

Transit 是 Cognitect 设计的格式,目标是在 JSON 之上保留更多类型,同时支持 JSON 与 MessagePack 两种后端:

(require '[cognitect.transit :as transit])
(import '[java.io ByteArrayOutputStream ByteArrayInputStream])

(defn write-transit [x]
  (let [out (ByteArrayOutputStream.)]
    (transit/write (transit/writer out :json) x)
    (.toString out "UTF-8")))

(defn read-transit [s]
  (let [in (ByteArrayInputStream. (.getBytes s "UTF-8"))]
    (transit/read (transit/reader in :json))))

(write-transit {:tags #{:a :b} :id (java.util.UUID/randomUUID)})
;; 输出里 set 与 UUID 被标记为 "~#set" / "~#uuid",读回时自动还原
(read-transit (write-transit {:tags #{:a :b}}))
;; => {:tags #{:a :b}}   ← set 类型保留

关键价值:set 还是 set、UUID 还是 UUID,而 JSON 会退化。Clojure 前端(ClojureScript)与后端用 Transit 通信,能无损传递 keyword、set、uuid、instant。

5.2 选 JSON 还是 MessagePack

后端可读性体积浏览器支持
:json人类可读大原生
:msgpack二进制小 30~50%需库

内部服务间通信可用 :msgpack 省带宽;对浏览器则用 :json。

6. Nippy:二进制序列化

6.1 用途

Nippy 面向内部存储与缓存:把任意 Clojure 数据冻成字节串,性能与体积都优于文本格式。

(require '[taoensso.nippy :as nippy])

(def frozen (nippy/freeze {:a 1 :b #{:x :y} :c (java.util.Date.)}))
;; => byte[]

(nippy/thaw frozen)
;; => {:a 1, :b #{:x :y}, :c #object[java.util.Date ...]}

6.2 与文本格式对比

格式相对体积相对速度可读性
Nippy1.01.0(最快)无
Transit+msgpack1.20.6无
JSON2.50.4有
EDN3.00.3有

Redis 缓存值、Kafka 消息体这类「机器读」场景,Nippy 是首选——Kafka 流处理 中的消息体序列化常直接用它。

6.3 版本兼容

Nippy 冻结的数据带版本头,跨版本一般可读,但自定义类型需要注册序列化器:

(nippy/extend-freeze MyRecord :my/record
  (fn [x out] (.writeUTF out (pr-str x))))

(nippy/extend-thaw :my/record
  (fn [in] (edn/read-string (.readUTF in))))

长期存储的数据要注意:结构变更后旧数据可能读不出来,需保留向后兼容的读路径。

7. 流式解析大文档

7.1 问题

json/parse-string 会把整个文档读进内存。一个 500MB 的导出文件会直接 OOM。

7.2 Jackson 流式 API

(require '[cheshire.parse :as parse])
(import '[com.fasterxml.jackson.core JsonFactory JsonParser])

(defn stream-array [file f]
  (with-open [p (.createParser (JsonFactory.) (java.io.File. file))]
    (.nextToken p)                    ;; START_ARRAY
    (loop []
      (when (= JsonToken/START_OBJECT (.nextToken p))
        (f (parse/parse-object p))    ;; 逐条处理,不保留全部
        (.nextToken p)                ;; END_OBJECT
        (recur)))))

;; 逐条处理,内存恒定
(stream-array "big-export.json"
  (fn [record] (persist-record! record)))

7.3 JSON Lines 格式

更简单的做法:让数据源输出 JSON Lines(每行一个 JSON 对象),逐行读:

(defn process-jsonl [path f]
  (with-open [r (io/reader path)]
    (doseq [line (line-seq r)]
      (when (seq line)
        (f (json/parse-string line true))))))

JSON Lines 天然支持流式与并行,是日志、导出场景的推荐格式。这类批量数据的后续处理常接 数据操作 中的 map/filter/reduce 流水线。

8. 序列化在 API 层的实践

8.1 边界转换

推荐的分层原则:

业务逻辑层:纯 Clojure 数据(kebab-case 关键字、java.time 类型)
      ↓ 序列化边界
传输层:JSON(camelCase 字符串键、ISO-8601 字符串)

只在控制器/路由处做转换,业务代码不感知 JSON 的存在。这样测试业务逻辑时无需构造 JSON 字符串。REST 与 GraphQL 的响应编码分别可参考 REST API 设计 与 GraphQL API 。

8.2 常见陷阱清单

陷阱后果对策
大整数用 JSON number精度丢失转字符串
nil 与缺字段混淆语义歧义显式约定或省略
关键字命名空间丢失读回后键不匹配记录映射规则
core/read-string 解析外部输入远程代码执行用 edn/read-string
日期用默认时区跨时区错乱统一 UTC ISO-8601
集合顺序set 变 array用 Transit 或排序

9. 小结

序列化的核心是在边界处明确语义:

  • 对外 API:JSON + 显式命名风格转换 + 大整数转字符串;
  • Clojure 服务间:Transit(保留 set/UUID/instant),带宽敏感用 msgpack 后端;
  • 缓存与内部存储:Nippy,快且紧凑;
  • 配置与可信文件:EDN,edn/read-string 安全解析;
  • 大文档:Jackson 流式 API 或 JSON Lines,避免整体载入内存。

选库的原则很简单:先看语义保真需求(要不要关键字、set、精度),再看性能。cheshire 与 jsonista 覆盖了 90% 的场景,剩下的用 Transit 与 Nippy 补齐。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「clojure」更多文章

  1. Clojure 桌面 UI:cljfx 与 JavaFX 实战
  2. JVM 调优与容器化部署:GC、JFR 与 Docker
  3. 解析与 DSL:Instaparse 与解析器组合子