那天前端同事跑过来跟我说,"你那个接口返回的订单ID,更新数据时接口没返回异常,但数据重新查又没更新成功"
我当时第一反应是:不可能,后端查出来是什么就返回什么,肯定是你前端哪里处理出了问题。
然后我打开浏览器F12一看,Network面板里接口返回的JSON里,orderId 的值是 1784523698741256200,而数据库里存的是 1784523698741256193,后端拿1784523698741256200来更新数据确实没报异常。
好家伙,最后几位确实被改了。但问题是,这串数字在Java后端打印日志的时候是完完全全正确的,偏偏到了浏览器这边就变了。
根因其实很简单
稍微查了一下就明白了。我们后端之前是用的mysql的自增id做主键 设置的为int型,后来为了统一系统主键值 用雪花算法生成主键 设置为了Long,JavaScript里的 Number 类型是双精度浮点数,它能安全表示的最大整数是 2^53 - 1,也就是 9007199254740991。而Java的 Long 最大值是 2^63 - 1,远远超过了JS能精确表示的范围。
所以当后端把一个超出JS安全范围的Long值以数字形式写进JSON,浏览器在解析的时候就会丢精度。这个不是后端的bug,也不是前端的bug,是JavaScript语言本身的限制。
我们项目里的ID基本都是雪花算法生成的,长度在18~19位,妥妥超出 Number.MAX_SAFE_INTEGER,所以只要以数字返回,前端就一定拿不到精确值。
解决思路
思路其实很直接:后端在序列化成JSON的时候,把所有Long类型转成字符串。 这样JSON里的数字就变成了字符串,浏览器解析的时候不会当作Number去处理,精度自然不会丢失。
实现方式有很多种,可以在每个DTO的Long字段上加 @JsonSerialize(using = ToStringSerializer.class) 注解,但这样做太麻烦了,项目里几十个实体类,每个类里可能都有Long字段,一个个加注解纯粹是体力活。
更好的做法是全局统一配置,在消息转换器层面一次性解决。
项目里的实现
我们项目里是通过继承 WebMvcConfigurationSupport,重写 configureMessageConverters 方法来实现的。核心代码其实就这么几行:
scss
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
MappingJackson2HttpMessageConverter jackson2HttpMessageConverter =
new MappingJackson2HttpMessageConverter();
ObjectMapper objectMapper = new ObjectMapper();
// 序列化成json时,将所有的long变成string
SimpleModule simpleModule = new SimpleModule();
simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
simpleModule.addSerializer(Long.TYPE, ToStringSerializer.instance);
objectMapper.registerModule(simpleModule);
// 前端多传了字段也不报错
objectMapper.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
jackson2HttpMessageConverter.setObjectMapper(objectMapper);
converters.add(jackson2HttpMessageConverter);
}
做的事情拆开来看就三步:
第一步,创建一个 SimpleModule,往里面注册两条序列化规则------Long.class(包装类型)和 Long.TYPE(基本类型 long)都使用 ToStringSerializer。这两个缺一不可,因为有的实体用 Long,有的用 long,少注册一个就会漏掉。
第二步,把这个 SimpleModule 注册到 ObjectMapper 里,这样Jackson在序列化任何Long值的时候都会自动调用 toString(),输出到JSON里就是带引号的字符串。
第三步,把自定义的 ObjectMapper 设给 MappingJackson2HttpMessageConverter,再把它加到转换器列表里。
一个容易踩的坑
这里有个细节要注意。如果你用的是实现 WebMvcConfigurer 接口的方式,configureMessageConverters 方法里的配置可能会失效。因为 WebMvcConfigurer 的 configureMessageConverters 在Spring Boot某些版本下行为不太一致,自定义的转换器可能被默认转换器覆盖或者追加顺序不对。
我们项目里是直接继承 WebMvcConfigurationSupport,这个方式的优先级更高,配置更可靠。但代价是继承了它之后,Spring Boot关于WebMvc的自动配置就不再完全生效了,所以像静态资源映射、拦截器这些都得自己手动配,项目里也确实都配了:
typescript
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/");
registry.addResourceHandler("swagger-ui.html", "doc.html")
.addResourceLocations("classpath:/META-INF/resources/");
// ...
}
另外顺带解决的两个问题
在同一个方法里,还顺带处理了两件事:
一是把 FAIL_ON_UNKNOWN_PROPERTIES 设成了 false。前端有时候会多传一些字段过来,如果不关掉这个配置,Jackson反序列化时遇到不认识字段就会抛 Unrecognized field 异常,很烦人。
二是加了一个 StringHttpMessageConverter 并且关掉了 WriteAcceptCharset,解决的是访问 actuator/prometheus 接口返回406的问题。
ini
StringHttpMessageConverter stringHttpMessageConverter = new StringHttpMessageConverter();
stringHttpMessageConverter.setWriteAcceptCharset(false);
converters.add(stringHttpMessageConverter);
效果
改完之后重启服务,再看接口返回:
json
// 改之前
{ "orderId": 1784523698741256193 }
// 改之后
{ "orderId": "1784523698741256193" }
前端拿到的是字符串,精度不再丢失,问题解决。
这个问题说实话不算难,也是个容易复现的问题,但第一次遇到的时候确实容易懵,尤其是排查的时候日志里看到的值都是对的,偏偏前端拿到就是错的,会让人怀疑人生。核心就一句话:JS的数字装不下Java的Long,让后端序列化成字符串就行了。
全局配置比注解优雅得多,一处配置,全项目生效省心。在这里仅记录一下。