Appearance
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
接口是整个格式化系统的核心,它继承了 Printer
和 Parser
两个接口:
让我们看看如何实现一个自定义的 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
在解析失败时,应该抛出 ParseException
或 IllegalArgumentException
,让 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
对于频繁使用的格式化器,考虑缓存 DateTimeFormatter
或 NumberFormat
实例。
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 应用!🎉