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. 附录

时间字符串转 LocalDateTime 工具类。转换不同时区的时间为本地时间(默认使用 ZoneId.systemDefault() 为本地时区)

  • 支持10位秒时间戳
  • 13位毫秒时间戳
  • 通用时间格式: yyyy-MM-dd HH:mm[:ss][.sss]
  • ISO-8601 时间格式: yyyy-MM-ddTHH:mm[:ss][.sss][Z|(+|-)HH:mm]
  • RFC-3339 时间格式: yyyy-MM-dd HH:mm[:ss][.sss][Z|(+|-)HH:mm]
  • 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
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    /**
    * 时间字符串转 LocalDateTime 工具类
    *
    * @author teble
    */
    public abstract class LocalDateTimeUtils {

    private final static Pattern DATE_TIME_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}.*$");

    private final static ZoneId LOCAL_ZONE_ID = ZoneId.systemDefault();

    private final static DateTimeFormatter LOCAL_DATE_TIME_FORMATTER = new DateTimeFormatterBuilder()
    .parseCaseInsensitive()
    .append(ISO_LOCAL_DATE)
    .optionalStart()
    .appendLiteral('T')
    .optionalEnd()
    .optionalStart()
    .appendLiteral(' ')
    .optionalEnd()
    .append(ISO_LOCAL_TIME)
    .toFormatter();

    private static boolean isTimestamp(@NonNull String resolver) {
    for (int i = 0; i < resolver.length(); i++) {
    char ch = resolver.charAt(i);
    if (!Character.isDigit(ch)) {
    return false;
    }
    }
    return resolver.length() == 10 || resolver.length() == 13;
    }

    private static boolean isOffsetDateTime(@NonNull String resolver) {
    for (int i = 10; i < resolver.length(); i++) {
    char ch = resolver.charAt(i);
    if (ch == 'Z' || ch == '+' || ch == '-') {
    return true;
    }
    }
    return false;
    }

    private static boolean isZonedDateTime(@NonNull String resolver) {
    return resolver.endsWith("]");
    }

    @NonNull
    private static String getISOTimeStr(@NonNull String resolver) {
    if (resolver.charAt(10) == ' ') {
    return resolver.substring(0, 10) + "T" + resolver.substring(11);
    } else {
    return resolver;
    }
    }

    @Nullable
    public static LocalDateTime convert(@Nullable String resolver) {
    if (resolver == null) {
    return null;
    }
    if (isTimestamp(resolver)) {
    Instant instant;
    if (resolver.length() == 10) {
    instant = Instant.ofEpochSecond(Long.parseLong(resolver));
    } else {
    instant = Instant.ofEpochMilli(Long.parseLong(resolver));
    }
    return LocalDateTime.ofInstant(instant, LOCAL_ZONE_ID);
    }
    if (DATE_TIME_PATTERN.matcher(resolver).matches()) {
    // compatibility RFC 3339
    resolver = getISOTimeStr(resolver);
    boolean isZoned = isZonedDateTime(resolver);
    boolean isOffset = isOffsetDateTime(resolver);
    if (isOffset && isZoned) {
    return ZonedDateTime.parse(resolver, DateTimeFormatter.ISO_ZONED_DATE_TIME)
    .withZoneSameInstant(LOCAL_ZONE_ID)
    .toLocalDateTime();
    } else if (isOffset) {
    return ZonedDateTime.parse(resolver, DateTimeFormatter.ISO_OFFSET_DATE_TIME)
    .withZoneSameInstant(LOCAL_ZONE_ID)
    .toLocalDateTime();
    } else {
    return LocalDateTime.parse(resolver, LOCAL_DATE_TIME_FORMATTER);
    }
    }
    return null;
    }

    public static void main(String[] args) {
    // ISO 8601
    System.out.println(convert("2011-12-03T10:15:30+01:00[Europe/Paris]"));
    System.out.println(convert("2011-12-03T10:15:30+01:00"));
    System.out.println(convert("2011-12-03T10:15:30Z"));
    // RFC 3339
    System.out.println(convert("2011-12-03 10:15:30.123Z"));
    System.out.println(convert("2011-12-03 10:15:30.123+01:00"));
    // LOCAL TIME
    System.out.println(convert("2011-12-03 10:15:30"));
    // TIMESTAMP
    System.out.println(convert("1322819330"));
    System.out.println(convert("1322819330123"));
    // INVALID
    System.out.println(convert("123"));
    }
    }

    本作品采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可