写点什么

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

用户头像
YourBatman
关注
发布于: 2021 年 01 月 11 日
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 用于控制国际化

@FunctionalInterfacepublic interface Printer<T> {	// 将Object写为String类型	String print(T object, Locale locale);}
复制代码
  • Parser:解析接口。将 String 类型转到 T 类型,Locale 用于控制国际化。

@FunctionalInterfacepublic 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.0public 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 它们的优先级谁先谁后,一看便知。


代码示例
@Testpublic 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 协议层可以使用自己单独的一套转换模版机制

  1. 日期时间模版不要在每次使用时去临时创建,而是集中统一创建好管理起来(比如放 IoC 容器内),这样维护起来方便很多


说明:DateTimeFormatterFactoryBean这个 API 在 Spring 内部并未使用,这是 Spring 专门给使用者用的,因为 Spring 也希望你这么去做从而把日期时间格式化模版管理起来


代码示例
@Testpublic 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:442020-12-2622:44:442020-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; }
复制代码


代码示例:

@Testpublic 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.0451220.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 模版都不需要指定。代码示例:

@Testpublic 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来描述货币。代码示例:

@Testpublic 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.1CNYclass 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) 原创文章,未经作者允许不得转载,谢谢合作。


☀推荐阅读☀



发布于: 2021 年 01 月 11 日阅读数: 76
用户头像

YourBatman

关注

分享、成长,拒绝浅尝辄止。 2018.01.21 加入

分享、成长,拒绝浅尝辄止。公众号:BAT的乌托邦

评论 (1 条评论)

发布
用户头像
A哥 YourBatman BAT的乌托邦
2021 年 01 月 11 日 06:38
回复
没有更多了
8. 格式化器大一统 -- Spring的Formatter抽象