Skip to content

作者:杨绿寒轻 链接:https://juejin.cn/post/6844904177479450632 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

Java8已经发布很多年了,但是很多人在开发时仍然坚持使用着DateSimpleDateFormat进行时间操作。SimpleDateFormat不是线程安全的,而Date处理时间很麻烦,所以Java8提供了LocalDateTimeLocalDateLocalTime等全新的时间操作API。无论是Date还是LocalDate,在开发Spring Boot应用时经常需要在每个实体类的日期字段上加上@DateTimeFormat注解来接收前端传值与日期字段绑定,加上@JsonFormat注解来让返回前端的日期字段格式化成我们想要的时间格式。时间和日期类型在开发中使用的频率是非常高的,如果每个字段都加上这两个注解的话是非常繁琐的,有没有一种全局设置的处理方式呢?今天就来向大家介绍一下。

注:本文基于Springboot2.3.0版本。

根据不同的请求方式需要做不同的配置,下文中分为了JSON方式传参和GET请求及POST表单方式传参两种情况。


为什么需要LocalDate、LocalTime、LocalDateTime

主要有三个原因:

  • Date打印出的日期可读性差
  • SimpleDateFormat线程不安全
  • Date对时间处理比较麻烦

下面详细进行说明: 1、Date如果不格式化,打印出的日期可读性差

java
Tue Sep 10 09:34:04 CST 2019

**2、**使用SimpleDateFormat对时间进行格式化,但SimpleDateFormat是线程不安全的 SimpleDateFormatformat方法最终调用代码:

java
private StringBuffer format(Date date, StringBuffer toAppendTo,
                              FieldDelegate delegate) {
        // Convert input date to time field list
        calendar.setTime(date);

        boolean useDateFormatSymbols = useDateFormatSymbols();

        for (int i = 0; i < compiledPattern.length; ) {
            int tag = compiledPattern[i] >>> 8;
            int count = compiledPattern[i++] & 0xff;
            if (count == 255) {
                count = compiledPattern[i++] << 16;
                count |= compiledPattern[i++];
            }

            switch (tag) {
            case TAG_QUOTE_ASCII_CHAR:
                toAppendTo.append((char)count);
                break;

            case TAG_QUOTE_CHARS:
                toAppendTo.append(compiledPattern, i, count);
                i += count;
                break;

            default:
                subFormat(tag, count, delegate, toAppendTo, useDateFormatSymbols);
                break;
            }
        }
        return toAppendTo;
    }

calendar是共享变量,并且这个共享变量没有做线程安全控制。当多个线程同时使用相同的SimpleDateFormat对象【如用static修饰的SimpleDateFormat】调用format方法时,多个线程会同时调用calendar.setTime方法,可能一个线程刚设置好time值另外的一个线程马上把设置的time值给修改了导致返回的格式化时间可能是错误的。在多并发情况下使用SimpleDateFormat需格外注意 SimpleDateFormat除了format是线程不安全以外,parse方法也是线程不安全的。parse方法实际调用alb.establish(calendar).getTime()方法来解析,alb.establish(calendar)方法里主要完成了

  • 重置日期对象cal的属性值
  • 使用calb中中属性设置cal
  • 返回设置好的cal对象

但是这三步不是原子操作。

那么多线程并发条件下使用**SimpleDateFormat**如何保证线程安全:

  • 避免线程之间共享一个SimpleDateFormat对象,每个线程使用时都创建一次SimpleDateFormat对象 => 创建和销毁对象的开销大
  • 对使用formatparse方法的地方进行加锁 => 线程阻塞性能差
  • 使用ThreadLocal保证每个线程最多只创建一次SimpleDateFormat对象 => 较好的方法

JSON方式传参

这种情况指的是类型POST,Content-Type 是application/json 方式的请求。对于这类请求,controller中需要加上@RequestBody注解来标注到我们用来接收请求参数的局部变量上,代码如下:

java
@SpringBootApplication
@RestController
public class SpringbootDateLearningApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringbootDateLearningApplication.class, args);
    }
    
     /**
     * DateTime格式化字符串
     */
    private static final String DEFAULT_DATETIME_PATTERN = "yyyy-MM-dd HH:mm:ss";

    /**
     * Date格式化字符串
     */
    private static final String DEFAULT_DATE_PATTERN = "yyyy-MM-dd";

    /**
     * Time格式化字符串
     */
    private static final String DEFAULT_TIME_PATTERN = "HH:mm:ss";

    public static class DateEntity {
        private LocalDate date;

        private LocalDateTime dateTime;

        private Date originalDate;

        public LocalDate getDate() {
            return date;
        }

        public void setDate(LocalDate date) {
            this.date = date;
        }

        public LocalDateTime getDateTime() {
            return dateTime;
        }

        public void setDateTime(LocalDateTime dateTime) {
            this.dateTime = dateTime;
        }

        public Date getOriginalDate() {
            return originalDate;
        }

        public void setOriginalDate(Date originalDate) {
            this.originalDate = originalDate;
        }

    }

    @RequestMapping("/date")
    public DateEntity getDate(@RequestBody DateEntity dateEntity) {
        return dateEntity;
    }
}    
复制代码

假设默认的接收和返回值的格式都是yyyy-MM-dd HH:mm:ss,可以有以下几个方案。

配置application.yml 文件

在application.yml文件中配置上如下内容:

xml
spring:
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: GMT+8

小结:

  • 支持Content-Type 是application/json的POST请求,请求参数字符串和返回的格式都是yyyy-MM-dd HH:mm:ss如果请求参数是其他格式,如yyyy-MM-dd字符串则报400 Bad Request异常。
  • 不支持LocalDate等Java8日期API。

增加Jackson配置

java
/**
  * Jackson序列化和反序列化转换器,用于转换Post请求体中的json以及将对象序列化为返回响应的json
  */
@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
    return builder -> builder
            .serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATETIME_PATTERN)))
            .serializerByType(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_PATTERN)))
            .serializerByType(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_PATTERN)))
            .serializerByType(Date.class, new DateSerializer(false, new SimpleDateFormat(DEFAULT_DATETIME_PATTERN)))
            .deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATETIME_PATTERN)))
            .deserializerByType(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_PATTERN)))
            .deserializerByType(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_PATTERN)))
            .deserializerByType(Date.class, new DateDeserializers.DateDeserializer(DateDeserializers.DateDeserializer.instance, new SimpleDateFormat(DEFAULT_DATETIME_PATTERN), DEFAULT_DATETIME_PATTERN))
            ;
}
复制代码

小结:

  • 支持Content-Type 是application/json的POST请求,请求参数字符串和返回的格式都是yyyy-MM-dd HH:mm:ss如果请求参数是其他格式,如yyyy-MM-dd字符串则报400 Bad Request异常。
  • 支持LocalDate等Java8日期API。

PS:上面的方式是通过配置一个Jackson2ObjectMapperBuilderCustomizerBean完成的,除了这种,也可以通过自定义一个MappingJackson2HttpMessageConverter来实现。

java
@Bean
public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
    MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
    ObjectMapper objectMapper = new ObjectMapper();
    // 指定时区
    objectMapper.setTimeZone(TimeZone.getTimeZone("GMT+8:00"));
    // 日期类型字符串处理
    objectMapper.setDateFormat(new SimpleDateFormat(DEFAULT_DATETIME_PATTERN));

    // Java8日期日期处理
    JavaTimeModule javaTimeModule = new JavaTimeModule();
    javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATETIME_PATTERN)));
    javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_PATTERN)));
    javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_PATTERN)));
    javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATETIME_PATTERN)));
    javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_PATTERN)));
    javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_PATTERN)));
    objectMapper.registerModule(javaTimeModule);

    converter.setObjectMapper(objectMapper);
    return converter;
}
复制代码

以上几种方式都可以实现JSON传参时的全局化配置,更推荐后两种代码中增加配置bean的方式,可以同时支持DateLocalDate


GET请求及POST表单方式传参

这种方式和上面的JSON方式,在Spring Boot处理的方式是完全不同的。上一种JSON方式传参是在HttpMessgeConverter中通过jackson的ObjectMapper将http请求体转换成我们写在controller中的参数对象的,而这种方式用的是Converter接口(spring-core中定义的用于将源类型(一般是String)转成目标类型的接口),两者是有本质区别的。

自定义参数转换器(Converter)

自定义一个参数转换器,实现上面提到的org.springframework.core.convert.converter.Converter接口,在配置类里配置上以下几个bean,示例如下:

java
@Bean
public Converter<String, Date> dateConverter() {
    return new Converter<>() {
        @Override
        public Date convert(String source) {
            SimpleDateFormat formatter = new SimpleDateFormat(DEFAULT_DATETIME_PATTERN);
            try {
                return formatter.parse(source);
            } catch (Exception e) {
                throw new RuntimeException(String.format("Error parsing %s to Date", source));
            }
        }
    };
}

@Bean
public Converter<String, LocalDate> localDateConverter() {
    return new Converter<>() {
        @Override
        public LocalDate convert(String source) {
            return LocalDate.parse(source, DateTimeFormatter.ofPattern(DEFAULT_DATE_PATTERN));
        }
    };
}

@Bean
public Converter<String, LocalDateTime> localDateTimeConverter() {
    return new Converter<>() {
        @Override
        public LocalDateTime convert(String source) {
            return LocalDateTime.parse(source, DateTimeFormatter.ofPattern(DEFAULT_DATETIME_PATTERN));
        }
    };
}
复制代码

同时把controller接口增加一些参数,可以发现在接口里单独用变量接收也是可以正常转换的。

java
@RequestMapping("/date")
public DateEntity getDate(
        LocalDate date,
        LocalDateTime dateTime,
        Date originalDate,
        DateEntity dateEntity) {
    System.out.printf("date=%s, dateTime=%s, originalDate=%s \n", date, dateTime, originalDate);
    return dateEntity;
}
复制代码

小结:

  • GET请求及POST表单方式请求。
  • 支持LocalDate等Java8日期API。

使用@DateTimeFormat注解

和前面提到的一样,GET请求及POST表单方式也是可以用@DateTimeFormat来处理的,单独在controller接口参数或者实体类属性中都可以使用,比如@DateTimeFormat(pattern = "yyyy-MM-dd") Date originalDate。注意,如果使用了自定义参数转化器(Converter),Spring会优先使用该方式进行处理,即**@DateTimeFormat**注解不生效,两种方式是不兼容的。

那么假如我们使用了自定义参数转换器,但是还是想兼容用yyyy-MM-dd形式接受呢?我们可以把前面的dateConverter改成用正则匹配方式,这样也不失为一种不错的解决方案,示例如下。

java
/**
 * 日期正则表达式
 */
private static final String DATE_REGEX = "[1-9]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])";

/**
 * 时间正则表达式
 */
private static final String TIME_REGEX = "(20|21|22|23|[0-1]\d):[0-5]\d:[0-5]\d";

/**
 * 日期和时间正则表达式
 */
private static final String DATE_TIME_REGEX = DATE_REGEX + "\s" + TIME_REGEX;

/**
 * 13位时间戳正则表达式
 */
private static final String TIME_STAMP_REGEX = "1\d{12}";

/**
 * 年和月正则表达式
 */
private static final String YEAR_MONTH_REGEX = "[1-9]\d{3}-(0[1-9]|1[0-2])";

/**
 * 年和月格式
 */
private static final String YEAR_MONTH_PATTERN = "yyyy-MM";

@Bean
public Converter<String, Date> dateConverter() {
    return new Converter<String, Date>() {
        @SuppressWarnings("NullableProblems")
        @Override
        public Date convert(String source) {
            if (StrUtil.isEmpty(source)) {
                return null;
            }
            if (source.matches(TIME_STAMP_REGEX)) {
                return new Date(Long.parseLong(source));
            }
            DateFormat format;
            if (source.matches(DATE_TIME_REGEX)) {
                format = new SimpleDateFormat(DEFAULT_DATETIME_PATTERN);
            } else if (source.matches(DATE_REGEX)) {
                format = new SimpleDateFormat(DEFAULT_DATE_FORMAT);
            } else if (source.matches(YEAR_MONTH_REGEX)) {
                format = new SimpleDateFormat(YEAR_MONTH_PATTERN);
            } else {
                throw new IllegalArgumentException();
            }
            try {
                return format.parse(source);
            } catch (ParseException e) {
                throw new RuntimeException(e);
            }
        }
    };
}
复制代码

小结:

  • GET请求及POST表单方式请求,但是需要在每个使用的地方加上@DateTimeFormat注解。
  • 与自定义参数转化器(Converter)不兼容。
  • 支持LocalDate等Java8日期API。

使用@ControllerAdvice配合@initBinder

java
/*
 * 在类上加上@ControllerAdvice
 */
@ControllerAdvice
@SpringBootApplication
@RestController
public class SpringbootDateLearningApplication {
    ...
    @InitBinder
    protected void initBinder(WebDataBinder binder) {
        binder.registerCustomEditor(LocalDate.class, new PropertyEditorSupport() {
            @Override
            public void setAsText(String text) throws IllegalArgumentException {
                setValue(LocalDate.parse(text, DateTimeFormatter.ofPattern(DEFAULT_DATE_PATTERN)));
            }
        });
        binder.registerCustomEditor(LocalDateTime.class, new PropertyEditorSupport() {
            @Override
            public void setAsText(String text) throws IllegalArgumentException {
                setValue(LocalDateTime.parse(text, DateTimeFormatter.ofPattern(DEFAULT_DATETIME_PATTERN)));
            }
        });
        binder.registerCustomEditor(LocalTime.class, new PropertyEditorSupport() {
            @Override
            public void setAsText(String text) throws IllegalArgumentException {
                setValue(LocalTime.parse(text, DateTimeFormatter.ofPattern(DEFAULT_TIME_PATTERN)));
            }
        });
        binder.registerCustomEditor(Date.class, new PropertyEditorSupport() {
            @Override
            public void setAsText(String text) throws IllegalArgumentException {
                SimpleDateFormat formatter = new SimpleDateFormat(DEFAULT_DATETIME_PATTERN);
                try {
                    setValue(formatter.parse(text));
                } catch (Exception e) {
                    throw new RuntimeException(String.format("Error parsing %s to Date", text));
                }
            }
        });
    }   
    ...
}    
复制代码

在实际应用中,我们可以把上面代码放到父类中,所有接口继承这个父类,达到全局处理的效果。原理就是与AOP类似,在参数进入handler之前进行转换时使用我们定义的PropertyEditorSupport来处理。

小结:

  • GET请求及POST表单方式请求。
  • 支持LocalDate等Java8日期API。

局部差异化处理

假设按照前面的全局日期格式设置的是:yyyy-MM-dd HH:mm:ss,但是某个Date 类型的字段需要特殊处理成yyyy/MM/dd格式来接收或者返回,有以下方案可以选择。

使用@DateTimeFormat@JsonFormat注解

java
@JsonFormat(pattern = "yyyy/MM/dd", timezone = "GMT+8")
@DateTimeFormat(pattern = "yyyy/MM/dd HH:mm:ss")
private Date originalDate;

如上所示,可以在字段上增加@DateTimeFormat@JsonFormat注解,可以分别单独指定该字段的接收和返回的日期格式。

PS:@JsonFormat@DateTimeFormat注解都不是Spring Boot提供的,在Spring应用中也可以使用。

再次提醒,如果使用了自定义参数转化器(Converter),Spring会优先使用该方式进行处理,即**@DateTimeFormat**注解不生效

自定义序列化器和反序列化器

java
/**
 * {@link Date} 序列化器
 */
public class DateJsonSerializer extends JsonSerializer<Date> {
    @Override
    public void serialize(Date date, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws
            IOException {
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
        jsonGenerator.writeString(dateFormat.format(date));
    }
}

/**
 * {@link Date} 反序列化器
 */
public class DateJsonDeserializer extends JsonDeserializer<Date> {
    @Override
    public Date deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
        try {
            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
            return dateFormat.parse(jsonParser.getText());
        } catch (ParseException e) {
            throw new IOException(e);
        }
    }
}

/**
 * 使用方式
 */
@JsonSerialize(using = DateJsonSerializer.class)
@JsonDeserialize(using = DateJsonDeserializer.class)
private Date originalDate;
复制代码

如上所示,可以在字段上使用@JsonSerialize@JsonDeserialize注解来指定在序列化和反序列化时使用我们自定义的序列化器和反序列化器。

最后再来个兼容JSON方式和GET请求及POST表单方式的完整的配置吧。

java
@Configuration
public class GlobalDateTimeConfig {

    /**
     * 日期正则表达式
     */
    private static final String DATE_REGEX = "[1-9]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])";

    /**
     * 时间正则表达式
     */
    private static final String TIME_REGEX = "(20|21|22|23|[0-1]\d):[0-5]\d:[0-5]\d";

    /**
     * 日期和时间正则表达式
     */
    private static final String DATE_TIME_REGEX = DATE_REGEX + "\s" + TIME_REGEX;

    /**
     * 13位时间戳正则表达式
     */
    private static final String TIME_STAMP_REGEX = "1\d{12}";

    /**
     * 年和月正则表达式
     */
    private static final String YEAR_MONTH_REGEX = "[1-9]\d{3}-(0[1-9]|1[0-2])";

    /**
     * 年和月格式
     */
    private static final String YEAR_MONTH_PATTERN = "yyyy-MM";

    /**
     * DateTime格式化字符串
     */
    private static final String DEFAULT_DATETIME_PATTERN = "yyyy-MM-dd HH:mm:ss";

    /**
     * Date格式化字符串
     */
    private static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";

    /**
     * Time格式化字符串
     */
    private static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";

    /**
     * LocalDate转换器,用于转换RequestParam和PathVariable参数
     */
    @Bean
    public Converter<String, LocalDate> localDateConverter() {
        return new Converter<String, LocalDate>() {
            @SuppressWarnings("NullableProblems")
            @Override
            public LocalDate convert(String source) {
                if (StringUtils.isEmpty(source)) {
                    return null;
                }
                return LocalDate.parse(source, DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT));
            }
        };
    }

    /**
     * LocalDateTime转换器,用于转换RequestParam和PathVariable参数
     */
    @Bean
    public Converter<String, LocalDateTime> localDateTimeConverter() {
        return new Converter<String, LocalDateTime>() {
            @SuppressWarnings("NullableProblems")
            @Override
            public LocalDateTime convert(String source) {
                if (StringUtils.isEmpty(source)) {
                    return null;
                }
                return LocalDateTime.parse(source, DateTimeFormatter.ofPattern(DEFAULT_DATETIME_PATTERN));
            }
        };
    }

    /**
     * LocalDate转换器,用于转换RequestParam和PathVariable参数
     */
    @Bean
    public Converter<String, LocalTime> localTimeConverter() {
        return new Converter<String, LocalTime>() {
            @SuppressWarnings("NullableProblems")
            @Override
            public LocalTime convert(String source) {
                if (StringUtils.isEmpty(source)) {
                    return null;
                }
                return LocalTime.parse(source, DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT));
            }
        };
    }

    /**
     * Date转换器,用于转换RequestParam和PathVariable参数
     */
    @Bean
    public Converter<String, Date> dateConverter() {
        return new Converter<String, Date>() {
            @SuppressWarnings("NullableProblems")
            @Override
            public Date convert(String source) {
                if (StringUtils.isEmpty(source)) {
                    return null;
                }
                if (source.matches(TIME_STAMP_REGEX)) {
                    return new Date(Long.parseLong(source));
                }
                DateFormat format;
                if (source.matches(DATE_TIME_REGEX)) {
                    format = new SimpleDateFormat(DEFAULT_DATETIME_PATTERN);
                } else if (source.matches(DATE_REGEX)) {
                    format = new SimpleDateFormat(DEFAULT_DATE_FORMAT);
                } else if (source.matches(YEAR_MONTH_REGEX)) {
                    format = new SimpleDateFormat(YEAR_MONTH_PATTERN);
                } else {
                    throw new IllegalArgumentException();
                }
                try {
                    return format.parse(source);
                } catch (ParseException e) {
                    throw new RuntimeException(e);
                }
            }
        };
    }

    /**
     * Json序列化和反序列化转换器,用于转换Post请求体中的json以及将我们的对象序列化为返回响应的json
     */
    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
        return builder -> builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATETIME_PATTERN)))
                .serializerByType(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
                .serializerByType(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))
                .serializerByType(Long.class, ToStringSerializer.instance)
                .deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATETIME_PATTERN)))
                .deserializerByType(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
                .deserializerByType(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));
    }

}

源码解析

Spring MVC是如何进行参数绑定的。