前端说:你的接口又报 400 了
那天下午前端同事在工位上喊我,说提交表单一直 400,请求都发出去了。我看了下 Chrome 的 Network 面板:
Request URL: /order/list
Request Method: GET
Status Code: 400 Bad Request
请求参数:startTime=2018-03-20&endTime=2018-03-21
我的 Controller 长这样:
@GetMapping("/order/list")
public List<Order> list(Date startTime, Date endTime) {
return orderService.query(startTime, endTime);
}
参数名对得上,为什么 400?把日志级别调高之后,看到了真正的报错:
org.springframework.validation.BindException:
Field error in object 'orderQuery' on field 'startTime':
rejected value [2018-03-20];
default message [Failed to convert property value of type 'java.lang.String'
to required type 'java.util.Date' for property 'startTime']
类型转换失败。前端传的是字符串,我接的是 Date,中间需要转换器,而 Spring MVC 默认没有为 2018-03-20 这种格式准备转换器。
Spring 默认认识哪种日期格式
这事儿得看 WebDataBinder 里注册了哪些 PropertyEditor / Converter。默认情况下 Spring 支持的是按当前 Locale 的短日期格式,在中文环境下大致是 yyyy/MM/dd 这种带斜杠的。
我随手验证了一下,把参数改成 startTime=2018/03/20,居然真的通了。但很明显不能让前端去改格式,而且这种"依赖Ubuntu 服务器 Locale"的行为非常脆弱——换个 Region 设置就挂了,我们测试环境就因为 Locale 和测试机不一致,出现过本地正常、测试环境 400 的情况。
解法一:字段上打 @DateTimeFormat
最省事的局部方案:
@GetMapping("/order/list")
public List<Order> list(
@DateTimeFormat(pattern = "yyyy-MM-dd") Date startTime,
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Date endTime) {
return orderService.query(startTime, endTime);
}
如果是用对象接参数,注解打在字段上:
public class OrderQuery {
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date startTime;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date endTime;
}
好处是精确到字段,不同接口可以用不同格式。坏处是每个日期字段都要写一遍,我们项目里 30 多个查询对象,改起来要命,而且新人很容易漏。
解法二:注册全局 Converter
我最后选了这个。写一个 String → Date 的转换器,注册到 Spring MVC 的类型转换服务里:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new Converter<String, Date>() {
@Override
public Date convert(String source) {
if (source == null || source.trim().isEmpty()) {
return null;
}
source = source.trim();
if (source.matches("^\\d{4}-\\d{2}-\\d{2}$")) {
return parse(source, "yyyy-MM-dd");
}
if (source.matches("^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$")) {
return parse(source, "yyyy-MM-dd HH:mm:ss");
}
if (source.matches("^\\d{13}$")) {
return new Date(Long.parseLong(source));
}
throw new IllegalArgumentException("不支持的日期格式: " + source);
}
private Date parse(String s, String pattern) {
try {
return new SimpleDateFormat(pattern).parse(s);
} catch (ParseException e) {
throw new IllegalArgumentException("日期解析失败: " + s, e);
}
}
});
}
}
一个细节:这里 SimpleDateFormat 是在方法内部 new 的,不是静态成员变量。 SimpleDateFormat 内部持有 Calendar 实例做中间计算,多线程共享会算出错误日期。这个坑我在另一篇里专门写了。
另外注意,如果 @DateTimeFormat 和全局 Converter 同时存在,注解优先级更高。所以个别接口需要特殊格式时,依然可以用注解覆盖。
解法三:别用 Date,用 LocalDateTime
JDK 8 之后有了 java.time 包,我后来新写的接口都改成 LocalDateTime 了:
public class OrderQuery {
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
}
但有个前提:Spring 4.x 对 JSR-310 的支持需要额外注册模块。Spring Boot 2.0 已经内置了 JacksonAutoConfiguration 和对应的格式化器,直接用就行;纯 Spring MVC 项目得手动注册:
@Bean
public FormattingConversionService conversionService() {
FormattingConversionService service = new DefaultFormattingConversionService();
DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
registrar.setUseIsoFormat(true);
registrar.registerFormatters(service);
return service;
}
返回 JSON 的日期也要配
入参解决了,出参还有一坑。Date 类型默认被 Jackson 序列化成时间戳:
{"createTime":1521542400000}
前端希望拿到 2018-03-20 20:00:00。Spring Boot 里一行配置搞定:
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8
time-zone 那行一定要加,不加会差 8 小时。我们线上就因为这个,订单创建时间在页面上显示比实际早了 8 小时,被用户投诉过。
如果是纯 Spring MVC(非 Boot),在 ObjectMapper 上设:
objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
objectMapper.setTimeZone(TimeZone.getTimeZone("GMT+8"));
就写到这。如果哪天你也被《Spring MVC 日期参数绑定 400 错误排查》里同一个坑绊住,回来翻这篇,能省半小时。