Skip to content

Spring Field Formatting:让数据格式化变得优雅 ✨

什么是 Spring Field Formatting?

在日常的 Web 开发中,我们经常需要处理各种数据格式转换的问题。比如用户在前端输入 "2024-01-15",后端需要转换为 Date 对象;或者后端的 BigDecimal 金额需要格式化为 "$123.45" 显示给用户。

Spring Field Formatting 就是专门解决这类字符串与对象之间双向转换问题的优雅方案!

NOTE

Field Formatting 是建立在 Spring 的 core.convert 类型转换系统之上的,专门针对客户端环境(如 Web 应用)的格式化需求而设计。

为什么需要 Field Formatting?🤔

让我们先看看没有 Field Formatting 时会遇到什么问题:

kotlin
@RestController
class UserController {
    
    @PostMapping("/user")
    fun createUser(@RequestBody userDto: UserDto): User {
        // 手动解析日期字符串 - 容易出错
        val birthDate = try {
            SimpleDateFormat("yyyy-MM-dd").parse(userDto.birthDateStr) 
        } catch (e: ParseException) {
            throw IllegalArgumentException("日期格式错误")
        }
        
        // 手动解析金额字符串 - 代码冗余
        val salary = try {
            BigDecimal(userDto.salaryStr.replace("$", "").replace(",", "")) 
        } catch (e: NumberFormatException) {
            throw IllegalArgumentException("金额格式错误")
        }
        
        return User(birthDate = birthDate, salary = salary)
    }
}
kotlin
@RestController
class UserController {
    
    @PostMapping("/user")
    fun createUser(@RequestBody @Valid userDto: UserDto): User {
        // Spring 自动处理格式转换,无需手动解析!
        return User(
            birthDate = userDto.birthDate,  // 自动从字符串转换为 Date
            salary = userDto.salary         // 自动处理货币格式
        )
    }
}

data class UserDto(
    @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE) // [!code ++]
    val birthDate: Date,
    
    @field:NumberFormat(style = NumberFormat.Style.CURRENCY) // [!code ++]
    val salary: BigDecimal
)

核心组件解析 🔧

1. Formatter SPI - 格式化的核心接口

Formatter 接口是整个格式化系统的核心,它继承了 PrinterParser 两个接口:

让我们看看如何实现一个自定义的 Formatter

kotlin
/**
 * 自定义的金额格式化器
 * 支持中文货币格式:¥1,234.56
 */
class ChineseMoneyFormatter : Formatter<BigDecimal> {
    
    private val numberFormat = NumberFormat.getCurrencyInstance(Locale.CHINA)
    
    /**
     * 将 BigDecimal 对象格式化为字符串显示
     */
    override fun print(money: BigDecimal, locale: Locale): String {
        return if (money == null) {
            ""
        } else {
            numberFormat.format(money) 
        }
    }
    
    /**
     * 将字符串解析为 BigDecimal 对象
     */
    @Throws(ParseException::class)
    override fun parse(text: String, locale: Locale): BigDecimal? {
        if (text.isBlank()) return null
        
        return try {
            // 移除货币符号和空格,然后解析
            val cleanText = text.replace("¥", "").replace(",", "").trim() 
            BigDecimal(cleanText)
        } catch (e: NumberFormatException) {
            throw ParseException("无法解析金额: $text", 0) 
        }
    }
}

2. 注解驱动的格式化 📝

Spring 提供了强大的注解驱动格式化功能,让我们可以通过简单的注解来声明格式化规则:

常用格式化注解

kotlin
data class ProductDto(
    // 日期格式化 - ISO 标准格式
    @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
    val releaseDate: LocalDate,
    
    // 日期时间格式化 - 自定义模式
    @field:DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    val createdAt: LocalDateTime,
    
    // 数字格式化 - 货币格式
    @field:NumberFormat(style = NumberFormat.Style.CURRENCY)
    val price: BigDecimal,
    
    // 数字格式化 - 百分比格式
    @field:NumberFormat(style = NumberFormat.Style.PERCENT)
    val discountRate: Double,
    
    // 数字格式化 - 自定义模式
    @field:NumberFormat(pattern = "#,###.##")
    val quantity: Long,
    
    // 时长格式化 - ISO 8601 格式
    @field:DurationFormat(style = DurationFormat.Style.ISO8601)
    val processingTime: Duration
)

自定义注解格式化器

当内置注解不能满足需求时,我们可以创建自定义的注解格式化器:

自定义手机号格式化器示例
kotlin
/**
 * 手机号格式化注解
 */
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
annotation class PhoneFormat(
    val pattern: String = "###-####-####"  // 默认格式
)

/**
 * 手机号格式化器工厂
 */
class PhoneFormatAnnotationFormatterFactory : AnnotationFormatterFactory<PhoneFormat> {
    
    override fun getFieldTypes(): Set<Class<*>> {
        return setOf(String::class.java)
    }
    
    override fun getPrinter(annotation: PhoneFormat, fieldType: Class<*>): Printer<String> {
        return PhoneFormatter(annotation.pattern)
    }
    
    override fun getParser(annotation: PhoneFormat, fieldType: Class<*>): Parser<String> {
        return PhoneFormatter(annotation.pattern)
    }
}

/**
 * 手机号格式化器实现
 */
class PhoneFormatter(private val pattern: String) : Formatter<String> {
    
    override fun print(phone: String, locale: Locale): String {
        if (phone.isBlank()) return ""
        
        // 格式化显示:13812345678 -> 138-1234-5678
        return when (pattern) {
            "###-####-####" -> {
                if (phone.length == 11) {
                    "${phone.substring(0, 3)}-${phone.substring(3, 7)}-${phone.substring(7)}"
                } else phone
            }
            else -> phone
        }
    }
    
    @Throws(ParseException::class)
    override fun parse(text: String, locale: Locale): String {
        if (text.isBlank()) return ""
        
        // 解析输入:138-1234-5678 -> 13812345678
        val cleanPhone = text.replace("-", "").replace(" ", "") 
        
        // 验证手机号格式
        if (!cleanPhone.matches(Regex("^1[3-9]\\d{9}$"))) { 
            throw ParseException("无效的手机号格式: $text", 0)
        }
        
        return cleanPhone
    }
}

// 使用示例
data class UserProfile(
    @field:PhoneFormat(pattern = "###-####-####") // [!code highlight]
    val mobile: String
)

3. FormatterRegistry - 格式化器注册中心

FormatterRegistry 是管理所有格式化器的注册中心:

kotlin
@Configuration
class FormattingConfig : WebMvcConfigurer {
    
    override fun addFormatters(registry: FormatterRegistry) {
        // 注册自定义格式化器
        registry.addFormatter(ChineseMoneyFormatter()) 
        
        // 按字段类型注册
        registry.addFormatterForFieldType(
            BigDecimal::class.java, 
            ChineseMoneyFormatter()
        ) 
        
        // 注册注解格式化器工厂
        registry.addFormatterForFieldAnnotation(
            PhoneFormatAnnotationFormatterFactory()
        ) 
    }
}

实际应用场景 🚀

场景1:电商订单系统

kotlin
/**
 * 订单数据传输对象
 */
data class OrderDto(
    // 订单创建时间 - 标准 ISO 格式
    @field:DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
    val createdAt: LocalDateTime,
    
    // 订单金额 - 货币格式显示
    @field:NumberFormat(style = NumberFormat.Style.CURRENCY)
    val totalAmount: BigDecimal,
    
    // 折扣率 - 百分比格式
    @field:NumberFormat(style = NumberFormat.Style.PERCENT)
    val discountRate: Double,
    
    // 商品数量 - 千分位分隔符
    @field:NumberFormat(pattern = "#,###")
    val quantity: Int,
    
    // 预计送达时间 - 自定义格式
    @field:DateTimeFormat(pattern = "MM月dd日 HH:mm")
    val estimatedDelivery: LocalDateTime
)

@RestController
@RequestMapping("/api/orders")
class OrderController {
    
    @PostMapping
    fun createOrder(@RequestBody @Valid orderDto: OrderDto): ResponseEntity<Order> {
        // Spring 自动处理所有格式转换,无需手动解析!
        val order = Order(
            createdAt = orderDto.createdAt,
            totalAmount = orderDto.totalAmount,
            discountRate = orderDto.discountRate,
            quantity = orderDto.quantity,
            estimatedDelivery = orderDto.estimatedDelivery
        )
        
        return ResponseEntity.ok(orderService.save(order))
    }
    
    @GetMapping("/{id}")
    fun getOrder(@PathVariable id: Long): ResponseEntity<OrderDto> {
        val order = orderService.findById(id)
        
        // 返回时自动格式化为字符串
        val orderDto = OrderDto(
            createdAt = order.createdAt,      // 自动格式化为 ISO 字符串
            totalAmount = order.totalAmount,  // 自动格式化为货币字符串
            discountRate = order.discountRate, // 自动格式化为百分比字符串
            quantity = order.quantity,        // 自动添加千分位分隔符
            estimatedDelivery = order.estimatedDelivery // 自动格式化为自定义格式
        )
        
        return ResponseEntity.ok(orderDto)
    }
}

场景2:国际化支持

kotlin
/**
 * 支持多语言的格式化配置
 */
@Configuration
class InternationalizationFormattingConfig {
    
    @Bean
    fun formattingConversionService(): FormattingConversionService {
        val service = DefaultFormattingConversionService()
        
        // 注册支持多语言的日期格式化器
        service.addFormatterForFieldType(
            LocalDate::class.java,
            LocalizedDateFormatter()
        )
        
        // 注册支持多语言的货币格式化器
        service.addFormatterForFieldType(
            BigDecimal::class.java,
            LocalizedCurrencyFormatter()
        )
        
        return service
    }
}

/**
 * 本地化日期格式化器
 */
class LocalizedDateFormatter : Formatter<LocalDate> {
    
    override fun print(date: LocalDate, locale: Locale): String {
        val formatter = when (locale.language) {
            "zh" -> DateTimeFormatter.ofPattern("yyyy年MM月dd日", locale) 
            "en" -> DateTimeFormatter.ofPattern("MMM dd, yyyy", locale)   
            else -> DateTimeFormatter.ISO_LOCAL_DATE
        }
        return date.format(formatter)
    }
    
    @Throws(ParseException::class)
    override fun parse(text: String, locale: Locale): LocalDate {
        val formatter = when (locale.language) {
            "zh" -> DateTimeFormatter.ofPattern("yyyy年MM月dd日", locale)
            "en" -> DateTimeFormatter.ofPattern("MMM dd, yyyy", locale)
            else -> DateTimeFormatter.ISO_LOCAL_DATE
        }
        
        return try {
            LocalDate.parse(text, formatter) 
        } catch (e: DateTimeParseException) {
            throw ParseException("无法解析日期: $text", 0) 
        }
    }
}

最佳实践与注意事项 ⚠️

1. 线程安全性

IMPORTANT

确保你的 Formatter 实现是线程安全的,因为 Spring 会在多线程环境中重用同一个实例。

kotlin
// ✅ 好的实践 - 线程安全
class SafeDateFormatter(private val pattern: String) : Formatter<LocalDate> {
    
    override fun print(date: LocalDate, locale: Locale): String {
        // 每次都创建新的 DateTimeFormatter,线程安全
        val formatter = DateTimeFormatter.ofPattern(pattern, locale)
        return date.format(formatter)
    }
    
    override fun parse(text: String, locale: Locale): LocalDate {
        val formatter = DateTimeFormatter.ofPattern(pattern, locale) 
        return LocalDate.parse(text, formatter)
    }
}

// ❌ 不好的实践 - 线程不安全
class UnsafeDateFormatter(pattern: String) : Formatter<LocalDate> {
    
    private val formatter = SimpleDateFormat(pattern) 
    
    override fun print(date: LocalDate, locale: Locale): String {
        // SimpleDateFormat 不是线程安全的!
        return formatter.format(Date.from(date.atStartOfDay(ZoneId.systemDefault()).toInstant()))
    }
}

2. 错误处理

WARNING

在解析失败时,应该抛出 ParseExceptionIllegalArgumentException,让 Spring 能够正确处理验证错误。

kotlin
class RobustFormatter : Formatter<BigDecimal> {
    
    @Throws(ParseException::class)
    override fun parse(text: String, locale: Locale): BigDecimal? {
        if (text.isBlank()) return null
        
        return try {
            BigDecimal(text.trim())
        } catch (e: NumberFormatException) {
            // 提供清晰的错误信息
            throw ParseException("无法解析数字 '$text': ${e.message}", 0)
        }
    }
}

3. 性能优化

TIP

对于频繁使用的格式化器,考虑缓存 DateTimeFormatterNumberFormat 实例。

kotlin
class OptimizedDateFormatter : Formatter<LocalDate> {
    
    // 使用 ThreadLocal 缓存格式化器,既线程安全又高效
    private val formatterCache = ThreadLocal.withInitial {
        mutableMapOf<String, DateTimeFormatter>()
    }
    
    override fun print(date: LocalDate, locale: Locale): String {
        val key = "${locale.language}_${locale.country}"
        val formatter = formatterCache.get().computeIfAbsent(key) { 
            DateTimeFormatter.ofPattern("yyyy-MM-dd", locale)
        }
        return date.format(formatter)
    }
}

总结 📝

Spring Field Formatting 为我们提供了一套完整而优雅的数据格式化解决方案:

简化开发:通过注解驱动,大大减少了手动格式转换的代码 ✅ 类型安全:强类型的 Formatter SPI 确保类型安全 ✅ 国际化支持:内置的 Locale 支持让国际化变得简单 ✅ 可扩展性:灵活的 SPI 设计支持自定义格式化需求 ✅ 集成性:与 Spring MVC、数据绑定等无缝集成

NOTE

Field Formatting 不仅仅是技术工具,更是提升用户体验的重要手段。合适的数据格式化能让应用更加用户友好,减少用户输入错误,提高系统的可用性。

通过掌握 Spring Field Formatting,你将能够构建出更加专业、用户友好的 Web 应用!🎉