ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot 实战:JSONPath 一行代码解决嵌套 JSON 解析难题

Spring Boot 实战:JSONPath 一行代码解决嵌套 JSON 解析难题 最近接一个第三方订单接口的对接需求又踩了一遍 JSON 嵌套地狱外层套 datadata 里套 orderListorderList 里再套 items取第一个商品的名称要连续写好几层 get还要小心翼翼处理空指针。后来换了 JSONPath一行表达式解决问题。Spring Boot 项目里处理 JSON不管是对接第三方接口、解析大模型返回还是给监控日志做字段摘要JSONPath 都是被低估的利器。这篇文章我会从选型理由、核心语法、工程化封装到实战场景把 JSONPath 在 Spring Boot 里的用法讲透。适合正在做接口对接、数据清洗、后端测试的 Java 开发者尤其是被嵌套 JSON 折磨过的人。1. 为什么 Spring Boot 项目里要专门处理 JSON三条路线和选型理由1.1 手工逐层拆解代码写得再规整也会被嵌套结构逼疯在没用 JSONPath 之前我处理嵌套 JSON 基本都是下面这种写法JsonNode root objectMapper.readTree(json); JsonNode data root.get(data); if (data null) { throw new BusinessException(缺少 data 节点); } JsonNode orderList data.get(orderList); if (orderList null || orderList.isEmpty()) { return null; } JsonNode firstOrder orderList.get(0); JsonNode items firstOrder.get(items); if (items null || items.isEmpty()) { return null; } String name items.get(0).get(name).asText();这段代码看着规整风险其实很大每一层都可能缺节点数组还可能越界漏掉任何一个空判断就是 NullPointerException 或 IndexOutOfBoundsException。更麻烦的是如果第三方接口某天在 orderList 外层又包了一层 records所有下标和路径都要跟着改。维护这种代码久了你会发现自己不是在写业务而是在做链路补空。嵌套层级一旦超过三层手工拆解的代码就基本不可读了。你很难一眼看出这段代码到底在取什么字段哪怕写了注释过两个月再看还是会头皮发麻。遇到接口返回嵌套结构我建议先用文本编辑器或格式化工具把 JSON 展开确认结构后再决定怎么取。说句题外话JSON 本质上是纯文本用什么编辑器都能打开看到乱码多半是文件编码问题和 JSON 格式本身没关系。1.2 用 Jackson 的 JsonNode灵活但表达力不够后来我换过一段时间的 JsonNode 操作至少比层层 get 干净一点。Jackson 提供了 path() 方法缺失节点返回 MissingNode不会直接抛空指针代码可以少写很多 if。比如上面的例子可以压成一行String name root.path(data).path(orderList).path(0).path(items).path(0).path(name).asText(null);问题在于当你需要从多个嵌套位置取数据或者要过滤某个数组里满足条件的元素时JsonNode 的表达力明显不够。过滤数组你得写循环取多个同名字段你得写迭代器更别提递归找到所有 price 字段这种需求——用 JsonNode 写出来的代码又长又绕还容易在遍历逻辑里出 bug。JsonNode 的优势是它是 Jackson 序列化模型的一部分性能好、和 ObjectMapper 无缝配合。但它的定位是底层 API不是查询语言。你用它操作 JSON就像拿汇编写业务逻辑能跑但写起来痛苦维护起来更痛苦。1.3 引入 JSONPath把 JSON 当文件路径来定位第一次接触 JSONPath 时我潜意识里把它和 XPath 对 XML 的作用联系在了一起实际用下来发现这个类比很准确。JSONPath 就是一套声明式的 JSON 查询语言用类似文件路径的写法直接定位 JSON 里的节点不需要关心中间层结构。String name JsonPath.read(json, $.data.orderList[0].items[0].name);一行代码把手工拆解五六行、判断好几层的工作替代掉了。而且路径本身可读性极强看一眼就知道取的是什么字段。数据结构的层数不再影响代码复杂度嵌套再深也只是路径长一点。更关键的是JSONPath 天生容错。通过配置可以做到中间节点不存在时返回 null而不是抛异常这在实际对接第三方接口时非常重要——你永远不知道对方的返回结构哪天会多一个节点、少一个字段。1.4 依赖选型为什么我建议用 jayway 的 json-pathSpring Boot 生态里常见的 JSONPath 实现有两个一个是 com.jayway.jsonpath:json-path一个是 fastjson 自带的 JSONPath。我建议优先用 jayway 的。jayway 是独立的 JSONPath 实现本身不绑定某个 JSON 序列化框架可以通过 Configuration 自由对接 Jackson、Gson、json-smart 等。fastjson 的版本把 JSONPath 耦合在自己的序列化体系里用起来没那么灵活而且 fastjson 历史上出过多次安全漏洞公司安全扫描基本都会拦没必要为了一个 JSONPath 引入整个 fastjson。还有一个容易忽略的点Spring Boot 的 spring-boot-starter-test 里其实已经带了 json-path很多人在写测试时用过 JsonPath.read()但生产代码没显式引入依赖结果一上线就报 ClassNotFoundException。我的习惯是只要生产代码用了就在 pom 里显式声明不依赖传递依赖。选型理由总结一下对比项jayway json-pathfastjson 自带 JSONPath独立性独立实现可对接多种序列化框架绑定 fastjson 体系安全口碑无明显历史漏洞历史漏洞较多常在封禁名单泛型支持TypeRef 支持好泛型支持一般与 Spring Boot 兼容性官方组件常依赖兼容性稳需要单独适配2. JSONPath 核心语法从订单报文出发逐条拆解2.1 定位与遍历$、.、[]、..、* 的组合玩法语法光看文档记不住我拿一个实际对接中常见的订单聚合报文举例。下面这段 JSON 结构是我从一个电商平台订单查询接口脱敏后的返回{ code: 0, message: success, data: { orderList: [ { orderNo: SO20241101-001, buyer: 张三, totalAmount: 1288.00, items: [ {skuId: SKU-1001, name: 蓝牙耳机, price: 399.00, qty: 2}, {skuId: SKU-1002, name: 充电器, price: 98.50, qty: 1} ] }, { orderNo: SO20241101-002, buyer: 李四, totalAmount: 596.00, items: [ {skuId: SKU-2001, name: 机械键盘, price: 596.00, qty: 1} ] } ], logistics: { company: 顺丰速运, trackingNo: SF1234567890 } } }最基本的写法是$表示根节点.表示子节点[]表示数组访问这和 JavaScript 访问对象属性的方式几乎一致。取第一个订单号JsonPath.read(json, $.data.orderList[0].orderNo); // 结果SO20241101-001取所有订单的买家用*通配符JsonPath.read(json, $.data.orderList[*].buyer); // 结果[张三, 李四]..是递归下降作用是在整个文档中搜索所有指定名称的字段无视层级。比如我想知道这批订单所有商品的价格不管嵌套在哪一层JsonPath.read(json, $..price); // 结果[399.0, 98.5, 596.0]这个写法在处理异构数据时特别好用。有些第三方内容接口返回的字段位置会随业务类型变化但字段名是固定的用..一把抓出来省去逐层适配的功夫。实测下来这种语法在报文里嵌套三层以上时收益最明显手工遍历需要写递归JSONPath 一个表达式搞定。2.2 条件过滤与函数?()、、length()、min() 等[]不只是数组索引配合?()可以做条件过滤表示当前正在遍历的元素。比如找出金额大于 1000 的订单JsonPath.read(json, $.data.orderList[?(.totalAmount 1000)].orderNo); // 结果[SO20241101-001]还可以用逻辑运算符组合条件、||、!都支持。比如找买家不是张三且商品数量大于等于 2 的订单可以先看商品数量再判断JsonPath.read(json, $.data.orderList[?(.buyer ! 张三 .items.length() 2)].orderNo);正则匹配也是 JSONPath 的常见用法用~操作符。比如筛选订单号以 SO2024 开头的JsonPath.read(json, $.data.orderList[?(.orderNo ~ /SO2024.*/)].orderNo);jayway 还支持一些函数比如取数组长度、最小值、最大值、平均值。想看订单列表有几条JsonPath.read(json, $.data.orderList.length()); // 结果2这里要提醒一个新手容易犯的错过滤条件里的字符串用单引号还是双引号不同实现有差异。jayway 两种都认但为了防止在别家实现上翻车我通常统一用单引号。2.3 一份速查表先收藏再实战JSONPath 表达式含义示例输出$根节点整个文档整个 JSON$.data.orderList[0]取数组第一个元素第一个订单对象$.data.orderList[*].buyer取所有订单的 buyer 字段[张三,李四]$..price递归查找所有 price 字段[399.0, 98.5, 596.0]$..*递归展开所有层级文档中所有叶子节点$.data.orderList[?(.totalAmount 1000)]条件过滤数组元素满足条件的订单数组$.data.orderList[?(.buyer ~ /李.*/)]正则匹配买家姓李的订单$.data.orderList.length()调用函数求长度2$.data.orderList[0].items[0:2].name数组切片取前两个商品名[蓝牙耳机,充电器]3. Spring Boot 工程落地依赖、工具类与三种实战场景3.1 Maven 依赖与版本兼容既然要在 Spring Boot 生产环境用第一步是把依赖显式加到 pom 里。以 Spring Boot 3.x 为例实测下来 json-path 2.9.0 没遇到兼容性问题dependency groupIdcom.jayway.jsonpath/groupId artifactIdjson-path/artifactId version2.9.0/version /dependency注意别用太老的版本比如 2.4.0 这个区间的版本在处理某些转义字符串时会有编码问题。如果项目还在 Spring Boot 2.3.x 或者 2.6.x2.9.0 也能用它只依赖 JSON 处理的基础能力不绑定 Servlet 或 Spring 版本。另外spring-boot-starter-test 里的 json-path 是测试用的和生产依赖不冲突但版本可能跟你在 pom 里显式声明的版本不一致这时候以 pom 里声明的为准。Maven 的依赖仲裁规则是就近声明优先所以显式声明版本号反而能避免测试和生产行为不一致。3.2 封装一个够用的 JsonPathUtils 工具类直接在用业务代码里到处写 JsonPath.read 不是不行但可维护性差。我建议封装一个工具类把配置、异常处理、默认值统一收敛起来。下面这个类我放在项目 common 模块里用了两年接口对接场景足够用了import com.jayway.jsonpath.Configuration; import com.jayway.jsonpath.JsonPath; import com.jayway.jsonpath.Option; import com.jayway.jsonpath.TypeRef; public class JsonPathUtils { private static final Configuration CONFIG Configuration.builder() .options(Option.DEFAULT_PATH_LEAF_TO_NULL, Option.SUPPRESS_EXCEPTIONS) .build(); private JsonPathUtils() { } public static T T read(String json, String path, ClassT clazz) { try { return JsonPath.using(CONFIG).parse(json).read(path, clazz); } catch (Exception e) { return null; } } public static T T read(String json, String path, TypeRefT typeRef) { try { return JsonPath.using(CONFIG).parse(json).read(path, typeRef); } catch (Exception e) { return null; } } public static String readString(String json, String path, String defaultValue) { String value read(json, path, String.class); return value ! null ? value : defaultValue; } public static Integer readInt(String json, String path, Integer defaultValue) { Integer value read(json, path, Integer.class); return value ! null ? value : defaultValue; } }这里有两个关键配置必须解释一下。Option.DEFAULT_PATH_LEAF_TO_NULL的作用是当路径中间的某个节点不存在时直接把最终结果置为 null而不是抛 PathNotFoundException。对接第三方接口时对方返回结构少一个字段是家常便饭这个配置能让整个查询变得宽容很多。Option.SUPPRESS_EXCEPTIONS的作用是当路径完全无法匹配时静默处理异常返回 null。加上之后工具类外部就不需要再包 try-catch 了代码立马清爽。TypeRef 重载是为了支持泛型。比如要直接读取一个 List 对象Class表达不了ListOrderDTO这种泛型类型TypeRef 可以。后面实战场景里会用到。3.3 场景一第三方接口嵌套字段提取最典型的场景就是对接第三方接口。我之前做一个多商户平台的项目需要调用物流服务商的运单查询接口对方的返回报文结构和文档对不上实际返回长这样{ resp: { records: [ { waybillNo: SF1029384756, statusDesc: 已签收, events: [ {time: 2024-11-01 10:00, desc: 快件到达【北京中转场】} ] } ] } }要从这个报文里拿到第一个运单号、当前状态和最新一条物流轨迹用 JSONPath 写就是String json apiClient.queryWaybill(param); String waybillNo JsonPathUtils.readString(json, $.resp.records[0].waybillNo, ); String statusDesc JsonPathUtils.readString(json, $.resp.records[0].statusDesc, 未知); String latestEvent JsonPathUtils.readString(json, $.resp.records[0].events[0].desc, );如果不用 JSONPath三层嵌套每个字段都要写一堆空判断三个字段下来代码几十行而且字段取值逻辑和结构强耦合。用 JSONPath 之后表达式直接表达我要什么而不是我该怎么走。我自己的体会是处理这种外部接口时JSONPath 不只是简化代码它还提高了对接口结构变化的容忍度。对方的报文多包一层、少一个字段只要主路径不变代码基本不用动。3.4 场景二接口日志与监控字段摘要Spring Boot 项目做监控是高频需求网上常看到spring boot 实现监控都有哪些需求和功能这类问题。实际项目里我们经常要把监控接口返回的大 JSON 做摘要只记录几个关键字段到日志或数据库。比如 Spring Boot Actuator 的 /health 接口返回结构类似这样{ status: UP, components: { db: { status: UP, details: { database: MySQL, validationQuery: SELECT 1 } }, diskSpace: { status: UP, details: { total: 1000, free: 300 } } } }如果要把 db 状态和磁盘状态写入审计日志用 JSONPath 提取就是String healthJson actuatorClient.health(); String dbStatus JsonPathUtils.readString(healthJson, $.components.db.status, UNKNOWN); String diskTotal JsonPathUtils.readString(healthJson, $.components.diskSpace.details.total, ); log.info(健康检查摘要 db{}, diskTotal{}, dbStatus, diskTotal);这种日志摘要写法清爽而且不会因为某个组件暂时没有上报就导致整条日志写不出来。监控需求里关键字段缺失时记录 UNKNOWN这种兜底逻辑JSONPath 做起来几乎零成本。3.5 场景三大模型返回结构化 JSON 的解析现在很多 AI 应用接大模型接口大模型返回的 content 里经常夹着一段 JSON 字符串而且外层报文结构固定内层内容结构却是模型自己生成的。比如翻译类大模型的返回{ choices: [ { message: { content: {\code\:0,\data\:{\text\:\你好世界\,\lang\:\zh-CN\}} } } ] }这里 content 字段的值是一段被转义过的 JSON 字符串。用 JSONPath 取 content 的值String content JsonPathUtils.readString(json, $.choices[0].message.content, );取出来的是字符串还得再解析一次才能拿到真正的翻译结果JsonObject result JsonParser.parseString(content).getAsJsonObject(); String translatedText result.get(data).getAsJsonObject().get(text).getAsString();更骚的操作是内层 JSON 再用 JSONPath 解析避免一层层 get 绕String translatedText JsonPathUtils.readString(content, $.data.text, );这种JSON 套字符串、字符串里再套 JSON的结构在大模型接口里太常见了。用 JSONPath 处理外层再用 JSONPath 处理内层逻辑上非常统一。顺带一提如果大模型返回的 content 本身格式不稳定建议解析前先做一层 try-catch毕竟模型输出的格式没法保证 100% 符合预期。4. 高级用法与性能优化注意事项4.1 Option 选项静默处理异常和缺失节点前面工具类里已经用了两个 Option但还有几个值得了解。Option.ALWAYS_RETURN_LIST强制让返回值始终是 List。这个在不确定路径匹配到一个值还是多个值的时候很有用。比如$.data.orderList[0].items..price如果第一个订单只有一个商品返回可能是个单值如果多个商品返回数组。加上这个 Option 后结果统一是 List省得在业务代码里判断类型。但要注意全局配置强制返回 List 可能会影响单值读取的场景所以我不太建议直接配在全局 Configuration 里。更推荐的做法是在具体查询时用 Configuration 的派生配置Configuration listConfig Configuration.builder() .options(Option.ALWAYS_RETURN_LIST, Option.SUPPRESS_EXCEPTIONS) .build(); ListString names JsonPath.using(listConfig).parse(json) .read($.data.orderList[*].buyer, List.class);另一个好用但少有人提的是Option.ALWAYS_RETURN_LIST配合Option.DEFAULT_PATH_LEAF_TO_NULL时的行为差异实测时最好写个单元测试锁住结果别靠猜。4.2 动态路径拼接与 TypeRef 泛型读取对接业务时路径经常是动态的。比如根据用户传入的索引取订单用字符串拼接就行String path String.format($.data.orderList[%d].buyer, index); String buyer JsonPathUtils.readString(json, path, );注意 JSONPath 的索引是 0 开始的业务上传的序号如果从 1 开始拼接前记得减一。如果过滤条件的值来自外部参数比如按订单号精确查找我更推荐用 jayway 提供的 Criteria API而不是手动拼字符串。因为 Criteria 会帮你做特殊字符转义import com.jayway.jsonpath.Criteria; import com.jayway.jsonpath.Filter; Filter orderFilter Filter.filter(Criteria.where(orderNo).eq(orderNo)); ListMapString, Object orders JsonPathUtils.read(json, $.data.orderList[?], new TypeRefListMapString, Object() {}, orderFilter);其实 jayway 的 read 方法支持把 Filter 参数透传到路径里上面的写法能避免把外部输入直接拼进表达式减少注入风险。至于泛型读取我之前踩过个坑想直接读取 List 用 Class 传参只能拿到 List里面是 LinkedHashMap转对象还得手动做。后来改成 TypeRef 就顺畅了ListOrderDTO orders JsonPathUtils.read(json, $.data.orderList, new TypeRefListOrderDTO() {});拆箱即得路径匹配和 Jackson 反序列化一步到位。这个模式强烈建议固定下来项目里所有人都走这个范式减少五花八门的类型转换代码。4.3 性能实测与缓存策略JSONPath 会不会影响性能这是我在引入前最关心的问题。实测了一次十万节点量级的 JSON 报文简单路径如$.data.orderList[0].orderNo解析耗时在亚毫秒范围可以忽略不计。但..递归下降会遍历整个文档树在超大报文上耗时明显上升热路径要慎用。jayway 内部对已编译的路径有缓存所以同一个表达式重复执行不会重复解析。如果你对同一个路径高频调用建议把编译后的 JsonPath 对象缓存起来省掉每次的路径解析开销JsonPath compiledPath JsonPath.compile($.data.orderList[0].items[*].price); // 后续直接 compiledPath.read(json) 复用另外一个实战经验如果报文本身已经用 Jackson 反序列化成 JsonNode 了再传给 JSONPath 解析前最好确认当前 Configuration 的 JsonProvider 是不是能识别 JsonNode。默认的 provider 是 json-smart它不认识 Jackson 的 JsonNode直接把 JsonNode 传进去会报错。要么转成字符串再解析要么引入 jackson-json-path-provider 并做配置两条路都能走但别指望默认配置开箱即用。5. 常见问题与排查技巧实录5.1 六类高频踩坑现场这里把我实际遇到的坑整理成一张速查表很多问题在网上的提问里反复出现现象根本原因解决方案抛 PathNotFoundException路径中间节点缺失默认配置是抛异常配置 Option.DEFAULT_PATH_LEAF_TO_NULL返回结果类型转换失败 ClassCastException路径匹配多个值却用 Class 接收单值用 TypeRef 接 List或加 ALWAYS_RETURN_LIST把 JsonNode 传 parse 报错默认 provider 不识别 Jackson 的 JsonNode转成字符串再解析或配 JacksonJsonProvider数组索引越界异常下标超过实际长度先用 length() 判断或全局兜底异常过滤条件查不到数据单引号/双引号用错或漏写 用 Criteria API 构建条件减少拼串出错路径拼接外部参数时空指针参数未判空拼接前判空或改用 Filter 传参其中过滤条件查不到数据这个坑最隐蔽。有一次我用$.data.orderList[?(.buyer 张三)]怎么都查不到后来发现是我把写在了双引号外面还是引号用了全角这类问题对眼睛极度不友好。现在我的习惯是先在任何支持 JSONPath 的在线校验工具上验证表达式再落到代码里。这比在断点里反复试快得多。5.2 排查思路和协作建议排查 JSONPath 问题我的标准流程是这样。先在本地写一个独立测试用例只测表达式本身。比如在测试类里放一段最小化的 JSON 报文然后断言 JSONPath 的读取结果。这一步能排除业务代码干扰确认问题出在表达式还是出在数据。如果表达式在测试里是对的那问题多半是运行时拿到的 JSON 字符串和预期不一致这时候打印或断点看原始字符串重点检查有没有转义。处理大模型或第三方接口返回时字符串里可能有不可见字符比如换行符、转义斜杠路径匹配不出来。最好的办法不是肉眼瞪而是先格式化输出一次结构对了再进业务。这里还要提一下和测试同事的协作。JMeter 里有个 JSON Extractor用的就是 JSONPath 语法很多做性能测试的同学会用它在压测时提取接口返回值并生成结果文件。我发现同一个 JSONPath 表达式在 JMeter 里能取到值那后端代码里也一定能取到除非管道的转义规则不一致。所以遇到线上取不到值可以先让测试同学在 JMeter 里验证同一路径把问题快速定界到表达式错还是数据错避免后端一个人闷头排查。还有一个排查技巧把路径拆短。一次复杂的递归下降查不到结果就先查$..price能不能出数再逐步加层级约束每一步都能确认数据在哪一段断掉。这个思路在报文层级特别深时很管用。个人体会我给团队封装 JsonPathUtils 那阵子最大的感触是JSONPath 并不是银弹它解决的是查询和定位的问题而不是反序列化和性能的问题。该用 Jackson 流式处理大报文的时候别硬套 JSONPath该写 DTO 的时候也别所有字段都靠路径现取。最合理的姿势是结合外来不可控的嵌套数据用 JSONPath 定位业务核心数据用 DTO 承接校验。现在我在新项目里对接任何接口第一反应已经从写逐层 get变成了先想路径表达式。这也让我反思很多时候代码复杂不是业务复杂而是工具选型没到位。下次写接口对接的代码前不妨先看数据结构再决定用哪把钥匙。
返回列表