【重写SpringFramework】格式化器(chapter 3-13)

119 阅读6分钟

1. 前言

在日常使用中,会经常碰到时间日期、数值、金额等类型的数据,它们有一个共同的特点,即同一个值往往有多种表现方式。就拿时间日期来说,不同国家和地区的表达习惯是不一样的,中国是年月日,大单位在前,小单位在后。西方是日月年,小单位在前而大单位在后。即便是同一种语言习惯,具体的表达方式也不尽相同,比如 2022 年 7 月 8 日,2022-07-08,2022/07/08。

时间日期在 Java 中使用 longDate 等类型表示,当转换为字符串之后,却有多种多样的表现形式。这不仅是一个类型转换成另一个类型的问题,而是与文本内容的显示风格(style)有关。转换器的主要功能是不同类型之间的转换,我们需要一种新的组件聚焦表达方式的多样性,而这正是格式化器(formatter)的用武之地。

2. 格式化器

2.1 概述

格式化是指将指定类型转换成字符串,以文本的方式进行展示。这意味着对同一个原始值来说,以不同的文本形式进行展示,这是一种一对多的关系java.text 包定义了常见类型的格式化类,比如 DateFormatNumberFormatMessageFormat等。Spring 的格式化器实际上是一个包装类,底层逻辑主要由 Java 的格式化工具类完成。对于转换器(Converter)来说,两个类型之间的单向转换,因此类名必须写明 A 转换到 B。格式化器不用这么麻烦,转换的一端固定是字符串,因此只需言明另一端的类型即可,比如 DateFormatter 表示 Date 与字符串的转换。

13.1 格式化器示意图.png

Formatter 接口定义了格式化器的两个主要工作,其中 parse 表示将字符串解析为指定的格式(泛型 T),print 方法表示将指定的数据转换成字符串形式。从这里可以看到,格式化器和转换器有两个明显的区别,如下所示:

  • 从数据流动方面来看,Converter 是单向的,Formatter 是双向的。

  • 从转换类型方面来看,Converter 是两个类型之间的转换,Formatter 则是一个类型与字符串(固定类型)之间的转换。

public interface Formatter <T>{
    T parse(String text, Locale locale) throws ParseException;
    String print(T object, Locale locale);
}

2.2 DateFormatter

DateFormatter 类的 parse 方法和 print 方法都是调用 DateFormat 的相关方法,因此我们只需要关心 DateFormat 对象是如何创建的。getDateFormat 方法实现了三种创建方式,如下所示:

  1. 使用指定的 pattern,比如 yyyy-MM-dd HH:mm:ss

  2. 通过 ISO 枚举间接指定 pattern,仅支持三种 pattern。其中 yyyy-MM-dd 表示日期,HH:mm:ss.SSSZ 表示时间,yyyy-MM-dd'T'HH:mm:ss.SSSZ 表示日期时间。

  3. 通过 style 字段指定 DateFormat 的相关样式,详情请查阅相关 API。

public class DateFormatter implements Formatter<Date> {
    private String pattern;
    private int style = DateFormat.DEFAULT;
    private ISO iso;

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

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

    protected DateFormat getDateFormat(Locale locale) {
        //使用指定的pattern
        if (StringUtils.hasLength(this.pattern)) {
            return new SimpleDateFormat(this.pattern, locale);
        }

        //通过IOS枚举指定pattern
        if (this.iso != null && this.iso != ISO.NONE) {
            String pattern = ISO_PATTERNS.get(this.iso);
            if (pattern == null) {
                throw new IllegalStateException("Unsupported ISO format " + this.iso);
            }
            SimpleDateFormat format = new SimpleDateFormat(pattern, locale);
            format.setTimeZone(UTC);
            return format;
        }
        //指定DateFormat的样式,默认为MEDIUM
        return DateFormat.getDateInstance(this.style, locale);
    }
}

2.3 NumberStyleFormatter

与时间日期格式化的逻辑类似,数值格式化也是把相关操作委托给 Java 原生的 NumberFormat 处理,getNumberFormat 方法实现了两种格式化器。其中 NumberFormat 用于处理普通的数值,DecimalFormat 可以处理特殊的数值,比如使用科学计数法表示的数值。

public class NumberStyleFormatter implements Formatter<Number> {
    @Override
    public Number parse(String text, Locale locale) throws ParseException {
        return getNumberFormat().parse(text);
    }

    @Override
    public String print(Number number, Locale locale) {
        return getNumberFormat().format(number);
    }

    private NumberFormat getNumberFormat(Locale locale) {
        //处理普通的数值
        NumberFormat format = NumberFormat.getInstance(locale);
        if (!(format instanceof DecimalFormat)) {
            if (this.pattern != null) {
                throw new IllegalStateException("Cannot support pattern for non-DecimalFormat: " + format);
            }
            return format;
        }

        //处理Decimal数值
        DecimalFormat decimalFormat = (DecimalFormat) format;
        decimalFormat.setParseBigDecimal(true);
        if (this.pattern != null) {
            decimalFormat.applyPattern(this.pattern);
        }
        return decimalFormat;
    }
}

2.4 CurrencyStyleFormatter

除了被用来计算的数字之外,还有一种经常会用到的数值类型,就是货币的金额。货币金额与普通数字的区别在于,除了表示面额的数字外,还有表示币种的特殊字符。比如 ¥ 表示人民币,$ 表示美元,€ 表示欧元,£ 表示英镑,因此货币金额特别强调本地化(Locale)。CurrencyStyleFormatter 用于对货币金额进行转换,包括四个属性:

  • fractionDigits 表示小数位数,默认保留两位小数
  • roundingMode 表示如何取近似值,比如四舍五入、截断等
  • currency 表示货币的种类
  • pattern 表示对应字符串的格式
public class CurrencyStyleFormatter implements Formatter<Number> {
    private int fractionDigits = 2;		//小数位数
    private RoundingMode roundingMode;	//近似值模式
    private Currency currency;			//货币
    private String pattern;				//指定格式
}

parseprint方法都需要调用 getNumberFormat 方法来获取 NumberFormat 实例。需要注意的是,getNumberFormat 方法创建的是 NumberFormat 的子类DecimalFormat,用来精确地表示浮点数),且必须指定 Locale 参数。然后还要对实例进行设置,比如指定保留几位小数(默认是 2 位);近似值的取值模式,直接截断还是四舍五入等;还可以通过 Currency 指定币种,或者直接指定 pattern

public class CurrencyStyleFormatter implements Formatter<Number> {

    @Override
    public Number parse(String text, Locale locale) throws ParseException {
        BigDecimal decimal = (BigDecimal) getNumberFormat(locale).parse(text);
        if (decimal != null) {
            if (this.roundingMode != null) {
                decimal = decimal.setScale(this.fractionDigits, this.roundingMode);
            }
            else {
                decimal = decimal.setScale(this.fractionDigits);
            }
        }
        return decimal;
    }


    @Override
    public String print(Number number, Locale locale) {
        return getNumberFormat(locale).format(number);
    }


    //创建一个可以处理Decimal的格式化类的实例
    protected NumberFormat getNumberFormat(Locale locale) {
        DecimalFormat format = (DecimalFormat) NumberFormat.getCurrencyInstance(locale);
        format.setParseBigDecimal(true);
        format.setMaximumFractionDigits(this.fractionDigits);
        format.setMinimumFractionDigits(this.fractionDigits);
        if (this.roundingMode != null) {
            format.setRoundingMode(this.roundingMode);
        }
        if (this.currency != null) {
            format.setCurrency(this.currency);
        }
        if (this.pattern != null) {
            format.applyPattern(this.pattern);
        }
        return format;
    }
}

3. 转换服务

3.1 概述

格式化器单独使用的意义并不大,Java 提供的各种格式化工具类就可以胜任这一工作。格式化器真正的用武之地是作为转换器的补充,依托 Spring 的转换服务,提供强大的类型转换功能。整合过程分为三步:

  1. 将 JDK 格式化类包装成 Spring 格式化器。
  2. Spring 格式化器被进一步包装成 Converter 对象。由于格式化器是双向转换,而 Converter 是单向转换,因此一个格式化器对应两个 Converter
  3. Converter 注册到 FormattingConversionService 的转换器列表中,以类型转换的方式来提供格式化的服务。

13.2 ConversionService整合Formatter.png

从类图中可以看到,FormattingConversionService 作为核心类,继承了已有的转换服务,并拥有注册格式化器的能力。

13.3 格式化转换服务类图.png

3.2 FormatterRegistry

FormatterRegistry 接口继承了 ConverterRegistry 接口,定义了添加格式化器的方法。

public interface FormatterRegistry extends ConverterRegistry {
    void addFormatter(Formatter<?> formatter);
}

3.3 FormattingConversionService

FormattingConversionService 除了用于转换器的功能之外,还新增了格式化的功能。格式化器是包装成转换器来使用的,由于格式化器是双向的,而转换器是单向的,因此 addFormatter 方法的实质是添加了两个对应的转换器来进行适配。这两个转换器都是 FormattingConversionService 的内部类,各自负责一个方向的转换。

  • PrinterConverter 表示将指定类型转成字符串,底层调用了格式化器的 print 方法
  • ParserConverter 表示将字符串转成指定类型,底层调用了格式化器的 parse 方法
public class FormattingConversionService extends GenericConversionService implements FormatterRegistry {

    @Override
    public void addFormatter(Formatter<?> formatter) {
        Class<?> fieldType = GenericTypeResolver.resolveTypeArgument(formatter.getClass(), Formatter.class);
        addConverter(new PrinterConverter(fieldType, formatter));
        addConverter(new ParserConverter(fieldType, formatter));
    }
}

PrinterConverter 实现了 GenericConverter 接口,getConvertibleTypes 方法的作用是判断转换前后的两个类型,converter 方法是真正的转换逻辑,实际上调用了格式化器的 print 方法。ParserConverter 的逻辑是类似的,这里不展开介绍,详情参考代码。

//内部类
private static class PrinterConverter implements GenericConverter {
    private final Class<?> fieldType;
    private final Formatter formatter;


    public PrinterConverter(Class<?> fieldType, Formatter<?> formatter) {
        this.fieldType = fieldType;
        this.formatter = formatter;
    }

    @Override
    public Set<ConvertiblePair> getConvertibleTypes() {
        return Collections.singleton(new ConvertiblePair(this.fieldType, String.class));
    }

    @Override
    @SuppressWarnings("unchecked")
    public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) {
        if (source == null) {
            return "";
        }
        return this.formatter.print(source, Locale.getDefault());
    }
}

//ParserConverter,实现略

4. 测试

4.1 时间日期格式化

//测试方法
@Test
public void testDateFormatter() throws ParseException {
    //指定pattern
    Locale locale = Locale.getDefault();
    DateFormatter patternFt = new DateFormatter("yyyy/MM/dd");
    System.out.println("指定pattern -> 编码:" + patternFt.print(new Date(), locale));
    System.out.println("指定pattern -> 解码:" + patternFt.parse("2022/08/08", locale));

    //指定ISO枚举
    DateFormatter isoFt = new DateFormatter();
    isoFt.setIso(DateFormatter.ISO.TIME);
    System.out.println("指定ISO类型 -> 编码:" + isoFt.print(new Date(), locale));
    System.out.println("指定ISO类型 -> 解码:" + isoFt.parse("12:05:38.148+0800", locale));

    //使用默认的DateFormat格式,即yyyy-mm-dd
    DateFormatter defaultFt = new DateFormatter();
    System.out.println("默认格式 -> 编码:" + defaultFt.print(new Date(), locale));
    System.out.println("默认格式 -> 解码:" + defaultFt.parse("2022-8-16", locale));
}

测试结果分为三组,

指定pattern -> 编码:2023/05/30
指定pattern -> 解码:Mon Aug 08 00:00:00 CST 2022
指定ISO类型 -> 编码:01:45:47.720+0000
指定ISO类型 -> 解码:Thu Jan 01 12:05:38 CST 1970
默认格式 -> 编码:2023-5-30
默认格式 -> 解码:Tue Aug 16 00:00:00 CST 2022

4.2 数值格式化

将字符串转为数值,实际类型是 BigDecimal。将数值类型转换为字符串,原始的数字可以表示为科学计数法。

//测试方法
@Test
public void testNumberStyleFormatter() throws ParseException {
    Locale locale = Locale.getDefault();
    NumberStyleFormatter formatter = new NumberStyleFormatter();
    System.out.println("字符串转Decimal:" + formatter.parse("1,138.04", locale));
    System.out.println("Decimal转字符串:" + formatter.print(new BigDecimal("1.23E+3"), locale));
}

从测试结果可以看到,字符串和 BigDecimal 可以互相转换。

字符串转数值:1138.04
Decimal转字符串:1,230

4.3 货币金额格式化

首先创建一个 CurrencyStyleFormatter 实例,在进行解析的时候要指定正确的 Locale 参数,否则会抛出 ParseException 异常。

//测试方法
@Test
public void testCurrencyStyleFormatter() throws ParseException {
    CurrencyStyleFormatter formatter = new CurrencyStyleFormatter();
    //人民币
    Number cnPrice = formatter.parse("¥999.26", Locale.SIMPLIFIED_CHINESE);
    System.out.println("人民币: " + cnPrice);

    //美元
    Number usPrice = formatter.parse("$11,050.26", Locale.US);
    System.out.println("美元: " + usPrice);

    //欧元(德国)
    Number dePrice = formatter.parse("128 €", Locale.GERMANY);
    System.out.println("欧元(德国):" + dePrice);

    //英镑
    Number ukPrice = formatter.parse("£588.09", Locale.UK);
    System.out.println("英镑:" + ukPrice);
}

从测试结果可以看到,不同国家的货币得到了正确的显示。

人民币: 999.26
美元: 11050.26
欧元(德国):128.00
英镑:588.09

4.4 格式化转换服务

在测试方法中,创建了一个 FormattingConversionService 实例,添加了两个格式化器 DateFormatterNumberStyleFormatter。实际调用的不再是格式化器的 parseprint 方法,而是转换器的 convert 方法。

//测试方法
@Test
public void testFormattingConversionService(){
    FormattingConversionService conversionService = new FormattingConversionService();
    conversionService.addFormatter(new DateFormatter("yyyy/MM/dd"));
    conversionService.addFormatter(new NumberStyleFormatter());

    //调用convert方法,以转换器的方式使用格式化器
    String dateStr = conversionService.convert(new Date(), String.class);
    String numberStr = conversionService.convert(new BigDecimal("1.23E+3"), String.class);
    System.out.println("日期格式化转换器: " + dateStr);
    System.out.println("数值格式化转换器: " + numberStr);
}

从测试结果可以看到,日期和数值的格式化执行成功,说明格式化器都被包装成了转换器。

日期格式化转换器: 2023/05/30
数值格式化转换器: 1,230

5. 总结

格式化器的主要作用是将某一类型的数据转换成字符串,并以不同的方式予以呈现。本节介绍了三种常见的格式化器,其中 DateFormatter 用于时间日期的格式化,NumberStyleFormatter 用于数值类型的格式化,CurrencyStyleFormatter 用于货币金额的格式化,是数值类型的一个细分类别。它们的共同特点是以 Java 提供的格式化类为基础,因此格式化器是一个包装类。

一般来说,格式化器不是用来单独使用的,而是作为 Spring 已有的类型转换功能的补充,先适配成转换器类,然后集成到转换服务中。FormattingConversionService 就是一个拥有了格式化功能的转换服务类,该类又作为 TypeConverter 的一部分,对外提供通用的类型转换服务。

6. 项目信息

新增修改一览,新增(7),修改(0)。

context
└─ src
   ├─ main
   │  └─ java
   │     └─ cn.stimd.spring
   │        └─ format
   │           ├─ datetime
   │           │  └─ DateFormatter.java (+)
   │           ├─ number
   │           │  ├─ CurrencyStyleFormatter.java (+)
   │           │  └─ NumberStyleFormatter.java (+)
   │           ├─ support
   │           │  └─ FormattingConversionService.java (+)
   │           ├─ Formatter.java (+)
   │           └─ FormatterRegistry.java (+)
   └─ test
      └─ java
         └─ context
            └─ data
               └─ FormatterTest.java (+)

注:+号表示新增、*表示修改

注:项目的 master 分支会跟随教程的进度不断更新,如果想查看某一节的代码,请选择对应小节的分支代码。


欢迎关注公众号【Java编程探微】,加群一起讨论。

原创不易,觉得内容不错请关注、点赞、收藏。