news 2026/10/10 5:09:27

Spring Boot中JSONPath实战:优雅解析嵌套JSON与第三方接口数据

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot中JSONPath实战:优雅解析嵌套JSON与第三方接口数据

接手一个跨境商城项目时,最让我头疼的不是业务逻辑,而是第三方接口返回的那一大坨嵌套 JSON —— 订单信息、商品快照、支付流水、物流轨迹全揉在一起,层级深得离谱。为了从里面抠出一个状态码或者金额,我写过一堆JSONObject.getJSONObject(...).getJSONObject(...).getString(...)式的面条代码,又丑又脆,稍微换个字段就崩。

后来在给 Spring Boot 服务做接口联调时接触到 JSONPath,情况才彻底改观。这玩意儿用起来和写 XPath 查 XML 差不多,一条路径表达式直接指到目标字段,代码量少一大半,读起来也清楚:你要什么,表达式就长什么样。这篇文章我就把在 Spring Boot 里用 JSONPath 处理 JSON 的完整经验整理出来,从为什么选它、核心语法,到项目落地的代码封装、真实场景拆解,再到我踩过的坑和性能优化,一次性讲透。适合正在和第三方接口较劲的后端开发,也适合想在项目里少写点解析模板代码的朋友参考。

1. 为什么我给 Spring Boot 项目选了 JSONPath

说一下我之前的习惯。Spring Boot 项目里处理 JSON,大家第一反应是用 Jackson,毕竟它自带 ObjectMapper,DTO 一定义,readValue一调,反序列化完事。这方式本身没毛病,但架不住现实场景刁钻:第三方接口返回的报文结构经常变,昨天还在的字段今天可能就没了;或者一个上百个字段的大 JSON,我只需要其中两个值,为这俩值定义一整坨 DTO 类,总觉得亏得慌。更麻烦的是那种"响应里有响应"的结构 —— 外层包着 code、message,内层才是数据,内层数据里又套数组套对象。用 DTO 硬映射,每层都得建类,类爆炸。

JSONPath 解决的是另一个纬度的问题:把 JSON 当作一棵树,用路径直接访问节点。它不关心这个 JSON 整体像什么形状,只关心你指定的那几条路通不通。这对三件事特别有用:

  • 接口联调期,第三方文档不齐全,你想快速验证响应里的某个字段到底在什么位置、值是什么;
  • 测试断言,Spring Boot 的 MockMvc 测接口返回值,用 JSONPath 比一层层反序列化断言省事得多;
  • 监控和运维脚本,从健康检查接口或者注册中心返回的 JSON 里提取关键指标,比如从 Spring Boot Actuator 的/health响应里拉出status和各个组件的状态。

我最终下决心在项目里铺开用,是因为一次对接物流查询接口的经历。对方返回的 JSON 里,轨迹信息不是固定的对象,而是一个数组,数组里元素的字段名还带序号(track_1、track_2这种)。用 DTO 映射基本是噩梦,但我用 JSONPath 写过滤表达式,直接就把所有轨迹节点捞出来做了遍历,前后不到半小时。

和手写解析相比,JSONPath 的核心优势是可读性和稳定性。你看到$.data.orderList[0].amount,不用在脑子里跑一遍代码,直觉就知道取的是订单列表第一项的金额。而且表达式可以外置到配置文件里,第三方字段一旦变更,改配置就行,不用重新编译发版,这对线上服务来说意义很大。

2. JSONPath 核心语法拆解:一条路径看懂整个体系

在用 Spring Boot 之前,我建议先把 JSONPath 的语法过一遍。别看它叫"XXPath",其实核心规则没几条,能覆盖九成使用场景。

2.1 节点访问:.和..的区别要搞清

最基础的是用点号访问子节点,比如$.store.book表示取根节点下 store 下的 book。这里$是根节点的意思,固定写法。如果我只写store.book,通常也能通过,因为很多实现(包括 Java 的 Jayway JsonPath)会自动补全$,但建议还是写完整,语义清楚。

真正容易懵的是递归下降操作符..。$.store..price表示从 store 节点开始往下找所有名为 price 的字段,不管它在第几层。这招在应对"字段层级不确定"的场景特别香,比如你要从响应里抓所有amount字段做汇总,哪怕它们散落在三个不同层级的节点里,一条$..amount就全捞出来了。

注意这里的递归下降有性能代价,它会遍历整个子树。我用它做一次性排查没问题,但如果在高 QPS 的接口解析链路里频繁使用,还是尽量显式写全路径。这个坑我后面会展开讲。

2.2 数组操作:下标、切片和通配

数组访问在 JSONPath 里灵活得离谱。最基本的$.store.book[0]取第一本,$.store.book[2]取第三本。下标从 0 开始,和 Java 数组一样,写错会报错。

切片语法[start:end]是从 Python 借鉴过来的,左闭右开。$.store.book[0:2]取前两本,$.store.book[1:]表示从第二本到末尾,$.store.book[:2]表示开头到第二本。这招在分页场景很好用,比如第三方接口直接返回全量列表,客户端用切片取自己需要的那一段。

通配符*是压箱底的利器。$.store.book[*]遍历数组所有元素;$.store.*取 store 节点下的所有子节点,可能是数组也可能是对象。和递归下降配合还能玩出花活:$..book[*]就能从任意层级找到所有 book 数组里的所有元素。不过我提醒一句,通配符用得越爽,性能就越差,因为它本质上是把整个子树的遍历都跑了一遍。

2.3 过滤表达式:?()是查询的灵魂

数组里按条件挑元素,这是日常最高频的需求。语法是$.store.book[?(@.price > 10)],意思是取 store 下 book 数组中所有 price 大于 10 的元素。@表示当前正在遍历的元素,类似 Java 8 Stream 里的x -> x.getPrice()那个x。

过滤支持的操作符有==、!=、>、<、>=、<=、=~(正则匹配)、in、nin(不在集合中),还能用&&和||做逻辑组合。比如$.data.orderList[?(@.status == 'PAID' && @.amount > 100)]就直接把已支付且金额大于 100 的订单全部筛出来,返回的仍然是一个数组。

这里有个细节容易踩坑:字符串比较必须带引号,而且用单引号,写成?(@.status == "PAID")在部分实现里会直接报错,Jayway 的实现就是只认单引号。

2.4 一个表说清常用表达式

我把自己平时用得最多的表达式整理成一张表,别看简单,覆盖的场景相当广:

表达式含义实际例子
$.a.b.c按层级取字取订单的收货人姓名
$.a[0].b取数组第一个元素的子节点取轨迹列表第一条的节点名称
$.a[*].b取数组所有元素的子节点取所有 SKU 的商品名
$..b递归找所有名为 b 的字段不管层级,抓所有快递单号
$.a[?(@.b == 'x')]过滤数组元素筛出所有状态为"已发货"的订单
$.a.length()取数组长度确认返回了多少条记录

有了这张表,大部分接口联调和数据抽取需求都能直接上手。

3. Spring Boot 实操:依赖、封装与第一个完整案例

语法只是热身,进 Spring Boot 项目才算真正开始。这一节讲怎么从零把 JSONPath 用起来,包括依赖引入、工具类封装,以及一个完整的接口解析案例。

3.1 Maven 依赖和生产环境的选择

Java 生态里 JSONPath 的实现不少,我用的最多的是 Jayway 的json-path,它在 Spring Boot 的测试模块里其实是个老熟人 —— spring-boot-starter-test 里就带着它,专门给 MockMvc 做响应断言。只需要引入一个坐标:

<dependency> <groupId>com.jayway.jsonpath</groupId> <artifactId>json-path</artifactId> <version>2.9.0</version> </dependency>

如果项目里已经引入了 spring-boot-starter-web,Jackson 本身就带着,JSONPath 反序列化时会自动借用 Jackson 的 ObjectMapper,不需要额外再引 Jackson 相关的 JSONPath 扩展包(虽然 Jayway 也提供了单独的和 Jackson 集成的模块json-path-assert,但生产代码里一般用不上)。

这里有个版本建议:2.9.0 是 2024 年前后比较稳定的版本,2.8.0 也还在广泛使用。如果你的 Spring Boot 版本较老,担心依赖冲突,可以用spring-boot-dependencies的 BOM 管理版本,省心。

3.2 一个够用的 JsonUtils 工具类

生产代码里我不建议到处直接调JsonPath.read(),因为异常处理和类型转换的代码会散得到处都是。我一般封装一个极简的工具类,只提供两个方法:按表达式读值、按表达式反序列化为对象。代码不长,直接抄就行:

import com.jayway.jsonpath.Configuration; import com.jayway.jsonpath.JsonPath; import com.jayway.jsonpath.Option; import com.jayway.jsonpath.ReadContext; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.util.StringUtils; public class JsonUtils { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final Configuration CONF = Configuration.builder() .options(Option.DEFAULT_PATH_LEAF_TO_NULL) .build(); public static <T> T read(String json, String path, Class<T> type) { if (!StringUtils.hasText(json) || !StringUtils.hasText(path)) { return null; } try { Object value = JsonPath.using(CONF).parse(json).read(path); if (value == null) { return null; } if (type == String.class) { return type.cast(value.toString()); } if (value instanceof java.util.List || value instanceof java.util.Map) { return MAPPER.convertValue(value, type); } return MAPPER.convertValue(value, type); } catch (Exception e) { throw new IllegalArgumentException( "JSONPath read failed. path=" + path + ", error=" + e.getMessage(), e); } } public static String pretty(String json) { try { return MAPPER.writerWithDefaultPrettyPrinter() .writeValueAsString(MAPPER.readTree(json)); } catch (Exception e) { return json; } } }

这个封装解决三个问题:字段不存在时不抛异常而是返回 null(通过Option.DEFAULT_PATH_LEAF_TO_NULL);String 类型读到数字或布尔值时自动转成字符串;复杂对象(List、Map、DTO)统一用 Jackson 的 convertValue 做类型转换,省得手动处理。

3.3 第一个案例:从订单大 JSON 中提取核心字段

光有工具类还不够,用一个真实案例走一遍完整流程。假设第三方商城接口返回了下面这样的订单快照(我简化了字段,但结构保留了常见的嵌套和数组):

{ "code": 0, "message": "success", "data": { "orderId": "SO20250101001", "buyer": { "nickname": "张三", "phone": "13800000000" }, "shop": { "shopName": "某某数码专营店", "rating": 4.8 }, "skuList": [ { "skuId": "SKU1001", "title": "无线蓝牙耳机", "price": 299.00, "quantity": 2, "status": "PAID" }, { "skuId": "SKU1002", "title": "快充充电器", "price": 49.00, "quantity": 1, "status": "PAID" }, { "skuId": "SKU1003", "title": "手机支架", "price": 19.90, "quantity": 0, "status": "CANCELLED" } ], "logistics": { "company": "顺丰速运", "trackNo": "SF123456789", "trace": [ {"code": "ARRIVED", "desc": "已到达目的地"}, {"code": "SIGNED", "desc": "已签收"} ] } } }

对接这个接口,我需要提取:订单号、买家手机号、所有已支付 SKU 的总金额、物流公司。按老写法,得定义OrderResponse、Data、Buyer、Sku、Logistics五个类。用 JSONPath 的话:

String orderNo = JsonUtils.read(json, "$.data.orderId", String.class); String buyerPhone = JsonUtils.read(json, "$.data.buyer.phone", String.class); List<String> company = JsonUtils.read(json, "$.data.logistics.company", String.class);

注意第三行我故意没写对。如果直接取$.data.logistics.company,正常返回的是一个 String。但假如物流信息有时候不返回、有时候返回多个,用 String 接收会出问题。这就是我在实战里最常用的一个决策点:先用简单的 read 确认类型,再决定用 String 还是 List 接。用 JSONPath 的排查类方法能很快看清结构,后面我在排查技巧里专门讲。

然后算已支付 SKU 的总金额,这是个典型的"读取 + 聚合"需求:

List<BigDecimal> paidPrices = JsonUtils.read(json, "$.data.skuList[?(@.status == 'PAID')].price", List.class); BigDecimal total = BigDecimal.ZERO; for (BigDecimal price : paidPrices) { total = total.add(price); }

注意表达式$.data.skuList[?(@.status == 'PAID')].price直接取到了所有已支付 SKU 的 price 字段数组,这比先把整个 list 拿出来再在 Java 里 filter、map 省好几行。实测下来,遇到"筛选 + 提取"组合需求,JSONPath 的表达效率是碾压式的。

4. 进阶实战:复杂过滤、配置外置与监控数据提取

基本的能跑了,再看几个我在真实项目里踩过的场景,这些才是我把 JSONPath 煮成熟饭的关键。

4.1 多条件过滤和正则匹配

真实的第三方返回往往条件不止一个。比如跨境商城里要找出"金额大于 100 且状态是已支付"的订单,写:

List<Map<String, Object>> targetOrders = JsonUtils.read(json, "$.data.orders[?(@.amount > 100 && @.status == 'PAID')]", List.class);

返回的每个 Map 就是一个完整的订单对象。如果你还想要某个字段,直接在表达式末尾.orderNo就取走了,只拿需要的字段,返回结果更轻。

正则匹配我用的频率不高,但遇到过一次:对方的优惠券编码规则是COUPON_开头加 6 位数字,我要从一堆券里过滤出有效的。写成:

List<Map<String, Object>> validCoupons = JsonUtils.read(json, "$.data.coupons[?(@.code =~ /COUPON_\\d{6}/)]", List.class);

注意 Java 字符串里\\d要写成\\d(双反斜杠),这个和正则表达式本身没关系,是 Java 转义的问题,坑过我好几次。

4.2 把 JSONPath 表达式外置到配置文件

这是我最推荐的一个工程化实践。第三方接口的字段路径一旦变了,改代码、走 CI、重新发版,最快也要十分钟。但如果把路径表达式放到application.yml里,改完配置刷新一下就行。

配置写法:

third-party: order: order-no-path: "$.data.orderId" buyer-phone-path: "$.data.buyer.phone" logistics-company-path: "$.data.logistics.company"

配合一个简单的配置类:

@Component @ConfigurationProperties(prefix = "third-party.order") @Data public class ThirdPartyPathProperties { private String orderNoPath; private String buyerPhonePath; private String logisticsCompanyPath; }

业务代码里注入这个配置类,然后用它去调JsonUtils.read。好处很直观:对接文档一变,我不动代码,只改配置。尤其适合那种第三方接口文档三天两头更新的情况,能少折腾很多次发版。

当然这也不是银弹,如果表达式本身涉及复杂的业务逻辑判断,比如过滤条件里的数值阈值,也一并外置,但要注意别把配置堆到失控的程度,量力而行。

4.3 从 Actuator 健康检查接口提取指标

前面热词里反复出现"Spring Boot 实现监控",这里插一个实际场景:Spring Boot Actuator 的/health端点返回的 JSON 包含了所有健康检查项的明细,比如数据库连接、磁盘空间、RabbitMQ 连接等,嵌套很深。如果你做告警或监控面板,需要把这些状态拉出来。

/health的返回大致长这样(取决于 expose 的配置):

{ "status": "UP", "components": { "db": { "status": "UP", "details": { "database": "MySQL", "validationQuery": "SELECT 1" } }, "diskSpace": { "status": "UP", "details": { "total": 500000000000, "free": 299000000000 } } } }

提取所有组件的状态,一条表达式搞定:

Map<String, Object> componentStatus = JsonUtils.read(healthJson, "$.components.*.status", Map.class);

这里components.*把所有子组件对象过一遍,再取它们的 status 字段,返回的 Map 键是组件名、值是状态。要判断某个具体组件是否 UP,也可以写$.components.db.status直接读。这个用法在监控数据采集的小工具里相当好用,不用引一堆监控框架,自己写个定时任务拉取加解析就完事。

4.4 用 MockMvc + JSONPath 做接口测试断言

最后是测试环节。Spring Boot 的spring-boot-starter-test已经带了 JSONPath 的断言支持,配合 MockMvc 对 Controller 返回值做校验,比反序列化成对象再断言快得多。写法也很直接:

mockMvc.perform(get("/api/orders/{id}", 1L)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.orderId").value("SO20250101001")) .andExpect(jsonPath("$.data.skuList[0].title").value("无线蓝牙耳机")) .andExpect(jsonPath("$.data.skuList[?(@.status == 'CANCELLED')]").isNotEmpty());

注意 spring-boot 的jsonPath方法和 Jayway JSONPath 的语法完全一致,所以你可以复用前面学的过滤表达式。上面最后一行就是断言已取消状态的 SKU 列表不为空,这在做状态流转类的接口测试时特别有用。我推荐在 Controller 测试和集成测试里大量使用这种方式,业务接口的响应结构一变,测试马上就能报出来,比人肉 postman 点来点去可靠得多。

5. 实战经验:那些文档里不会写的问题与性能提醒

用 JSONPath 写了半年多,中间没少踩坑。把这些问题和排查方法整理成速查表,是这篇博文里我觉得最值钱的部分。

常见问题报错/现象原因和解决方案
PathNotFoundException读取时抛出路径写错或字段不存在。先打印 JSON 确认路径结构,或者用Option.DEFAULT_PATH_LEAF_TO_NULL让不存在的路径返回 null
InvalidPathException路径解析报错多半是单引号写成了双引号,过滤表达式必须用单引号包字符串
数组越界读取数组第 N 个元素报错数组长度小于下标。先用length()判断长度,或者用通配符遍历而不是硬编码下标
类型转换异常取到的是 List 强行转成 String确认节点结构,先用read(..., Object.class)看真实类型再决定接收类型
数字精度丢失大金额变成了科学计数法或丢失小尾数金额相关字段统一用 BigDecimal 接收,不要用 Double

5.1 路径写错时的三步排查法

如果你拿到 JSON 但路径怎么写都不对,我建议按这个顺序干:

  1. 先用格式化工具把 JSON 打印出来,一行层一个缩进,看清节点在哪个层级;
  2. 用JsonUtils.read(json, param, Object.class)读一次,别用具体类型,看返回类型是 Map 还是 List,确认当前的节点是对象还是数组;
  3. 如果字段名有歧义,用JsonPath.parse(json).jsonString()打印当前路径命中的子结构,确认过滤条件是否把内容筛空了。

这个"先 Object 后具体类型"的习惯我保持了很久,尤其面对第三方接口时,能省掉大量和文档比对的猜测时间。

5.2 性能提醒:别让 JSONPath 变成接口瓶颈

JSONPath 虽然方便,但它不是银弹。$..递归下降和*通配符都是全量遍历,响应 JSON 一大,性能会很不好看。我有一次从一份几百 KB 的报表 JSON 里用$..amount汇总所有金额,单次调用花了接近 200 毫秒,这在低 QPS 的内部工具里没问题,但要是放在用户主链路接口里,分分钟拖垮响应时间。

我的经验原则是三条:

  • 有限次数:单个接口解析逻辑里,同一个 JSON 用 JSONPath 读取的次数不要超过 5 次,超过就考虑先把 JSON 反序列化成 DTO,哪怕只取部分字段,整体性能也更好;
  • 不递归下降:生产链路里尽量写全路径,避免$..,宁可路径长一点,也别让每层都遍历;
  • 缓存编译结果:如果你确定表达式是固定的,可以用JsonPath.compile(path)预先编译,复用ReadContext,避免每次 parse 重复编译路径。尤其在定时任务、批处理场景里,这个优化效果很明显。

5.3 类型坑:金额、时间和嵌套泛型

最后说三个和 Spring Boot 联动时最容易出的类型问题。

金额字段,第三方接口返回的经常是"price": 299.00这种带两位小数的字符串或者浮点数,用String接收没问题,用Double接收会有精度问题,用Float更加不推荐。我统一用BigDecimal接收,也因为工具类的 convertValue 走的是 Jackson,所以new BigDecimal("299.00")自动就处理好了,不会出现二进制浮点的尾数问题。

时间是另一类高频坑。第三方返回的时间戳可能是秒级(10 位)也可能是毫秒级(13 位),还有的是 ISO 8601 字符串。用 JSONPath 取出来之后我习惯先保持原始格式,不要急着转LocalDateTime,确认单位之后再统一转换。因为 JSONPath 本身不负责类型推断,读出来可能是一个Integer也可能是一个Long,直接强转会出事。

嵌套泛型这种,比如List<Map<String, List<OrderLine>>>,JSONPath 取出来后用 Jackson 转的时候,泛型信息容易丢。我建议复杂结构用TypeReference配合 convertValue 来接收,而不是简单传List.class。工具类里那个read方法虽然能转,但遇到复杂泛型时最好还是单独写一个方法:

public static <T> T read(String json, String path, TypeReference<T> typeRef) { Object value = JsonPath.read(json, path); return MAPPER.convertValue(value, typeRef); }

这种写法在面对List<List<Map<String, Object>>>这种多层嵌套时,才不会在运行期报 Cast 异常。

6. 我的使用体会

把 JSONPath 引入 Spring Boot 项目这半年,最大的感受是很多以前"不得不写"的解析代码消失了。接口对接时,拿到一份陌生的 JSON,我可以先写几条表达式探路,再决定要不要定义 DTO;遇到对方加字段,我不用改代码,配置文件改一下路径就完事;给监控面板写数据采集小工具,也不需要为了两个指标建一整套实体类。它解决的不只是"少写几行代码"的问题,更重要的是降低了和 JSON 数据打交道时的认知负担——路径不但是提取数据的手段,它本身就是对数据结构的一种直观描述。

最后再说个细节:如果你和第三方对接时对方说"字段可能有变动,你兼容一下",以前我会琢磨怎么在 DTO 里塞一堆@JsonIgnoreProperties(ignoreUnknown = true)再加几个可空字段,现在直接 JSONPath 取需要的,其他一概不管。这种"按需提取"的思路,才是 JSONPath 在 Spring Boot 项目里最值得发挥的价值。希望这篇经验能帮你在下一次面对嵌套 JSON 时,少踩几个坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 5:08:30

stm32cubemx 固件 FW-F4 V1.28.0离线安装教程

由于现在stm32cubemx 下载需要myST登录&#xff0c;但是注册myST又经常无反应&#xff0c;所以我就找了STM32Cube FW-F4 V1.28.0固件版本&#xff0c;进行本地安装&#xff0c;以下是本地安装教程及固件下载路径安装流程1&#xff0c;以管理员身份打开CUBE,单击INSTALL/REMOVE2…

作者头像 李华
网站建设 2026/10/10 5:06:29

【通信原理笔记】【一】确定信号分析——1.6 频带信号的复包络

文章目录前言一、频带信号的复包络二、频带信号的三种表示三、等效基带分析总结前言 上一篇我们学习了解析信号&#xff0c;它将信号的负频率部分镜像叠加到正频率部分便于分析。然而&#xff0c;频带信号有着不同的载频&#xff0c;分析起来还是不够方便&#xff0c;这篇我们…

作者头像 李华
网站建设 2026/10/10 5:06:14

vscode中4个json的区别和联系

在vscode中快捷键ctrlshiftp&#xff0c;然后输入setting&#xff0c;会出现下图几个选项 当不同设置之间出现冲突时&#xff0c;听谁的&#xff1a; Open Workspace Settings(JSON) > Open Settings(JSON) Open User Settings > Open Default Settings(JSON) Open Wo…

作者头像 李华