【基础数据篇】数据格式化妆师:Formatter模式

1. 前言

在软件系统中,数据始终存在两种形态:内部存储形态外部展示形态

试想一下这些场景:

  • 数据库中存储的时间戳 1640995200000,在用户界面上需要显示为 "2024年1月1日"
  • 内部计算的金额 19.9(浮点数),在发票上需要格式化为 "¥19.90"
  • 用于逻辑判断的枚举值 UserStatus.ACTIVE,在日志中需要输出为清晰的 "活跃用户"

如果将这些格式化逻辑散落、硬编码在业务代码的各个角落,会导致一系列问题:代码重复、难以维护、本地化支持困难,并且破坏了单一职责原则------业务逻辑类不该关心数据该如何显示。

Formatter(格式化器)模式正是为了解决这一问题而生的。它扮演着一位专业的"化妆师",将数据的"素颜"(内部格式)根据场景需求,优雅地转换为得体的"妆容"(外部格式),从而实现数据表现与业务逻辑的彻底解耦。

2. 定义

Formatter 模式是一种行为设计模式,它旨在将对象的数据转换为其适合人类阅读或特定协议传输的字符串表示形式,并将字符串解析回对象。该模式的核心是将格式化与解析的逻辑封装在专用的对象中。

该模式通常涉及两个核心操作:

  1. 格式化: 将对象 T 转换为字符串 String
  2. 解析: 将字符串 String 还原为对象 T
text 复制代码
核心思想: Formatter 模式的精髓在于分离关注点。它承认"数据是什么"和"数据如何展示"是两个截然不同的问题。通过引入一个专门的格式化器角色:
1. 领域对象可以保持纯净,只关注自身的业务属性和行为。
2. 格式化逻辑被集中管理和复用,易于统一修改和扩展。
3. 客户端代码无需关心格式化的细节,只需委托给合适的格式化器即可。

3. 应用

Formatter 模式的应用场景无处不在,特别是在需要与用户交互或进行系统间通信的地方。

3.1 场景一:日期时间格式化

这是最经典的应用。根据用户所在的地区显示不同格式的日期。

java 复制代码
// 内部统一使用 java.time 对象
LocalDateTime now = LocalDateTime.now();

// 应用不同的"化妆师"
DateTimeFormatter chinaFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss", Locale.CHINA);
DateTimeFormatter usFormatter = DateTimeFormatter.ofPattern("MM/dd/yyyy hh:mm a", Locale.US);

String dateForChina = now.format(chinaFormatter); // "2024年01月01日 14:30:00"
String dateForUS = now.format(usFormatter);       // "01/01/2024 02:30 PM"

3.2 场景二:数字与货币格式化

自动处理千分位、小数位数和货币符号。

java 复制代码
double price = 12345.6789;

NumberFormat numberFormat = NumberFormat.getNumberInstance(Locale.GERMANY);
String formattedNumber = numberFormat.format(price); // "12.345,679" - 德国使用点作为千分位,逗号作为小数点

NumberFormat currencyFormat = NumberFormat.getCurrencyInstance(Locale.CHINA);
String formattedCurrency = currencyFormat.format(price); // "¥12,345.68" - 自动四舍五入到两位小数并添加货币符号

3.3 场景三:自定义对象格式化

为领域对象定义专用的格式化器。

java 复制代码
public class Product {
    private String name;
    private BigDecimal price;
    // ... getters
}

// 自定义格式化器
public class ProductFormatter implements Formatter<Product> {
    @Override
    public String print(Product product, Locale locale) {
        return String.format("%s - %s", 
            product.getName(), 
            NumberFormat.getCurrencyInstance(locale).format(product.getPrice()));
    }

    @Override
    public Product parse(String text, Locale locale) throws ParseException {
        // 实现从字符串解析回Product对象的逻辑
        // ... 
    }
}

// 使用
Product product = new Product("笔记本电脑", new BigDecimal("5999.99"));
ProductFormatter formatter = new ProductFormatter();
String displayText = formatter.print(product, Locale.CHINA); // "笔记本电脑 - ¥5,999.99"

4. 开源代码解析

Formatter 模式在众多开源框架中有着至关重要且优雅的实现。

4.1 DateTimeFormatter:时间格式化标准实现

Java 8 引入的日期时间 API 是 Formatter 模式的典范,DateTimeFormatter 的核心是一个不可变对象,它通过组合模式将格式化的步骤分解为不同的职责单元。


DateTimeFormatter 的核心状态由以下几个 final 变量定义,确保了其不可变性和线程安全。

java 复制代码
public final class DateTimeFormatter {
	// 1. 格式/解析引擎:承载了模式字符串编译后的指令集,是执行格式化和解析操作的核心。
	private final CompositePrinterParser printerParser;
	
	// 2. 地域信息:控制文本(如月份、星期)的显示语言和地区相关的计算规则(如一周的第一天)。
	private final Locale locale;
	
	// 3. 数字样式:控制数字相关符号,如小数点、正负号等。
	private final DecimalStyle decimalStyle;
	
	// 4. 解析风格:决定解析时的严格程度,如STRICT(严格)、SMART(智能调整)、LENIENT(宽松计算)。
	private final ResolverStyle resolverStyle;
	
	// 5. 需解析字段集:用于优化性能,指定需要解析和验证的字段集合;为null时表示处理所有字段。
	private final Set<TemporalField> resolverFields;
	
	// 6. 年表(历法):定义日期背后的日历系统,如ISO、ThaiBuddhist等;为null时使用默认ISO历法。
	private final Chronology chrono;
	
	// 7. 默认时区:当被格式化的对象不含时区信息时,使用此时区进行补充;为null则表示不覆盖。
	private final ZoneId zone;
}

第一步:入口方法 format(TemporalAccessor temporal)

java 复制代码
/**  
 * Formats a date-time object using this formatter. 
 * This formats the date-time to a String using the rules of the formatter. 
 * @param temporal  the temporal object to format, not null  
 * @return the formatted string, not null 
 * @throws DateTimeException if an error occurs during formatting  
 */public String format(TemporalAccessor temporal) {  
    StringBuilder buf = new StringBuilder(32);  
    formatTo(temporal, buf);  
    return buf.toString();  
}
  • 目的:提供最简洁的API,接受一个日期时间对象,返回格式化后的字符串。
  • 动作 :创建一个 StringBuilder 作为结果容器,然后调用重载的 formatTo 方法。

第二步:准备上下文 formatTo(TemporalAccessor temporal, Appendable appendable)

java 复制代码
/**  
 * Formats a date-time object to an {@code Appendable} using this formatter. 
 * This outputs the formatted date-time to the specified destination. 
 * {@link Appendable} is a general purpose interface that is implemented by all  
 * key character output classes including {@code StringBuffer}, {@code StringBuilder}, 
 * {@code PrintStream} and {@code Writer}. 
 * Although {@code Appendable} methods throw an {@code IOException}, this method does not. 
 * Instead, any {@code IOException} is wrapped in a runtime exception. 
 * @param temporal  the temporal object to format, not null  
 * @param appendable  the appendable to format to, not null  
 * @throws DateTimeException if an error occurs during formatting  
 */
 public void formatTo(TemporalAccessor temporal, Appendable appendable) {  
    Objects.requireNonNull(temporal, "temporal");  
    Objects.requireNonNull(appendable, "appendable");  
    try {  
        DateTimePrintContext context = new DateTimePrintContext(temporal, this);  
        if (appendable instanceof StringBuilder) {  
            printerParser.format(context, (StringBuilder) appendable);  
        } else {  
            // buffer output to avoid writing to appendable in case of error  
            StringBuilder buf = new StringBuilder(32);  
            printerParser.format(context, buf);  
            appendable.append(buf);  
        }  
    } catch (IOException ex) {  
        throw new DateTimeException(ex.getMessage(), ex);  
    }  
}
  • 目的:初始化格式化操作所需的上下文环境。
  • 动作 :创建 DateTimePrintContext 对象
    • 要格式化的目标对象 (temporal)
    • 当前格式化器 (this) 的配置信息,如 locale, decimalStyle 等。

第三步:引擎执行 printerParser.format(context, appendable)

这里的 printerParserCompositePrinterParser 的一个实例。

CompositePrinterParser 是什么?

java 复制代码
static final class CompositePrinterParser implements DateTimePrinterParser {  
    private final DateTimePrinterParser[] printerParsers;  
    private final boolean optional;
}

当使用 DateTimeFormatter.ofPattern("yyyy-MM-dd") 时,这个模式字符串会被解析并编译成三个子处理器(PrinterParser):

  1. 一个用于处理4位年份的 NumberPrinterParser
  2. 一个用于输出字面量 '-'StringLiteralPrinterParser
  3. 一个用于处理2位月份的 NumberPrinterParser ... 以此类推。

format 方法在组合处理器内部:

java 复制代码
@Override  
public boolean format(DateTimePrintContext context, StringBuilder buf) {  
    int length = buf.length();  
    if (optional) {  
        context.startOptional();  
    }  
    try {  
        for (DateTimePrinterParser pp : printerParsers) {  
            if (pp.format(context, buf) == false) {  
                buf.setLength(length);  // reset buffer  
                return true;  
            }  
        }  
    } finally {  
        if (optional) {  
            context.endOptional();  
        }  
    }  
    return true;  
}
  • 目的 :按顺序执行组合处理器(CompositePrinterParser)中的所有子处理器(printerParsers),并确保在遇到失败时,如果该组合是一个可选节,能够正确地回滚状态,避免输出部分结果。
  • 补充 :如果这个 CompositePrinterParser 被标记为 optional(例如,由 DateTimeFormatterBuilder.optionalStart() 创建),则通知格式化上下文(DateTimePrintContext)开始一个可选节。在"可选节"内,pp.format(context, buf)这段代码当从 TemporalAccessor 查询字段值时,如果该字段不存在,可能会返回一个"失败"状态,而不是直接抛出异常。这为后续的回滚判断提供了依据。

第四步:结合代码示例回顾上面的流程

java 复制代码
// 1. 创建一个包含可选节的格式化器
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM['XX']");
// 2. 格式化一个LocalDate(没有"XX"需要的数据)
String result = formatter.format(LocalDate.of(2024, 5, 24));
System.out.println(result); // 输出: 2024-05
  1. 初始化 printerParsers 列表 模式 "yyyy-MM['XX']" 被编译成如下结构:
java 复制代码
printerParsers = [
    NumberPrinterParser(YEAR),    // 处理 yyyy
    StringLiteralPrinterParser("-"), // 处理 -
    NumberPrinterParser(MONTH),   // 处理 MM
    CompositePrinterParser([      // 处理 ['XX'],标记为 optional=true
        StringLiteralPrinterParser("XX")
    ], optional=true)
]
  1. 格式化执行过程
    • 前三个处理器正常执行,buf 变为 "2024-05"
    • 执行可选节 ['XX']
      • 记录起点:length = 8("2024-05"的长度)
      • 执行子处理器 StringLiteralPrinterParser("XX")
        • 直接向 buf 追加 "XX"buf 变为 "2024-05XX"
        • 返回 true
    • 循环结束,返回 true
  2. 最终结果:"2024-05XX"

4.2 PatternLayout: 日志系统中的Formatter

在主流日志框架(如 Logback、Log4j 2)中,负责格式化的组件不叫 Formatter,而叫 LayoutFormatter,它的职责就是将一个日志事件(Log Event)转换成一条具体的日志字符串。


第一步:SLF4J使用方式

java 复制代码
// 典型的SLF4J使用方式
logger.info("订单{}创建成功,金额:{}", orderId, amount);

logger.info(String format, Object arg1, Object arg2) -> 实际调用的是底层实现(如 Logback)的 Logger 实现类。但在此之前,SLF4J 的门面层会先进行一个关键预处理:消息格式化


第二步:SLF4J 的参数化消息格式化

SLF4J 不会直接将原始消息和参数传递给底层实现。它首先使用 org.slf4j.helpers.MessageFormatter 来将模板和参数合并成一个完整的字符串。

java 复制代码
// 在 Logback 的 Logger 实现中(如 ch.qos.logback.classic.Logger)
public void info(String format, Object arg1, Object arg2) {
    if (!isInfoEnabled()) return;
    // 关键调用:格式化消息
    FormattingTuple ft = MessageFormatter.arrayFormat(format, new Object[]{arg1, arg2});
    // 然后调用过滤、追加等核心逻辑
    filterAndLog_0_Or3Plus(null, Level.INFO, null, ft.getMessage(), ft.getThrowable());
}

MessageFormatter.arrayFormat:这个方法的核心是循环处理每个 {} 占位符,并将对应的参数值"格式化"成字符串后替换进去。这里的"格式化"非常朴素:对于非空参数,直接调用其 toString() 方法。orderIdamounttoString() 方法被调用,结果被拼接到字符串中。

java 复制代码
 public static final FormattingTuple arrayFormat(String messagePattern, Object[] argArray) {  
    Throwable throwableCandidate = getThrowableCandidate(argArray);  
    Object[] args = argArray;  
    if (throwableCandidate != null) {  
        args = trimmedCopy(argArray);  
    }  
  
    return arrayFormat(messagePattern, args, throwableCandidate);  
}

public static final FormattingTuple arrayFormat(String messagePattern, Object[] argArray, Throwable throwable) {  
    if (messagePattern == null) {  
        return new FormattingTuple((String)null, argArray, throwable);  
    } else if (argArray == null) {  
        return new FormattingTuple(messagePattern);  
    } else {  
        int i = 0;  
        StringBuilder sbuf = new StringBuilder(messagePattern.length() + 50);  
  
        for(int L = 0; L < argArray.length; ++L) {  
            int j = messagePattern.indexOf("{}", i);  
            if (j == -1) {  
                if (i == 0) {  
                    return new FormattingTuple(messagePattern, argArray, throwable);  
                }  
  
                sbuf.append(messagePattern, i, messagePattern.length());  
                return new FormattingTuple(sbuf.toString(), argArray, throwable);  
            }  
  
            if (isEscapedDelimeter(messagePattern, j)) {  
                if (!isDoubleEscaped(messagePattern, j)) {  
                    --L;  
                    sbuf.append(messagePattern, i, j - 1);  
                    sbuf.append('{');  
                    i = j + 1;  
                } else {  
                    sbuf.append(messagePattern, i, j - 1);  
                    deeplyAppendParameter(sbuf, argArray[L], new HashMap());  
                    i = j + 2;  
                }  
            } else {  
                sbuf.append(messagePattern, i, j);  
                deeplyAppendParameter(sbuf, argArray[L], new HashMap());  
                i = j + 2;  
            }  
        }  
  
        sbuf.append(messagePattern, i, messagePattern.length());  
        return new FormattingTuple(sbuf.toString(), argArray, throwable);  
    }  
}

第三步:构建日志事件 (ILoggingEvent) 并交由 Logback 的 Layout

第二步结束时,我们停在了 Logback 的 Logger 实现类中的 filterAndLog_0_Or3Plus 方法。这个方法名看起来很复杂,但它本质上是日志记录的核心路由方法。

java 复制代码
// 在 ch.qos.logback.classic.Logger 中
// 这是一个处理日志事件的核心方法
private void filterAndLog_0_Or3Plus(String localFQCN, Level level, Marker marker, String msg, Object[] params, Throwable t) {

    // 检查过滤器链,如果被拒绝则直接返回
    FilterReply decision = loggerContext.getTurboFilterChainDecision_0_3OrMore(marker, this, level, msg, params, t);
    if (decision == FilterReply.DENY) {
        return;
    }

    // 最关键的一行:构建日志事件 (ILoggingEvent)
    LoggingEvent loggingEvent = new LoggingEvent(localFQCN, this, level, msg, t, params);

    // 继续后续的日志记录流程
    if (decision != FilterReply.NEUTRAL) {
        // 如果过滤器明确接受,则直接记录,跳过级别检查
        log(null, localFQCN, level, marker, msg, params, t);
    } else {
        // 否则,进行常规的级别检查后记录
        if (isEnabledFor(level, marker)) {
            log(loggingEvent, localFQCN, level, marker, msg, params, t);
        }
    }
}

在构建 LoggingEvent 时,它存储的是原始的、未格式化的消息模板 (message) 和参数数组 (argArray),而不是第二步中 MessageFormatter 生成的那个完整字符串。在 LoggingEvent 类中,有一个 getFormattedMessage() 方法,可以实现延迟计算获取消息的逻辑。

java 复制代码
// 在 ch.qos.logback.classic.LoggingEvent 中
public LoggingEvent(String fqcn, Logger logger, Level level, String message, Throwable throwable, Object[] argArray) {
    this.fqcn = fqcn;
    this.logger = logger;
    this.level = level;
    this.message = message;   // 注意:这里存的是原始消息模板 "订单{}创建成功,金额:{}"
    this.throwable = throwable;
    this.argArray = argArray; // 以及参数数组 [orderId, amount]
    this.timeStamp = System.currentTimeMillis(); // 记录时间戳
    this.threadName = Thread.currentThread().getName(); // 记录线程名
    // ... 其他初始化
}

第四步:Layout 的格式化 (PatternLayout.doLayout)

Layout(通常是 PatternLayout)的职责是将整个 ILoggingEvent 对象格式成一整行有特定格式的日志字符串。假设配置的模式是:%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger - %msg%n

PatternLayout 的工作流程:

  1. 解析模式字符串 :将 %d%thread%msg 等转换符解析出来。
  2. 构建转换器链 :为每个转换符创建一个对应的 Converter 对象,并链接起来。
  3. 遍历转换器链 :依次调用每个 Converterwrite 方法。
java 复制代码
public final class PatternLayout extends AbstractStringLayout {  
    public static final String DEFAULT_CONVERSION_PATTERN = "%m%n";  
    public static final String TTCC_CONVERSION_PATTERN = "%r [%t] %p %c %notEmpty{%x }- %m%n";  
    public static final String SIMPLE_CONVERSION_PATTERN = "%d [%t] %p %c - %m%n";  
    public static final String KEY = "Converter";  
    private final String conversionPattern;  
    private final PatternSelector patternSelector;  
    private final AbstractStringLayout.Serializer eventSerializer;
    
    //...
}

PatternLayoutBase 的核心方法如下:

java 复制代码
protected String writeLoopOnConverters(E event) {
    StringBuilder strBuilder = new StringBuilder(128);
    Converter<E> c = headConverter;
    while (c != null) {
        c.write(strBuilder, event); // 委托给每个转换器
        c = c.getNext();
    }
    return strBuilder.toString();
}

%msg 对应的 MessageConverter 为例:

java 复制代码
public class MessageConverter extends Converter<ILoggingEvent> {
    @Override
    public void write(StringBuilder buf, ILoggingEvent event) {
        // 直接追加在第一步中由 MessageFormatter 生成好的消息字符串
        buf.append(event.getFormattedMessage());
    }
}

%d 对应的 DateConverter 为例:

java 复制代码
public class DateConverter extends Converter<ILoggingEvent> {
    private DateTimeFormatter dtf; // 内部使用 java.time.format.DateTimeFormatter

    @Override
    public void write(StringBuilder buf, ILoggingEvent event) {
        if (dtf == null) {
            dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        }
        String dateStr = dtf.format(/* 从event中获取时间戳并转换 */);
        buf.append(dateStr);
    }
}

最终,所有这些 Converter 的输出被拼接起来,形成最终日志行: 2024-05-24 14:30:00 [http-nio-8080-exec-1] INFO c.example.OrderService - 订单12345创建成功,金额:99.99

5. 总结

Formatter 模式的本质远不止是简单的字符串拼接或类型转换。其核心设计思想是:在数据的内部表示与外部表示之间建立一个双向的"转换层",从而将格式化和解析的复杂逻辑封装起来,实现对数据表现形式的管理、控制和扩展。

它深刻体现了以下几个经典设计模式的思想:

  1. 策略模式: Formatter 接口定义了一个可互换的算法家族。针对同一类型(如 Date),可以有多种格式化策略(如 ISO 格式、中文格式、自定义格式)。系统可以根据上下文(如注解、区域设置)轻松地切换不同的策略,而无需修改客户端代码。
  2. 装饰器模式: 可以将原始的对象到字符串的转换(如 toString())视作基础功能。而 Formatter 在其之上"装饰"了更强大的能力,如本地化支持模式化输出异常处理,从而提供了更丰富、更健壮的转换行为,同时保持了客户端调用接口的简洁性。
  3. 工厂模式: 在注解驱动的场景下(如 @DateTimeFormat),AnnotationFormatterFactory 扮演了抽象工厂 的角色。它根据字段上的元数据(注解)动态地创建和配置具体的 Formatter 实例,将对象的创建逻辑与使用逻辑彻底解耦,实现了高度的灵活性。

核心价值: 将"数据表示转换"这一常见需求,从一个容易出错、散布各处的过程式代码,提升为一个可复用、可配置、可测试的架构性组件。


思考与探讨

  1. 格式化的边界在哪里: Formatter 应该只负责"表现层"的格式(如日期显示为 yyyy-MM-dd),还是可以包含"业务逻辑"的转换(如将订单状态枚举 PAID 显示为"已支付")?

  2. 性能与资源的权衡:DateTimeFormatter 这样的线程安全、不可变对象,创建成本较高,因此需要复用。在 Web 等高并发场景下,你是如何管理和缓存 Formatter 实例的?


下一篇预告: 在下一篇文章 《【基础数据篇】数据等价裁判:Comparer模式》中,我们将探讨 equalshashCodeComparator 如何协同工作,为集合操作、排序和唯一性判断建立可靠的标准,并深入解析其在构建稳定、可预测的软件系统中所扮演的基石角色。

相关推荐
chenyuhao20241 小时前
MySQL索引特性
开发语言·数据库·c++·后端·mysql
oouy1 小时前
《Java泛型:给你的代码装上“快递分拣系统”,再也不会拆出一双鞋!》
后端
Python私教1 小时前
别再瞎折腾 LangChain 了:从 0 到 1 搭建 RAG 知识库的架构决策实录
后端
微学AI1 小时前
openGauss在AI时代的向量数据库应用实践与技术演进深度解析
后端
前端伪大叔1 小时前
第29篇:99% 的量化新手死在挂单上:Freqtrade 隐藏技能揭秘
后端·python·github
随风飘的云1 小时前
redis的qps从100飙升到10000的全流程解决方案
后端
用户345848285051 小时前
java除了AtomicInteger,还有哪些常用的原子类?
后端
IT_陈寒2 小时前
React 18并发渲染实战:5个核心API让你的应用性能飙升50%
前端·人工智能·后端
一 乐2 小时前
购物|明星周边商城|基于springboot的明星周边商城系统设计与实现(源码+数据库+文档)
java·数据库·spring boot·后端·spring