从零开始 Spring Boot 24:处理时间
图源:简书 (jianshu.com)
本文示例基于从零开始 Spring Boot 23:MyBatis - 红茶的个人站点 (icexmoon.cn)的最终示例代码修改而来,可以从learn_spring_boot/ch23 (github.com)获取完整示例。
在从零开始 Spring Boot 16:枚举 - 红茶的个人站点 (icexmoon.cn)中我详细说明了如何在Spring Boot项目中处理枚举类型,其中包含在接口的输入和输出阶段处理枚举,除了枚举以外,通常我们还需要处理时间类型,具体来说就是标准类库中的LocalDateTime 或LocalDate 类。
LocalDateTime 和LocalDate 是JDK8引入的时间类,相比Date 和DateTime ,它们本身包含了时区概念,不需要额外处理时区的问题,而且它们的相关格式化处理函数都是线程安全的。所以Java程序中的时间都应该使用这两种类型来处理。
一般的,我们会在在VO和DTO类中将时间相关属性定义为字符串形式,并借助工具函数进行转换,比如:
package cn.icexmoon.books2.book.entity.dto;
@Data
public class CouponDTO {
private Integer addUserId;
private Double amount;
private String expireTime;
private Double enoughAmount;
private CouponType type;
}
package cn.icexmoon.books2.book.service.impl;
@Service
public class CouponServiceImpl implements CouponService {
@Autowired
private CouponMapper couponMapper;
@Override
public Coupon getCouponById(int id) {
return couponMapper.getCouponById(id);
}
@Override
public int addCoupon(CouponDTO dto) {
Coupon coupon;
switch (dto.getType()) {
case FREE_COUPON:
coupon = new FreeCoupon();
break;
case ENOUGH_COUPON:
coupon = new EnoughCoupon()
.setEnoughAmount(dto.getEnoughAmount());
break;
default:
throw new RuntimeException("不正确的优惠券类型");
}
coupon.setAddTime(LocalDateTime.now())
.setAddUserId(dto.getAddUserId())
.setAmount(dto.getAmount())
.setExpireTime(MyTimeUtil.convert2DateTime(dto.getExpireTime()))
.setType(dto.getType());
couponMapper.addCoupon(coupon);
return coupon.getId();
}
}
因为DTO中时间是字符串,所以这里需要通过工具类转换:
MyTimeUtil.convert2DateTime(dto.getExpireTime())
相应的时间工具函数:
package cn.icexmoon.books2.system.util;
public class MyTimeUtil {
private static DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static DateTimeFormatter dayFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
public static LocalDateTime convert2DateTime(String time) {
return LocalDateTime.parse(time, timeFormatter);
}
}
当然,在Entity类中时间是LocalDateTime ,在持久层MyBatis可以正常处理这种类型的读写,无需我们做额外处理:
package cn.icexmoon.books2.book.entity;
@Data
@Accessors(chain = true)
public class Coupon {
private Integer id;
private Integer addUserId;
private LocalDateTime addTime;
private LocalDateTime expireTime;
private CouponType type;
private Double amount;
}
@JsonFormat
虽然这样做也没什么太大问题,但需要额外的类型处理依然不是很方便,实际上我们可以借助Jackson在HTTP request body转换为对象时就可以生成正确的时间类型:
package cn.icexmoon.books2.book.entity.dto;
@Data
public class CouponDTO {
private Integer addUserId;
private Double amount;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private LocalDateTime expireTime;
private Double enoughAmount;
private CouponType type;
}
注解@JsonFormat 可以让时间类型的属性正确从JSON中解析出来或者解析成JSON。
其中shape 属性指定的是JSON中的原始类型,pattern 是时间模式,timezone 是时区。
绝大多数情况原始类型都是String,模式是yyyy-MM-dd HH:mm:ss ,时区是东八区,即:
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
在实际使用中,时间相应的入参都是String,且时区使用默认值即可,所以可以简写为:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat
对于复杂传参,一般推荐用JSON作为请求报文体传递,不推荐使用查询字符串,因为基于HTTP协议规范,后者有长度限制,且可能被URL编码。但如果通过后者传递时间参数,服务端如何处理?
package cn.icexmoon.books2.book.controller;
@RestController
@RequestMapping("/book/coupon")
public class CouponController {
@PostMapping("/params-add")
Result addCouponWithParams(@RequestParam Integer addUserId,
@RequestParam Double amount,
@RequestParam LocalDateTime expireTime,
@RequestParam Double enoughAmount,
@RequestParam CouponType type){
CouponDTO dto = new CouponDTO()
.setAddUserId(addUserId)
.setAmount(amount)
.setExpireTime(expireTime)
.setEnoughAmount(enoughAmount)
.setType(type);
return Result.success(couponService.addCoupon(dto));
}
}
这里处理器方法addCouponWithParams 以查询字符串方式接受入参,并且其中有一个时间类型的参数expireTime 。
默认配置下的Spring Boot不能正常进行类型转换,会报错:
org.springframework.web.method.annotation.MethodArgumentTypeMismatchException: Failed to convert value of type 'java.lang.String' to required type 'java.time.LocalDateTime';
这个问题可以通过使用@DateTimeFormat 注解来解决,该注解是Spring的一个注解。
package cn.icexmoon.books2.book.controller;
@RestController
@RequestMapping("/book/coupon")
public class CouponController {
@PostMapping("/params-add")
Result addCouponWithParams(@RequestParam Integer addUserId,
@RequestParam Double amount,
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@RequestParam LocalDateTime expireTime,
@RequestParam Double enoughAmount,
@RequestParam CouponType type){
}
}
和@JsonFormat 注解类似,通过pattern 属性为@DateTimeFormat 注解指定一个时间模式即可让框架正确地将查询字符串中的入参转换为时间类型。
入参中还包含枚举类型CouponType ,这需要一些额外处理,详情可以阅读从零开始 Spring Boot 16:枚举 - 红茶的个人站点 (icexmoon.cn)。
修改默认配置
上面的两种方式相结合,已经可以解决问题,但是需要在项目中添加大量的注解。如果是一个现有项目,可能这样做是合适的,因为修改默认配置可能会引发一些未知的bug。但如果是一个新项目,完全可以通过修改Jackson的默认配置来实现这一点,进而避免添加大量的注解。
可以通过注入一个Jackson2ObjectMapperBuilderCustomizer 类型的JavaBean来修改默认的Jackson的解析行为,在应用启动时,Jackson会加载所有类型为Jackson2ObjectMapperBuilderCustomizer 的Bean,然后通过其customer 方法对用于解析的相关核心组件进行设置。
package cn.icexmoon.books2.system;
@Configuration
public class MyJacksonConfig {
@Bean
@Order(1)
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilder() {
return jacksonObjectMapperBuilder -> {
jacksonObjectMapperBuilder.simpleDateFormat("yyyy-MM-dd HH:mm:ss");
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addSerializer(LocalDateTime.class,new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
javaTimeModule.addDeserializer(LocalDateTime.class,new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
jacksonObjectMapperBuilder.modules(javaTimeModule);
};
}
}
为了确保自定义的Jackson2ObjectMapperBuilderCustomizer 在系统自动生成的Bean之后注入,这里指定其顺序@Order(1) (系统自定义的顺序为0)。
这样设置好后就可以正确处理LocalDateTime 类型的JSON解析和编码。
以上这种方式是Spring Boot推荐的在不破坏自动配置机制的情况下修改Jackson编码行为的方式,如果不起作用,可以检查下你搭的应用是否屏蔽了自动配置机制,比如我的示例中就因为在添加Converter 时采用以下方式引入配置类:
@Configuration
public class MyWebAppConfigurer extends WebMvcConfigurationSupport {
}
导致了自动配置功能被屏蔽,进而导致上边修改Jackson配置的代码不起作用。
上边修改Jackson配置的示例使用了给jacksonObjectMapperBuilder 添加Model 的方式,这是Jackson官方推荐的方式,除此以外,也可以直接按照待处理类型来添加解析器和编码器:
package cn.icexmoon.books2.system;
@Configuration
public class MyJacksonConfig {
@Bean
@Order(1)
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilder() {
return jacksonObjectMapperBuilder -> {
jacksonObjectMapperBuilder.simpleDateFormat("yyyy-MM-dd HH:mm:ss");
jacksonObjectMapperBuilder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
jacksonObjectMapperBuilder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
};
}
}
这两种方式效果是相同的。
如果想通过jacksonObjectMapperBuilder 修改Jackson的其它配置,可以参考Jackson序列化(3)— Jackson中ObjectMapper配置详解 - 简书 (jianshu.com)。
@JsonComponent
除了上边常规方式以外,Spring Boot本身还提供一个注解@JsonComponent ,可以通过这个注解以更简单直观的方式给特定类型加上特殊的JSON编码/解码行为:
package cn.icexmoon.books2.system;
@JsonComponent
public class DateTimeJsonComponent {
public static class Serializer extends JsonSerializer<LocalDateTime> {
@Override
public void serialize(LocalDateTime localDateTime, JsonGenerator jgen, SerializerProvider serializerProvider) throws IOException {
jgen.writeString(MyTimeUtil.convert2timeStr(localDateTime));
}
}
public static class Deserializer extends JsonDeserializer<LocalDateTime> {
@Override
public LocalDateTime deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException, JacksonException {
return MyTimeUtil.convert2DateTime(jsonParser.getText());
}
}
}
谢谢阅读。
最终的完整示例代码可以从learn_spring_boot/ch24 (github.com)获取。
参考资料
|