自定義Formatter格式化器?用它就對嘍
前言
你好,我是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
- ApplicationConversionService不繼承于DefaultFormattingConversionService但功能強于它:表現在額外增加了更多轉換器,且能夠從容器里自動檢索出Converter/Formatter類型的Bean然后注冊上去
- WebConversionService繼承自DefaultFormattingConversionService,并且增強了對JSR 310的更強支持。在Spring Boot的web環境下,該實例取代了通過注解 @EnableWebMvc/@EnableWebFlux默認指定的轉換服務實例
另外請切記,ConversionService作為基礎組件,并非全局只有一個。在Spring Framework和Spring Boot環境下有著不同表現,在本系列后半部分對此會再做詳細的使用分析。
為何需要?
根據本系列前面文章所講,雖然格式化器/轉換器的底層表現形式均為xxxConverter,但其“上層”的注冊方式卻不單一,提供了多種多樣的方式,表現出了極大的靈活性,便于使用和擴展。就拿FormatterRegistry(繼承自ConverterRegistry)注冊中心來說,它提供了很多方法讓你可以向注冊中心注冊格式化器/轉換器,如下API:
- // ==========1、直接注冊Converter轉換器==========
- void addConverter(Converter<?, ?> converter);
- <S, T> void addConverter(Class<S> sourceType, Class<T> targetType, Converter<? super S, ? extends T> converter);
- void addConverter(GenericConverter converter);
- void addConverterFactory(ConverterFactory<?, ?> factory);
- // ==========2、注冊Formatter格式化器(底層適配為Converter轉換器)==========
- void addPrinter(Printer<?> printer);
- void addParser(Parser<?> parser);
- void addFormatter(Formatter<?> formatter);
- void addFormatterForFieldType(Class<?> fieldType, Formatter<?> formatter);
- // ==========3、通過注解工廠方式為某些標有制定注解的格式注冊格式化器/轉換器==========
- 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和注解工廠方式。這里有兩點值得你特別注意:
- 并不支持單獨注冊Printer/Parser,因為Spring認為任何一個類型的格式化器應該是雙向的
- 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實例:
- WebMvcConfigurationSupport:
- @Bean
- public FormattingConversionService mvcConversionService() {
- FormattingConversionService conversionService = new DefaultFormattingConversionService();
- addFormatters(conversionService);
- return conversionService;
- }
- protected void addFormatters(FormatterRegistry registry) {
- }
暴露了addFormatters()這個擴展點,一般來講若你想自定義格式化器/轉換器的話,通過復寫此方法添加是被推薦的方式。
- ❝說明:這里僅代表在Spring Framework環境下,若在Spring Boot下會有不同表現和不同的自定義方式❞
另外呢,從這部分源碼可以看到這里并沒有通過FormattingConversionServiceFactoryBean來構建類型轉換服務實例,而是通過直接new的方式。其實來講,這里若使用FormattingConversionServiceFactoryBean來構建我認為是能夠更方便的,而且也更方便留下擴展點,你覺得呢?
DateTimeContext:細粒度個性化定制
Spring自4.0起提供了DateTimeContextHolder,其用于線程綁定DateTimeContext。而DateTimeContext提供了:Chronology(Java中的日歷系統)、ZoneId(JSR 310中的時區)、DateTimeFormatter(JSR 310格式化器)等上下文數據,如果需要這種上下文信息的話,可以使用這個API進行綁定。
- public class DateTimeContext {
- @Nullable
- private Chronology chronology;
- @Nullable
- private ZoneId timeZone;
- ... // 省略get/set
- }
若有定制需要,可以向該上下文實例設置這兩個值(日歷和時區),當然最重要的當屬從上下文中獲取到一個格式化器,這也是最終目的:

①:若設置了timeZone時區,就以其為準。否則執行步驟②②:若沒設置時區,嘗試從LocaleContext上下文里獲取時區,有就有沒有就沒有
簡而言之,這個步驟就是根據上下文設置的參數(有就有沒有就沒有)得到一個DateTimeFormatter實例用于格式化,注意:此方法是實例方法 而非靜態方法,所以先得自己new一個DateTimeContext喲。
再看DateTimeContextHolder,它用ThreadLocal把DateTimeContext和線程綁定,方便使用者獲取上下文數據:
- private static final ThreadLocal<DateTimeContext> dateTimeContextHolder = new NamedThreadLocal<>("DateTimeContext");
本類除了對DateTimeContext的維護外,提供了一個更直接的方法:根據當前上下文情況,直接獲取到DateTimeFormatter格式化器實例:

①:給調用者傳入的格式化器綁定上Locale屬性,若存在的話②:獲取到當前上下文對象DateTimeContext,進而根據當前上下文(若存在)得到加工后的DateTimeFormatter實例
該靜態方法可認為是對DateTimeContext#getFormatter()的封裝并擴展出Locale參數也可自定義,使用者可以一步到位獲取到和上下文相關的DateTimeFormatter實例,大多數時候我們直接使用此方法更為方便。
- ❝提問:為何Locale參數不一起放到LocalDateContext上下文屬性里呢?你能猜到Spring是如何設計如何考慮的嗎?❞
使用場景
和其它xxxContext一樣,結合使用場景去了解它才能更深刻,畢竟一切的學習都是為了應用嘛。Context上下文的概念在程序的世界里已經非常多見了,不管是做業務開發、中間件開發、基礎架構開發我認為都有理由會應用。
由于DateTimeFormatter是線程安全的,因此為了開發方便,通常會定一個(已經配置好的)全局通用的實例,形如這樣:
- /**
- * 全局通用的日期-時間格式化器(當然還可以有日期專用的、時間專用的...)
- */
- public static final DateTimeFormatter GLOBAL_DATETIME_FORMATTER = DateTimeFormatter
- .ofPattern("yyyy-MM-dd HH:mm:ss")
- .withLocale(Locale.CHINA)
- .withZone(ZoneId.of("Asia/Shanghai"))
- .withChronology(IsoChronology.INSTANCE);
這樣子項目中所有需要使用到格式化器DateTimeFormatter的地方從這里獲取即可,即便利又得到了統一管理,可謂一舉兩得。
但是,但是,但是,避免不了有時候會有個性化的的格式化需求,并且個性化的粒度還很細。如在Spring MVC場景下,不同的接口的返回值想自定義Locale、自定義ZoneId時區等從而返回不同的數據格式,但是又想復用全局的設置以盡量保持統一(畢竟個性化的參數一般僅1~2個而已)。
聽到不同接口,敏感的就能發現這是一個典型的可以用Context解決的場景:既不影響全局,又能實現線程級別的個性化定制。下面針對此場景,我用代碼示例模擬Demo。
代碼示例
- @Test
- public void test1() throws InterruptedException {
- // 模擬請求參數(同一個參數,在不同接口里的不同表現)
- Instant start = Instant.now();
- // 模擬Controller的接口1:zoneId不一樣
- new Thread(() -> {
- DateTimeContext context = new DateTimeContext();
- context.setTimeZone(ZoneId.of("America/New_York"));
- DateTimeContextHolder.setDateTimeContext(context);
- // 基于全局的格式化器 + 自己的上下文自定義一個本接口專用的格式化器
- DateTimeFormatter primaryFormatter = DateTimeContextHolder.getFormatter(GLOBAL_DATETIME_FORMATTER, null);
- System.out.printf("北京時間%s 接口1時間%s \n",
- GLOBAL_DATETIME_FORMATTER.format(start),
- primaryFormatter.format(start));
- }).start();
- // 模擬Controller的接口2:Locale不一樣
- new Thread(() -> {
- // 基于全局的格式化器 + 自己的上下文自定義一個本接口專用的格式化器
- DateTimeFormatter primaryFormatter = DateTimeContextHolder.getFormatter(GLOBAL_DATETIME_FORMATTER, Locale.US);
- System.out.printf("北京時間%s 接口2時間%s \n",
- GLOBAL_DATETIME_FORMATTER.format(start),
- primaryFormatter.format(start));
- }).start();
- TimeUnit.SECONDS.sleep(2);
- }
運行程序,輸出:
- 北京時間2021-03-15T07:29:37.8+08:00[Asia/Shanghai] 接口1時間2021-03-14T19:29:37.8-04:00[America/New_York]
- 北京時間2021-03-15T07:29:37.8+08:00[Asia/Shanghai] 接口2時間2021-03-15T07:29:37.8+08:00[Asia/Shanghai]
完美。通過這種操作上下文的方式達到了既復用又個性化的目的:
- 復用了全局格式化器的配置
- 個性化只局部個性化,對全局的格式化器沒有任何影響,風險可控,卻又實現了非常自由的個性化需求
可能有同學會問,若想自定義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