欧美bbbwbbbw肥妇,免费乱码人妻系列日韩,一级黄片

如何在Spring Boot應(yīng)用中優(yōu)雅的使用Date和LocalDateTime的教程詳解

 更新時(shí)間:2020年07月01日 11:08:07   作者:kangjh  
這篇文章主要介紹了如何在Spring Boot應(yīng)用中優(yōu)雅的使用Date和LocalDateTime,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下

Java8已經(jīng)發(fā)布很多年了,但是很多人在開發(fā)時(shí)仍然堅(jiān)持使用著DateSimpleDateFormat進(jìn)行時(shí)間操作。SimpleDateFormat不是線程安全的,而Date處理時(shí)間很麻煩,所以Java8提供了LocalDateTime、LocalDateLocalTime等全新的時(shí)間操作API。無論是Date還是LocalDate,在開發(fā)Spring Boot應(yīng)用時(shí)經(jīng)常需要在每個(gè)實(shí)體類的日期字段上加上@DateTimeFormat注解來接收前端傳值與日期字段綁定,加上@JsonFormat注解來讓返回前端的日期字段格式化成我們想要的時(shí)間格式。時(shí)間和日期類型在開發(fā)中使用的頻率是非常高的,如果每個(gè)字段都加上這兩個(gè)注解的話是非常繁瑣的,有沒有一種全局設(shè)置的處理方式呢?今天就來向大家介紹一下。

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

根據(jù)不同的請(qǐng)求方式需要做不同的配置,下文中分為了JSON方式傳參和GET請(qǐng)求及POST表單方式傳參兩種情況。

JSON方式傳參

這種情況指的是類型POST,Content-Type 是application/json 方式的請(qǐng)求。對(duì)于這類請(qǐng)求,controller中需要加上@RequestBody注解來標(biāo)注到我們用來接收請(qǐng)求參數(shù)的局部變量上,代碼如下:

@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;
 }
} 

假設(shè)默認(rèn)的接收和返回值的格式都是yyyy-MM-dd HH:mm:ss,可以有以下幾個(gè)方案。

配置application.yml 文件

在application.yml文件中配置上如下內(nèi)容:

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

小結(jié):

  • 支持Content-Type 是application/json的POST請(qǐng)求,請(qǐng)求參數(shù)字符串和返回的格式都是yyyy-MM-dd HH:mm:ss如果請(qǐng)求參數(shù)是其他格式,如yyyy-MM-dd字符串則報(bào)400 Bad Request異常。
  • 不支持LocalDate等Java8日期API。

增加Jackson配置

/**
 * Jackson序列化和反序列化轉(zhuǎn)換器,用于轉(zhuǎn)換Post請(qǐng)求體中的json以及將對(duì)象序列化為返回響應(yīng)的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))
   ;
}

小結(jié):

  • 支持Content-Type 是application/json的POST請(qǐng)求,請(qǐng)求參數(shù)字符串和返回的格式都是yyyy-MM-dd HH:mm:ss如果請(qǐng)求參數(shù)是其他格式,如yyyy-MM-dd字符串則報(bào)400 Bad Request異常。
  • 支持LocalDate等Java8日期API。

PS:上面的方式是通過配置一個(gè)Jackson2ObjectMapperBuilderCustomizerBean完成的,除了這種,也可以通過自定義一個(gè)MappingJackson2HttpMessageConverter來實(shí)現(xiàn)。

@Bean
public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
 MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
 ObjectMapper objectMapper = new ObjectMapper();
 // 指定時(shí)區(qū)
 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;
}

以上幾種方式都可以實(shí)現(xiàn)JSON傳參時(shí)的全局化配置,更推薦后兩種代碼中增加配置bean的方式,可以同時(shí)支持DateLocalDate

GET請(qǐng)求及POST表單方式傳參

這種方式和上面的JSON方式,在Spring Boot處理的方式是完全不同的。上一種JSON方式傳參是在HttpMessgeConverter中通過jackson的ObjectMapper將http請(qǐng)求體轉(zhuǎn)換成我們寫在controller中的參數(shù)對(duì)象的,而這種方式用的是Converter接口(spring-core中定義的用于將源類型(一般是String)轉(zhuǎn)成目標(biāo)類型的接口),兩者是有本質(zhì)區(qū)別的。

自定義參數(shù)轉(zhuǎn)換器(Converter)

自定義一個(gè)參數(shù)轉(zhuǎn)換器,實(shí)現(xiàn)上面提到的org.springframework.core.convert.converter.Converter接口,在配置類里配置上以下幾個(gè)bean,示例如下:

@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));
  }
 };
}

同時(shí)把controller接口增加一些參數(shù),可以發(fā)現(xiàn)在接口里單獨(dú)用變量接收也是可以正常轉(zhuǎn)換的。

@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;
}

小結(jié):

  • GET請(qǐng)求及POST表單方式請(qǐng)求。
  • 支持LocalDate等Java8日期API。

使用@DateTimeFormat注解

和前面提到的一樣,GET請(qǐng)求及POST表單方式也是可以用@DateTimeFormat來處理的,單獨(dú)在controller接口參數(shù)或者實(shí)體類屬性中都可以使用,比如@DateTimeFormat(pattern = "yyyy-MM-dd") Date originalDate。注意,如果使用了自定義參數(shù)轉(zhuǎn)化器(Converter),Spring會(huì)優(yōu)先使用該方式進(jìn)行處理,即@DateTimeFormat注解不生效,兩種方式是不兼容的。

那么假如我們使用了自定義參數(shù)轉(zhuǎn)換器,但是還是想兼容用yyyy-MM-dd形式接受呢?我們可以把前面的dateConverter改成用正則匹配方式,這樣也不失為一種不錯(cuò)的解決方案,示例如下。

/**
 * 日期正則表達(dá)式
 */
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])";

/**
 * 時(shí)間正則表達(dá)式
 */
private static final String TIME_REGEX = "(20|21|22|23|[0-1]\\d):[0-5]\\d:[0-5]\\d";

/**
 * 日期和時(shí)間正則表達(dá)式
 */
private static final String DATE_TIME_REGEX = DATE_REGEX + "\\s" + TIME_REGEX;

/**
 * 13位時(shí)間戳正則表達(dá)式
 */
private static final String TIME_STAMP_REGEX = "1\\d{12}";

/**
 * 年和月正則表達(dá)式
 */
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);
   }
  }
 };
}

小結(jié):

  • GET請(qǐng)求及POST表單方式請(qǐng)求,但是需要在每個(gè)使用的地方加上@DateTimeFormat注解。
  • 與自定義參數(shù)轉(zhuǎn)化器(Converter)不兼容。
  • 支持LocalDate等Java8日期API。

使用@ControllerAdvice配合@initBinder

/*
 * 在類上加上@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));
    }
   }
  });
 } 
 ...
} 

在實(shí)際應(yīng)用中,我們可以把上面代碼放到父類中,所有接口繼承這個(gè)父類,達(dá)到全局處理的效果。原理就是與AOP類似,在參數(shù)進(jìn)入handler之前進(jìn)行轉(zhuǎn)換時(shí)使用我們定義的PropertyEditorSupport來處理。

小結(jié):

  • GET請(qǐng)求及POST表單方式請(qǐng)求。
  • 支持LocalDate等Java8日期API。
  • 局部差異化處理

假設(shè)按照前面的全局日期格式設(shè)置的是:yyyy-MM-dd HH:mm:ss,但是某個(gè)Date類型的字段需要特殊處理成yyyy/MM/dd格式來接收或者返回,有以下方案可以選擇。

使用@DateTimeFormat@JsonFormat注解

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

如上所示,可以在字段上增加@DateTimeFormat@JsonFormat注解,可以分別單獨(dú)指定該字段的接收和返回的日期格式。

PS:@JsonFormat@DateTimeFormat注解都不是Spring Boot提供的,在Spring應(yīng)用中也可以使用。

再次提醒,如果使用了自定義參數(shù)轉(zhuǎn)化器(Converter),Spring會(huì)優(yōu)先使用該方式進(jìn)行處理,即@DateTimeFormat注解不生效。

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

/**
 * {@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注解來指定在序列化和反序列化時(shí)使用我們自定義的序列化器和反序列化器。

最后再來個(gè)兼容JSON方式和GET請(qǐng)求及POST表單方式的完整的配置吧。

@Configuration
public class GlobalDateTimeConfig {

 /**
  * 日期正則表達(dá)式
  */
 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])";

 /**
  * 時(shí)間正則表達(dá)式
  */
 private static final String TIME_REGEX = "(20|21|22|23|[0-1]\\d):[0-5]\\d:[0-5]\\d";

 /**
  * 日期和時(shí)間正則表達(dá)式
  */
 private static final String DATE_TIME_REGEX = DATE_REGEX + "\\s" + TIME_REGEX;

 /**
  * 13位時(shí)間戳正則表達(dá)式
  */
 private static final String TIME_STAMP_REGEX = "1\\d{12}";

 /**
  * 年和月正則表達(dá)式
  */
 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轉(zhuǎn)換器,用于轉(zhuǎn)換RequestParam和PathVariable參數(shù)
  */
 @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轉(zhuǎn)換器,用于轉(zhuǎn)換RequestParam和PathVariable參數(shù)
  */
 @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轉(zhuǎn)換器,用于轉(zhuǎn)換RequestParam和PathVariable參數(shù)
  */
 @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轉(zhuǎn)換器,用于轉(zhuǎn)換RequestParam和PathVariable參數(shù)
  */
 @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序列化和反序列化轉(zhuǎn)換器,用于轉(zhuǎn)換Post請(qǐng)求體中的json以及將我們的對(duì)象序列化為返回響應(yīng)的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)));
 }

}

源碼剖析

在了解完怎么樣進(jìn)行全局設(shè)置后,接下來我們通過debug源碼來深入剖析一下Spring MVC是如何進(jìn)行參數(shù)綁定的。

仍然是以上面的controller為例進(jìn)行debug。

@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;
}

以下是收到請(qǐng)求后的方法調(diào)用棧的一些關(guān)鍵方法:

// DispatcherServlet處理請(qǐng)求
doService:943, DispatcherServlet
// 處理請(qǐng)求
doDispatch:1040, DispatcherServlet
// 生成調(diào)用鏈(前處理、實(shí)際調(diào)用方法、后處理)
handle:87, AbstractHandlerMethodAdapter
handleInternal:793, RequestMappingHandlerAdapter
// 反射獲取到實(shí)際調(diào)用方法,準(zhǔn)備開始調(diào)用
invokeHandlerMethod:879, RequestMappingHandlerAdapter
invokeAndHandle:105, ServletInvocableHandlerMethod 
// 關(guān)鍵步驟,從這里開始處理請(qǐng)求參數(shù)
invokeForRequest:134, InvocableHandlerMethod
getMethodArgumentValues:167, InvocableHandlerMethod
resolveArgument:121, HandlerMethodArgumentResolverComposite

下面我們從關(guān)鍵的invokeForRequest:134, InvocableHandlerMethod處開始分析,源碼如下

// InvocableHandlerMethod.java
@Nullable
public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
  Object... providedArgs) throws Exception {
 // 這里完成參數(shù)的轉(zhuǎn)換,得到的是轉(zhuǎn)換后的值
 Object[] args = getMethodArgumentValues(request, mavContainer, providedArgs);
 if (logger.isTraceEnabled()) {
  logger.trace("Arguments: " + Arrays.toString(args));
 }
 // 反射調(diào)用,真正開始執(zhí)行方法
 return doInvoke(args);
}
// 具體實(shí)現(xiàn)
protected Object[] getMethodArgumentValues(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
  Object... providedArgs) throws Exception {
 // 獲取當(dāng)前handler method的方法參數(shù)數(shù)組,封裝了入?yún)⑿畔?,比如類型、泛型?
 MethodParameter[] parameters = getMethodParameters();
 if (ObjectUtils.isEmpty(parameters)) {
  return EMPTY_ARGS;
 }
 // 該數(shù)組用來存放從MethodParameter轉(zhuǎn)換后的結(jié)果
 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;
  }
  // resolvers是定義的成員變量,HandlerMethodArgumentResolverComposite類型,是各式各樣的HandlerMethodArgumentResolver的集合。這里來判斷一下是否存在支持當(dāng)前方法參數(shù)的參數(shù)處理器
  if (!this.resolvers.supportsParameter(parameter)) {
   throw new IllegalStateException(formatArgumentError(parameter, "No suitable resolver"));
  }
  try {
   // 調(diào)用HandlerMethodArgumentResolverComposite來處理參數(shù),下面會(huì)重點(diǎn)看一下內(nèi)部的邏輯
   args[i] = this.resolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory);
  }
  catch (Exception ex) {
 ......
  }
 }
 return args;
}

下面需要進(jìn)入HandlerMethodArgumentResolverComposite#resolveArgument方法源碼里面。

// HandlerMethodArgumentResolverComposite.java
@Override
@Nullable
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
  NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
 // 這里來獲取匹配當(dāng)前方法參數(shù)的參數(shù)解析器
 HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter);
 if (resolver == null) {
  throw new IllegalArgumentException("Unsupported parameter type [" +
    parameter.getParameterType().getName() + "]. supportsParameter should be called first.");
 }
 // 調(diào)用真正的參數(shù)解析器來處理參數(shù)并返回
 return resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
}
// 獲取匹配當(dāng)前方法參數(shù)的參數(shù)解析器
@Nullable
private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
 // 首先從緩存中查詢是否有適配當(dāng)前方法參數(shù)的參數(shù)解析器,首次進(jìn)入是沒有的
 HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
 if (result == null) {
  for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) {
   // 逐個(gè)遍歷argumentResolvers這個(gè)list里的參數(shù)解析器來判斷是否支持
   if (resolver.supportsParameter(parameter)) {
    result = resolver;
    this.argumentResolverCache.put(parameter, result);
    break;
   }
  }
 }
 return result;
}

argumentResolvers里一共有26個(gè)參數(shù)解析器,下面羅列一下常見的。

this.argumentResolvers = {LinkedList@6072} size = 26
 0 = {RequestParamMethodArgumentResolver@6098} 
 1 = {RequestParamMapMethodArgumentResolver@6104} 
 2 = {PathVariableMethodArgumentResolver@6111} 
 3 = {PathVariableMapMethodArgumentResolver@6112} 
 ......
 7 = {RequestResponseBodyMethodProcessor@6116} 
 8 = {RequestPartMethodArgumentResolver@6117} 
 9 = {RequestHeaderMethodArgumentResolver@6118} 
 10 = {RequestHeaderMapMethodArgumentResolver@6119} 
 ......
 14 = {RequestAttributeMethodArgumentResolver@6123} 
 15 = {ServletRequestMethodArgumentResolver@6124} 
 ......
 24 = {RequestParamMethodArgumentResolver@6107} 
 25 = {ServletModelAttributeMethodProcessor@6133} 

所有的參數(shù)解析器都實(shí)現(xiàn)了HandlerMethodArgumentResolver接口。

public interface HandlerMethodArgumentResolver {

 // 上面用到用來判斷當(dāng)前參數(shù)解析器是否支持給定的方法參數(shù)
 boolean supportsParameter(MethodParameter parameter);

 // 解析給定的方法參數(shù)并返回
 @Nullable
 Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
 NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;

}

到這里我們整理一下思路,對(duì)方法參數(shù)的解析都是通過逐個(gè)遍歷找到合適的HandlerMethodArgumentResolver來完成的。比如,如果參數(shù)上標(biāo)注了@RequestParam或者@RequestBody或者@PathVariable注解,SpringMVC會(huì)用不同的參數(shù)解析器來解析。下面挑一個(gè)最常用的RequestParamMethodArgumentResolver來深入分析一下詳細(xì)的解析流程。

RequestParamMethodArgumentResolver繼承自AbstractNamedValueMethodArgumentResolver,AbstractNamedValueMethodArgumentResolver實(shí)現(xiàn)了HandlerMethodArgumentResolver接口的resolveArgument方法。

// AbstractNamedValueMethodArgumentResolver.java
@Override
@Nullable
public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
  NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

 // 解析出傳入的原始值,作為下面方法的參數(shù)
 Object arg = resolveName(resolvedName.toString(), nestedParameter, webRequest);
 ......
 if (binderFactory != null) {
  // 創(chuàng)建 DataBinder 
  WebDataBinder binder = binderFactory.createBinder(webRequest, null, namedValueInfo.name);
  try {
   // 通過DataBinder進(jìn)行參數(shù)綁定,參數(shù)列表:原始值,目標(biāo)類型,方法參數(shù)
   arg = binder.convertIfNecessary(arg, parameter.getParameterType(), parameter);
  }
 ......
 }

 handleResolvedValue(arg, namedValueInfo.name, parameter, mavContainer, webRequest);

 return arg;
}

// DataBinder.java
@Override
@Nullable
public <T> T convertIfNecessary(@Nullable Object value, @Nullable Class<T> requiredType,
  @Nullable MethodParameter methodParam) throws TypeMismatchException {
 // 調(diào)用子類的convertIfNecessary方法,這里的具體實(shí)現(xiàn)是TypeConverterSupport
 return getTypeConverter().convertIfNecessary(value, requiredType, methodParam);
}

// TypeConverterSupport.java
@Override
@Nullable
public <T> T convertIfNecessary(@Nullable Object value, @Nullable Class<T> requiredType,
  @Nullable MethodParameter methodParam) throws TypeMismatchException {
 // 調(diào)用重載的convertIfNecessary方法,通過MethodParameter構(gòu)造了類型描述符TypeDescriptor
 return convertIfNecessary(value, requiredType,
   (methodParam != null ? new TypeDescriptor(methodParam) : TypeDescriptor.valueOf(requiredType)));
}
// convertIfNecessary方法
@Nullable
@Override
public <T> T convertIfNecessary(@Nullable Object value, @Nullable Class<T> requiredType,
  @Nullable TypeDescriptor typeDescriptor) throws TypeMismatchException {

 Assert.state(this.typeConverterDelegate != null, "No TypeConverterDelegate");
 try {
  // 調(diào)用TypeConverterDelegate的convertIfNecessary方法
  return this.typeConverterDelegate.convertIfNecessary(null, null, value, requiredType, typeDescriptor);
 }
 ......
}

接下來進(jìn)入TypeConverterDelegate的源碼。

// TypeConverterDelegate.java
@Nullable
public <T> T convertIfNecessary(@Nullable String propertyName, @Nullable Object oldValue, @Nullable Object newValue,
  @Nullable Class<T> requiredType, @Nullable TypeDescriptor typeDescriptor) throws IllegalArgumentException {

 // 查找是否有適合需求類型的自定義的PropertyEditor。還記得上面的 使用@ControllerAdvice配合@initBinder 那一節(jié)嗎,如果有按那樣配置,這里就會(huì)找到
 PropertyEditor editor = this.propertyEditorRegistry.findCustomEditor(requiredType, propertyName);

 ConversionFailedException conversionAttemptEx = null;

 // 查找到類型轉(zhuǎn)換服務(wù) ConversionService
 ConversionService conversionService = this.propertyEditorRegistry.getConversionService();
 // 關(guān)鍵判斷,如果沒有PropertyEditor 就使用ConversionService
 if (editor == null && conversionService != null && newValue != null && typeDescriptor != null) {
  TypeDescriptor sourceTypeDesc = TypeDescriptor.forObject(newValue);
  if (conversionService.canConvert(sourceTypeDesc, typeDescriptor)) {
   try {
    // #1,類型轉(zhuǎn)換服務(wù)轉(zhuǎn)換完成后就返回,下面會(huì)詳細(xì)解釋
    return (T) conversionService.convert(newValue, sourceTypeDesc, typeDescriptor);
   }
   catch (ConversionFailedException ex) {
    // fallback to default conversion logic below
    conversionAttemptEx = ex;
   }
  }
 }

 Object convertedValue = newValue;

 // 關(guān)鍵判斷,如果有PropertyEditor就使用PropertyEditor
 if (editor != null || (requiredType != null && !ClassUtils.isAssignableValue(requiredType, convertedValue))) {
  ......
  // 由editor完成轉(zhuǎn)換 
  convertedValue = doConvertValue(oldValue, convertedValue, requiredType, editor);
 }

 boolean standardConversion = false;

 if (requiredType != null) {
  // Try to apply some standard type conversion rules if appropriate.

  if (convertedValue != null) {
   if (Object.class == requiredType) {
    return (T) convertedValue;
   }
   // 下面是數(shù)組、集合類型屬性的處理,這里會(huì)遍歷集合元素,遞歸調(diào)用convertIfNecessary轉(zhuǎn)化,再收集處理結(jié)果
   else if (requiredType.isArray()) {
    // Array required -> apply appropriate conversion of elements.
    if (convertedValue instanceof String && Enum.class.isAssignableFrom(requiredType.getComponentType())) {
     convertedValue = StringUtils.commaDelimitedListToStringArray((String) convertedValue);
    }
    return (T) convertToTypedArray(convertedValue, propertyName, requiredType.getComponentType());
   }
   else if (convertedValue instanceof Collection) {
    // Convert elements to target type, if determined.
    convertedValue = convertToTypedCollection(
      (Collection<?>) convertedValue, propertyName, requiredType, typeDescriptor);
    standardConversion = true;
   }
   else if (convertedValue instanceof Map) {
    // Convert keys and values to respective target type, if determined.
    convertedValue = convertToTypedMap(
      (Map<?, ?>) convertedValue, propertyName, requiredType, typeDescriptor);
    standardConversion = true;
   }
   if (convertedValue.getClass().isArray() && Array.getLength(convertedValue) == 1) {
    convertedValue = Array.get(convertedValue, 0);
    standardConversion = true;
   }
   if (String.class == requiredType && ClassUtils.isPrimitiveOrWrapper(convertedValue.getClass())) {
    // We can stringify any primitive value...
    return (T) convertedValue.toString();
   }
   else if (convertedValue instanceof String && !requiredType.isInstance(convertedValue)) {
 ......
   }
   else if (convertedValue instanceof Number && Number.class.isAssignableFrom(requiredType)) {
    convertedValue = NumberUtils.convertNumberToTargetClass(
      (Number) convertedValue, (Class<Number>) requiredType);
    standardConversion = true;
   }
  }
  else {
   // convertedValue == null,空值處理
   if (requiredType == Optional.class) {
    convertedValue = Optional.empty();
   }
  }

 ......
 }
 // 異常處理
 if (conversionAttemptEx != null) {
  if (editor == null && !standardConversion && requiredType != null && Object.class != requiredType) {
   throw conversionAttemptEx;
  }
  logger.debug("Original ConversionService attempt failed - ignored since " +
    "PropertyEditor based conversion eventually succeeded", conversionAttemptEx);
 }

 return (T) convertedValue;
}

假如我們配置了自定義的Converter,會(huì)進(jìn)入#1的分支,由ConversionService進(jìn)行類型轉(zhuǎn)換,以其子類GenericConversionService為例。

// GenericConversionService.java
@Override
@Nullable
public Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType) {
 ......
 // 從緩存中找到匹配類型的conveter,以LocalDateTime為例,會(huì)找到我們自定義的localDateTimeConverter
 GenericConverter converter = getConverter(sourceType, targetType);
 if (converter != null) {
  // 通過工具方法調(diào)用真正的converter完成類型轉(zhuǎn)換。至此,完成了源類型到目標(biāo)類型的轉(zhuǎn)換
  Object result = ConversionUtils.invokeConverter(converter, source, sourceType, targetType);
  return handleResult(sourceType, targetType, result);
 }
 return handleConverterNotFound(source, sourceType, targetType);
}

以上就是處理標(biāo)注@RequestParam注解的參數(shù)的RequestParamMethodArgumentResolver解析流程。

下面來看一下處理標(biāo)注@RequestBody注解的參數(shù)的RequestResponseBodyMethodProcessor的解析流程,仍然是從resolveArgument方法切入。

// RequestResponseBodyMethodProcessor.java
@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
  NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {

 parameter = parameter.nestedIfOptional();
 // 在這里完成參數(shù)的解析
 Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
 ......
 return adaptArgumentIfNecessary(arg, parameter);
}

@Override
protected <T> Object readWithMessageConverters(NativeWebRequest webRequest, MethodParameter parameter,
  Type paramType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {

 HttpServletRequest servletRequest = webRequest.getNativeRequest(HttpServletRequest.class);
 Assert.state(servletRequest != null, "No HttpServletRequest");
 ServletServerHttpRequest inputMessage = new ServletServerHttpRequest(servletRequest);
 // 調(diào)用父類AbstractMessageConverterMethodArgumentResolver完成參數(shù)解析
 Object arg = readWithMessageConverters(inputMessage, parameter, paramType);
 if (arg == null && checkRequired(parameter)) {
  throw new HttpMessageNotReadableException("Required request body is missing: " +
    parameter.getExecutable().toGenericString(), inputMessage);
 }
 return arg;
}

下面進(jìn)入父類AbstractMessageConverterMethodArgumentResolver的源碼。

// AbstractMessageConverterMethodArgumentResolver.java
@Nullable
protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
  Type targetType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {
 ......
 EmptyBodyCheckingHttpInputMessage message;
 try {
  message = new EmptyBodyCheckingHttpInputMessage(inputMessage);
 // 遍歷HttpMessageConverter
  for (HttpMessageConverter<?> converter : this.messageConverters) {
   Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
   GenericHttpMessageConverter<?> genericConverter =
     (converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter<?>) converter : null);
   if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
     (targetClass != null && converter.canRead(targetClass, contentType))) {
    if (message.hasBody()) {
     HttpInputMessage msgToUse =
       getAdvice().beforeBodyRead(message, parameter, targetType, converterType);
     // 實(shí)際由MappingJackson2HttpMessageConverter調(diào)用父類AbstractJackson2HttpMessageConverter的read方法完成解析,
     body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
       ((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
     body = getAdvice().afterBodyRead(body, msgToUse, parameter, targetType, converterType);
    }
    else {
     body = getAdvice().handleEmptyBody(null, message, parameter, targetType, converterType);
    }
    break;
   }
  }
 }
 ......
 return body;
}

// AbstractJackson2HttpMessageConverter.java
@Override
public Object read(Type type, @Nullable Class<?> contextClass, HttpInputMessage inputMessage)
  throws IOException, HttpMessageNotReadableException {
 // 獲得要轉(zhuǎn)換的目標(biāo)參數(shù)Java類型,如LocalDateTime等
 JavaType javaType = getJavaType(type, contextClass);
 // 調(diào)用本類的readJavaType方法
 return readJavaType(javaType, inputMessage);
}

// AbstractJackson2HttpMessageConverter.java
private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage) throws IOException {
 try {
  if (inputMessage instanceof MappingJacksonInputMessage) {
   Class<?> deserializationView = ((MappingJacksonInputMessage) inputMessage).getDeserializationView();
   if (deserializationView != null) {
    return this.objectMapper.readerWithView(deserializationView).forType(javaType).
      readValue(inputMessage.getBody());
   }
  }
  // 調(diào)用jackson類庫(kù),將HTTP的json請(qǐng)求信息解析為需要的參數(shù)類型。至此,將json請(qǐng)求轉(zhuǎn)換成目標(biāo)Java類型
  return this.objectMapper.readValue(inputMessage.getBody(), javaType);
 }
 ......
}

總結(jié)

controller方法的參數(shù)是通過不同的HandlerMethodArgumentResolver完成解析的。如果參數(shù)標(biāo)注了@RequestBody注解,實(shí)際上是通過MappingJackson2HttpMessageConverterObjectMapper將傳入json格式數(shù)據(jù)反序列化解析成目標(biāo)類型的。如果標(biāo)注了@RequestParam注解,是通過在應(yīng)用初始化時(shí)注入到ConversionService的一個(gè)個(gè)Converter來實(shí)現(xiàn)的。其他的HandlerMethodArgumentResolver也是各有各的用處,大家可以再看看相關(guān)代碼,以便加深理解。

到此這篇關(guān)于在Spring Boot應(yīng)用中優(yōu)雅的使用Date和LocalDateTime的教程詳解的文章就介紹到這了,更多相關(guān)Spring Boot使用Date和LocalDateTime內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!

相關(guān)文章

  • Java后端長(zhǎng)時(shí)間無操作自動(dòng)退出的實(shí)現(xiàn)方式

    Java后端長(zhǎng)時(shí)間無操作自動(dòng)退出的實(shí)現(xiàn)方式

    這篇文章主要介紹了Java后端長(zhǎng)時(shí)間無操作自動(dòng)退出的實(shí)現(xiàn)方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教
    2022-01-01
  • SpringBoot整合aws的示例代碼

    SpringBoot整合aws的示例代碼

    本文通過實(shí)例代碼給大家介紹SpringBoot整合aws的全過程,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友參考下吧
    2021-12-12
  • Spring中@ConfigurationProperties的用法解析

    Spring中@ConfigurationProperties的用法解析

    這篇文章主要介紹了Spring中@ConfigurationProperties的用法解析,傳統(tǒng)的Spring一般都是基本xml配置的,后來spring3.0新增了許多java config的注解,特別是spring boot,基本都是清一色的java config,需要的朋友可以參考下
    2023-11-11
  • java應(yīng)用占用內(nèi)存過高排查的解決方案

    java應(yīng)用占用內(nèi)存過高排查的解決方案

    這篇文章主要介紹了java應(yīng)用占用內(nèi)存過高排查的解決方案,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧
    2021-03-03
  • 解決Maven中的依賴導(dǎo)包問題(組合技巧)

    解決Maven中的依賴導(dǎo)包問題(組合技巧)

    自從我開始接觸了以spring為框架的項(xiàng)目學(xué)習(xí)后,這個(gè)maven導(dǎo)包老是出現(xiàn)問題,每次在這個(gè)上面花費(fèi)好多時(shí)間,于是乎打算寫一個(gè)秘籍出來,這篇文章主要介紹了解決Maven中的依賴導(dǎo)包問題,需要的朋友可以參考下
    2023-11-11
  • Java咖啡館(1)——嘆咖啡

    Java咖啡館(1)——嘆咖啡

    這篇文章主要給大家介紹了關(guān)于Java咖啡館之嘆咖啡,需要的朋友可以參考下
    2006-12-12
  • Spring Bean的線程安全問題

    Spring Bean的線程安全問題

    Spring容器中的Bean是否線程安全,本文主要介紹了Spring Bean的線程安全問題,文中通過示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下
    2022-06-06
  • 使用HttpServletResponse對(duì)象獲取請(qǐng)求行信息

    使用HttpServletResponse對(duì)象獲取請(qǐng)求行信息

    這篇文章主要介紹了使用HttpServletResponse對(duì)象獲取請(qǐng)求行信息,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教
    2022-02-02
  • 使用Spring?Boot?2.x構(gòu)建Web服務(wù)的詳細(xì)代碼

    使用Spring?Boot?2.x構(gòu)建Web服務(wù)的詳細(xì)代碼

    這篇文章主要介紹了使用Spring?Boot?2.x構(gòu)建Web服務(wù)的詳細(xì)代碼,主要基于JWT的身份認(rèn)證,本文通過實(shí)例代碼給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下
    2022-03-03
  • springboot新建項(xiàng)目jdk只有17/21,無法選中1.8解決辦法

    springboot新建項(xiàng)目jdk只有17/21,無法選中1.8解決辦法

    最近博主也有創(chuàng)建springboot項(xiàng)目,發(fā)現(xiàn)了IntelliJ IDEA在通過Spring Initilizer初始化項(xiàng)目的時(shí)候已經(jīng)沒有java8版本的選項(xiàng)了,這里給大家總結(jié)下,這篇文章主要給大家介紹了springboot新建項(xiàng)目jdk只有17/21,無法選中1.8的解決辦法,需要的朋友可以參考下
    2023-12-12

最新評(píng)論