Clojure 有自己原生的数据表示法 EDN(Extensible Data Notation),但对外通信——HTTP API、消息队列、配置文件——几乎都要求 JSON。两种格式看似都是「花括号 + 键值」,语义却差得不小:EDN 有关键字(Keyword)、符号(Symbol)、集合、任意精度的整数,JSON 只有字符串键、数字与布尔。
序列化(Serialization)出问题的场合,往往不是「转不出来」,而是「转出去和转回来不一样」:关键字变成了字符串、nil 与缺字段混淆、大整数丢精度。本文把 JSON 与 EDN 的边界讲清楚,并给出各库的选型与性能数据。
1. EDN 与 JSON 的语义差异
| 特性 | EDN | JSON |
|---|---|---|
| 键类型 | 任意(keyword/symbol/string) | 仅字符串 |
| 关键字 | :name | 无(用 "name") |
| 符号 | foo/bar | 无 |
| 集合 | list/vector/set/map | array/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 三大库对比
| 库 | 底层 | 速度 | 依赖 | 特点 |
|---|---|---|---|---|
cheshire | Jackson | 中 | 较重 | 生态最广、API 友好 |
jsonista | Jackson + 优化 | 快 | 中 | 关键字处理最优、性能高 |
clojure.data.json | 纯 Clojure | 慢 | 无 | 零依赖、可定制 |
基准测试(序列化 1 万条记录,单位 ms,量级参考):
| 库 | 序列化 | 反序列化 |
|---|---|---|
| cheshire | 42 | 55 |
| jsonista | 28 | 36 |
| data.json | 120 | 150 |
对大多数服务,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 与文本格式对比
| 格式 | 相对体积 | 相对速度 | 可读性 |
|---|---|---|---|
| Nippy | 1.0 | 1.0(最快) | 无 |
| Transit+msgpack | 1.2 | 0.6 | 无 |
| JSON | 2.5 | 0.4 | 有 |
| EDN | 3.0 | 0.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 补齐。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。