自定義Formatter格式化器?用它就對(duì)嘍
前言
你好,我是A哥(YourBatman)。
本系列(Spring類(lèi)型轉(zhuǎn)換)到現(xiàn)在,大部分的理論基礎(chǔ)已經(jīng)搞定了,很抽象甚至很枯燥有木有。還好終于快到頭了,此處應(yīng)給跟著“學(xué)”過(guò)來(lái)的自己1秒鐘掌聲。接下來(lái)的內(nèi)容會(huì)更多的偏向于應(yīng)用,比如在Spring MVC中的應(yīng)用、在IoC容器里的應(yīng)用、在JPA里的應(yīng)用等。
后續(xù)內(nèi)容相較于前面基礎(chǔ)孰輕孰重姑且不能一概而論,但相信大部分同學(xué)會(huì)更感興趣些。畢竟具象化的東西更易接受,更順應(yīng)人性,并且很多都是些工作中會(huì)用、考試中會(huì)考、面試中會(huì)問(wèn)的知識(shí)點(diǎn),自然積極性也會(huì)高上不少。
本文作為“二者”的承上啟下,將介紹自定義ConversionService類(lèi)型轉(zhuǎn)換服務(wù)的集大成者FormattingConversionServiceFactoryBean,以及較少人會(huì)關(guān)注但設(shè)計(jì)思路卻很重要的DateTimeContext和DateTimeContextHolder內(nèi)容,很值得你看它一看。
本文提綱

版本約定
- Spring Framework:5.3.x
- Spring Boot:2.4.x
正文
ConversionService是Spring自3.0提出的一個(gè)全新的、統(tǒng)一的類(lèi)型轉(zhuǎn)換服務(wù),在Spring Framework下它有兩大實(shí)現(xiàn)可用于生產(chǎn):
- DefaultConversionService:默認(rèn)注冊(cè)了非常多常規(guī)的類(lèi)型轉(zhuǎn)換器,如Number -> String、String -> Collection ...,但是它并沒(méi)有關(guān)于日期/時(shí)間、數(shù)字格式化方面的組件
- DefaultFormattingConversionService:它在DefaultConversionService基礎(chǔ)上增強(qiáng)(但不繼承于它),增加了格式化相關(guān)的內(nèi)容。如支持:Date、JSR 310、數(shù)字錢(qián)幣百分?jǐn)?shù)等格式化相關(guān)內(nèi)容
雖說(shuō)Spring內(nèi)置的轉(zhuǎn)換器/格式化器能“應(yīng)付”絕大部分場(chǎng)景,但不免有時(shí)候我們依舊需要DIY。通過(guò)前面的學(xué)習(xí)我們知道了,向注冊(cè)中心注冊(cè)格式化器/轉(zhuǎn)換器的方式多種多樣,能否降低使用者門(mén)檻提供一種較為統(tǒng)一的編程體驗(yàn)?zāi)?有,它就是今天的主角:FormattingConversionServiceFactoryBean。
FormattingConversionServiceFactoryBean
一個(gè)工廠類(lèi),用于產(chǎn)生FormattingConversionService實(shí)例,設(shè)計(jì)它的目的是方便的集中化配置它。
在這之前,小復(fù)習(xí)一下:FormattingConversionService實(shí)現(xiàn)了FormatterRegistry接口,并且繼承自GenericConversionService,所以功能上它是DefaultConversionService的超集。一般來(lái)講,我們常說(shuō)的ConversionService轉(zhuǎn)換服務(wù)底層實(shí)現(xiàn)使用的就是它(的子類(lèi)),區(qū)分如下case:
- 在Spring Framework環(huán)境下,其子類(lèi) 只有 DefaultFormattingConversionService(默認(rèn)有很多格式化器/轉(zhuǎn)換器,支持JSR 310、數(shù)字格式化、格式化注解等)
- 在Spring Boot環(huán)境下,其子類(lèi)還有 ApplicationConversionService和WebConversionService
- ApplicationConversionService不繼承于DefaultFormattingConversionService但功能強(qiáng)于它:表現(xiàn)在額外增加了更多轉(zhuǎn)換器,且能夠從容器里自動(dòng)檢索出Converter/Formatter類(lèi)型的Bean然后注冊(cè)上去
- WebConversionService繼承自DefaultFormattingConversionService,并且增強(qiáng)了對(duì)JSR 310的更強(qiáng)支持。在Spring Boot的web環(huán)境下,該實(shí)例取代了通過(guò)注解 @EnableWebMvc/@EnableWebFlux默認(rèn)指定的轉(zhuǎn)換服務(wù)實(shí)例
另外請(qǐng)切記,ConversionService作為基礎(chǔ)組件,并非全局只有一個(gè)。在Spring Framework和Spring Boot環(huán)境下有著不同表現(xiàn),在本系列后半部分對(duì)此會(huì)再做詳細(xì)的使用分析。
為何需要?
根據(jù)本系列前面文章所講,雖然格式化器/轉(zhuǎn)換器的底層表現(xiàn)形式均為xxxConverter,但其“上層”的注冊(cè)方式卻不單一,提供了多種多樣的方式,表現(xiàn)出了極大的靈活性,便于使用和擴(kuò)展。就拿FormatterRegistry(繼承自ConverterRegistry)注冊(cè)中心來(lái)說(shuō),它提供了很多方法讓你可以向注冊(cè)中心注冊(cè)格式化器/轉(zhuǎn)換器,如下API:
- // ==========1、直接注冊(cè)Converter轉(zhuǎn)換器==========
- 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、注冊(cè)Formatter格式化器(底層適配為Converter轉(zhuǎn)換器)==========
- void addPrinter(Printer<?> printer);
- void addParser(Parser<?> parser);
- void addFormatter(Formatter<?> formatter);
- void addFormatterForFieldType(Class<?> fieldType, Formatter<?> formatter);
- // ==========3、通過(guò)注解工廠方式為某些標(biāo)有制定注解的格式注冊(cè)格式化器/轉(zhuǎn)換器==========
- void addFormatterForFieldAnnotation(AnnotationFormatterFactory<? extends Annotation> annotationFormatterFactory);
除了這些直接用于注冊(cè)的接口API能夠完成注冊(cè)外,Spring還提供了一些批量注冊(cè)方式。雖然底層依舊依賴(lài)于這些API接口,但這種聚合手段大大提高了其可治理性,簡(jiǎn)化了注冊(cè)流程。譬如前面用專(zhuān)門(mén)文章重點(diǎn)介紹過(guò)的FormatterRegistrar注冊(cè)員就是典型代表。
關(guān)于格式化器/轉(zhuǎn)換器的注冊(cè)方式,A哥嘗試畫(huà)張圖來(lái)表示:

由此清晰可見(jiàn),注冊(cè)格式化器/轉(zhuǎn)換器的方式有很多很多。因此為了方便起見(jiàn),Spring設(shè)計(jì)了FormattingConversionServiceFactoryBean來(lái)集中化的向容器提供一個(gè)ConversionService實(shí)例,盡量提供統(tǒng)一化編程體驗(yàn)來(lái)屏蔽更多細(xì)節(jié),對(duì)使用者友好。
如何實(shí)現(xiàn)?
知曉了此FactoryBean的功能定位,實(shí)現(xiàn)其實(shí)就比較簡(jiǎn)單嘍,無(wú)非就是把各種“手段”整合到一起,可集中化定制和管理罷了。

從這些成員變量就能看到注冊(cè)轉(zhuǎn)換器的所有手段都被包含了進(jìn)來(lái)。細(xì)心的你有可能會(huì)疑問(wèn):咋沒(méi)看到通過(guò)注解工廠AnnotationFormatterFactory的方式呀???
其實(shí)它被歸類(lèi)到了Set formatters(Set的泛型類(lèi)型是?),如下源碼可“證明”:

①:負(fù)責(zé)注冊(cè)所有的轉(zhuǎn)換器。包括Converter、ConverterFactory、GenericConverter三種類(lèi)型,覆蓋1:1、N:1、N:N所有場(chǎng)景②:負(fù)責(zé)注冊(cè)格式化器Formatter和注解工廠方式。這里有兩點(diǎn)值得你特別注意:
- 并不支持單獨(dú)注冊(cè)Printer/Parser,因?yàn)镾pring認(rèn)為任何一個(gè)類(lèi)型的格式化器應(yīng)該是雙向的
- AnnotationFormatterFactory是放在Set formatters里的,和Formatter放在一起
③:負(fù)責(zé)處理注冊(cè)員xxxRegistrar的批量注冊(cè)動(dòng)作。如DateTimeFormatterRegistrar和DateFormatterRegistrar等,關(guān)于注冊(cè)員FormatterRegistrar詳細(xì)介紹可參見(jiàn)這篇文章:11. 春節(jié)禮物:Spring的Registrar倒排思想送給你
最后,從上面這張圖還有一點(diǎn)值得你關(guān)注:該工廠產(chǎn)生的ConversionService實(shí)例是固定的 DefaultFormattingConversionService,這就是我為何說(shuō)在Spring Framework環(huán)境下默認(rèn)使用的ConversionService實(shí)例都是它的原因,這不管是web還是非web場(chǎng)景。
使用場(chǎng)景
誠(chéng)然,直接使用FormattingConversionServiceFactoryBean的場(chǎng)景是不多的,除非你對(duì)此機(jī)制非常了解想進(jìn)行完全替換,那么推薦你使用它。
舉個(gè)例子:在Spring Framework環(huán)境下,若要啟用Spring MVC模塊的話會(huì)使用@EnableWebMvc注解來(lái)開(kāi)啟,此時(shí)Spring MVC默認(rèn)就向容器放入了一個(gè)ConversionService實(shí)例:
- WebMvcConfigurationSupport:
- @Bean
- public FormattingConversionService mvcConversionService() {
- FormattingConversionService conversionService = new DefaultFormattingConversionService();
- addFormatters(conversionService);
- return conversionService;
- }
- protected void addFormatters(FormatterRegistry registry) {
- }
暴露了addFormatters()這個(gè)擴(kuò)展點(diǎn),一般來(lái)講若你想自定義格式化器/轉(zhuǎn)換器的話,通過(guò)復(fù)寫(xiě)此方法添加是被推薦的方式。
- ❝說(shuō)明:這里僅代表在Spring Framework環(huán)境下,若在Spring Boot下會(huì)有不同表現(xiàn)和不同的自定義方式❞
另外呢,從這部分源碼可以看到這里并沒(méi)有通過(guò)FormattingConversionServiceFactoryBean來(lái)構(gòu)建類(lèi)型轉(zhuǎn)換服務(wù)實(shí)例,而是通過(guò)直接new的方式。其實(shí)來(lái)講,這里若使用FormattingConversionServiceFactoryBean來(lái)構(gòu)建我認(rèn)為是能夠更方便的,而且也更方便留下擴(kuò)展點(diǎn),你覺(jué)得呢?
DateTimeContext:細(xì)粒度個(gè)性化定制
Spring自4.0起提供了DateTimeContextHolder,其用于線程綁定DateTimeContext。而DateTimeContext提供了:Chronology(Java中的日歷系統(tǒng))、ZoneId(JSR 310中的時(shí)區(qū))、DateTimeFormatter(JSR 310格式化器)等上下文數(shù)據(jù),如果需要這種上下文信息的話,可以使用這個(gè)API進(jìn)行綁定。
- public class DateTimeContext {
- @Nullable
- private Chronology chronology;
- @Nullable
- private ZoneId timeZone;
- ... // 省略get/set
- }
若有定制需要,可以向該上下文實(shí)例設(shè)置這兩個(gè)值(日歷和時(shí)區(qū)),當(dāng)然最重要的當(dāng)屬?gòu)纳舷挛闹蝎@取到一個(gè)格式化器,這也是最終目的:

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

①:給調(diào)用者傳入的格式化器綁定上Locale屬性,若存在的話②:獲取到當(dāng)前上下文對(duì)象DateTimeContext,進(jìn)而根據(jù)當(dāng)前上下文(若存在)得到加工后的DateTimeFormatter實(shí)例
該靜態(tài)方法可認(rèn)為是對(duì)DateTimeContext#getFormatter()的封裝并擴(kuò)展出Locale參數(shù)也可自定義,使用者可以一步到位獲取到和上下文相關(guān)的DateTimeFormatter實(shí)例,大多數(shù)時(shí)候我們直接使用此方法更為方便。
- ❝提問(wèn):為何Locale參數(shù)不一起放到LocalDateContext上下文屬性里呢?你能猜到Spring是如何設(shè)計(jì)如何考慮的嗎?❞
使用場(chǎng)景
和其它xxxContext一樣,結(jié)合使用場(chǎng)景去了解它才能更深刻,畢竟一切的學(xué)習(xí)都是為了應(yīng)用嘛。Context上下文的概念在程序的世界里已經(jīng)非常多見(jiàn)了,不管是做業(yè)務(wù)開(kāi)發(fā)、中間件開(kāi)發(fā)、基礎(chǔ)架構(gòu)開(kāi)發(fā)我認(rèn)為都有理由會(huì)應(yīng)用。
由于DateTimeFormatter是線程安全的,因此為了開(kāi)發(fā)方便,通常會(huì)定一個(gè)(已經(jīng)配置好的)全局通用的實(shí)例,形如這樣:
- /**
- * 全局通用的日期-時(shí)間格式化器(當(dāng)然還可以有日期專(zhuān)用的、時(shí)間專(zhuān)用的...)
- */
- 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);
這樣子項(xiàng)目中所有需要使用到格式化器DateTimeFormatter的地方從這里獲取即可,即便利又得到了統(tǒng)一管理,可謂一舉兩得。
但是,但是,但是,避免不了有時(shí)候會(huì)有個(gè)性化的的格式化需求,并且個(gè)性化的粒度還很細(xì)。如在Spring MVC場(chǎng)景下,不同的接口的返回值想自定義Locale、自定義ZoneId時(shí)區(qū)等從而返回不同的數(shù)據(jù)格式,但是又想復(fù)用全局的設(shè)置以盡量保持統(tǒng)一(畢竟個(gè)性化的參數(shù)一般僅1~2個(gè)而已)。
聽(tīng)到不同接口,敏感的就能發(fā)現(xiàn)這是一個(gè)典型的可以用Context解決的場(chǎng)景:既不影響全局,又能實(shí)現(xiàn)線程級(jí)別的個(gè)性化定制。下面針對(duì)此場(chǎng)景,我用代碼示例模擬Demo。
代碼示例
- @Test
- public void test1() throws InterruptedException {
- // 模擬請(qǐng)求參數(shù)(同一個(gè)參數(shù),在不同接口里的不同表現(xiàn))
- Instant start = Instant.now();
- // 模擬Controller的接口1:zoneId不一樣
- new Thread(() -> {
- DateTimeContext context = new DateTimeContext();
- context.setTimeZone(ZoneId.of("America/New_York"));
- DateTimeContextHolder.setDateTimeContext(context);
- // 基于全局的格式化器 + 自己的上下文自定義一個(gè)本接口專(zhuān)用的格式化器
- DateTimeFormatter primaryFormatter = DateTimeContextHolder.getFormatter(GLOBAL_DATETIME_FORMATTER, null);
- System.out.printf("北京時(shí)間%s 接口1時(shí)間%s \n",
- GLOBAL_DATETIME_FORMATTER.format(start),
- primaryFormatter.format(start));
- }).start();
- // 模擬Controller的接口2:Locale不一樣
- new Thread(() -> {
- // 基于全局的格式化器 + 自己的上下文自定義一個(gè)本接口專(zhuān)用的格式化器
- DateTimeFormatter primaryFormatter = DateTimeContextHolder.getFormatter(GLOBAL_DATETIME_FORMATTER, Locale.US);
- System.out.printf("北京時(shí)間%s 接口2時(shí)間%s \n",
- GLOBAL_DATETIME_FORMATTER.format(start),
- primaryFormatter.format(start));
- }).start();
- TimeUnit.SECONDS.sleep(2);
- }
運(yùn)行程序,輸出:
- 北京時(shí)間2021-03-15T07:29:37.8+08:00[Asia/Shanghai] 接口1時(shí)間2021-03-14T19:29:37.8-04:00[America/New_York]
- 北京時(shí)間2021-03-15T07:29:37.8+08:00[Asia/Shanghai] 接口2時(shí)間2021-03-15T07:29:37.8+08:00[Asia/Shanghai]
完美。通過(guò)這種操作上下文的方式達(dá)到了既復(fù)用又個(gè)性化的目的:
- 復(fù)用了全局格式化器的配置
- 個(gè)性化只局部個(gè)性化,對(duì)全局的格式化器沒(méi)有任何影響,風(fēng)險(xiǎn)可控,卻又實(shí)現(xiàn)了非常自由的個(gè)性化需求
可能有同學(xué)會(huì)問(wèn),若想自定義Pattern怎么辦呢?答案是:做不到。Java的DateTimeFormatter和Pattern屬于強(qiáng)綁定關(guān)系,Pattern改了就得用個(gè)全新的DateTimeFormatter實(shí)例,其它屬性無(wú)法(內(nèi)部)拷貝。至于什么原因,A哥在講解JDK日期時(shí)間時(shí)有提及,具體可關(guān)注我參考JDK日期時(shí)間系列。
- ❝說(shuō)明:一般情況對(duì)一個(gè)項(xiàng)目而言,Pattern是不太可能需要個(gè)性化的。若真有此情況,那么請(qǐng)完整的自定義一個(gè)DateTimeFormatter處理吧❞
總結(jié)
本文介紹了Spring兩個(gè)組件:
- FormattingConversionServiceFactoryBean:類(lèi)型轉(zhuǎn)換服務(wù)工廠,注冊(cè)管理格式化器/轉(zhuǎn)換器的推薦方案
- DateTimeContext:因?yàn)樽远x日期時(shí)間格式化器屬比較常見(jiàn)的需求,因此Spring在4.0推出這套API方便使用者實(shí)現(xiàn)更細(xì)粒度的控制。還是那句話,使用好了事半功倍且代碼優(yōu)雅更易維護(hù)
關(guān)于Spring轉(zhuǎn)換器/格式化器的基礎(chǔ)內(nèi)容基本就到這了,希望這打破了很多同學(xué)以為的:類(lèi)型轉(zhuǎn)換就等于Spring MVC Controller自動(dòng)封裝的思維定式,要知道它的應(yīng)用空間還大著哩。
本系列接下來(lái)會(huì)更偏向于應(yīng)用層面的case分析,Spring MVC場(chǎng)景的使用更是”首當(dāng)其沖“嘍,歡迎關(guān)注一起探討、交流和學(xué)習(xí)。
本文思考題
本文所屬專(zhuān)欄:Spring類(lèi)型轉(zhuǎn)換,后臺(tái)回復(fù)專(zhuān)欄名即可獲取全部?jī)?nèi)容,已被https://yourbatman.cn收錄。
看完了不一定懂,看懂了不一定會(huì)。來(lái),文末3個(gè)思考題幫你復(fù)盤(pán):
如何使用FormattingConversionServiceFactoryBean自定義類(lèi)型轉(zhuǎn)換服務(wù)?
Spring設(shè)計(jì)出DateTimeContext和DateTimeContextHolder旨在解決什么問(wèn)題?
為何DateTimeContextHolder#getFormatter方法的第二個(gè)參數(shù)Locale不放到DateTimeContext里?明明可以這么干的呀
系列推薦
12. 查漏補(bǔ)缺@DateTimeFormat到底干了些啥
11. 春節(jié)禮物:Spring的Registrar倒排思想送給你
10. 原來(lái)是這么玩的,@DateTimeFormat和@NumberFormat