在Java后端开发里,对象转Json算是每天都会碰到的操作。所谓Json美化排版,就是在序列化完成之后,把原本挤成一长串、看起来毫无结构的字符串,重新组织成带换行、带缩进的可读格式。我最近把这件事封装成了一个独立的工具类,平时打印日志、调试接口、造测试数据都用它,顺手整理下来,就是这篇文章。
这个工具解决的核心痛点很简单:光会转JSON不够,转出来的内容还得能看懂。很多人在IDEA里打印一个对象,看到的要么是Object@1a2b3c这种毫无信息量的地址串,要么是一行长得不能再长的compact JSON,嵌套深的报文根本没法肉眼追踪字段。如果你要跟第三方接口联调、排查线上日志、或者给前端展示接口返回结构,这个工具能帮你省掉大量“复制到在线格式化工具里再回贴”的往返时间。
1. 为什么Java开发每天都逃不开对象转JSON
1.1 从一次接口联调说起
有一次我跟外部系统做联调,对方返回的报文里有个多层嵌套的订单结构,里面还有List和Map混在一起的数据。当时我把响应体直接打到了日志里,日志输出的是一整行JSON,长度大概有三四千字符。我盯着控制台想找paymentInfo这个字段,上下翻页翻了好半天都没找到,最后只能把日志内容复制到文本编辑器里手动换行,折腾了十分钟才定位到问题。
这还不是最难受的。后面排查一个问题,需要确认自己组装的请求对象到底长什么样。我用System.out.println(request)打印,结果输出是com.example.dto.OrderRequest@5e9f23b4,连字段值都看不到。那时候我就意识到,日常开发里“把对象转成可读的JSON字符串”这件小事,如果不提前封装好,急用的时候是真耽误事。
很多Spring Boot项目里,Controller反序列化和序列化都是框架自动做的,@RestController一标注,返回的对象自动变成JSON给前端。但框架帮你搞定的场景,恰恰只占了日常开发的一部分。日志调试、接口Mock、测试断言、配置读取、消息队列报文打印,这些场景都需要手动把对象转成JSON,而其中一大部分场景需要的是“美化排版后的JSON”。
1.2 主流JSON库怎么选
Java生态里做对象转JSON,绕不开几个选择:Jackson、Gson、Fastjson,以及Hutool里封装的JSONUtil。我自己用下来,优先级是Jackson优先,Gson备用,Fastjson基本不碰,Hutool适合简单场景。
Jackson是Spring Boot的默认JSON处理库,功能非常全,性能靠谱,社区活跃,已经成了事实上的标准。它能处理泛型反序列化、多态类型、自定义序列化器,几乎所有需求都能找到对应的配置项。Gson的API设计更简洁,上手更快,但处理复杂泛型和自定义序列化时,没有Jackson那么细腻。Fastjson性能确实快,但历史上出过多个安全漏洞,在安全审计严一点的公司里早就被拉黑了,新项目我不会选它。
Hutool的JSONUtil.toJsonPrettyStr(obj)一行代码就能输出美化排版,适合小工具场景。但Hutool是一个工具集合,如果项目里没有引入它,为了一个JSON方法拉进来几十个工具类,成本不太划算。而且真要深度定制(比如日期格式、null值策略、缩进长度),还是得回到Jackson这种专门的库上。
所以我的方案是:基于Jackson封装一个轻量工具类,不引入额外依赖,任何Spring Boot项目都能直接用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 美化排版的背后:序列化原理与JSON的两种形态
2.1 序列化到底做了什么
要理解“对象转JSON再美化排版”,首先得知道序列化在做什么。Java对象在内存里是一堆堆上的数据结构,有字段名、有引用关系、有类型元数据,但这些结构不能直接通过网络传输或者写进日志文件。JSON是纯文本协议,序列化就是把Java对象内部的字段名和字段值,按照JSON语法转换成字符串。
这个过程看起来简单,实际有很多细节。字段名是camelCase还是snake_case,要不要在序列化时做转换;字段值是null的时候,是输出"field": null还是直接跳过;日期类型是输出时间戳还是格式化好的字符串;对象里有循环引用的时候怎么防止栈溢出。这些都是序列化框架要处理的问题。
Jackson处理这些问题的思路是:反射读取Java Bean的getter方法,拿到每个字段的名称和值,逐层递归处理嵌套对象和集合,最后拼装成JSON文本。它默认只认getter,也就是“读取”能力。如果一个类没有getter,或者字段前面没有遵循标准命名,序列化结果就会出问题,后面我会专门讲这个坑。
2.2 JSON的紧凑格式与美化格式
JSON文本本身允许两种呈现方式。第一种是紧凑格式(compact),比如:
json复制{"orderId":"20250112001","amount":99.9,"items":[{"name":"Java编程思想","price":79.9}]}
这种格式所有内容挤在一行,占用空间小,是网络传输的首选。浏览器Network面板里看到的请求响应,默认就是这种形态。
第二种是美化格式(pretty),比如:
json复制{
"orderId" : "20250112001",
"amount" : 99.9,
"items" : [ {
"name" : "Java编程思想",
"price" : 79.9
} ]
}
这种格式加入了换行和缩进,层级关系一目了然。Log日志、接口调试、文档展示,都适合用美化格式。
Jackson里控制这两种输出的核心开关是SerializationFeature.INDENT_OUTPUT。开启了它,ObjectMapper在序列化时就会调用默认的DefaultPrettyPrinter进行排版;不开启,输出紧凑格式。
2.3 为什么“自带美化排版”这么重要
有人可能会说,调试的时候把JSON复制到在线工具里格式化一下不就行了?问题在于,你不可能每次都在日志和在线工具之间来回切换,尤其是线上环境,日志堆积如山,每一条都要复制出去格式化,效率低到难以想象。
更重要的是,很多线上问题的排查窗口非常短。日志里如果直接输出美化好的JSON,字段名对齐、缩进清晰,扫一眼就能看出哪个字段没值、哪个数据结构跟预期不符。如果是compact格式,严重依赖眼睛在一长串字符里逐个找,容易漏,也容易错。
我自己的习惯是:所有涉及对象输出的调试日志,一律走美化排版。哪怕占几个字节的空间,换来的是排查问题时一眼定位的体验,这个交换绝对划算。
2.4 每次现写ObjectMapper的问题
没封装工具类之前,我见过不少同事在代码里现写:
java复制ObjectMapper mapper = new ObjectMapper();
mapper.enable(SerializationFeature.INDENT_OUTPUT);
System.out.println(mapper.writeValueAsString(obj));
这段代码本身没有错,但问题在于:第一,ObjectMapper创建成本不低,每次new一个实例纯属浪费;第二,每个人写得都不统一,有人忘了enable(INDENT_OUTPUT),有人日期格式没设置,有人没处理异常。最后项目里到处都是风格不一的JSON输出代码。
封装工具类的好处是:所有配置只写一次,所有调用者共享同一套行为。想改日期格式、想调整缩进、想统一忽略null字段,只需要改动一个文件,全项目生效。
3. 手写JSON工具类的完整实现
3.1 依赖引入与版本说明
先搞定依赖。如果你用的是Spring Boot项目,里面已经带了jackson-databind,不需要额外加。如果是一个纯粹的Maven工程,加这一段:
xml复制<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
版本选择上,2.15.x是目前比较稳定的一个版本线,修复了之前的一些安全漏洞,也支持了JDK 17、JDK 21的新特性。如果你的项目还在用JDK 8,选2.13.x也完全够用,不必盲目追新。
3.2 核心代码:自带美化排版的JsonUtils
直接上完整工具类代码,这是我在项目里实际使用的版本,做了精简但保留了核心功能:
java复制import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
/**
* JSON转换工具类
* 基于Jackson实现,提供紧凑格式和美化了排版的两种JSON输出
*/
public class JsonUtils {
/**
* 美化排版专用Mapper:开启缩进输出
*/
private static final ObjectMapper PRETTY_MAPPER = new ObjectMapper();
/**
* 紧凑格式专用Mapper:默认配置
*/
private static final ObjectMapper COMPACT_MAPPER = new ObjectMapper();
static {
// 开启美化排版的关键
PRETTY_MAPPER.enable(SerializationFeature.INDENT_OUTPUT);
// 空对象不抛异常,避免某些场景下序列化失败
PRETTY_MAPPER.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
COMPACT_MAPPER.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
}
private JsonUtils() {
throw new IllegalStateException("工具类不允许实例化");
}
/**
* 对象转美化JSON,带缩进和换行
*/
public static String toPrettyJson(Object obj) {
if (obj == null) {
return "null";
}
try {
return PRETTY_MAPPER.writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new RuntimeException("对象转美化JSON失败: " + e.getMessage(), e);
}
}
/**
* 对象转紧凑JSON,单行输出
*/
public static String toJson(Object obj) {
if (obj == null) {
return "null";
}
try {
return COMPACT_MAPPER.writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new RuntimeException("对象转JSON失败: " + e.getMessage(), e);
}
}
}
这里有几个关键点需要解释。
enable(SerializationFeature.INDENT_OUTPUT)是美化排版的灵魂,没有这一行,输出永远是紧凑格式。FAIL_ON_EMPTY_BEANS这个开关很有用,默认情况下如果对象没有任何getter方法,Jackson会抛InvalidDefinitionException,比如你直接传一个Map.Entry或者某些代理对象进去,就会中招。禁用这个开关之后,空对象会被序列化成{},不至于直接中断日志输出。
两个Mapper实例分别服务于不同场景,避免频繁修改配置导致线程安全问题。ObjectMapper本身是线程安全的,只要配置阶段完成,之后可以放心并发调用。
使用方式很简单:
java复制Order order = buildOrder();
System.out.println(JsonUtils.toPrettyJson(order));
输出效果就是前面示例里的那种带缩进层级格式。
3.3 进阶配置:自定义缩进长度
ObjectMapper默认的DefaultPrettyPrinter缩进是两个空格,这个长度在大多数场景下够用,但如果你希望输出跟IDEA格式化后的效果一致,或者项目规范要求四个空格缩进,可以自定义:
java复制import com.fasterxml.jackson.core.util.DefaultIndenter;
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
import com.fasterxml.jackson.databind.ObjectWriter;
DefaultPrettyPrinter printer = new DefaultPrettyPrinter();
// 四个空格缩进,换行符使用系统默认
printer.indentObjectsWith(new DefaultIndenter(" ", DefaultIndenter.SYS_LF));
ObjectWriter writer = PRETTY_MAPPER.writer(printer);
String json = writer.writeValueAsString(obj);
DefaultIndenter构造函数的第一个参数是缩进字符串,你可以用四个空格、两个空格、Tab,甚至自定义任意字符串。第二个参数是行分隔符,DefaultIndenter.SYS_LF表示使用系统默认换行,如果是Linux环境就是\n,Windows环境是\r\n。
我建议在工具类初始化的时候就把缩进策略定好,不要在每次调用时临时创建Writer。原因很简单:ObjectWriter也是不可变且线程安全的,复用能减少不必要的对象创建开销。
3.4 日期格式化
日期类型是JSON序列化里最容易被忽略的环节。默认情况下,Jackson会把java.util.Date序列化成自1970年以来的毫秒时间戳,也就是一个long类型的数字。这在传输上没问题,但日志里看到1736654400000这种数字,很难一眼读出来是哪一天。
解决办法是在Mapper上配置日期格式:
java复制import java.text.SimpleDateFormat;
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
PRETTY_MAPPER.setDateFormat(dateFormat);
COMPACT_MAPPER.setDateFormat(dateFormat);
如果你用的是java.time.LocalDateTime、LocalDate,还需要额外注册jsr310模块:
java复制import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
PRETTY_MAPPER.registerModule(new JavaTimeModule());
Spring Boot的ObjectMapper默认集成了这个模块,但你自己new出来的Mapper是没有的。不注册的话,LocalDateTime字段会直接报错或转成数组形式的结构。这一点我在早期踩过坑,这里单独提一句。
4. 实战高频坑位排查实录
4.1 字段顺序变来变去,怎么保住声明顺序
“对象转JSON之后字段顺序跟类里声明的顺序不一致”,这个问题在论坛上反复出现。先说结论:如果转换的是普通Java Bean,Jackson默认会按照类中字段的声明顺序输出,绝大多数情况下你不需要额外处理。
但有一种情况会乱序:你转换的对象里包含Map,而Map的实现类是HashMap。HashMap在底层基于哈希表存储,本身就不保证迭代顺序。如果你调用LinkedHashMap把值put进去,顺序可能还是乱的。解决办法是使用LinkedHashMap,它可以按插入顺序输出。
如果希望显式控制Java Bean的字段顺序,用@JsonPropertyOrder注解:
java复制@JsonPropertyOrder({"orderId", "amount", "items"})
public class Order {
private String orderId;
private BigDecimal amount;
private List<Item> items;
}
注意,这个注解只对Java Bean有效,对Map类型的转换不生效。
4.2 null字段到底该不该输出
默认情况下,Jackson会把值为null的字段也输出成"field": null。这在响应结构展示时是好事,前端能明确知道这个字段存在但是没有值;但在日志调试时,大量null字段会淹没有效信息,干扰阅读。
Jackson控制null输出有三个层级:类级别、字段级别、全局级别。全局最容易操作:
java复制import com.fasterxml.jackson.annotation.JsonInclude;
PRETTY_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL);
这样设置之后,所有null字段都会被忽略。还有NON_EMPTY选项,它会把空字符串、空集合、空Optional也一并过滤掉,适合追求简洁输出的场景。
我的建议是:专门用于日志输出的Mapper可以开启NON_NULL,专门模拟接口响应的工具方法保留默认输出。因为这个配置影响面大,如果全局关了null,调试的时候可能看不到某些关键字段,反而误判。
4.3 循环引用导致的栈溢出
对象A里面有一个B对象,B里面又引用了A对象,比如:
java复制public class Parent {
private String name;
private Child child;
}
public class Child {
private String name;
private Parent parent;
}
如果Parent和Child都完整填充了互相引用,直接JsonUtils.toPrettyJson(parent)会抛出StackOverflowError。Jackson在序列化时会递归进入child,再从child进入parent,无限循环直到栈溢出。
处理方式有三种。第一种是@JsonIgnore,在不希望输出的字段上标记,比如:
java复制public class Child {
private String name;
@JsonIgnore
private Parent parent;
}
第二种是@JsonManagedReference和@JsonBackReference,分别标注父引用和子引用,Jackson在序列化时自动处理双向关系。第三种最省事,就是把对象先转成Map或者DTO再序列化,人为切断循环引用链。
实际项目里,我最多用的是第一种和第三种。@JsonIgnore写起来简单直接,效果可控,但它会影响反序列化——不输出的字段在反序列化时也进不来。如果对象既要做序列化又要做反序列化,需要谨慎使用。DTO方案虽然要多写几个转换方法,但边界清晰,不会污染实体类。
4.4 Lombok大写开头字段的转换坑
这个话题在搜热词里出现了:java bean 大写字母开头的变量json时就变成小写了。这是Lombok和Jackson组合在一起的经典坑位。
如果一个Java类字段叫URL、HTML这种全大写的缩写词,Lombok生成的getter方法是getURL()。而Jackson在识别字段名时,会从getter方法名反推属性名:把get去掉,剩下URL,再按照JavaBeans规范做首字母小写处理,结果就可能变成URL或者uRL,跟实际字段名对不上。
我在一个老项目里见过这种情况:类里定义的是private String name;没问题,但有个字段叫private String uRL;(故意写成大小写混合),序列化出来变成"url",反序列化回来的对象里uRL是null,排查了很久才找到根因。
解决办法很粗暴,在字段上加@JsonProperty("uRL")显式指定JSON字段名:
java复制public class ConfigItem {
private String name;
@JsonProperty("uRL")
private String uRL;
}
不建议依赖“把getter改成geturl()”这种奇技淫巧,规范就是规范,字段命名和JSON字段名映射尽量保持简单清晰。
4.5 泛型对象转换的TypeReference
如果你要转换的对象带泛型,比如Result<List<Order>>,直接用PRETTY_MAPPER.writeValueAsString(obj)是没有问题的,因为你是把一个已经构造好的对象转成JSON,泛型信息在对象里已经固定了。
反过来,把JSON转回带泛型的对象时,才需要TypeReference:
java复制Result<List<Order>> result = mapper.readValue(json, new TypeReference<Result<List<Order>>>() {});
这一点在工具类设计里容易被忽略。如果只做“对象转JSON”,泛型不构成问题;但如果工具类要扩展出fromJson方法,务必支持TypeReference重载。
5. 常见问题排查速查
下面用表格整理我在实战中遇到的典型问题、原因和解决方案,方便直接对照排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
输出全是Object@xxx |
直接打印对象,没有调用JSON工具 | 使用JsonUtils.toPrettyJson() |
| 字段顺序被打乱 | 使用了HashMap,或类上缺少顺序注解 | 改用LinkedHashMap,或加@JsonPropertyOrder |
| 日期输出为数字时间戳 | 未设置DateFormat | mapper.setDateFormat(new SimpleDateFormat(...)) |
| LocalDateTime报错或输出为数组 | 缺少jsr310模块 | 注册JavaTimeModule,或依赖SpringBoot默认配置 |
| 栈溢出StackOverflowError | 对象存在循环引用 | @JsonIgnore或转Map/DTO再序列化 |
| 大写开头字段序列化后变小写 | Lombok getter命名与Jackson推断冲突 | 字段加@JsonProperty显式指定名称 |
| 一串JSON挤在一行不可读 | 未开启INDENT_OUTPUT | PRETTY_MAPPER.enable(SerializationFeature.INDENT_OUTPUT) |
| 空对象转JSON报错 | FAIL_ON_EMPTY_BEANS默认开启 | mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) |
| null字段太多影响阅读 | 未配置null处理策略 | setSerializationInclusion(JsonInclude.Include.NON_NULL) |
| Map序列化后键值乱序 | HashMap不保证顺序 | 使用LinkedHashMap |
这张表基本覆盖了我碰到过的所有高频问题。如果你遇到表中没包含的现象,建议先看异常堆栈,是序列化还是反序列化阶段出的问题,再针对性地查Jackson配置,大概率能定位到具体原因。
6. 工具类的扩展玩法:日志、请求报文、配置文件
6.1 在HTTP调试里打印完整报文
前后端联调的时候,经常需要确认自己到底给后端传了什么、后端返回了什么。用Spring的RestTemplate或者WebClient做HTTP请求时,可以在调用前后分别打印请求体和响应体:
java复制String requestBody = JsonUtils.toPrettyJson(buildRequest());
log.info("请求参数: \n{}", requestBody);
ResponseEntity<String> response = restTemplate.postForEntity(url, requestBody, String.class);
log.info("响应结果: \n{}", JsonUtils.toPrettyJson(response.getBody()));
注意,restTemplate.postForEntity的第二个参数如果是String,它不会自动做JSON解析,你把它打印出来得到的是原始字符串。如果响应体本身就是JSON格式的字符串,想要美化排版,就不能直接调用toPrettyJson(response.getBody()),因为getBody()返回String,Jackson会把整个String当作JSON字符串再包装一层。
正确的做法是先反序列化成JsonNode,再美化输出:
java复制ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readTree(response.getBody());
String prettyJson = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node);
这段逻辑可以封装到工具类里,加一个toPrettyJson(String jsonStr)的重载方法,用于把JSON字符串本身格式化。
6.2 从JSON文件读取配置
有些小项目不愿意引入application.yml或者Nacos配置中心,直接用JSON文件存配置。这时候可以用Jackson把JSON文件反序列化成配置对象:
java复制ObjectMapper mapper = new ObjectMapper();
MyConfig config = mapper.readValue(new File("config.json"), MyConfig.class);
如果配置文件里写了注释(JSON标准不支持注释,但有些人会写//),Jackson默认会报错。这时候需要启用ALLOW_COMMENTS特性:
java复制mapper.enable(com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_COMMENTS);
这个功能在本地开发调试时很有用,配置项多的时候加个注释说明,比开一个Wiki文档方便多了。
6.3 结合日志框架输出调试信息
日志打印是所有工具类最核心的使用场景之一。SLF4J的占位符{}只负责拼接字符串,如果你直接传对象进去,它调用的是对象的toString()方法,而不是JSON序列化。所以正确的做法是:
java复制log.debug("订单信息: {}", JsonUtils.toPrettyJson(order));
这样每次调用都会执行一次序列化,在热点路径上会有一定的性能开销。如果是生产环境的DEBUG级别日志,而且对象很大、调用很频繁,建议在打印之前先判断日志级别:
java复制if (log.isDebugEnabled()) {
log.debug("订单信息: {}", JsonUtils.toPrettyJson(order));
}
我见过有人为了提高性能,把所有JSON调试日志都改成了if判断,结果代码里到处都是嵌套的日志块。折中方案是:低频操作(接口调用、任务跑批、异常分支)直接用,高频循环(比如for循环里打印每条记录)做级别判断。
6.4 从工具类到公共库的一点体会
说到扩展,其实这个JsonUtils完全可以沉淀到公司内部的公共组件库里。我经历过的项目里,凡是把这类小工具独立成模块、统一引用的团队,后期维护成本都断崖式下降。因为JSON处理策略一旦统一,遇到问题只需要改一处,升级Jackson版本也只需要关注一个文件。
当然,做成公共库之前要明确一点:工具类应该保持功能聚焦,不要什么都往里塞。有些同事会把toJson、toPrettyJson、fromJson、fromJsonList、readTree、writeToFile全部写进一个类,最后类膨胀到几百行。我的建议是只保留最核心的序列化和反序列化方法,文件读写、XML转换、YAML转换这些需求,等真正出现的时候再说。
在实际项目里用了一段时间后,我发现一个事实:不管是几百万行的系统,还是自己写的个人项目,“对象转JSON + 美化排版”这个小功能,确实能实打实提升调试效率。我个人最推荐的用法是:把序列化配置收敛到独立的JsonUtils类里,所有需要打印调试信息的模块统一走这一个入口,而不是每个类都new ObjectMapper()。这样无论统一日期格式、统一null策略,还是将来升级Jackson版本,都只改一个地方就行。如果你们项目还没有这样一个工具类,强烈建议按文中的方式封装一个,动手一次,后续调试至少省下一半的“复制格式化”时间。
