前端Long类型精度丢失问题:@JsonFormat与Jackson全局配置的实战对比
1. 问题来了:为什么前端拿到你的Long型ID会“变脸”?
最近在做一个用户中心的后端项目,数据库主键用的是雪花算法生成的ID,标准的19位Long类型。开发的时候一切正常,直到前端同事跑过来问我:“哥,你这用户ID传过来怎么最后三位老是变成000啊?用户A的ID应该是1357924681011121314,我这怎么显示1357924681011120000?”
我一开始还以为是前端解析的问题,结果自己用Postman一测,返回的JSON里,那个Long型的id字段,果然在1314的位置变成了1300。这就是典型的JavaScript数字精度丢失问题。你可能也遇到过,特别是当你的后端用Java,数据库主键用了像雪花算法这种会生成超大Long型数字(超过16位)的时候。
这里简单说下原理,让你彻底明白。JavaScript里,所有数字都是用IEEE 754标准的双精度浮点数来存储的。这种格式能“安全”表示的整数范围是-(2^53 - 1)到(2^53 - 1),也就是-9007199254740991到9007199254740991。你数一下,这个范围的最大值只有16位(9007199254740991是16位数字)。而我们雪花算法生成的ID,长度是19位,妥妥地超出了这个安全范围。
所以,当后端返回一个19位的Long型数字给前端时,JavaScript在解析JSON字符串为对象的过程中,遇到这个超出安全整数范围的数字,就会进行“四舍五入”到最接近的可表示数值,导致最后几位精度丢失。这根本不是网络传输的问题,而是数据到了前端JavaScript运行时,在反序列化(把JSON字符串变成JS对象)这一步出的岔子。
那怎么解决呢?核心思路就一条:不让这个Long型数字以数字的形式出现在JSON里。最直接的办法,就是把它变成字符串。字符串可没有精度限制,传多少位就是多少位。在Spring Boot生态里,最常用的两个武器就是@JsonFormat注解和Jackson的全局配置。下面我就结合自己踩过的坑,给你详细掰扯掰扯这两种方案该怎么选、怎么用。
2. 方案一:精准点射——@JsonFormat注解
当你只有少数几个字段需要处理,或者不同字段需要不同的处理规则时,@JsonFormat注解就像一把狙击枪,指哪打哪,非常灵活。
2.1 怎么用?一个注解搞定
用法简单得超乎想象。假设你有一个用户返回的UserDTO,里面有个Long类型的id字段。
import com.fasterxml.jackson.annotation.JsonFormat; public class UserDTO { // 核心就是这行注解 @JsonFormat(shape = JsonFormat.Shape.STRING) private Long id; private String username; // ... 其他字段和getter/setter }这个@JsonFormat(shape = JsonFormat.Shape.STRING)就是在告诉Jackson库:“兄弟,在把这个对象序列化成JSON的时候,别把这个id当数字处理,直接给我转成字符串。” 同样地,当从JSON反序列化回Java对象时,如果对应字段是字符串形式的数字,Jackson也会尝试把它转换回Long。
你可以在Controller里直接返回这个对象:
@GetMapping("/user/{userId}") public Result<UserDTO> getUser(@PathVariable Long userId) { UserDTO user = userService.getById(userId); return Result.success(user); // 返回的JSON中,id字段会是字符串类型 }这时,返回给前端的JSON就会是这样:
{ "code": 200, "data": { "id": "1357924681011121314", // 注意,这里是带引号的字符串! "username": "张三" } }前端拿到这个id,就是一个完整的字符串"1357924681011121314",再也不用担心精度丢失了。需要当数字用的时候(比如比较大小),调用一下BigInt(id)或者用第三方库如json-bigint来解析就行。
2.2 进阶玩法:格式化与局部控制
@JsonFormat的功能不止于此,它特别擅长处理各种“格式化”需求。比如你的日期字段:
public class OrderDTO { @JsonFormat(shape = JsonFormat.Shape.STRING) private Long orderId; // 指定日期格式和时区 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime createTime; // 数字格式化,比如金额保留两位小数 @JsonFormat(shape = JsonFormat.Shape.STRING) private BigDecimal amount; }优点总结一下:
- 极其灵活:每个字段可以独立配置,互不影响。A字段转字符串,B字段可以保持数字并指定格式。
- 意图清晰:在实体类或DTO上直接标注,代码可读性高,一看就知道这个字段要怎么处理。
- 非侵入性:只影响被注解的字段,不会动到其他任何
Long类型字段,比如那些不会超过安全范围的普通状态字段。
但是,坑也不少:
- 容易遗漏:这是最大的问题。项目大了,DTO满天飞,今天在这个类上加一个
@JsonFormat,明天可能就忘了在另一个类似的类上加。一旦遗漏,前端就可能收到一个被截断的ID,bug埋得悄无声息。 - 维护成本高:如果后来决定所有超过16位的ID都用字符串传输,你得把所有相关的DTO、VO都翻出来加一遍注解,工作量不小。
- 只对序列化有效:这个注解主要管的是对象转JSON(序列化)这个过程。如果你接收入参,前端传过来一个字符串格式的ID,希望自动转成
Long,这个注解在反序列化时也有效。但如果你有更复杂的自定义反序列化逻辑,它可能就力不从心了。
所以,@JsonFormat适合局部、精细化的控制。当你的精度丢失问题只出现在几个核心的、确定的实体上时,用它准没错。
3. 方案二:一劳永逸——Jackson全局配置
如果你受够了在无数个DTO上重复打补丁,想要一个“根治”的方案,那么自定义Jackson的ObjectMapper,进行全局配置,就是你的不二之选。这相当于给你的整个应用装上了一套统一的“数据处理规则”。
3.1 打造你的专属ObjectMapper
Spring Boot默认使用Jackson来处理HTTP消息的转换(即@RestController返回对象变JSON,接收JSON变对象)。我们可以通过继承ObjectMapper来定制它的行为。
新建一个类,比如叫JacksonObjectMapper:
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import java.math.BigInteger; /** * 自定义Jackson对象映射器 * 目标:将所有Long类型、BigInteger类型在序列化时转为字符串 */ public class JacksonObjectMapper extends ObjectMapper { public JacksonObjectMapper() { super(); // 1. 创建一个简单模块 SimpleModule simpleModule = new SimpleModule(); // 2. 为Long类型和BigInteger类型注册“转字符串”的序列化器 // ToStringSerializer.instance 会把对象直接调用toString()方法输出 simpleModule.addSerializer(Long.class, ToStringSerializer.instance); simpleModule.addSerializer(Long.TYPE, ToStringSerializer.instance); // 处理基本类型long simpleModule.addSerializer(BigInteger.class, ToStringSerializer.instance); // 3. 注册这个模块到ObjectMapper this.registerModule(simpleModule); // 4. (强烈建议)注册Java8时间模块,方便处理LocalDateTime等 this.registerModule(new JavaTimeModule()); // 5. (可选但推荐)忽略JSON中存在的、但Java对象没有的字段,防止反序列化失败 this.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } }这段代码干了件什么事呢?它创建了一个新的规则:无论在你的应用哪个角落,只要是Long(包括基本类型long)或者BigInteger类型的字段,在转换成JSON字符串时,统统给我变成字符串格式。这样一来,所有雪花ID、所有可能的大数字,从源头上就被保护起来了。
3.2 让配置全局生效
光有自定义的ObjectMapper还不行,得让Spring Boot用它替换掉默认的那个。我们通过一个配置类来实现:
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; @Configuration public class JacksonConfig { @Bean public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) { // 方式一:直接使用我们自定义的JacksonObjectMapper // return new JacksonObjectMapper(); // 方式二(更优雅):利用Builder创建,并混入自定义配置 ObjectMapper objectMapper = builder.createXmlMapper(false).build(); // 在builder创建的基础上,手动添加我们的“Long转String”模块 SimpleModule bigIntegerModule = new SimpleModule(); bigIntegerModule.addSerializer(Long.class, ToStringSerializer.instance); bigIntegerModule.addSerializer(Long.TYPE, ToStringSerializer.instance); bigIntegerModule.addSerializer(BigInteger.class, ToStringSerializer.instance); objectMapper.registerModule(bigIntegerModule); // 同样配置忽略未知字段 objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); return objectMapper; } }我更喜欢第二种方式,因为Jackson2ObjectMapperBuilder已经帮我们预配置了很多Spring Boot的最佳实践(比如日期格式),我们在其基础上“打补丁”,更稳妥。
配置完成后,你的整个Spring Boot应用在序列化任何对象时,都会自动应用这条“Long转String”的规则。你再也不需要为任何一个DTO字段添加@JsonFormat注解了。
3.3 全局配置的威力与注意事项
这个方案的优点非常突出:
- 一劳永逸:配置一次,全局生效。新写的DTO、VO,只要字段是
Long型,自动享受“字符串保护”,彻底杜绝遗漏。 - 维护方便:规则集中在一处管理。哪天你想调整,比如只想对超过18位的Long转字符串,或者想换个格式,改这一个地方就行。
- 功能强大:你不仅可以处理
Long,还可以在这里统一配置日期格式、处理空值、美化输出等等,是Jackson功能的集大成者。
当然,权力越大,责任越大,有些坑你得留意:
- “误伤”问题:全局配置是无差别的。你有一个
Long类型的status字段,值就1、2、3,本来完全在安全范围内,现在也被转成了字符串。虽然对前端功能可能没影响,但会让API返回的JSON看起来不那么“纯粹”(数字和字符串混在一起),也可能轻微增加传输体积。有些严格的前端框架或类型系统(如TypeScript)可能会对类型变化比较敏感。 - 可能影响第三方库:如果你的项目引入了其他库,这些库内部也依赖Jackson进行序列化,并且对类型有严格预期,全局修改可能会带来意想不到的影响。不过这种情况在Web层序列化中较少见。
- 反序列化的考虑:上面的配置主要针对序列化(Java -> JSON)。如果前端传参时,也以字符串形式传递ID,Spring Boot默认也能反序列化回
Long。但为了更严谨,你可以在自定义模块中也添加对应的反序列化器,不过通常ToStringSerializer在反序列化时也能工作。
4. 实战对比:我该用哪个?
光讲理论不够,我们放到真实的项目场景里比比看。
场景A:小型工具类项目,实体清晰你正在开发一个内部用的运维管理系统,核心实体就User、Server、Task那么几个,而且这些实体的ID都是雪花ID。这种情况下,我强烈推荐使用全局配置。在项目初期花5分钟配好JacksonObjectMapper,之后所有关于ID精度的问题都与你无关了,开发体验极其流畅。不用担心遗漏,代码也干净。
场景B:大型复杂业务系统,字段语义多样你维护的是一个电商平台,有Order(订单)、Product(商品)、Payment(支付)等上百个DTO。里面Long类型的字段五花八门:有雪花算法生成的orderId,有来自外部系统的externalProductId(可能也很大),也有普通的categoryId(数值很小),还有表示状态的status,表示库存的stock。 这时,无差别的全局转换可能就不太合适了。把status和stock这种小数字也变成字符串,会让接口返回值显得很奇怪,也可能影响前端一些数字计算逻辑(虽然可以转换,但增加了心智负担)。 更合适的策略是混合使用:
- 使用全局配置,处理那些明确会很大的ID字段,比如所有以
Id、No、Sn结尾的字段,我们可以通过更精细的全局配置(例如使用@JsonSerialize配合自定义逻辑)来针对性处理,但这需要更高级的Jackson技巧。 - 对于普通的、数值小的
Long字段,保持原样。 - 对于少数特殊的、需要特定格式(如日期、金额)的字段,使用
@JsonFormat。
实际上,在大型项目中,我见过更常见的做法是:定义明确的规约。比如,在团队内约定,所有在接口中传输的、可能超过JavaScript安全整数范围的标识字段(通常是数据库主键ID),其DTO字段类型直接定义为String。从Service层开始,就使用String类型的ID进行传递和返回。这样,Jackson甚至不需要做任何特殊处理,因为字段本来就是字符串。这虽然需要在业务层做一些类型转换,但语义最清晰,前后端都不会有任何歧义。
5. 避坑指南与最佳实践
踩过几次坑之后,我总结了几条经验,希望能帮你少走弯路。
第一,测试一定要做透。无论是用注解还是全局配置,改完之后,不要只用一个ID测试。准备几个边界值:
- 一个16位的数字(如
9007199254740991),这是安全边界。 - 一个17位的数字(如
12345678901234567)。 - 一个典型的19位雪花ID。 用Postman或单元测试调用你的接口,查看返回的JSON,确认长的ID是否变成了带引号的字符串,短的ID是否保持了数字形式(如果你希望如此)。同时,也要测试反序列化,让前端同事配合传一个字符串ID过来,看你的
@PathVariable或@RequestBody能否正确接收为Long。
第二,关注序列化与反序列化的对称性。你让Long序列化成String,那么前端传参时,如果也传String,你的Controller能否自动接受到Long?默认情况下,Spring Boot的Jackson是可以做到的。但如果你配置了过于复杂的自定义序列化/反序列化器,最好两边都测试一下。一个简单的测试接口就能搞定。
第三,谨慎处理数据库查询和MyBatis。这里解决的是HTTP层的序列化问题。如果你的数据直接从MyBatis查询出来,其中Long类型的ID在Java内存中还是正确的,经过我们的配置,在返回给前端时才被转成字符串。这本身没问题。 但要小心一种情况:如果你在某些逻辑里,直接拿前端传回来的字符串ID(比如"1357924681011121314")去和数据库查询出来的Long类型ID(1357924681011121314L)进行相等比较(==或equals),一个String一个Long,肯定是不相等的。所以,在比较时,要么都转成String再比,要么把String转回Long再比。
第四,考虑前端协作。和前端同学定好规矩。用了全局配置后,可以明确告诉他们:“咱们后端所有id字段返回的都是字符串了,你们用的时候注意一下。” 他们可能会用到BigInt或者一些大数处理库来解析。如果前端是强类型语言(如TypeScript),记得让他们更新接口类型定义,把id从number改成string。
最后,我的个人建议是:对于全新的项目,如果确定主要ID生成策略是雪花这类会生成大数的算法,直接在项目脚手架里就配上Jackson全局Long转String的配置。这是成本最低、效果最好的预防措施。对于老项目改造,如果涉及到的实体不多,可以先用@JsonFormat注解逐个击破,快速解决问题。如果实体很多,且确定要统一处理,那么再花点时间上全局配置,长远来看更省心。
技术选型没有绝对的对错,只有适合与否。理解了@JsonFormat的灵活和Jackson全局配置的霸道,你就能根据自己项目的实际情况,做出最顺手的选择。毕竟,解决问题的终极目标,是让代码更清晰,让协作更顺畅,而不是单纯追求某一种技术。
