16

10. 原来是这么玩的,@DateTimeFormat和@NumberFormat

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzI0MTUwOTgyOQ%3D%3D&%3Bmid=2247493900&%3Bidx=1&%3Bsn=f903380f7d7065192959b07acd1683ae
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
FZfIbmq.jpg!mobile

前言

你好,我是A哥(YourBatman)。

在本系列中间,用几篇文章彻彻底底的把JDK日期时间系列深入讲解了一遍,此系列有可能是把JDK日期时间讲得最好最全的,强烈建议你前往看它一看。

本系列的上篇文章 对格式化器Formatter进行了剖析,Spring对日期时间、数字、钱币等常用类型都内置了相应的格式化器实现,开发者可拿来就用。但是,这在使用上依旧有一定门槛:开发者需要知道对应API的细节。比如若需要对Date、LocalDate进行格式化操作的话,就需要分别了解处理他俩的正确API,这在使用上是存在一定“难度”的。

另外,在 面向元数编程 大行其道的今天,硬编码往往是被吐槽甚至被拒绝的,声明式才会受到欢迎。Spring自3.0起大量的引入了“更为时尚”的元数据编程支持,从而稳固了其“江湖地位”。@DateTimeFormat和@NumberFormat两个注解是Spring在类型转换/格式化方面的元编程代表,本文一起来探讨下。

本文提纲

fIjENnB.png!mobile

版本约定

  • Spring Framework:5.3.x

  • Spring Boot:2.4.x

正文

据我了解,@DateTimeFormat是开发中 出镜率很高 的一个注解,其作用见名之意很好理解:对日期时间格式化。但使用起来常常迷糊。比如:使用它还是 com.fasterxml.jackson.annotation.JsonFormat 注解呢?能否使用在Long类型上?能否使用在JSR 310日期时间类型上?

有这些问号其实蛮正常,但切忌囫囵吞枣,也不建议强记这些问题的答案,而是通过规律在原理层面去理解,不仅能更牢靠而且更轻松,这或许是学习编程最重要的必备技巧之一。

@DateTimeFormat和@NumberFormat

在类型转换/格式化方面注解,Spring提供了两个:

  • @DateTimeFormat :将Field/方法参数格式化为日期/时间类型
  • @NumberFormat :将Field/方法参数格式化为数字类型

值得关注的是:这里所说的日期/时间类型有很多,如最古老的java.util.Date类型、JSR 310的LocalDate类型甚至时间戳Long类型都可称作日期时间类型;同样的,数字类型也是个泛概念,如Number类型、百分数类型、 钱币类型 也都属此范畴。

话外音:这两个注解能够作用的类型很广很广

分别看看这两个注解定义,不可谓不简单:

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
public @interface DateTimeFormat {

String style() default "SS";
ISO iso() default ISO.NONE;
String pattern() default "";

}
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
public @interface NumberFormat {

Style style() default Style.DEFAULT;
String pattern() default "";
}

哥俩有两个共通的点:

  1. 都支持标注在方法Method、字段Field、方法参数Parameter上

  2. 均支持灵活性极大的 pattern 属性,此属性支持Spring占位符书写形式

  3. 对于pattern属性你既可以用字面量写死,也可以用形如 ${xxx.xxx.pattern} 占位符形式这种更富弹性的写法

咱们在使用这两个注解时,最最最常用的是pattern这个属性没有之一,理由是它非常的灵活强大,能满足各式各样格式化需求。从这一点也侧面看出,咱们在日期/时间、数字方面的格式化,并不遵循国际标准(如ISO),而普遍使用的“中国标准”。

由于这两个注解几乎所有同学都在Spring MVC上使用过,那么本文就先原理再示例。在知晓了其 背后原理 后再去使用,别有一番体会。

AnnotationFormatterFactory

说到格式化注解,就不得不提该工厂类,它是实现原理的核心所在。

字面含义:注解格式化工厂。用大白话解释:该工厂用于为标注在 Field字段上的注解 创建对应的格式化器进而对值进行格式化处理。从这句话里可提取出几个关键因素:

NjMRJ3n.png!mobile
  1. 注解

  2. 字段Field

  3. 这里Field并不只表示 java.lang.reflect.Field ,像方法返回类型、参数类型都属此范畴,下面使用示例会有所体会
  4. 格式化器Formatter

接口定义:

public interface AnnotationFormatterFactory<A extends Annotation> {

Set<Class<?>> getFieldTypes();

Printer<?> getPrinter(A annotation, Class<?> fieldType);
Parser<?> getParser(A annotation, Class<?> fieldType);
}

接口共定义了三个方法:

  • getFieldTypes:支持的类型。注解标注在这些类型上时该工厂就能够处理,否则不管

  • getPrinter:为标注有annotation注解的fieldType类型生成一个Printer

  • getParser:为标注有annotation注解的fieldType类型生成一个Parser

此接口Spring内建有如下实现:

BVJZBzj.png!mobile

虽然实现有5个之多,但其实只有两类,也就是说面向使用者而言只需做两种区分即可,分别对应上面所讲的两个注解。这里A哥把它绘制成图所示:

iURzMb.png!mobile

红色框框部分(以及其处理的Field类型)是咱们需要关注的 重点 ,其它的留个印象即可。

关于日期时间类型,我在多篇文章里不再推荐使用java.util.Date类型(更不建议使用Long类型喽),而是使用Java 8提供的JSR 310日期时间类型100%代替(包括代替joda-time)。但是呢,在当下阶段java.util.Date类型依旧不可忽略(庞大存量市场,庞大“存量”程序员的存在),因此决定把DateTimeFormatAnnotationFormatterFactory依旧还是抬到桌面上来叙述叙述,但求做得更全面些。

关于JDK的日期时间我写了一个非常全的系列,详情 点击这里直达:日期时间系列 ,建议先行了解

DateTimeFormatAnnotationFormatterFactory

对应的格式化器API是: org.springframework.format.datetime.DateFormatter

@since 3.2版本就已存在,专用于对java.util.Date体系 + @DateTimeFormat的支持:创建出相应的Printer/Parser。下面解读其源码:

①:该工厂类专为@DateTimeFormat注解服务 :借助Spring的StringValueResolver对 占位符 (若存在)做替换

r6ZZ7n.png!mobile

这部分源码告诉我们:@DateTimeFormat注解标注在如图的这些类型上时才有效,才能被该工厂处理从而完成相应创建工作。

注意:除了Date和Calendar类型外,还有Long类型哦,请不要忽略了

Ib67nmY.png!mobile

核心处理逻辑也比较好理解:不管是Printer还是Parser最终均委托给DateFormatter去完成,而此API在本系列前面文章已做了详细讲解。电梯直达

值得注意的是:DateFormatter 只能 处理Date类型。换句话讲getFormatter()方法的第二个参数fieldType在此方法里 并没有 被使用,也就是说缺省情况下 @DateTimeFormat 注解并不能正常处理其标注在Calendar、Long类型的Case。若要得到支持,需 自行重写 其getPrinter/getParser等方法。

使用示例

由于 @DateTimeFormat 可以标注在成员属性、方法参数、方法(返回值)上,且当其标注在Date、Calendar、Long等类型上时方可交给 本工厂类 来处理生成相应的处理类,本文共用三个案例case进行覆盖。

case1:成员属性 + Date类型。输入 + 输出

准备一个标注有@DateTimeFormat注解的Field属性,为Date类型

@Data
@AllArgsConstructor
class Person {

@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date birthday;

}

书写测试程序:

@Test
public void test1() throws Exception {
AnnotationFormatterFactory annotationFormatterFactory = new DateTimeFormatAnnotationFormatterFactory();

// 找到该field
Field field = Person.class.getDeclaredField("birthday");
DateTimeFormat annotation = field.getAnnotation(DateTimeFormat.class);
Class<?> type = field.getType();

// 输出:
System.out.println("输出:Date -> String====================");
Printer printer = annotationFormatterFactory.getPrinter(annotation, type);
Person person = new Person(new Date());
System.out.println(printer.print(person.getBirthday(), Locale.US));

// 输入:
System.out.println("输入:String -> Date====================");
Parser parser = annotationFormatterFactory.getParser(annotation, type);
Object output = parser.parse("2021-02-06 19:00:00", Locale.US);
person = new Person((Date) output);
System.out.println(person);
}

运行程序,输出:

输出:Date -> String====================
2021-02-06 22:21:56
输入:String -> Date====================
Person(birthday=Sat Feb 06 19:00:00 CST 2021)

完美。

case2:方法参数 + Calendar。输入

@Test
public void test2() throws NoSuchMethodException, ParseException {
AnnotationFormatterFactory annotationFormatterFactory = new DateTimeFormatAnnotationFormatterFactory();

// 拿到方法入参
Method method = this.getClass().getDeclaredMethod("method", Calendar.class);
Parameter parameter = method.getParameters()[0];
DateTimeFormat annotation = parameter.getAnnotation(DateTimeFormat.class);
Class<?> type = parameter.getType();

// 输入:
System.out.println("输入:String -> Calendar====================");
Parser parser = annotationFormatterFactory.getParser(annotation, type);
Object output = parser.parse("2021-02-06 19:00:00", Locale.US);
// 给该方法传入“转换好的”参数,表示输入
method((Calendar) output);
}

public void method(@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") Calendar calendar) {
System.out.println(calendar);
System.out.println(calendar.getTime());
}

运行程序,报错:

输入:String -> Calendar====================

java.lang.ClassCastException: java.util.Date cannot be cast to java.util.Calendar
...

通过文上的阐述,这个错误是在意料之中的。下面通过自定义一个 增强实现 来达到目的:

class MyDateTimeFormatAnnotationFormatterFactory extends DateTimeFormatAnnotationFormatterFactory {

@Override
public Parser<?> getParser(DateTimeFormat annotation, Class<?> fieldType) {
if (fieldType.isAssignableFrom(Calendar.class)) {
return new Parser<Calendar>() {
@Override
public Calendar parse(String text, Locale locale) throws ParseException {
// 先翻译为Date
Formatter<Date> formatter = getFormatter(annotation, fieldType);
Date date = formatter.parse(text, locale);

// 再翻译为Calendar
Calendar calendar = Calendar.getInstance(TimeZone.getDefault());
calendar.setTime(date);
return calendar;
}

};
}
return super.getParser(annotation, fieldType);
}
}

将测试程序中的工厂类换为自定义的增强实现:

AnnotationFormatterFactory annotationFormatterFactory = new MyDateTimeFormatAnnotationFormatterFactory();

再次运行程序,输出:

输入:String -> Calendar====================
java.util.GregorianCalendar[time=1612609200000, ...
Sat Feb 06 19:00:00 CST 2021

完美。

case3:方法返回值 + Long。输出 建议自行实现,略

时间戳被经常用来做时间传递,那么传输中的Long类型如何 被自动封装 为Date类型(输入)呢?动动手巩固下吧~

Jsr310DateTimeFormatAnnotationFormatterFactory

对应的格式化器API是:Spring的 org.springframework.format.datetime.standard.DateTimeFormatterFactory 以及JDK的 java.time.format.DateTimeFormatter

@since 4.0。JSR 310时间是伴随着Java 8的出现而出现的,Spring自4.0 开始支持 Java 8,自5.0至少基于 Java 8,因此此类since 4.0就不好奇喽。

从类名能读出它用于处理JSR 310日期时间。下面解读一下它的部分源码,透过现象看其本质:

①:该工厂专为@DateTimeFormat注解服务 :借助Spring的StringValueResolver对 占位符 (若存在)做替换

6faYVf6.png!mobile

@DateTimeFormat注解标注在这些类型上时,就会交给此工厂类来负责其格式化器的创建工作。

vi2mamA.png!mobile

①:得到一个JDK的 java.time.format.DateTimeFormatter ,由它负责将 日期/时间 -> String类型的格式化。由于JSR 310日期/时间的格式化JDK自己实现得已经非常完善,Spring只需要将它整合进来就成。但是呢,DateTimeFormatter它是线程安全的无法同时设置iso、pattern等个性化参数,于是Spring就造了DateTimeFormatterFactory工厂类,用它用来 抹平使用上的差异 ,达到(和java.util.Date)一致的使用体验。当然喽,这个知识点属于上篇文章的内容,欲回顾详情可点击这里电梯直达。

回到本处,getFormatter()方法得到格式化器实例是关键,具体代码如下:

nym2IbI.png!mobile

使用Spring的工厂类DateTimeFormatterFactory构建出一个JSR 310的日期时间格式化器DateTimeFormatter来处理。有了上篇文章的铺垫,相信这个逻辑无需再多费一言解释了哈。

②:这一大块是对LocalXXX(含LocalDate/Time)标准格式化器做的特殊处理:将ISO_XXX格式化模版适配为更加适用的ISO_Local_XXX格式化模版,更加精确。 :TemporalAccessorPrinter它就是个 Printer<TemporalAccessor> ,实际的格式化器依旧是DateTimeFormatter,只不过它的作用是兼容到了 上下文级别 (和当前线程绑定)的格式化器,从而有能力用到上下文级别的格式化参数,具有更好的 可定制性 ,如下图所示(源码来自TemporalAccessorPrinter):

UviQ3q7.png!mobile

强调:别看这个特性很小,但非常有用,有 四两拨千斤 的功效。因为它和我们业务系统息息相关,掌握这个点可 轻松实现事半功倍 的效果,别人加班你加薪。关于此知识点的应用,A哥觉得值得专门写篇文章来描述,敬请期待下文。

接下来再看看getParser()部分的实现:

N3YN7fj.png!mobile

①:TemporalAccessorParser是个 Parser<TemporalAccessor> ,同样的也是利用了具有Context上下文特性的DateTimeFormatter来完成String -> TemporalAccessor工作的。熟悉这个方向的转换逻辑的同学就知道,因为都是静态方法调用,所以必须用“枚举”的方式一一处理,截图如下(源码来自TemporalAccessorParser):

BbIFZ3V.png!mobile

到此,整个Jsr310DateTimeFormatAnnotationFormatterFactory的源码就分析完了,总结一下:

  1. 此工厂专为标注在JSR 310日期/时间类型的@DateTimeFormat注解服务

  2. 底层格式化器双向均使用的是 和上下文相关 的的DateTimeFormatter,具有高度可定制化的特性。此特性虽小却有四两拨千斤的效果,后面会专文给出使用场景

  3. @DateTimeFormat注解的style和pattern属性都是支持占位符形式书写的,更富弹性

使用示例

它不像DateTimeFormatAnnotationFormatterFactory只提供了 部分支持 ,而是提供了全部功能,感受一下。

case1:成员属性 + LocalDate类型。输入 + 输出

@Data
@AllArgsConstructor
class Father {
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
private LocalDate birthday;
}

测试代码:

@Test
public void test4() throws NoSuchFieldException, ParseException {
AnnotationFormatterFactory annotationFormatterFactory = new Jsr310DateTimeFormatAnnotationFormatterFactory();

// 找到该field
Field field = Father.class.getDeclaredField("birthday");
DateTimeFormat annotation = field.getAnnotation(DateTimeFormat.class);
Class<?> type = field.getType();

// 输出:
System.out.println("输出:LocalDate -> String====================");
Printer printer = annotationFormatterFactory.getPrinter(annotation, type);
Father father = new Father(LocalDate.now());
System.out.println(printer.print(father.getBirthday(), Locale.US));

// 输入:
System.out.println("输入:String -> Date====================");
Parser parser = annotationFormatterFactory.getParser(annotation, type);
Object output = parser.parse("2021-02-07", Locale.US);
father = new Father((LocalDate) output);
System.out.println(father);
}

运行程序,输出:

输出:LocalDate -> String====================
2021-02-07
输入:String -> Date====================
Father(birthday=2021-02-07)

完美。

case2:方法参数 + LocalDate类型。输入

@Test
public void test5() throws ParseException, NoSuchMethodException {
AnnotationFormatterFactory annotationFormatterFactory = new Jsr310DateTimeFormatAnnotationFormatterFactory();

// 拿到方法入参
Method method = this.getClass().getDeclaredMethod("methodJSR310", LocalDate.class);
Parameter parameter = method.getParameters()[0];
DateTimeFormat annotation = parameter.getAnnotation(DateTimeFormat.class);
Class<?> type = parameter.getType();


// 输入:
System.out.println("输入:String -> LocalDate====================");
Parser parser = annotationFormatterFactory.getParser(annotation, type);
Object output = parser.parse("2021-02-06", Locale.US);
// 给该方法传入“转换好的”参数,表示输入
methodJSR310((LocalDate) output);
}

public void methodJSR310(@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate localDate) {
System.out.println(localDate);
}

运行程序,输出:

输入:String -> LocalDate====================
2021-02-06

case3:方法返回值 + LocalDate类型。输入

@Test
public void test6() throws NoSuchMethodException {
AnnotationFormatterFactory annotationFormatterFactory = new Jsr310DateTimeFormatAnnotationFormatterFactory();

// 拿到方法返回值类型
Method method = this.getClass().getDeclaredMethod("method1JSR310");
DateTimeFormat annotation = method.getAnnotation(DateTimeFormat.class);
Class<?> type = method.getReturnType();


// 输出:
System.out.println("输出:LocalDate -> 时间格式的String====================");
Printer printer = annotationFormatterFactory.getPrinter(annotation, type);

LocalDate returnValue = method1JSR310();
System.out.println(printer.print(returnValue, Locale.US));
}

@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
public LocalDate method1JSR310() {
return LocalDate.now();
}

完美。

NumberFormatAnnotationFormatterFactory

对应的格式化器API是: org.springframework.format.number.AbstractNumberFormatter 的三个子类

eANvMru.png!mobile

@since 3.0。直奔主题,源码喽几眼:

有了上面的“经验”,此part不用解释了吧。

RFzeamm.png!mobileBBn6FbI.png!mobile

①:@NumberFormat可以标注在Number的子类型上,并生成对应的格式化器处理。

底层实现:实际的格式化动作Printer/Parser如下图所示,全权委托给前面已介绍过的格式化器来完成,就不做过多介绍啦。有知识盲区的可乘坐电梯前往本系列前面文章查看~

FvauM3r.png!mobile

使用示例

@NumberFormat支持标注在多种类型上,如小数、百分数、钱币等等,由于文上已做好了铺垫,所以这里只给出个简单使用案例即可,举一反三。

@Test
public void test2() throws NoSuchMethodException, ParseException {
AnnotationFormatterFactory annotationFormatterFactory = new NumberFormatAnnotationFormatterFactory();

// 获取待处理的目标类型(方法参数、字段属性、方法返回值等等)
Method method1 = this.getClass().getMethod("method2", double.class);
Parameter parameter = method1.getParameters()[0];
NumberFormat annotation = parameter.getAnnotation(NumberFormat.class);
Class<?> fieldType = parameter.getType();

// 1、根据注解和field类型生成一个解析器,完成String -> LocalDateTime
Parser parser = annotationFormatterFactory.getParser(annotation, fieldType);
// 2、模拟转换动作,并输出结果
Object result = parser.parse("11%", Locale.US);
System.out.println(result.getClass());
System.out.println(result);

}

public void method2(@NumberFormat(style = NumberFormat.Style.PERCENT) double d) {
}

运行程序,输出:

class java.math.BigDecimal
0.11

完美的将 11% 这种百分数数字转换为BigDecimal了。至于为何是BigDecimal类型而不是double,那都在PercentStyleFormatter里了。

总结

这两个注解更像是高层抽象:模糊掉开发者的使用成本,能够达到的效果是:

  • @DateTimeFormat:日期时间类型的格式化,找我就够了

  • @NumberFormat:数字类型的格式化,找我就够了

这两个由于过于常用Spring内置提供了,若你有特殊需求,Spring也提供了钩子,可以自定义注解 + 扩展 AnnotationFormatterFactory 接口来实现。注解 + 工厂类组合在一起像是一个分发器,模糊掉类型上的差异,让使用者有统一感受。

有了本系列前面知识的铺垫,本文一路读下来毫不费力,底层基础决定上层建筑。这些都是在Spring MVC场景下使用的这些注解的 底层原理 ,本系列对其抽丝剥茧后,那些使用上的问题自当无师自通,迎刃而解。

当然喽,在实际应用中不可能像本例一样这样编码实现,开发者应该只需知道注解使用在哪即可。既然要方便,那就需要整合。下篇文章将继续了解Spring是如何将此功能整合进注册中心,大大简化使用方式的。

本文思考题

本文所属专栏: Spring类型转换 ,后台回复专栏名即可获取全部内容,已被 https://www.yourbatman.cn 收录。

看完了不一定懂,看懂了不一定会。来,文末3个思考题帮你复盘:

  1. 传入Long类型时间戳,如何能支持自动封装到Date类型?

  2. @DateTimeFormat一般用于Controller层?那么它能用在Service层吗?如何做?

  3. 为什么并不建议在Service/Dao层使用@DateTimeFormat等注解呢?

系列推荐

iyyi6f.gif!mobile
System.out.println("点个赞吧!");
print_r('关注【BAT的乌托邦】!');
var_dump('私聊A哥:fsx1056342982');
console.log("点个赞吧!");
NSLog(@"关注【BAT的乌托邦】!");
print("私聊A哥:fsx1056342982");
echo("点个赞吧!");
cout << "关注【BAT的乌托邦】!" << endl;
printf("私聊A哥:fsx1056342982");
Console.WriteLine("点个赞吧!");
fmt.Println("关注【BAT的乌托邦】!");
Response.Write("私聊A哥:fsx1056342982");
alert("点个赞吧!");

A哥(YourBatman) :Spring Framework开源贡献者,Java架构师,领域专家。文章不标题党,不哗众取宠,每篇文章都成系列去 系统的 攻破一个知识点,每个系列可能是 全网最佳/唯一 。注重基本功修养,底层基础决定上层建筑。现有IDEA系列、Spring N多系列、Bean Validation系列、日期时间系列......关注免费获取

bAFBVbr.png!mobile


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK