当前位置:主页 > 查看内容

自定义Formatter格式化器?用它就对喽

发布时间:2021-04-27 00:00| 位朋友查看

简介:前言 你好,我是A哥(YourBatman)。 本系列(Spring类型转换)到现在,大部分的理论基础已经搞定了,很抽象甚至很枯燥有木有。还好终于快到头了,此处应给跟着学过来的自己1秒钟掌声。接下来的内容会更多的偏向于应用,比如在Spring MVC中的应用、在IoC容器里的……

前言

你好,我是A哥(YourBatman)。

本系列(Spring类型转换)到现在,大部分的理论基础已经搞定了,很抽象甚至很枯燥有木有。还好终于快到头了,此处应给跟着“学”过来的自己1秒钟掌声。接下来的内容会更多的偏向于应用,比如在Spring MVC中的应用、在IoC容器里的应用、在JPA里的应用等。

后续内容相较于前面基础孰轻孰重姑且不能一概而论,但相信大部分同学会更感兴趣些。毕竟具象化的东西更易接受,更顺应人性,并且很多都是些工作中会用、考试中会考、面试中会问的知识点,自然积极性也会高上不少。

本文作为“二者”的承上启下,将介绍自定义ConversionService类型转换服务的集大成者FormattingConversionServiceFactoryBean,以及较少人会关注但设计思路却很重要的DateTimeContext和DateTimeContextHolder内容,很值得你看它一看。

本文提纲


版本约定

  • Spring Framework:5.3.x
  • Spring Boot:2.4.x

正文

ConversionService是Spring自3.0提出的一个全新的、统一的类型转换服务,在Spring Framework下它有两大实现可用于生产

  • DefaultConversionService:默认注册了非常多常规的类型转换器,如Number -> String、String -> Collection ...,但是它并没有关于日期/时间、数字格式化方面的组件
  • DefaultFormattingConversionService:它在DefaultConversionService基础上增强(但不继承于它),增加了格式化相关的内容。如支持:Date、JSR 310、数字钱币百分数等格式化相关内容

虽说Spring内置的转换器/格式化器能“应付”绝大部分场景,但不免有时候我们依旧需要DIY。通过前面的学习我们知道了,向注册中心注册格式化器/转换器的方式多种多样,能否降低使用者门槛提供一种较为统一的编程体验呢?有,它就是今天的主角:FormattingConversionServiceFactoryBean。

FormattingConversionServiceFactoryBean

一个工厂类,用于产生FormattingConversionService实例,设计它的目的是方便的集中化配置它。

在这之前,小复习一下:FormattingConversionService实现了FormatterRegistry接口,并且继承自GenericConversionService,所以功能上它是DefaultConversionService的超集。一般来讲,我们常说的ConversionService转换服务底层实现使用的就是它(的子类),区分如下case:

  • 在Spring Framework环境下,其子类 只有 DefaultFormattingConversionService(默认有很多格式化器/转换器,支持JSR 310、数字格式化、格式化注解等)
  • 在Spring Boot环境下,其子类还有 ApplicationConversionService和WebConversionService
  1. ApplicationConversionService不继承于DefaultFormattingConversionService但功能强于它:表现在额外增加了更多转换器,且能够从容器里自动检索出Converter/Formatter类型的Bean然后注册上去
  2. WebConversionService继承自DefaultFormattingConversionService,并且增强了对JSR 310的更强支持。在Spring Boot的web环境下,该实例取代了通过注解 @EnableWebMvc/@EnableWebFlux默认指定的转换服务实例

 

另外请切记,ConversionService作为基础组件,并非全局只有一个。在Spring Framework和Spring Boot环境下有着不同表现,在本系列后半部分对此会再做详细的使用分析。

为何需要?

根据本系列前面文章所讲,虽然格式化器/转换器的底层表现形式均为xxxConverter,但其“上层”的注册方式却不单一,提供了多种多样的方式,表现出了极大的灵活性,便于使用和扩展。就拿FormatterRegistry(继承自ConverterRegistry)注册中心来说,它提供了很多方法让你可以向注册中心注册格式化器/转换器,如下API:

  1. // ==========1、直接注册Converter转换器========== 
  2. void addConverter(Converter<?, ?> converter); 
  3. <S, T> void addConverter(Class<S> sourceType, Class<T> targetType, Converter<? super S, ? extends T> converter); 
  4. void addConverter(GenericConverter converter); 
  5. void addConverterFactory(ConverterFactory<?, ?> factory); 
  6.  
  7. // ==========2、注册Formatter格式化器(底层适配为Converter转换器)========== 
  8. void addPrinter(Printer<?> printer); 
  9. void addParser(Parser<?> parser); 
  10. void addFormatter(Formatter<?> formatter); 
  11. void addFormatterForFieldType(Class<?> fieldType, Formatter<?> formatter); 
  12.  
  13. // ==========3、通过注解工厂方式为某些标有制定注解的格式注册格式化器/转换器========== 
  14. void addFormatterForFieldAnnotation(AnnotationFormatterFactory<? extends Annotation> annotationFormatterFactory); 

除了这些直接用于注册的接口API能够完成注册外,Spring还提供了一些批量注册方式。虽然底层依旧依赖于这些API接口,但这种聚合手段大大提高了其可治理性,简化了注册流程。譬如前面用专门文章重点介绍过的FormatterRegistrar注册员就是典型代表。

关于格式化器/转换器的注册方式,A哥尝试画张图来表示:


由此清晰可见,注册格式化器/转换器的方式有很多很多。因此为了方便起见,Spring设计了FormattingConversionServiceFactoryBean来集中化的向容器提供一个ConversionService实例,尽量提供统一化编程体验来屏蔽更多细节,对使用者友好。

如何实现?

知晓了此FactoryBean的功能定位,实现其实就比较简单喽,无非就是把各种“手段”整合到一起,可集中化定制和管理罢了。


从这些成员变量就能看到注册转换器的所有手段都被包含了进来。细心的你有可能会疑问:咋没看到通过注解工厂AnnotationFormatterFactory的方式呀???

其实它被归类到了Set formatters(Set的泛型类型是?),如下源码可“证明”:


①:负责注册所有的转换器。包括Converter、ConverterFactory、GenericConverter三种类型,覆盖1:1、N:1、N:N所有场景②:负责注册格式化器Formatter和注解工厂方式。这里有两点值得你特别注意:

  1. 并不支持单独注册Printer/Parser,因为Spring认为任何一个类型的格式化器应该是双向的
  2. AnnotationFormatterFactory是放在Set formatters里的,和Formatter放在一起

③:负责处理注册员xxxRegistrar的批量注册动作。如DateTimeFormatterRegistrar和DateFormatterRegistrar等,关于注册员FormatterRegistrar详细介绍可参见这篇文章:11. 春节礼物:Spring的Registrar倒排思想送给你

最后,从上面这张图还有一点值得你关注:该工厂产生的ConversionService实例是固定的 DefaultFormattingConversionService,这就是我为何说在Spring Framework环境下默认使用的ConversionService实例都是它的原因,这不管是web还是非web场景。

使用场景

诚然,直接使用FormattingConversionServiceFactoryBean的场景是不多的,除非你对此机制非常了解想进行完全替换,那么推荐你使用它。

举个例子:在Spring Framework环境下,若要启用Spring MVC模块的话会使用@EnableWebMvc注解来开启,此时Spring MVC默认就向容器放入了一个ConversionService实例:

  1. WebMvcConfigurationSupport: 
  2.  
  3.  @Bean 
  4.  public FormattingConversionService mvcConversionService() { 
  5.   FormattingConversionService conversionService = new DefaultFormattingConversionService(); 
  6.   addFormatters(conversionService); 
  7.   return conversionService; 
  8.  } 
  9.  
  10.  protected void addFormatters(FormatterRegistry registry) { 
  11.  } 

暴露了addFormatters()这个扩展点,一般来讲若你想自定义格式化器/转换器的话,通过复写此方法添加是被推荐的方式。

  • ❝说明:这里仅代表在Spring Framework环境下,若在Spring Boot下会有不同表现和不同的自定义方式❞

另外呢,从这部分源码可以看到这里并没有通过FormattingConversionServiceFactoryBean来构建类型转换服务实例,而是通过直接new的方式。其实来讲,这里若使用FormattingConversionServiceFactoryBean来构建我认为是能够更方便的,而且也更方便留下扩展点,你觉得呢?

DateTimeContext:细粒度个性化定制

Spring自4.0起提供了DateTimeContextHolder,其用于线程绑定DateTimeContext。而DateTimeContext提供了:Chronology(Java中的日历系统)、ZoneId(JSR 310中的时区)、DateTimeFormatter(JSR 310格式化器)等上下文数据,如果需要这种上下文信息的话,可以使用这个API进行绑定。

  1. public class DateTimeContext { 
  2.  
  3.  @Nullable 
  4.  private Chronology chronology; 
  5.  @Nullable 
  6.  private ZoneId timeZone; 
  7.  
  8.  ... // 省略get/set 

若有定制需要,可以向该上下文实例设置这两个值(日历和时区),当然最重要的当属从上下文中获取到一个格式化器,这也是最终目的:


①:若设置了timeZone时区,就以其为准。否则执行步骤②②:若没设置时区,尝试从LocaleContext上下文里获取时区,有就有没有就没有

简而言之,这个步骤就是根据上下文设置的参数(有就有没有就没有)得到一个DateTimeFormatter实例用于格式化,注意:此方法是实例方法 而非静态方法,所以先得自己new一个DateTimeContext哟。

再看DateTimeContextHolder,它用ThreadLocal把DateTimeContext和线程绑定,方便使用者获取上下文数据:

  1. private static final ThreadLocal<DateTimeContext> dateTimeContextHolder = new NamedThreadLocal<>("DateTimeContext"); 

本类除了对DateTimeContext的维护外,提供了一个更直接的方法:根据当前上下文情况,直接获取到DateTimeFormatter格式化器实例:


①:给调用者传入的格式化器绑定上Locale属性,若存在的话②:获取到当前上下文对象DateTimeContext,进而根据当前上下文(若存在)得到加工后的DateTimeFormatter实例

该静态方法可认为是对DateTimeContext#getFormatter()的封装并扩展出Locale参数也可自定义,使用者可以一步到位获取到和上下文相关的DateTimeFormatter实例,大多数时候我们直接使用此方法更为方便。

  • ❝提问:为何Locale参数不一起放到LocalDateContext上下文属性里呢?你能猜到Spring是如何设计如何考虑的吗?❞

使用场景

和其它xxxContext一样,结合使用场景去了解它才能更深刻,毕竟一切的学习都是为了应用嘛。Context上下文的概念在程序的世界里已经非常多见了,不管是做业务开发、中间件开发、基础架构开发我认为都有理由会应用。

由于DateTimeFormatter是线程安全的,因此为了开发方便,通常会定一个(已经配置好的)全局通用的实例,形如这样:

  1. /** 
  2.  * 全局通用的日期-时间格式化器(当然还可以有日期专用的、时间专用的...) 
  3.  */ 
  4. public static final DateTimeFormatter GLOBAL_DATETIME_FORMATTER = DateTimeFormatter 
  5.         .ofPattern("yyyy-MM-dd HH:mm:ss"
  6.         .withLocale(Locale.CHINA) 
  7.         .withZone(ZoneId.of("Asia/Shanghai")) 
  8.         .withChronology(IsoChronology.INSTANCE); 

这样子项目中所有需要使用到格式化器DateTimeFormatter的地方从这里获取即可,即便利又得到了统一管理,可谓一举两得。

但是,但是,但是,避免不了有时候会有个性化的的格式化需求,并且个性化的粒度还很细。如在Spring MVC场景下,不同的接口的返回值想自定义Locale、自定义ZoneId时区等从而返回不同的数据格式,但是又想复用全局的设置以尽量保持统一(毕竟个性化的参数一般仅1~2个而已)。

听到不同接口,敏感的就能发现这是一个典型的可以用Context解决的场景:既不影响全局,又能实现线程级别的个性化定制。下面针对此场景,我用代码示例模拟Demo。

代码示例

  1. @Test 
  2. public void test1() throws InterruptedException { 
  3.     // 模拟请求参数(同一个参数,在不同接口里的不同表现) 
  4.     Instant start = Instant.now(); 
  5.  
  6.     // 模拟Controller的接口1:zoneId不一样 
  7.     new Thread(() -> { 
  8.         DateTimeContext context = new DateTimeContext(); 
  9.         context.setTimeZone(ZoneId.of("America/New_York")); 
  10.         DateTimeContextHolder.setDateTimeContext(context); 
  11.         // 基于全局的格式化器 + 自己的上下文自定义一个本接口专用的格式化器 
  12.         DateTimeFormatter primaryFormatter = DateTimeContextHolder.getFormatter(GLOBAL_DATETIME_FORMATTER, null); 
  13.  
  14.         System.out.printf("北京时间%s 接口1时间%s \n"
  15.                 GLOBAL_DATETIME_FORMATTER.format(start), 
  16.                 primaryFormatter.format(start)); 
  17.     }).start(); 
  18.  
  19.     // 模拟Controller的接口2:Locale不一样 
  20.     new Thread(() -> { 
  21.         // 基于全局的格式化器 + 自己的上下文自定义一个本接口专用的格式化器 
  22.         DateTimeFormatter primaryFormatter = DateTimeContextHolder.getFormatter(GLOBAL_DATETIME_FORMATTER, Locale.US); 
  23.  
  24.         System.out.printf("北京时间%s 接口2时间%s \n"
  25.                 GLOBAL_DATETIME_FORMATTER.format(start), 
  26.                 primaryFormatter.format(start)); 
  27.     }).start(); 
  28.  
  29.     TimeUnit.SECONDS.sleep(2); 

运行程序,输出:

  1. 北京时间2021-03-15T07:29:37.8+08:00[Asia/Shanghai] 接口1时间2021-03-14T19:29:37.8-04:00[America/New_York] 
  2. 北京时间2021-03-15T07:29:37.8+08:00[Asia/Shanghai] 接口2时间2021-03-15T07:29:37.8+08:00[Asia/Shanghai] 

完美。通过这种操作上下文的方式达到了既复用又个性化的目的:

  1. 复用了全局格式化器的配置
  2. 个性化只局部个性化,对全局的格式化器没有任何影响,风险可控,却又实现了非常自由的个性化需求

可能有同学会问,若想自定义Pattern怎么办呢?答案是:做不到。Java的DateTimeFormatter和Pattern属于强绑定关系,Pattern改了就得用个全新的DateTimeFormatter实例,其它属性无法(内部)拷贝。至于什么原因,A哥在讲解JDK日期时间时有提及,具体可关注我参考JDK日期时间系列。

  • ❝说明:一般情况对一个项目而言,Pattern是不太可能需要个性化的。若真有此情况,那么请完整的自定义一个DateTimeFormatter处理吧❞

总结

本文介绍了Spring两个组件:

  • FormattingConversionServiceFactoryBean:类型转换服务工厂,注册管理格式化器/转换器的推荐方案
  • DateTimeContext:因为自定义日期时间格式化器属比较常见的需求,因此Spring在4.0推出这套API方便使用者实现更细粒度的控制。还是那句话,使用好了事半功倍且代码优雅更易维护

关于Spring转换器/格式化器的基础内容基本就到这了,希望这打破了很多同学以为的:类型转换就等于Spring MVC Controller自动封装的思维定式,要知道它的应用空间还大着哩。

本系列接下来会更偏向于应用层面的case分析,Spring MVC场景的使用更是”首当其冲“喽,欢迎关注一起探讨、交流和学习。

本文思考题

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

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

如何使用FormattingConversionServiceFactoryBean自定义类型转换服务?

Spring设计出DateTimeContext和DateTimeContextHolder旨在解决什么问题?

为何DateTimeContextHolder#getFormatter方法的第二个参数Locale不放到DateTimeContext里?明明可以这么干的呀

系列推荐

12. 查漏补缺@DateTimeFormat到底干了些啥

11. 春节礼物:Spring的Registrar倒排思想送给你

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


本文转载自网络,原文链接:https://mp.weixin.qq.com/s/GCaH_kTpi4d2rTF-8GvRGA
本站部分内容转载于网络,版权归原作者所有,转载之目的在于传播更多优秀技术内容,如有侵权请联系QQ/微信:153890879删除,谢谢!

推荐图文

  • 周排行
  • 月排行
  • 总排行

随机推荐