8. 格式化器大一統 -- Spring的Formatter抽象

✍前言

你好,我是A哥(YourBatman)。

上篇文章 介紹了java.text.Format格式化體系,作爲JDK 1.0就提供的格式化器,除了設計上存在一定缺陷,過於底層無法標準化對使用者不夠友好,這都是對格式化器提出的更高要求。Spring作爲Java開發的標準基建,本文就來看看它做了哪些補充。

本文提綱

版本約定

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

✍正文

在應用中(特別是web應用),我們經常需要將前端/Client端傳入的字符串轉換成指定格式/指定數據類型,同樣的服務端也希望能把指定類型的數據按照指定格式 返回給前端/Client端,這種情況下Converter已經無法滿足我們的需求了。爲此,Spring提供了格式化模塊專門用於解決此類問題。

首先可以從宏觀上先看看spring-context對format模塊的目錄結構安排:

public interface Formatter<T> extends Printer<T>, Parser<T> {

}

可以看到,該接口本身沒有任何方法,而是聚合了另外兩個接口Printer和Parser。

Printer&Parser

這兩個接口是相反功能的接口。

  • Printer:格式化顯示(輸出)接口。將T類型轉爲String形式,Locale用於控制國際化
@FunctionalInterface
public interface Printer<T> {
	// 將Object寫爲String類型
	String print(T object, Locale locale);
}
  • Parser:解析接口。將String類型轉到T類型,Locale用於控制國際化。
@FunctionalInterface
public interface Parser<T> {
	T parse(String text, Locale locale) throws ParseException;
}

Formatter

格式化器接口,它的繼承樹如下:

由圖可見,格式化動作只需關心到兩個領域:

  • 時間日期領域
  • 數字領域(其中包括貨幣)

時間日期格式化

Spring框架從4.0開始支持Java 8,針對JSR 310日期時間類型的格式化專門有個包org.springframework.format.datetime.standard

值得一提的是:在Java 8出來之前,Joda-Time是Java日期時間處理最好的解決方案,使用廣泛,甚至得到了Spring內置的支持。現在Java 8已然成爲主流,JSR 310日期時間API 完全可以 代替Joda-Time(JSR 310的貢獻者其實就是Joda-Time的作者們)。因此joda庫也逐漸告別歷史舞臺,後續代碼中不再推薦使用,本文也會選擇性忽略。

除了Joda-Time外,Java中對時間日期的格式化還需分爲這兩大陣營來處理:

Date類型

雖然已經2020年了(Java 8於2014年發佈),但談到時間日期那必然還是得有java.util.Date,畢竟積重難返。所以呢,Spring提供了DateFormatter用於支持它的格式化。

因爲Date早就存在,所以DateFormatter是伴隨着Formatter的出現而出現,@since 3.0

// @since 3.0
public class DateFormatter implements Formatter<Date> {

	private static final TimeZone UTC = TimeZone.getTimeZone("UTC");
	private static final Map<ISO, String> ISO_PATTERNS;
	static {
		Map<ISO, String> formats = new EnumMap<>(ISO.class);
		formats.put(ISO.DATE, "yyyy-MM-dd");
		formats.put(ISO.TIME, "HH:mm:ss.SSSXXX");
		formats.put(ISO.DATE_TIME, "yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
		ISO_PATTERNS = Collections.unmodifiableMap(formats);
	}
}

默認使用的TimeZone是UTC標準時區,ISO_PATTERNS代表ISO標準模版,這和@DateTimeFormat註解的iso屬性是一一對應的。也就是說如果你不想指定pattern,可以快速通過指定ISO來實現。

另外,對於格式化器來說有這些屬性你都可以自由去定製:

DateFormatter:

	@Nullable
	private String pattern;
	private int style = DateFormat.DEFAULT;
	@Nullable
	private String stylePattern;
	@Nullable
	private ISO iso;
	@Nullable
	private TimeZone timeZone;

它對Formatter接口方法的實現如下:

DateFormatter:

	@Override
	public String print(Date date, Locale locale) {
		return getDateFormat(locale).format(date);
	}

	@Override
	public Date parse(String text, Locale locale) throws ParseException {
		return getDateFormat(locale).parse(text);
	}

	// 根據pattern、ISO等等得到一個DateFormat實例
	protected DateFormat getDateFormat(Locale locale) { ... }

可以看到不管輸入還是輸出,底層依賴的都是JDK的java.text.DateFormat(實際爲SimpleDateFormat),現在知道爲毛上篇文章要先講JDK的格式化體系做鋪墊了吧,萬變不離其宗。

因此可以認爲,Spring爲此做的事情的核心,只不過是寫了個根據Locale、pattern、IOS等參數生成DateFormat實例的邏輯而已,屬於應用層面的封裝。也就是需要知曉getDateFormat()方法的邏輯,此部分邏輯繪製成圖如下:

因此:pattern、iso、stylePattern它們的優先級誰先誰後,一看便知。

代碼示例
@Test
public void test1() {
    DateFormatter formatter = new DateFormatter();
    
    Date currDate = new Date();

    System.out.println("默認輸出格式:" + formatter.print(currDate, Locale.CHINA));
    formatter.setIso(DateTimeFormat.ISO.DATE_TIME);
    System.out.println("指定ISO輸出格式:" + formatter.print(currDate, Locale.CHINA));
    formatter.setPattern("yyyy-mm-dd HH:mm:ss");
    System.out.println("指定pattern輸出格式:" + formatter.print(currDate, Locale.CHINA));
}

運行程序,輸出:

默認輸出格式:2020-12-26
指定ISO輸出格式:2020-12-26T13:06:52.921Z
指定pattern輸出格式:2020-06-26 21:06:52

注意:ISO格式輸出的時間,是存在時差問題的,因爲它使用的是UTC時間,請稍加註意。

還記得本系列前面介紹的CustomDateEditor這個屬性編輯器嗎?它也是用於對String -> Date的轉化,底層依賴也是JDK的DateFormat,但使用靈活度上沒這個自由,已被拋棄/取代。

關於java.util.Date類型的格式化,在此,語重心長的號召一句:如果你是項目,請全項目禁用Date類型吧;如果你是新代碼,也請不要再使用Date類型,太拖後腿了。

JSR 310類型

JSR 310日期時間類型是Java8引入的一套全新的時間日期API。新的時間及日期API位於java.time中,此包中的是類是不可變且線程安全的。下面是一些關鍵類

  • Instant——代表的是時間戳(另外可參考Clock類)
  • LocalDate——不包含具體時間的日期,如2020-12-12。它可以用來存儲生日,週年紀念日,入職日期等
  • LocalTime——代表的是不含日期的時間,如18:00:00
  • LocalDateTime——包含了日期及時間,不過沒有偏移信息或者說時區
  • ZonedDateTime——包含時區的完整的日期時間還有時區,偏移量是以UTC/格林威治時間爲基準的
  • Timezone——時區。在新API中時區使用ZoneId來表示。時區可以很方便的使用靜態方法of來獲取到

同時還有一些輔助類,如:Year、Month、YearMonth、MonthDay、Duration、Period等等。

從上圖Formatter的繼承樹來看,Spring只提供了一些輔助類的格式化器實現,如MonthFormatter、PeriodFormatter、YearMonthFormatter等,且實現方式都是趨同的:

class MonthFormatter implements Formatter<Month> {

	@Override
	public Month parse(String text, Locale locale) throws ParseException {
		return Month.valueOf(text.toUpperCase());
	}
	@Override
	public String print(Month object, Locale locale) {
		return object.toString();
	}

}

這裏以MonthFormatter爲例,其它輔助類的格式化器實現其實基本一樣:

那麼問題來了:Spring爲毛沒有給LocalDateTime、LocalDate、LocalTime這種更爲常用的類型提供Formatter格式化器呢?

其實是這樣的:JDK 8提供的這套日期時間API是非常優秀的,自己就提供了非常好用的java.time.format.DateTimeFormatter格式化器,並且設計、功能上都已經非常完善了。既然如此,Spring並不需要再重複造輪子,而是僅需考慮如何整合此格式化器即可。

整合DateTimeFormatter

爲了完成“整合”,把DateTimeFormatter融入到Spring自己的Formatter體系內,Spring準備了多個API用於銜接。

  • DateTimeFormatterFactory

java.time.format.DateTimeFormatter的工廠。和DateFormatter一樣,它支持如下屬性方便你直接定製:

DateTimeFormatterFactory:

	@Nullable
	private String pattern;
	@Nullable
	private ISO iso;
	@Nullable
	private FormatStyle dateStyle;
	@Nullable
	private FormatStyle timeStyle;
	@Nullable
	private TimeZone timeZone;


	// 根據定製的參數,生成一個DateTimeFormatter實例
	public DateTimeFormatter createDateTimeFormatter(DateTimeFormatter fallbackFormatter) { ... }

優先級關係二者是一致的:

  • pattern
  • iso
  • dateStyle/timeStyle

說明:一致的設計,可以給與開發者近乎一致的編程體驗,畢竟JSR 310和Date表示的都是時間日期,儘量保持一致性是一種很人性化的設計考量。

  • DateTimeFormatterFactoryBean

顧名思義,DateTimeFormatterFactory用於生成一個DateTimeFormatter實例,而本類用於把生成的Bean放進IoC容器內,完成和Spring容器的整合。客氣的是,它直接繼承自DateTimeFormatterFactory,從而自己同時就具備這兩項能力:

  1. 生成DateTimeFormatter實例
  2. 將該實例放進IoC容器

多說一句:雖然這個工廠Bean非常簡單,但是它釋放的信號可以作爲編程指導

  1. 一個應用內,對日期、時間的格式化儘量只存在1種模版規範。比如我們可以向IoC容器裏扔進去一個模版,需要時注入進來使用即可
    1. 注意:這裏指的應用,一般不包含協議轉換層使用的模版規範。如Http協議層可以使用自己單獨的一套轉換模版機制
  2. 日期時間模版不要在每次使用時去臨時創建,而是集中統一創建好管理起來(比如放IoC容器內),這樣維護起來方便很多

說明:DateTimeFormatterFactoryBean這個API在Spring內部並未使用,這是Spring專門給使用者用的,因爲Spring也希望你這麼去做從而把日期時間格式化模版管理起來

代碼示例
@Test
public void test1() {
    // DateTimeFormatterFactory dateTimeFormatterFactory = new DateTimeFormatterFactory();
    // dateTimeFormatterFactory.setPattern("yyyy-MM-dd HH:mm:ss");

    // 執行格式化動作
    System.out.println(new DateTimeFormatterFactory("yyyy-MM-dd HH:mm:ss").createDateTimeFormatter().format(LocalDateTime.now()));
    System.out.println(new DateTimeFormatterFactory("yyyy-MM-dd").createDateTimeFormatter().format(LocalDate.now()));
    System.out.println(new DateTimeFormatterFactory("HH:mm:ss").createDateTimeFormatter().format(LocalTime.now()));
    System.out.println(new DateTimeFormatterFactory("yyyy-MM-dd HH:mm:ss").createDateTimeFormatter().format(ZonedDateTime.now()));
}

運行程序,輸出:

2020-12-26 22:44:44
2020-12-26
22:44:44
2020-12-26 22:44:44

說明:雖然你也可以直接使用DateTimeFormatter#ofPattern()靜態方法得到一個實例,但是 若在Spring環境下使用它我還是建議使用Spring提供的工廠類來創建,這樣能保證統一的編程體驗,B格也稍微高點。

使用建議:以後對日期時間類型(包括JSR310類型)就不要自己去寫原生的SimpleDateFormat/DateTimeFormatter了,建議可以用Spring包裝過的DateFormatter/DateTimeFormatterFactory,使用體驗更佳。

數字格式化

通過了上篇文章的學習之後,對數字的格式化就一點也不陌生了,什麼數字、百分數、錢幣等都屬於數字的範疇。Spring提供了AbstractNumberFormatter抽象來專門處理數字格式化議題:

public abstract class AbstractNumberFormatter implements Formatter<Number> {
	...
	@Override
	public String print(Number number, Locale locale) {
		return getNumberFormat(locale).format(number);
	}


	@Override
	public Number parse(String text, Locale locale) throws ParseException {
		// 僞代碼,核心邏輯就這一句
		return getNumberFormat.parse(text, new ParsePosition(0));
	}

	// 得到一個NumberFormat實例
	protected abstract NumberFormat getNumberFormat(Locale locale);
	...
}

這和DateFormatter的實現模式何其相似,簡直一模一樣:底層實現依賴於(委託給)java.text.NumberFormat去完成。

此抽象類共有三個具體實現:

  • NumberStyleFormatter:數字格式化,如小數,分組等
  • PercentStyleFormatter:百分數格式化
  • CurrencyStyleFormatter:錢幣格式化

數字格式化

NumberStyleFormatter使用NumberFormat的數字樣式的通用數字格式化程序。可定製化參數爲:pattern。核心源碼如下:

NumberStyleFormatter:

	@Override
	public NumberFormat getNumberFormat(Locale locale) {
		NumberFormat format = NumberFormat.getInstance(locale);
		...
		// 解析時,永遠返回BigDecimal類型
		decimalFormat.setParseBigDecimal(true);
		// 使用格式化模版
		if (this.pattern != null) {
			decimalFormat.applyPattern(this.pattern);
		}
		return decimalFormat;
	}

代碼示例:

@Test
public void test2() throws ParseException {
    NumberStyleFormatter formatter = new NumberStyleFormatter();

    double myNum = 1220.0455;
    System.out.println(formatter.print(myNum, Locale.getDefault()));

    formatter.setPattern("#.##");
    System.out.println(formatter.print(myNum, Locale.getDefault()));

    // 轉換
    // Number parsedResult = formatter.parse("1,220.045", Locale.getDefault()); // java.text.ParseException: 1,220.045
    Number parsedResult = formatter.parse("1220.045", Locale.getDefault());
    System.out.println(parsedResult.getClass() + "-->" + parsedResult);
}

運行程序,輸出:

1,220.045
1220.05

class java.math.BigDecimal-->1220.045
  1. 可通過setPattern()指定數字格式化的模版(一般建議顯示指定)
  2. parse()方法返回的是BigDecimal類型,從而保證了數字精度

百分數格式化

PercentStyleFormatter表示使用百分比樣式去格式化數字。核心源碼(其實是全部源碼)如下:

PercentStyleFormatter:

	@Override
	protected NumberFormat getNumberFormat(Locale locale) {
		NumberFormat format = NumberFormat.getPercentInstance(locale);
		if (format instanceof DecimalFormat) {
			((DecimalFormat) format).setParseBigDecimal(true);
		}
		return format;
	}

這個就更簡單啦,pattern模版都不需要指定。代碼示例:

@Test
public void test3() throws ParseException {
    PercentStyleFormatter formatter = new PercentStyleFormatter();

    double myNum = 1220.0455;
    System.out.println(formatter.print(myNum, Locale.getDefault()));

    // 轉換
    // Number parsedResult = formatter.parse("1,220.045", Locale.getDefault()); // java.text.ParseException: 1,220.045
    Number parsedResult = formatter.parse("122,005%", Locale.getDefault());
    System.out.println(parsedResult.getClass() + "-->" + parsedResult);
}

運行程序,輸出:

122,005%
class java.math.BigDecimal-->1220.05

百分數的格式化不能指定pattern,差評。

錢幣格式化

使用錢幣樣式格式化數字,使用java.util.Currency來描述貨幣。代碼示例:

@Test
public void test3() throws ParseException {
    CurrencyStyleFormatter formatter = new CurrencyStyleFormatter();

    double myNum = 1220.0455;
    System.out.println(formatter.print(myNum, Locale.getDefault()));

    System.out.println("--------------定製化--------------");
    // 指定貨幣種類(如果你知道的話)
    // formatter.setCurrency(Currency.getInstance(Locale.getDefault()));
    // 指定所需的分數位數。默認是2
    formatter.setFractionDigits(1);
    // 舍入模式。默認是RoundingMode#UNNECESSARY
    formatter.setRoundingMode(RoundingMode.CEILING);
    // 格式化數字的模版
    formatter.setPattern("#.#¤¤");

    System.out.println(formatter.print(myNum, Locale.getDefault()));

    // 轉換
    // Number parsedResult = formatter.parse("¥1220.05", Locale.getDefault());
    Number parsedResult = formatter.parse("1220.1CNY", Locale.getDefault());
    System.out.println(parsedResult.getClass() + "-->" + parsedResult);
}

運行程序,輸出:

¥1,220.05
--------------定製化--------------
1220.1CNY
class java.math.BigDecimal-->1220.1

值得關注的是:這三個實現在Spring 4.2版本之前是“耦合”在一起。直到4.2才拆開,職責分離。

✍總結

本文介紹了Spring的Formatter抽象,讓格式化器大一統。這就是Spring最強能力:API設計、抽象、大一統。

Converter可以從任意源類型,轉換爲任意目標類型。而Formatter則是從String類型轉換爲任務目標類型,有點類似PropertyEditor。可以感覺出Converter是Formater的超集,實際上在Spring中Formatter是被拆解成PrinterConverter和ParserConverter,然後再註冊到ConverterRegistry,供後續使用。

關於格式化器的註冊中心、註冊員,這就是下篇文章內容嘍,歡迎保持持續關注。

♨本文思考題♨

看完了不一定懂,看懂了不一定記住,記住了不一定掌握。來,文末3個思考題幫你覆盤:

  1. Spring爲何沒有針對JSR310時間類型提供專用轉換器實現?
  2. Spring內建衆多Formatter實現,如何管理?
  3. 格式化器Formatter和轉換器Converter是如何整合到一起的?

♚聲明♚

本文所屬專欄:Spring類型轉換,公號後臺回覆專欄名即可獲取全部內容。

分享、成長,拒絕淺藏輒止。關注公衆號【BAT的烏托邦】,回覆關鍵字專欄有Spring技術棧、中間件等小而美的原創專欄供以免費學習。本文已被 https://www.yourbatman.cn 收錄。

本文是 A哥(YourBatman) 原創文章,未經作者允許不得轉載,謝謝合作。

☀推薦閱讀☀

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章