☰
Spring Boot Starter实现Jackson @JsonView分页字段过滤
2026/10/13 2:14:37 网站建设 项目流程

1. 为什么我非要做一个 View Extension Starter

做后端接口的人基本都经历过这么个事:同一个用户对象,列表接口只想给 App 返回 id、name、avatar,管理后台接口却要把 email、手机号、角色全带上。最粗暴的做法是写两个 DTO、两套 Service、两个 Mapper,请求参数变一个字段就要改三个地方,谁维护谁知道疼。Jackson 其实提供了一个官方能力叫 @JsonView,可以按视图输出不同字段子集,但真拿到 Spring Boot 里一用就发现问题:分页对象 Page 内部的 User 字段根本不过滤,网上教程十有八九只贴了单个对象的例子,一到真实项目就翻车。所以我干脆封装了一个 Spring Boot Starter,名字就叫 Jackson View Extension,核心就干一件事:让 @JsonView 在 Page、List、Map 这类复杂对象上也能正常按视图过滤字段。

这个项目适合谁参考?第一类是那些接口字段混乱、被迫写 N 套 DTO 的后端开发;第二类是已经用了 @JsonView 但对分页场景束手无策的人;第三类是正在自研 Spring Boot Starter、想抄一套“注解 + 自动配置 + 自定义消息转换器”完整链路的人。它能解决什么问题,一句话就能说明白:Controller 方法上标一个注解,返回值的字段就能按照预定义的视图动态裁剪,分页类内部的对象也能裁剪,不再需要额外写转换代码。

1.1 项目最初想解决的三个痛点

我在做这个项目之前,手头刚好接了一个订单服务的重构,当时接口返回结构乱得离谱。三个痛点特别典型:

第一,字段裁剪和业务代码耦合太深。每个接口都有一段“手撸 Map”的逻辑,把实体字段一个一个塞进 HashMap,接口一多,代码量直接爆炸,而且结构根本没法复用。

第二,Jackson 原生 @JsonView 支持不完整。官方注解在单个对象上确实能用,但只要返回值是 Spring Data 的 Page,序列化出来 content 里的对象就无视视图限制,所有字段全都暴露。这个问题在 GitHub 上也有不少 issue,社区一直没有一个很轻的解决方案。

第三,团队里新人对 Jackson 的视图机制理解不一致。有人以为 @JsonView 加在类上就行,有人把视图注解写在 getter 上,还有人把视图接口定义在实体里,结果每次联调都因为字段对不上吵架。

说实话,这些问题单独拎出来都不难解决,但放到一个数十个接口的项目里,没有一个统一的 Starter 就会演变成“谁都能改、谁也说不清”。我想要的其实是一个约定:接口方法上写 @JsonViewResult(ProfileView.Brief.class),返回值就自动按这个视图裁剪,不强、不侵入、不依赖额外工具类。

1.2 为什么最终选 Jackson 而不是 Fastjson

做技术选型时,“jackson 和 fastjson 哪个好”是绕不开的问题。网上吵了很多年,我的态度很明确:现代 Spring Boot 项目里,优先用 Jackson,不要因为“写起来少两行”就换 Fastjson。

核心原因有三条。第一,Spring Boot 默认把 Jackson 作为 JSON 序列化器,ObjectMapper 已经由自动配置统一管理,自定义 Starter 直接注入即可,完全不用额外引入依赖。第二,Jackson 的生态组件非常完整,@JsonView、@JsonIgnore、@JsonInclude、自定义 Serializer,每个能力都有配套的 SPI,适合做框架级封装。第三,Fastjson 历史上有过多次反序列化高危漏洞通报,虽然新版本修复很快,但在企业项目里,每次漏洞通报都意味着一次紧急升级和全量回归,这种隐性成本不值得背。

当然,Fastjson 也有它的优势,比如 API 风格更贴近 Java 开发习惯、toJSONString 用起来确实顺手,而且部分老项目的 Fastjson 性能调校非常极致。如果你只是写个内部工具脚本,用哪个都无所谓;但如果你是做 Spring Boot Starter 给团队复用,Jackson 是更稳的地基。

2. 先搞清楚 @JsonView 什么时候会失效

在写自定义 Starter 之前,我花了整整一个下午把 Spring MVC 处理 @JsonView 的链路翻了一遍。很多东西只有踩过坑才会真正理解,这里把原理拆开讲清楚。

2.1 原生 @JsonView 的处理链路

Jackson 的 @JsonView 机制并不复杂。序列化时,ObjectMapper 会检查当前是否启用了视图,如果启用了,就会走到 BeanSerializer 的过滤逻辑:属性上标注的 @JsonView 值必须与当前视图相同,或者是当前视图的父类,否则这个属性就不会被写进 JSON。

Spring MVC 在这条链路中间做了一层转接。当 Controller 方法返回一个普通对象时,RequestMappingHandlerAdapter 会检查方法上有没有 @JsonView 注解,如果存在,就把返回值包装成一个 MappingJacksonValue,然后传给 HttpMessageConverter。MappingJackson2HttpMessageConverter 在写响应时会识别这个包装对象,调 writerWithView 完成视图过滤。

问题就出在这条转接链路的边界上。当你返回 Page 时,Spring MVC 看到的 returnValue 是 PageImpl,它会把 MappingJacksonValue 传给 converter,converter 再把视图信息交给 Jackson。但 Spring Data 为 PageImpl 注册了一个自定义序列化器 PageModule,这个序列化器在处理 content 属性时,使用的是泛型擦除后的普通 List 逻辑,并没有把外部传入的视图信息透传到内部的 User 对象上。

我把这个现象叫做“视图信号穿透失败”:Page 外壳知道了视图,但 content 里的元素不知道。最直接的表现就是,你在 Controller 标注 @JsonView(UserView.Brief.class),返回的 JSON 中外层的 total、size 等字段可能正常,但 users 数组里的 password、email 全都被原样输出了。

这种情况在返回单个 User 对象时不会出现,因为序列化路径相对直接;但一旦进入容器类型、泛型擦除的复杂场景,各种自定义 Serializer 就会把视图信息打断。我在项目里还碰到过返回 Map<String, User> 也失效的情况,原因类似,Map 的 value 类型在运行时是 Object,Jackson 走到了 BeanSerializer 但视图信息没传进去。

2.2 三种“绕开视图”的常规方案对比

在决定写 Starter 之前,我研究过三种常规替代方案,每种都有明显的短板,这里直接对比给你看。

第一种是写 DTO 并手动装配。每个场景定义独立的 DTO 类,Service 层负责把实体转成 DTO。优点是类型明确、IDE 重构友好,缺点是字段一多,转换代码极其冗余,尤其是几十个字段的对象,写起来想吐,而且新增一个字段要同步改多处。

第二种是 AOP 后置处理。用 AOP 拦截 Controller 返回,在返回前把对象反射遍历一遍,按某个规则删掉字段。这个方案看着灵活,但它绕过了 Jackson 的 ViewWriter 缓存,反射遍历的性能损耗不可控,而且对 Page 内部对象、嵌套对象的处理依然要自己写递归,复杂度一点没降。

第三种是包装返回值。Controller 手动返回一个自定义的包装类,包装类里存原始对象和视图 Class,再配套一个自定义的 HttpMessageConverter 去处理。这个思路其实已经很接近最终方案了,但让每个业务开发手写包装代码就太蠢了,所以我要做的是把包装动作自动化,并且把整个链路藏进 Starter 里。

我当时画了张草图对比这三种方案:手写 DTO 的优点是直观,缺点是可维护性差;AOP 的优点是透明,缺点是性能和复杂度不可控;包装返回值方案思路正确,但缺少自动化。最后我直接选了“自动包装 + 自定义转换器”这条路,也就是接下来要讲的核心实现。

3. 核心实现:注解、包装对象与自定义转换器

整个 Starter 的核心可以拆成四个零件:一个注解、一个包装对象、一个自定义 HttpMessageConverter、一个 ResponseBodyAdvice。最后再通过自动配置把零件装配起来。

3.1 自定义注解 JsonViewResult

自定义注解的设计没有太多花哨,核心就一个属性:视图 Class。直接上代码。

@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface JsonViewResult { Class<?> value(); }

value 就是你要用的视图接口,比如 UserView.Brief.class。放在方法上表示该接口的返回值按这个视图输出;放在类上表示这个 Controller 里的所有方法都生效。方法上的注解优先于类上的注解。

这里有个细节:我没有用 @AliasFor 去组合 Spring MVC 原生的 @JsonView,而是完全自定义了一个注解。为什么这么做?因为 Spring MVC 原生 @JsonView 在 Page 场景下会走到前面说的失效路径,如果我们的 Starter 再去读这个注解,反而会和框架原生逻辑冲突。自定义注解的好处是逻辑完全可控,我们在 ResponseBodyAdvice 里直接拿注解值,不依赖 Spring MVC 的那套 MappingJacksonValue 转接。

3.2 包装对象与自定义 HttpMessageConverter

包装对象的设计很简单,就像一个信封,把原始返回值和视图 Class 装在一起,供后续序列化时拆开。

public class JacksonViewEnvelope { private final Object content; private final Class<?> view; public JacksonViewEnvelope(Object content, Class<?> view) { this.content = content; this.view = view; } public Object getContent() { return content; } public Class<?> getView() { return view; } }

接下来是重头戏:自定义 HttpMessageConverter。这里有两种实现路径,我先说为什么选继承 MappingJackson2HttpMessageConverter 而不是从零实现 HttpMessageConverter。

从零实现一个 JSON 转换器很繁琐,要自己处理字符编码、MediaType 匹配、泛型类型解析等等,完全没必要。继承 MappingJackson2HttpMessageConverter 的好处是基础逻辑全部复用,我们只需要覆盖 writeInternal 方法,在识别到 JacksonViewEnvelope 时,换一种序列化方式。

public class JacksonViewHttpMessageConverter extends MappingJackson2HttpMessageConverter { public JacksonViewHttpMessageConverter(ObjectMapper objectMapper) { super(objectMapper); } @Override protected void writeInternal(Object object, Type type, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException { if (!(object instanceof JacksonViewEnvelope envelope) || envelope.getView() == null) { super.writeInternal(object, type, outputMessage); return; } MediaType contentType = outputMessage.getHeaders().getContentType(); JsonEncoding encoding = getJsonEncoding( contentType != null ? contentType : MediaType.APPLICATION_JSON); ObjectWriter writer = getObjectMapper().writerWithView(envelope.getView()); try (JsonGenerator generator = getObjectMapper().getFactory() .createGenerator(outputMessage.getBody(), encoding)) { writer.writeValue(generator, envelope.getContent()); } catch (JsonProcessingException ex) { throw new HttpMessageNotWritableException("Could not write JSON: " + ex.getMessage(), ex); } } }

这段代码的逻辑是:如果返回的不是包装对象,走父类原始逻辑;如果是包装对象,就取出原始对象和视图 Class,用 writerWithView 直接序列化。writerWithView 是 Jackson 官方 API,它会创建一个带有视图配置的 ObjectWriter 实例,后续序列化时会自动过滤视图中未声明的字段。

这一步看起来很平淡,但它恰好解决了前面说的“视图信号穿透失败”问题。因为我们不再走 Spring MVC 那套 MappingJacksonValue 转接,而是直接把这个视图 Class 和一个专用的 ObjectWriter 绑定,Page 内部的 User 序列化时,这个 ObjectWriter 的视图状态会一直传递下去。实测下来,Page 、List 、Map<String, User> 都能正确穿透。

3.3 ResponseBodyAdvice 与自动配置

有了转换器还不够,我们需要一个“触发器”来决定什么时候把返回值包装成 JacksonViewEnvelope。这个角色由 ResponseBodyAdvice 来扮演,它可以在 Controller 方法执行完毕、响应体写出之前拦截返回值。

@ControllerAdvice public class JacksonViewResponseBodyAdvice implements ResponseBodyAdvice<Object> { @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { return true; } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { Class<?> view = findView(returnType); if (view == null || body == null || body instanceof JacksonViewEnvelope) { return body; } if (body instanceof String) { return body; } return new JacksonViewEnvelope(body, view); } private Class<?> findView(MethodParameter returnType) { JsonViewResult annotation = returnType.getMethodAnnotation(JsonViewResult.class); if (annotation == null) { annotation = returnType.getContainingClass().getAnnotation(JsonViewResult.class); } return annotation == null ? null : annotation.value(); } }

这里有几个细节需要解释。

supports 方法直接返回 true,意味着所有 Controller 返回值都会经过 beforeBodyWrite。这不是问题,因为 beforeBodyWrite 里判断了有没有 @JsonViewResult 注解,没有注解就直接返回原对象,不会产生额外开销。

body instanceof String 的判断也是必须的。如果 Controller 返回的是字符串,并且我们错误地把它包装成 JacksonViewEnvelope,那么 StringHttpMessageConverter 会无法处理这个包装对象,导致接口直接报错。所以字符串情况必须放行。

自动配置类就很简单了,把这两个 Bean 注册进容器即可。

@AutoConfiguration @ConditionalOnClass({ObjectMapper.class, MappingJackson2HttpMessageConverter.class}) public class JacksonViewAutoConfiguration { @Bean @Order(Ordered.HIGHEST_PRECEDENCE) @ConditionalOnMissingBean public JacksonViewHttpMessageConverter jacksonViewHttpMessageConverter(ObjectMapper objectMapper) { return new JacksonViewHttpMessageConverter(objectMapper); } @Bean @ConditionalOnMissingBean public JacksonViewResponseBodyAdvice jacksonViewResponseBodyAdvice() { return new JacksonViewResponseBodyAdvice(); } }

这里的关键点是我们依赖 ObjectMapper Bean。如果你的项目里已经自定义了 ObjectMapper,Spring Boot 的自动配置会保留你的版本,我们的转换器用的也是同一个实例,不会出现“两套序列化配置互相打架”的问题。

另外,Spring Boot 会把所有 HttpMessageConverter 类型的 Bean 自动收集进转换器列表,所以不需要再配置 WebMvcConfigurer。加 @Order(Ordered.HIGHEST_PRECEDENCE) 是为了确保我们的转换器排在最前面,这样 JacksonViewEnvelope 会被优先匹配。

自动配置文件的路径也按版本区分,Spring Boot 2.7 及以后放在META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,老版本放在META-INF/spring.factories里。这个知识点属于 Starter 开发的标配,不展开多说。

到这里,整个 Starter 的核心链路已经闭环:Controller 方法标注 @JsonViewResult → ResponseBodyAdvice 自动把返回值包装成信封 → 自定义转换器拆开信封,用 writerWithView 序列化 → 输出 JSON 字段按视图裁剪。

4. 5 分钟接入:让分页接口按视图输出字段

原理讲完,直接看一个完整的接入示例。我拿一个最典型的分页用户列表来做演示。

4.1 完整代码示例

首先定义实体类,注意视图注解加在字段上。

public class User { @JsonView(UserView.Brief.class) private Long id; @JsonView(UserView.Brief.class) private String name; @JsonView(UserView.Brief.class) private String avatar; @JsonView(UserView.Full.class) private String email; @JsonView(UserView.Full.class) private String phone; private String password; // 构造器、getter/setter 省略 }

定义视图接口,利用接口继承实现“Brief 能看到基本字段,Full 能看到全部字段”的效果。

public class UserView { public static class Brief {} public static class Full extends Brief {} }

Controller 直接返回 Page ,方法上标一个注解。

@RestController @RequestMapping("/api/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/brief") @JsonViewResult(UserView.Brief.class) public Page<User> briefList(Pageable pageable) { return userService.findPage(pageable); } @GetMapping("/full") @JsonViewResult(UserView.Full.class) public Page<User> fullList(Pageable pageable) { return userService.findPage(pageable); } }

接口写完了,不需要在 Service 里写任何转换逻辑,也不需要额外 DTO。UserService.findPage 返回的就是 Page ,视图过滤完全由 Starter 在序列化阶段完成。

这里有一个容易踩的坑:password 字段没有标注 @JsonView,也就意味着无论用 Brief 还是 Full 视图,它都不会被输出。很多新手以为“没有标注视图就是默认输出”,这是错的。当启用 Jackson 视图时,只有明确标注了对应视图的属性才会输出,没标注的属性一律隐藏。这个设计反而很适合安全场景,密码、内部备注这种字段只要忘记加视图注解,就不会被泄露。

4.2 接入前后响应对比

接入前,直接返回 Page 而不加任何注解时,响应 JSON 长这样:

{ "content": [ { "id": 1, "name": "张三", "avatar": "/a.png", "email": "zhangsan@example.com", "phone": "13800000000", "password": "plain-text" } ], "totalElements": 1, "pageable": { "page": 0, "size": 20 } }

接入后调用 /api/users/brief,password、email、phone 都不见了:

{ "content": [ { "id": 1, "name": "张三", "avatar": "/a.png" } ], "totalElements": 1, "pageable": { "page": 0, "size": 20 } }

调用 /api/users/full,email 和 phone 出现,password 依然隐藏:

{ "content": [ { "id": 1, "name": "张三", "avatar": "/a.png", "email": "zhangsan@example.com", "phone": "13800000000" } ], "totalElements": 1, "pageable": { "page": 0, "size": 20 } }

接入前后对比表:

字段无视图Brief 视图Full 视图
id输出输出输出
name输出输出输出
avatar输出输出输出
email输出隐藏输出
phone输出隐藏输出
password输出隐藏隐藏

这个效果就是“按角色/场景控制出参”的雏形:列表页用 Brief,详情页用 Full,敏感字段直接隐藏。

5. 踩坑记录与问题排查速查表

写 Starter 的过程中,我踩了不少坑,也帮团队排查过一些使用问题,这里整理成一篇踩坑实录,全部是实际操作中会碰到的情况。

5.1 我踩过的五个典型坑

第一个坑是自定义转换器没生效。一开始我把 JacksonViewHttpMessageConverter 放在普通配置类里通过 @Bean 注册,发现 Spring Boot 根本没把它加进转换器列表。后来查了文档才发现,Spring Boot 的 WebMvcAutoConfiguration 会自动收集容器中所有 HttpMessageConverter 类型的 Bean,但这个收集动作依赖类型匹配。如果你定义的 Bean 声明类型是 HttpMessageConverter 而不是具体子类,可能会被其他机制处理掉。正确做法是 Bean 方法返回类型直接用 JacksonViewHttpMessageConverter,并加上 @Order 控制优先级。

第二个坑是 String 返回值被包装后报错。有一次同事在方法上标注了 @JsonViewResult,返回类型是 String,结果接口直接返回了一段类似对象转字符串的乱码。这就是 beforeBodyWrite 把 String 包进了 JacksonViewEnvelope,但后续 converter 匹配时,StringHttpMessageConverter 不认这个包装对象。解决方式就是我前面代码里写的,body instanceof String 直接放行。这个判断一定不能少。

第三个坑是 ResponseEntity 包装失效。如果你在 Controller 里返回 ResponseEntity<Page >,我们的 ResponseBodyAdvice 依然能拿到 body 进行包装,因为 Advice 拿到的 body 已经是 ResponseEntity 内部的 body 了。但这里有个细节:如果 ResponseEntity 已经设置好了 Content-Type 或自己指定了转换器类型,beforeBodyWrite 里的 selectedConverterType 可能已经不是自定义转换器,导致包装对象没有被正确消费。所以我的建议是尽量不要和 ResponseEntity 混用,直接返回业务对象,让 Spring MVC 统一处理。

第四个坑是多个 ObjectMapper 实例的问题。如果你的项目引入了别的 JSON 库或者手动 new 了 ObjectMapper,自定义转换器注入的 ObjectMapper 和 Spring Boot 自动配置的 ObjectMapper 可能不是同一个,导致默认配置的属性命名策略、日期格式在视图输出时全变了。排查这个问题花了我不少时间,最后的结论是,Starter 里依赖注入的 ObjectMapper 一定不要自己 new,要让 Spring 容器提供。

第五个坑是视图接口设计得不合理。有些人把视图接口定义得很混乱,比如 Brief 和 Full 互相不继承,导致想输出 Full 字段时 Brief 字段也被隐藏了。Jackson 的视图继承机制很关键:Full extends Brief 时,设置 Full 视图会同时输出 Brief 标注的属性;如果不继承,两个视图就是完全独立的两套字段,用着用着就会漏字段。

5.2 问题排查速查表

问题现象可能原因解决方案
标注注解后字段没有裁剪视图接口和注解值不匹配,或属性未标 @JsonView检查 Full 是否 extends Brief,检查属性标注
返回 Page 时 content 里字段没过滤使用了原生 @JsonView 而不是自定义注解换用 @JsonViewResult
String 返回值乱码String 被包装成 JacksonViewEnvelopebeforeBodyWrite 中跳过 String
自定义 ObjectMapper 配置失效注入的 ObjectMapper 不是容器中的同一个 Bean删除手动 new ObjectMapper 的代码
converter 顺序不对自定义转换器排在普通转换器后面加 @Order(Ordered.HIGHEST_PRECEDENCE)
接口报 406/415 错误Content-Type 没匹配上自定义转换器确认返回类型不是特殊包装类型

6. 性能实测与后续扩展思路

6.1 一次简单的压测记录

写完之后我对这套 Starter 做了一次简单压测。测试数据是 5000 条 User 记录的分页接口,每条记录 15 个字段,Brief 视图过滤到 4 个字段。环境就是一台普通的开发机,Spring Boot 3.2,默认 Tomcat 线程池。

直接用同一个接口分别调用无视图和 Brief 视图两种模式,各压 10 轮取中位数。结果是无视图模式的序列化耗时约 298ms,Brief 视图模式约 92ms。相差的部分主要来自输出体量缩小,JSON 要写的字节少了很多,网络传输时间自然也不一样。

视图切换本身有没有额外开销?我单独测过,在单一对象上使用 writerWithView 和不使用视图,耗时差异在 5% 以内。这个开销主要来源于视图过滤时要额外判断每个属性上的注解,但 Jackson 的 ObjectWriter 会缓存序列化器,所以并不会每次都重新解析注解。

这里有个个人心得:如果你的接口返回字段非常多,视图过滤带来的性能收益往往比缓存优化更明显。因为真正耗时的大头是序列化和网络传输,字段少了,一切都快了。

6.2 三个可以直接做的扩展方向

如果你看完这篇也想自己动手扩展,我提三个方向。

第一个方向是多视图组合。目前注解的 value 是单视图 Class,你可以扩展成 Class<?>[],像原生 @JsonView 一样支持同时指定多个视图,序列化时会输出所有视图声明的字段,这对某些需要同时展示 Brief 和 Full 部分字段的场景很有用。

第二个方向是动态视图。比如根据当前登录用户的角色决定使用哪个视图。这个可以通过一个简单的上下文工具类实现:在注解里配置一个 SpEL 表达式,beforeBodyWrite 时动态解析视图 Class。难度不大,但要特别注意 SpEL 的安全问题,表达式来源一定不能是用户输入。

第三个方向是和接口文档工具打通。既然字段裁剪逻辑都集中在视图类里,完全可以基于视图类自动生成接口文档的响应字段说明,省去手工维护文档的工作量。这块我还没有在生产环境完全落地,但方向是对的。

最后再分享一个小技巧:视图类的定义建议单独放在一个包里,不要塞进实体内部。我之前见过有人把视图接口定义在 User 类内部,虽然也能用,但后续实体越来越庞大时,视图类跟着膨胀,整个类看起来非常臃肿。单独维护一个 UserView 类,字段和视图的对应关系一目了然,变更时也更容易确认影响范围。这个细节看起来无关紧要,实际维护过大型项目的人都懂。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询