1.背景
最近在做一个后台项目,在这个项目中涉及到了多种与日期相关的请求参数,在请求对象中使用了LocalDate
与LocalDateTime
两个日期类型。通过日期字符串与前端进行交互,也包含两种格式:yyyy-MM-dd HH:mm:ss
与yyyy-MM-dd
。
在以前的旧项目中,使用的是Date
类型,这种类型兼容两种pattern
格式,于是在使用LocalDate
与LocalDateTime
时,也继续沿用之前的处理方式,结果发生了转换异常。
nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [java.time.LocalDate] for value ‘2023-04-12’; nested exception is java.lang.IllegalArgumentException: Parse attempt failed for value [2023-04-12]]
例如下图中的字段:
按照下面的参数进行请求。
发送请求后,控制台打印了一堆转换异常,看着头大:
于是花了一点时间处理了一下这类问题,让LocalDate与LocalDateTime都兼容两个pattern
。
代码位置:《Demo代码地址》,在里面找到web-test
分支。
2.参数转换器(表单类型参数)
SpringBoot Web(SpringMVC)在接受了表单类型的数据之后,会通过参数转换器将参数转换成我们在模型中定义的字段类型:
org.springframework.core.convert.converter.Converter
也就是说,针对LocalDate
和LocalDateTime
两种类型的字段,我们只需要按照当前项目中的需求,分别拓展一个自定义的类型转换器,覆盖默认的类型转换器即可。
2.1.转换器配置
如何进行拓展呢?
如果是SpringBoot的项目,拓展方式非常简单,只需要新建一个类,实现Converter
接口,并重写convert()
方法即可,下面就是LocalDateTime对象的转换器配置代码,一共就一行:
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.lang.Nullable;
import java.time.LocalDateTime;
@Configuration
public class AppLocalDateTimeConverter implements Converter<String, LocalDateTime>, AppBaseTimeDeserializer {
@Override
public LocalDateTime convert(@Nullable String source) {
// 日期格式转转换
return this.getLocalDateTimeBySource(source);
}
}
这里因为会多次使用到时间转换,所以转换的逻辑抽取到了父接口中:
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.regex.Pattern;
public interface AppBaseTimeDeserializer {
Pattern DATE_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$");
Pattern DATETIME_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$");
/**
* 将页面传入的原日期字符串进行转换
*/
default LocalDateTime getLocalDateTimeBySource(String source) {
if (source == null || source.trim().length() == 0) {
return null;
}
source = source.trim();
if (!DATE_PATTERN.matcher(source).matches() && !DATETIME_PATTERN.matcher(source).matches()) {
throw new RuntimeException("日期参数格式错误: " + source);
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss", Locale.CHINA);
if (DATE_PATTERN.matcher(source).matches()) {
source += " 00:00:00";
}
return LocalDateTime.parse(source, formatter);
}
}
同理,LocalDate的转换只需要集成Converter并同时实现父接口AppBaseTimeDeserializer
,获取到LocalDateTime之后使用toLocalDate()
方法即可,如下所示:
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.lang.Nullable;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* LocalDate 转换器
*/
@Configuration
public class AppLocalDateConverter implements Converter<String, LocalDate>, AppBaseTimeDeserializer {
@Override
public LocalDate convert(@Nullable String source) {
// 日期格式转转换
LocalDateTime localDateTime = this.getLocalDateTimeBySource(source);
return localDateTime == null ? null : localDateTime.toLocalDate();
}
}
在上述的两个自定义转换器代码中,已经使用@configuration
标记为Spring的Bean对象,在SpringBoot中这两个转换器就已经默认注册到了Web请求的转换器链中了,所以不需要更多的配置就已经生效了。
接下来测试一下。
2.2.测试
一般在Controller
中使用get请求时,会采用表单提交(也不一定,如果一定要用JSON类型也可以),现在提供一个请求对象,里面不再给日期对象标记注解:
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Getter
@Setter
public class GetDemoVo {
LocalDate localDate1;
LocalDate localDate2;
LocalDateTime localDateTime1;
LocalDateTime localDateTime2;
}
在Controller中也是最简单的调用方式,打印一下转换结果:
import com.ls.springbootdemo.vo.PostDemoVo;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
@RestController
@RequestMapping("/demo")
public class DemoController {
@GetMapping("getDemo")
public void getDemo(GetDemoVo demo) {
LocalDate localDate1 = demo.getLocalDate1();
LocalDate localDate2 = demo.getLocalDate2();
LocalDateTime localDateTime1 = demo.getLocalDateTime1();
LocalDateTime localDateTime2 = demo.getLocalDateTime2();
System.out.println(localDate1);
System.out.println(localDate2);
System.out.println(localDateTime1);
System.out.println(localDateTime2);
}
}
再次使用开篇的请求参数发起请求,看到的打印结果如下:
可以看到,成功兼容了两种不同的pattern
。
3.Jackson反序列化(JSON类型参数)
如果请求参数是JSON
类型,SpringBoot Web会默认使用Jackson
进行类型转换,而不会进入到类型转换器Converter
中,所以还需要分别实现一个自定义反序列化器。
3.1.自定义反序列化器
自定义反序列化器,除了继承的类不一样,实现方法与上面的Converter
几乎一模一样,如下所示:
- LocatDateTime:
import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import java.io.IOException; import java.time.LocalDateTime; /** * LocalDateTime JSON反序列化工具 * */ public class AppLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> implements AppBaseTimeDeserializer { @Override public LocalDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException { return this.getLocalDateTimeBySource(parser.getValueAsString()); } }
- LocalDate:
import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; import java.io.IOException; import java.time.LocalDate; import java.time.LocalDateTime; /** * LocalDate JSON反序列化工具 */ public class AppLocalDateDeserializer extends JsonDeserializer<LocalDate> implements AppBaseTimeDeserializer { @Override public LocalDate deserialize(JsonParser parser, DeserializationContext context) throws IOException { LocalDateTime localDateTime = this.getLocalDateTimeBySource(parser.getValueAsString()); return localDateTime == null ? null : localDateTime.toLocalDate(); } }
3.2.全局注册自定义反序列化器
与Converter不同的是,Jackson的反序列化器写好之后,还需要将这两个对象注册到Jackson的全局配置中才能生效。可以通过com.fasterxml.jackson.databind.ObjectMapper
来进行注册,代码如下:
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Configuration
public class JacksonConfig {
@Autowired
public void appConfigureObjectMapper(ObjectMapper objectMapper) {
SimpleModule module = new SimpleModule();
module.addDeserializer(LocalDateTime.class, new AppLocalDateTimeDeserializer());
module.addDeserializer(LocalDate.class, new AppLocalDateDeserializer());
objectMapper.registerModule(module);
}
}
3.3.测试
同样的,写一个Post接口,使用@RequestBody
来接收参数,打印一下转换结果:
import com.ls.springbootdemo.vo.PostDemoVo;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
@RestController
@RequestMapping("/demo")
public class DemoController {
@PostMapping("postDemo")
public void postDemo(@RequestBody PostDemoVo demo) {
LocalDate localDate1 = demo.getLocalDate1();
LocalDate localDate2 = demo.getLocalDate2();
LocalDateTime localDateTime1 = demo.getLocalDateTime1();
LocalDateTime localDateTime2 = demo.getLocalDateTime2();
System.out.println(localDate1);
System.out.println(localDate2);
System.out.println(localDateTime1);
System.out.println(localDateTime2);
}
}
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Getter
@Setter
public class PostDemoVo {
LocalDate localDate1;
LocalDate localDate2;
LocalDateTime localDateTime1;
LocalDateTime localDateTime2;
}
打印结果与Converter一致。
3.4.注意事项
Jackson的全局注册方式会在项目中的任何一个JSON反序列化的位置生效,影响面积比较大。如果想缩小影响面积,可以不使用全局配置的方式,转而在Vo对象的字段上通过@JsonDeserialize
注解标记即可。
现在删除上述3.2中的JacksonConfig
(即取消两个自定义反序列化类的注册),然后再PostDemoVo
中加上注解,如下所示:
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.ls.springbootdemo.config.AppLocalDateDeserializer;
import com.ls.springbootdemo.config.AppLocalDateTimeDeserializer;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Getter
@Setter
public class PostDemoVo {
@JsonDeserialize(using = AppLocalDateDeserializer.class)
LocalDate localDate1;
@JsonDeserialize(using = AppLocalDateDeserializer.class)
LocalDate localDate2;
@JsonDeserialize(using = AppLocalDateTimeDeserializer.class)
LocalDateTime localDateTime1;
@JsonDeserialize(using = AppLocalDateTimeDeserializer.class)
LocalDateTime localDateTime2;
}
再次进行测试,最终会得到一样的结果。
4.结语
SpringBoot Web的参数转换需要考虑参数的类型是表单类型还是JSON类型:
- 表单类型:通过继承
Converter
配置自定义参数转换器。 - JSON类型:配置自定义的Jackson反序列化器。
注意Jackson反序列化器全局注册是影响范围,如果想控制范围可以不进行全局配置,转而是用字段注解触发。