SpringBoot后端接收LocalDateTime参数最佳实践

  1. 1. 0x0. LocalDateTime在SpringBoot中的窘境
  2. 2. 0x1. 初步解决LocalDateTime作为query参数的问题
  3. 3. 0x2. 寻找全局解决方式
  4. 4. 0x3. 深入研究分析参数转换过程
  5. 5. 0x4. 创建参数解析器
  6. 6. 0x5. 推荐使用注册Convert的方式
  7. 7. 0x6. 小结
  8. 8. 0xf. 附录

0x0. LocalDateTime在SpringBoot中的窘境

问题由来:在前不久,我们后台就已经抛弃了Date这个类,而改用了java8提供的LocalDateTime,但是正常情况下LocalDateTime的构造函数是私有的,无法像Date那样直接被spring mvc直接处理,所以带给了我很多困扰,甚至有一次项目急迫致使我直接使用String去对时间进行接收,然后再通过DateTimeFormatter在Controller层对参数进行了一层处理,才能将LocalDateTime传给了Service进行处理,直接导致的结果就是方法变得有点丑陋了(逃~

0x1. 初步解决LocalDateTime作为query参数的问题

经过多方面查询得知,对于java8时间格式的问题,其实困扰着很多人,网上也提供了很多的解决方案,比如说下面这种在query参数上加注解的方式:

1
2
3
4
5
6
7
8
9
10
11
@RestController
@RequestMapping("/api")
public class TestApi {

@GetMapping("/time")
public RestInfo getTime(@RequestParam
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
LocalDateTime time) {
return RestInfo.ok(time);
}
}

请求结果如下:
请求结果

但是这样造成的后果是严重影响了代码的质量,每个使用LocalDateTime的参数的地方都需要使用两个注解。

0x2. 寻找全局解决方式

接着我又继续尝试了这一种在加了@Configuration的类中使用@Bean注入了一个Convert转换器的方式就是一种很常见的方法(PS:LocalDateTimeUtils是我自己写的一个工具类,后面再提):

1
2
3
4
5
6
7
8
9
10
11
12
@Bean
public Converter<String, LocalDateTime> localDateTimeConvert() {
return new Converter<String, LocalDateTime>() {
@Override
public LocalDateTime convert(String source) {
if (StringUtils.isEmpty(source)) {
return null;
}
return LocalDateTimeUtils.convert(source.trim());
}
};
}

Controller层代码:

1
2
3
4
5
6
7
8
9
@RestController
@RequestMapping("/api")
public class TestApi {

@GetMapping("/time")
public RestInfo getTime(LocalDateTime time) {
return RestInfo.ok(time);
}
}

但是我使用过后却并没有得到我想要的效果,出现了报错,这是为什么?
得到的报错如下java.lang.IllegalStateException: No primary or default constructor found for class java.time.LocalDateTime,仔细想了一下为什么别人能使用呢,后面才发现自己缺少了一个注解@RequestParam,好的,我这就去加上:

1
2
3
4
5
6
7
8
9
@RestController
@RequestMapping("/api")
public class TestApi {

@GetMapping("/time")
public RestInfo getTime(@RequestParam LocalDateTime time) {
return RestInfo.ok(time);
}
}

请求结果:
请求结果

nice!全局方式query也生效了,现在我们来尝试一下使用Model去接收参数:

TestModel:

1
2
3
4
5
@Data
public class TestModel {

private LocalDateTime time;
}

Controller层:

1
2
3
4
5
6
7
8
9
@RestController
@RequestMapping("/api")
public class TestApi {

@GetMapping("/model")
public RestInfo getModel(TestModel model) {
return RestInfo.ok(model);
}
}

请求结果:

嗯,现在已经大致满足要求了,但是对于简洁到了极致的自己来说却依旧不能感到满足,为什么LocalDateTime作为一个属性存在于Model中时却不需要使用注解方式去指定呢?而我们不使用@RequestParam时LocalDateTime却会出现报错?

0x3. 深入研究分析参数转换过程

我们先在先在Controller层下一个断点,然后查看调用堆栈,找到了一个可疑的方法:invokeForRequest

我们点进去看一看:

1
2
3
4
5
6
7
8
9
10
@Nullable
public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
// 这个方法应该是获取参数的,跟进方法看一下做了什么
Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
if (logger.isTraceEnabled()) {
logger.trace("Arguments: " + Arrays.toString(args));
}
return doInvoke(args);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
protected Object[] getMethodArgumentValues(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
Object... providedArgs) throws Exception {
// 获取方法参数列表
MethodParameter[] parameters = getMethodParameters();
if (ObjectUtils.isEmpty(parameters)) {
return EMPTY_ARGS;
}

Object[] args = new Object[parameters.length];
for (int i = 0; i < parameters.length; i++) {
MethodParameter parameter = parameters[i];
parameter.initParameterNameDiscovery(this.parameterNameDiscoverer);
args[i] = findProvidedArgument(parameter, providedArgs);
if (args[i] != null) {
continue;
}
if (!this.resolvers.supportsParameter(parameter)) {
throw new IllegalStateException(formatArgumentError(parameter, "No suitable resolver"));
}
try {
// 这里应该进行的是参数绑定操作,跟进
args[i] = this.resolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory);
}
catch (Exception ex) {
// Leave stack trace for later, exception may actually be resolved and handled...
if (logger.isDebugEnabled()) {
String exMsg = ex.getMessage();
if (exMsg != null && !exMsg.contains(parameter.getExecutable().toGenericString())) {
logger.debug(formatArgumentError(parameter, exMsg));
}
}
throw ex;
}
}
return args;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Override
@Nullable
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
// 继续跟进,方法就紧跟在下边
HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter);
if (resolver == null) {
throw new IllegalArgumentException("Unsupported parameter type [" +
parameter.getParameterType().getName() + "]. supportsParameter should be called first.");
}
return resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
}

@Nullable
private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
// 在springboot 2.2.0 时这里会直接将LocalDateTime的解析器确定为RequestParamMethodArgumentResolver
HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
if (result == null) {
// 这里argumentResolvers是注册的参数解析器列表
for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) {
if (resolver.supportsParameter(parameter)) {
result = resolver;
this.argumentResolverCache.put(parameter, result);
break;
}
}
}
return result;
}

我们展开参数解析器的列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
this.argumentResolvers = {LinkedList@6957}  size = 26
0 = {RequestParamMethodArgumentResolver@6959}
1 = {RequestParamMapMethodArgumentResolver@7055}
2 = {PathVariableMethodArgumentResolver@7056}
3 = {PathVariableMapMethodArgumentResolver@7057}
4 = {MatrixVariableMethodArgumentResolver@7058}
5 = {MatrixVariableMapMethodArgumentResolver@7059}
6 = {ServletModelAttributeMethodProcessor@7060}
7 = {RequestResponseBodyMethodProcessor@7061}
8 = {RequestPartMethodArgumentResolver@7062}
9 = {RequestHeaderMethodArgumentResolver@7063}
10 = {RequestHeaderMapMethodArgumentResolver@7064}
11 = {ServletCookieValueMethodArgumentResolver@7065}
12 = {ExpressionValueMethodArgumentResolver@7066}
13 = {SessionAttributeMethodArgumentResolver@7067}
14 = {RequestAttributeMethodArgumentResolver@7068}
15 = {ServletRequestMethodArgumentResolver@7069}
16 = {ServletResponseMethodArgumentResolver@7070}
17 = {HttpEntityMethodProcessor@7071}
18 = {RedirectAttributesMethodArgumentResolver@7072}
19 = {ModelMethodProcessor@7073}
20 = {MapMethodProcessor@7074}
21 = {ErrorsMethodArgumentResolver@7075}
22 = {SessionStatusMethodArgumentResolver@7076}
23 = {UriComponentsBuilderMethodArgumentResolver@7077}
24 = {RequestParamMethodArgumentResolver@7078}
25 = {ServletModelAttributeMethodProcessor@7079}

是不是看到了几个看着很眼熟的东西?RequestParam,PathVariable,RequestBody,在idea中查找RequestParamMethodArgumentResolver类,进入他的方法看一下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Override
public boolean supportsParameter(MethodParameter parameter) {
if (parameter.hasParameterAnnotation(RequestParam.class)) {
if (Map.class.isAssignableFrom(parameter.nestedIfOptional().getNestedParameterType())) {
// RequestParam眼熟不,getParameterAnnotation,对应的就是@RequestParam注解
RequestParam requestParam = parameter.getParameterAnnotation(RequestParam.class);
return (requestParam != null && StringUtils.hasText(requestParam.name()));
}
else {
return true;
}
}
else {
if (parameter.hasParameterAnnotation(RequestPart.class)) {
return false;
}
parameter = parameter.nestedIfOptional();
if (MultipartResolutionDelegate.isMultipartArgument(parameter)) {
return true;
}
else if (this.useDefaultResolution) {
return BeanUtils.isSimpleProperty(parameter.getNestedParameterType());
}
else {
return false;
}
}
}

这里可以基本确定一件事,在我这测试时默认的参数解析器有26个,每个参数解析器有着各自的用途,比如加了@RequestParam注解的参数会被RequestParamMethodArgumentResolver所解析,然后再查找类型有无相应的Convert转换器,在我们这里使用@Bean注入了一个Convert转换器,所以LocalDateTime能被正确的解析。

那么我们之前不加@RequestParam直接使用LocalDateTime作为参数的时候是被哪个解析器所捕获的呢,方法已经找到了,那么直接下个断点跑一遍查看result返回的是哪个解析器就知道了!

如果没记错ServletModelAttributeMethodProcessor是参数解析器的最后一个,我们进入到这个类看一眼,没有supportsParameter方法,但是他继承了ModelAttributeMethodProcessor,我们再进入他的父类中:

1
2
3
4
5
@Override
public boolean supportsParameter(MethodParameter parameter) {
return (parameter.hasParameterAnnotation(ModelAttribute.class) ||
(this.annotationNotRequired && !BeanUtils.isSimpleProperty(parameter.getParameterType())));
}

对于没有指定参数解析器的参数来说,默认指定的参数解析器是ModelAttributeMethodProcessor,并且由源码得知,如果加了@ModelAttribute注解,或者非简单属性则会被该解析器捕获,所以我们平时所使用的model去接收不需要加注解即可被正确的解析,既然都来了,那么顺便看一下resolveArgument方法是怎么解析参数的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Override
@Nullable
public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
//...
if (mavContainer.containsAttribute(name)) {
attribute = mavContainer.getModel().get(name);
}
else {
// Create attribute instance
try {
// 这里是对参数的绑定构建了,点进去看一眼
attribute = createAttribute(name, parameter, binderFactory, webRequest);
}
catch (BindException ex) {
if (isBindExceptionRequired(parameter)) {
// No BindingResult parameter -> fail with BindException
throw ex;
}
// Otherwise, expose null/empty value and associated BindingResult
if (parameter.getParameterType() == Optional.class) {
attribute = Optional.empty();
}
bindingResult = ex.getBindingResult();
}
}
//...
}

继续跟进createAttribute方法,看看他是怎么将值绑定给对象的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
protected Object createAttribute(String attributeName, MethodParameter parameter,
WebDataBinderFactory binderFactory, NativeWebRequest webRequest) throws Exception {

MethodParameter nestedParameter = parameter.nestedIfOptional();
Class<?> clazz = nestedParameter.getNestedParameterType();

Constructor<?> ctor = BeanUtils.findPrimaryConstructor(clazz);
if (ctor == null) {
Constructor<?>[] ctors = clazz.getConstructors();
if (ctors.length == 1) {
ctor = ctors[0];
}
else {
try {
ctor = clazz.getDeclaredConstructor();
}
catch (NoSuchMethodException ex) {
throw new IllegalStateException("No primary or default constructor found for " + clazz, ex);
}
}
}

Object attribute = constructAttribute(ctor, attributeName, parameter, binderFactory, webRequest);
if (parameter != nestedParameter) {
attribute = Optional.of(attribute);
}
return attribute;
}

看到这里是不是发现报错的地方有点熟悉?这就是我们之前LocalDateTime抛出异常的地方了,可以得知对于对象的绑定,先通过BeanUtils获取主要的构造函数,如果获取不到,则使用反射的方式先尝试获取声明为public的无参构造函数,最后才会尝试使用getDeclaredConstructor获取所有的无参构造函数,但是对于LocalDateTime这种使用工厂构造不存在无参构造函数的类来说就会直接抛出NoSuchMethodException异常。那么如果我们不想使用@RequestParam注解加在参数上怎么办呢?

0x4. 创建参数解析器

如果我们不希望在LocalDateTime上增加注解,然后再通过RequestParamMethodArgumentResolver解析,我们可以自己定制一种针对LocalDateTime的参数解析器,通过继承WebMvcConfigurer重写addArgumentResolvers方法,然后add一个自定义的HandlerMethodArgumentResolver即可解决刚才的问题,但是在2.2.0版本上会自动指定LocalDateTime的解析器原因未知(我太蔡了…找不到是怎么做到的,想看看他是怎么获取的,但是发现那个方法再无限调用,完全没有头绪。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Configuration
public class MvcConfig implements WebMvcConfigurer {
/**
* 增加 {@link LocalDateTime} 的自定义参数解析器,不需要用注解方式指定query参数即可解析
* 2.2.0版本后较大改变,自动确定LocalDateTime解析器为 {@link
* org.springframework.web.method.annotation.RequestParamMethodArgumentResolver}
* 但是依旧可以注册使用本解析器
*/
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new LocalDateArgumentResolverHandler());
}

public static class LocalDateArgumentResolverHandler implements HandlerMethodArgumentResolver {

@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(LocalDateTime.class);
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
String param = webRequest.getParameter(Objects.requireNonNull(parameter.getParameterName()));
if (StringUtils.isEmpty(param)) {
return null;
}
return LocalDateTimeUtils.convert(param.trim());
}
}
}

0x5. 推荐使用注册Convert的方式

相比于注入@Bean的方式创建Convert的方式,我个人更喜欢使用继承WebMvcConfigurer后重写addFormatters方法来注册自定义Convert。这种方式相比于@Bean注入,更容易让人理解,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Configuration
public class MvcConfig implements WebMvcConfigurer {

@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new LocalDateTimeConverter());
}

public static class LocalDateTimeConverter implements Converter<String, LocalDateTime> {

@Override
public LocalDateTime convert(String source) {
if (StringUtils.isEmpty(source)) {
return null;
}
return LocalDateTimeUtils.convert(source.trim());
}
}
}

0x6. 小结

对于request参数其实还有initBinder,Formatter等方式进行处理,这里就不一一贴出来了,有兴趣的各位可以在网上查询相关资料。还有jackson相关的序列化,通过继承com.fasterxml.jackson.databind.JsonDeserializer和com.fasterxml.jackson.databind.JsonSerializer生成自定义的Jackson Deserializer和Serializer,然后在@Configuration注解的配置类内注入@Bean将Jackson Module注册为@Bean,SpringBoot会自动注入进ObjectMapper中。另一种方式是自己注入一个自己定制的ObjectMapper为@Bean,然后将Module注入进ObjectMapper中。甚至可以通过定制转换规则,从而使类型支持多种参数。

0xf. 附录

支持秒时间戳,毫秒时间戳,自定义时间格式yyyy-MM-dd HH:mm[:ss][.sss],ISO标准时间yyyy-MM-ddTHH:mm[:ss][.sss],UTC标准时间yyyy-MM-ddTHH:mm:ss[.sss]Z,甚至可以支持更多种类型,但是考虑到时间复杂度,感觉这已经不能再简化了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* @author teble
* @date 2019/11/5 10:43
* @description
*/
public class LocalDateTimeUtils {
private final static String REGEX_TIME = "^(\\d{10,13}|\\d{4}-\\d{2}-\\d{2}.\\d{2}:\\d{2}.*)$";

public static LocalDateTime convert(String resolver) {
if (Pattern.matches(REGEX_TIME, resolver)) {
Instant instant;
switch (resolver.length()) {
case 10:
instant = Instant.ofEpochSecond(Long.parseLong(resolver));
return LocalDateTime.ofInstant(instant, ZoneId.of("GMT+8"));
case 13:
instant = Instant.ofEpochMilli(Long.parseLong(resolver));
return LocalDateTime.ofInstant(instant, ZoneId.of("GMT+8"));
default:
break;
}

if (resolver.endsWith("Z")) {
return LocalDateTime.ofInstant(Instant.parse(resolver), ZoneId.of("GMT+8"));
} else if (resolver.charAt(10) == 'T') {
return LocalDateTime.parse(resolver, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
} else if (resolver.charAt(10) == ' ') {
return LocalDateTime.parse(resolver, new DateTimeFormatterBuilder()
.parseCaseInsensitive()
.append(ISO_LOCAL_DATE)
.appendLiteral(' ')
.append(ISO_LOCAL_TIME)
.toFormatter());
}
}
return null;
}
}