从零开始 Spring Boot 24:处理时间

从零开始 Spring Boot 24:处理时间

spring boot

图源:简书 (jianshu.com)

本文示例基于从零开始 Spring Boot 23:MyBatis - 红茶的个人站点 (icexmoon.cn)的最终示例代码修改而来,可以从learn_spring_boot/ch23 (github.com)获取完整示例。

从零开始 Spring Boot 16:枚举 - 红茶的个人站点 (icexmoon.cn)中我详细说明了如何在Spring Boot项目中处理枚举类型,其中包含在接口的输入和输出阶段处理枚举,除了枚举以外,通常我们还需要处理时间类型,具体来说就是标准类库中的LocalDateTimeLocalDate类。

LocalDateTimeLocalDate是JDK8引入的时间类,相比DateDateTime,它们本身包含了时区概念,不需要额外处理时区的问题,而且它们的相关格式化处理函数都是线程安全的。所以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");
	// ...

    /**
     * 将时间字符串转换为LocalDateTime
     *
     * @param time 时间字符串
     * @return
     */
    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 -> {
            //针对于Date类型,文本格式化
            jacksonObjectMapperBuilder.simpleDateFormat("yyyy-MM-dd HH:mm:ss");

            //针对于JDK新时间类。序列化时带有T的问题,自定义格式化字符串
            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 -> {
            //针对于Date类型,文本格式化
            jacksonObjectMapperBuilder.simpleDateFormat("yyyy-MM-dd HH:mm:ss");

            //针对于JDK新时间类。序列化时带有T的问题,自定义格式化字符串
            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());
        }
    }
}

Converter

除了Json字符串以外,还需要修改对普通字符串参数的默认处理,这就需要借助ConverterPropertyEditor,这里以添加Converter举例:

public class Str2LocalDateTimeConverter implements Converter<String, LocalDateTime> {
    @Override
    public LocalDateTime convert(String source) {
        return MyTimeUtil.convert2DateTime(source);
    }
}

@Configuration
public class MyWebAppConfigurer implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
    	// ...
        registry.addConverter(new Str2LocalDateTimeConverter());
    }
}

之后就可以直接在Controller中接收LocalDateTime类型的查询参数了,Spring可以用我们添加的Converter进行转换:

@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){
        // ...
    }
}

这样做是可行的,但不是最方便和合理的,实际上可以利用java.time.format.DateTimeFormatter的相关类进行设置,具体可以阅读从零开始 Spring Boot 29:类型转换 - 红茶的个人站点 (icexmoon.cn)中的日期和时间部分。

谢谢阅读。

最终的完整示例代码可以从learn_spring_boot/ch24 (github.com)获取。

参考资料

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
当你从零开始学习Spring Boot时,以下是一个学习路线的建议: 1. Java基础知识:首先,你需要掌握Java编程语言的基础知识,包括语法、面向编程等。这将为你后续学习Spring Boot打下坚实的基础。 2. Spring框架:在学习Spring Boot之前,建议先学习Spring框架的基础知识。Spring是一个轻量级的Java开发框架,它提供了很多功能和特性,包括依赖注入、AOP等。了解Spring框架将有助于你更好地理解和使用Spring Boot。 3. Spring Boot入门:一旦你掌握了Spring框架的基础知识,可以开始学习Spring Boot了。你可以通过阅读官方文档、教程或者参加在线课程来学习Spring Boot的基本概念、核心特性和使用方法。 4. Web开发:Spring Boot广泛应用于Web开发领域,因此你需要学习相关的Web开发技术,如HTTP协议、Servlet、JSP、HTML、CSS、JavaScript等。此外,了解常用的Web开发框架(如Spring MVC)也是必要的。 5. 数据库和持久化:在实际的应用中,通常需要与数据库进行交互。因此,你需要学习数据库的基本知识,如SQL语言、关系型数据库(如MySQL)等。同时,了解Spring Boot中的持久化技术(如Spring Data JPA)也是必要的。 6. 安全性和认证:对于涉及用户身份验证和授权的应用,安全性是非常重要的。学习Spring Security可以帮助你理解和实现应用的安全性和认证功能。 7. 微服务和云原生:随着云计算和微服务架构的兴起,学习如何使用Spring Boot构建和部署微服务应用也是一个不错的选择。了解Docker、Kubernetes等相关技术将有助于你更好地应用Spring Boot

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值