发布于
迟早有一天,你得从一行含 `{orderId=10241, items=[Item(sku=AB-1, qty=2)], coupon=null}` 的日志里读出一次线上故障的经过。它像 JSON 像到让人恼火,但它不是 JSON。大多数时候它可以被机械地转换回来,而不能转换的那些情况值得精确理解 —— 因为猜错的转换器会给你一个看起来合理、但与程序实际持有的对象并不相同的结果。
你面对的到底是什么
`AbstractMap.toString()` —— `HashMap`、`LinkedHashMap`、`TreeMap` 以及大多数实现都继承它 —— 输出 `{`,然后每个条目写成 `key=value`,用 `, ` 连接,最后是 `}`。空 Map 是 `{}`。`AbstractCollection.toString()` —— `ArrayList`、`HashSet`、`ArrayDeque` —— 输出 `[a, b, c]`,空集合是 `[]`。注意两者的分隔符都是「逗号加一个空格」,而这是区分「分隔符」与「值内部逗号」的唯一依据,实在算不上有力。
Lombok 的 `@ToString` 输出 `ClassName(field=value, field=value)`。圆括号加前置类名是它的签名。`@ToString(callSuper = true)` 会把父类嵌进去,形如 `Child(super=Parent(a=1), b=2)`。`@ToString(includeFieldNames = false)` 则完全去掉字段名,给出 `Order(10241, Ada)`,这基本上不可恢复 —— 你拿到的是一串没有键的值。
Java record 生成第三种形状:`Point[x=1, y=2]`,方括号加类名。当类名被日志截断切掉时,它很容易和集合混淆。而一个什么都没覆写的类会打印 `com.example.Order@1b6d3586` —— 类名加身份哈希。这个字符串里没有数据;如果你的日志里就是这个,任何工具都帮不上忙,修复办法是给它加一个 `toString`。
| 形状 | 产生者 | 说明 |
|---|---|---|
| `{a=1, b=2}` | `AbstractMap.toString()` | 任意 `Map`;键序取决于 Map 的具体类型 |
| `[a, b, c]` | `AbstractCollection.toString()` | `List`、`Set`、`Queue` —— 类型没有被记录 |
| `Order(id=1, name=Ada)` | Lombok `@ToString` | 圆括号,前面带类名 |
| `Order(1, Ada)` | Lombok 关闭了 `includeFieldNames` | 完全没有键;无法还原成对象 |
| `Child(super=Parent(a=1), b=2)` | Lombok 开启了 `callSuper` | `super` 是伪字段,并非真实字段 |
| `Point[x=1, y=2]` | Java record | 方括号加类名 |
| `com.example.Order@1b6d3586` | 没有覆写 `toString` | 身份哈希;不含任何数据 |
| `[I@1b6d3586` | 数组的默认 `toString` | `[I` 表示 `int[]`;应当用 `Arrays.toString` |
| `(this Map)` / `(this Collection)` | 自引用结构 | Java 的循环保护;没有值可还原 |
逐条说明它为什么不是 JSON
什么都不加引号。在 JSON 里,引号既区分字符串 `"42"` 和数字 `42`,也界定含标点的值的边界。`toString` 输出里完全没有引号,于是这两项职责都没人履行:类型没了,而判断值在哪里结束的唯一办法就是找分隔符 —— 而分隔符可能就在值里面。
分隔符是 `=` 而不是 `:`,而且不做转义。键或值里含 `=` 就会产生不止一种切分方式。按第一个 `=` 切分是正确的启发式,能处理 `query=a=b` 这类常见情况,但遇到本身含 `=` 的 Map 键就彻底失效。
null 和空字符串都以裸文本打印。null 引用打印成 `null`,而四个字符的字符串 `"null"` 也打印成 `null`。空字符串什么都不打印,于是 `{a=, b=1}` 里的 `a` 既可能是空字符串,也可能是别的什么、只是字符串化之后为空。而且这种格式无法表达「值不存在」的键,因为 `toString` 只打印 Map 里实际有的东西。
一次完整的还原
流程中机械的那部分很直接。先去掉日志前缀,截到第一个结构字符。然后做词法扫描,跨 `{}`、`[]`、`()` 跟踪嵌套深度,只在当前深度上的 `, ` 处切分条目。每个条目按第一个 `=` 切成键和值。给所有键加引号。最后推断每个值的类型:裸的 `true`/`false` 变布尔,符合数字语法的文本变数字,`null` 变 null,其余一律变字符串。
最后这个推断步骤正是猜测所在,而它值得做成可以关掉的选项。如果你要把结果喂给在意类型的东西,把所有标量统统转成字符串才是诚实的默认 —— 它错得很无聊,而不是错得能骗过评审。
日志行
2026-03-01 12:04:11.238 INFO c.e.checkout.OrderService - created Order(id=10241, customer=Customer(id=77, name=Ada Lovelace, [email protected]), items=[Item(sku=AB-1, qty=2, price=12.99), Item(sku=CD-9, qty=1, price=4.50)], coupon=null, note=, createdAt=2026-03-01T12:04:11Z)
重建结果
{
"id": 10241,
"customer": {
"id": 77,
"name": "Ada Lovelace",
"email": "[email protected]"
},
"items": [
{ "sku": "AB-1", "qty": 2, "price": 12.99 },
{ "sku": "CD-9", "qty": 1, "price": 4.5 }
],
"coupon": null,
"note": "",
"createdAt": "2026-03-01T12:04:11Z"
}
哪些是猜的,按影响从大到小排列:
coupon=null -> null。同样可能是字符串 "null"。
note= -> ""。也可能是任何 toString 为空的对象。
price=4.50 -> 4.5。如果它本来是 BigDecimal,标度已经丢失;
用字符串 "4.50" 本可以保住。
qty=2 -> 数字。可能是 int、Integer、long,或字符串 "2"。
id=10241 -> 数字。同样的歧义。
createdAt=... -> 字符串。它多半是 Instant;JSON 没有日期类型。
Order、Customer、Item -> 丢弃。这是整行里唯一的类型信息,
而 JSON 的对象模型没有地方放它。无法消除的歧义
最难的情况是含 `, ` 的字符串值。看 `{note=hello, world, id=7}`。两种读法都与文本一致:一个有两个条目的 Map,其中 `note` 是 `"hello, world"`;或者一个有三个条目的 Map,其中第二个没有 `=`、因而格式非法。倾向前者的解析器把这一行读对了,却会在另一行上切错;倾向后者的解析器会在一个再普通不过的备注字段上报解析错误。字符串里没有任何信息能裁决这件事。含 `=`、`{`、`[`、`)` 的值同理:不加引号的值内部的任何结构字符,都可能被读成结构。
第二类不可能是 null 家族。输出里的 `null` 可能是 null 引用、字符串 `"null"`,或者一个 `toString` 返回 `"null"` 的对象。空白区间可能是空字符串,也可能是字符串化为空的对象。它们在日志写出之前就已经坍缩成同一段文本,再怎么用力解析也还原不了。
第三类是类型与集合身份。`2` 可能是任何整型,也可能是字符串。`[a, b]` 可能是 `List`、`Set` 或 `Queue` —— 而如果它是 `HashSet`,你看到的顺序是哈希的副产物,毫无意义,所以认为「第一个元素有特殊含义」的读者读的是噪声。同理,`HashMap` 的键序随容量和 JVM 变化,同一个对象的两行日志可以以不同顺序列出键。
| 打印为 | 可能的原值 | 能否还原 |
|---|---|---|
| `null` | null 引用;字符串 "null";toString 为 "null" 的对象 | 不能 |
| (分隔符之间什么都没有) | 空字符串;字符串化为空的对象 | 不能 |
| `42` | `int`、`Integer`、`long`、`BigInteger`,或字符串 "42" | 不能 |
| `4.50` | `BigDecimal("4.50")`、`double` 4.5,或字符串 "4.50" | 不能 —— JSON 会丢掉尾随零 |
| `true` | `boolean`、`Boolean`,或字符串 "true" | 不能 |
| `a` | `char`、单字符字符串,或某个枚举常量 | 不能 |
| 含 `, ` 的值 | 一个字符串值,或者两个条目 | 不能 —— 真正的歧义 |
| 含 `=` 的值 | 带等号的值,或者键值边界 | 通常可以 —— 按第一个 `=` 切分 |
| `[a, b]` | `List`、`Set`、`Queue`,或字符串 "[a, b]" | 内容可以,类型不行;`HashSet` 的顺序无意义 |
| `Order(...)` | Lombok 或手写的 toString | 字段可以,类名在 JSON 里无处安放 |
读懂嵌套与被截断的输出
嵌套是可以干净组合的,这是唯一对你有利的一点。Map 里套 Map 打印成 `{user={name=Ada, id=7}}`,深度跟踪就能处理。Map 条目里的一组 Lombok 对象打印成 `items=[Item(sku=AB-1), Item(sku=CD-9)]`,同一套深度跟踪也能处理这种混合括号。只要括号是配平的,转换器基本总能找出结构。
括号不配平最常见的原因是截断。日志框架会限制消息长度,长对象会被从某个值中间切开,留下不配对的括号和一个没有结束符的末尾条目。缺失的尾部谁也救不回来,但前缀依然可解析:自己把打开的括号补上,并把最后一个条目标记为不完整,而不是把整行扔掉。被日志采集器拆到多条记录里的多行对象,需要先重新拼接 —— 在尝试解析之前把续行合并起来。
嵌套的 Map 和列表 —— 可还原
{user={name=Ada, roles=[admin, ops]}, active=true}
-> {"user": {"name": "Ada", "roles": ["admin", "ops"]}, "active": true}
被日志框架截断 —— 解析前缀,标记尾部
Order(id=10241, items=[Item(sku=AB-1, qty=2), Item(sku=CD-9, qt
-> {"id": 10241, "items": [{"sku": "AB-1", "qty": 2}]} 并附带一条警告,
说明输入在第二个元素内部结束。
看起来像结构、其实不是的值
createdAt=Wed Mar 01 12:00:00 CET 2026 Date.toString()
timeout=PT2H30M Duration.toString()
nickname=Optional[ada] 有值的 Optional
nickname=Optional.empty 空的 Optional —— 不是 null
status=SHIPPED 一个枚举,或字符串 "SHIPPED"
raw=[B@6d06d69c 一个 byte[];数据已经没了
真正的歧义 —— 两种读法都说得通
{note=hello, world, id=7}
-> {"note": "hello, world", "id": 7} 或者是一个不含 '=' 的条目从源头解决问题
结构化日志是直接的答案。给你的日志后端配一个 JSON 编码器 —— Logback 用 `logstash-logback-encoder`,Log4j2 用 `JsonTemplateLayout` —— 每条事件都输出为一份合法的 JSON 文档,于是日志聚合系统可以索引字段而不是子串。把上下文值放进 MDC 使其成为顶层字段,并把对象本身作为结构化参数传入,而不是把它的 `toString` 插值进消息文本。如果做不到这些,至少在打日志的地方调用 `objectMapper.writeValueAsString(order)`,这一行改动就能产出可解析的输出。
在这项改造落地之前,请把转换后的日志输出当作线索而不是证据。用它找到请求标识,然后从数据库或上游服务取权威记录。一个把字符串 `"null"` 悄悄猜成 null 的重建结果,正是那种能把调查引向错误方向一整个下午的细节。
要点回顾
- 先认产生者:花括号是 `Map`,方括号加类名是 record,圆括号加类名是 Lombok,而 `ClassName@hash` 意味着根本没有数据可还原。
- 只在当前嵌套深度上的 `, ` 处切分条目,每个条目按第一个 `=` 切分;基于栈的转换器能处理正则搞不定的嵌套与混合括号。
- 接受这个事实:在这种格式里 `null`、字符串 "null" 和空值无法区分,含 `, ` 的字符串也无法可靠地与两个条目区分开。
- 把推断出来的类型当作猜测 —— `4.50` 丢了标度,`2` 也可能是字符串 —— 当结果要交给在意类型的消费方时,优先把所有标量转成字符串。
- 用重建结果找到标识符,再去取权威记录;同时从源头修复,改为输出排除了敏感信息的结构化 JSON 日志。