日志取证

从日志里的 Java toString() 输出中还原结构

Java 的 Map、集合、record 与 Lombok toString 格式是怎么构成的,为什么它们不是 JSON,如何把它们转换回来,以及哪些歧义是任何解析器都无法消除的。

迟早有一天,你得从一行含 `{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,其余一律变字符串。

最后这个推断步骤正是猜测所在,而它值得做成可以关掉的选项。如果你要把结果喂给在意类型的东西,把所有标量统统转成字符串才是诚实的默认 —— 它错得很无聊,而不是错得能骗过评审。

从日志行到 JSON,并标出所有猜测
日志行
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 日志。

继续了解相关检查与工具