Administrator
发布于 2018-03-20 / 1655 阅读
20

Spring MVC 日期参数绑定 400 错误排查

前端说:你的接口又报 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 错误排查》里同一个坑绊住,回来翻这篇,能省半小时。

参考