12 KiB
架构决策记录 (ADR) - 日期格式化技术选型
背景
在项目国际化需求(REQ-002)中,需要实现日期格式化功能,支持用户设置日期格式偏好,根据用户日期格式偏好显示日期。该功能需要满足以下核心需求:
- 多日期格式支持:支持常用日期格式(如:ISO 8601、欧洲、美国、中国等)
- 自动格式化:自动根据用户日期格式偏好格式化日期数据
- 时区转换:结合时区转换功能,支持跨时区日期格式化
- 多语言支持:支持多语言日期显示(如:星期几、月份名称的本地化)
- 高性能:日期格式化响应时间 < 10ms
- 易维护:代码结构清晰,易于扩展和维护
- 兼容性:与现有 Spring Boot 3.5.7 + 若依框架集成良好
当前系统所有日期数据使用 Date、LocalDate、LocalDateTime 类型存储,需要在 Service 层进行日期格式化,结合时区转换功能,使用 Redis 缓存用户日期格式偏好,支持用户 > 系统的日期格式优先级。
决策
决策 1:日期格式化技术选择
选定方案:java.time.format.DateTimeFormatter
选择理由:
- 无额外依赖:Java 8+ 内置,无需引入额外依赖,减少项目复杂度
- 功能完整:支持日期、日期时间、时区、本地化等格式化需求
- 国际化支持:支持国际化格式化,自动处理不同地区的日期格式
- 性能优秀:格式化时间 < 1ms,满足性能要求
- 线程安全:DateTimeFormatter 是线程安全的,可以在多线程环境下安全使用
- 与框架兼容:与 Spring Boot 3.5.7(支持 Java 21)完美集成
- 自定义灵活:支持自定义格式化模式,满足不同业务场景
- 类型安全:支持 LocalDate、LocalDateTime 等类型安全的日期时间类型
实现方案:
- 使用 DateTimeFormatter 的模式字符串定义日期格式(例如:
yyyy-MM-dd) - 使用 ofPattern() 方法创建格式化器
- 使用 withLocale() 方法设置语言环境
- 使用 ConcurrentHashMap 缓存 DateTimeFormatter 实例,提高性能
- 支持自定义日期格式模式
放弃方案的原因:
方案 A:Intl.DateTimeFormat(JavaScript 库)
- 放弃原因:
- 这是 JavaScript 库,不适用于 Java 后端
- 需要在前端实现,无法保证格式化逻辑的一致性
- 无法在 Service 层统一处理
方案 B:SimpleDateFormat
- 放弃原因:
- SimpleDateFormat 不是线程安全的,在多线程环境下使用需要同步
- 性能较差,每次格式化都需要创建新实例
- 已被 DateTimeFormatter 取代,属于过时的 API
- 不支持 LocalDate、LocalDateTime 等新日期时间类型
方案 C:第三方库(如:Joda-Time)
- 放弃原因:
- 需要引入额外依赖,增加项目复杂度
- Java 8+ 已内置了类似的功能,引入价值不大
- Joda-Time 已停止维护,不再推荐使用
方案 D:自定义格式化实现
- 放弃原因:
- 需要手动维护格式化规则,维护成本高
- 难以处理国际化格式化规则
- 容易出现格式化错误,影响数据准确性
- 开发成本高,风险大
决策 2:缓存策略选择
选定方案:Redis 缓存
选择理由:
- 已集成:项目已集成 Redis,无需额外配置和部署
- 性能优秀:Redis 响应时间 < 1ms,满足高性能要求
- 分布式支持:支持分布式部署,多实例共享缓存
- 自动过期:支持自动过期机制,无需手动清理过期数据
- 数据结构丰富:支持 String、Hash、List 等多种数据结构
- 持久化:支持数据持久化,防止数据丢失
缓存策略设计:
- 用户日期格式缓存:Key =
sys:date:format:{userId},TTL = 24 小时 - 系统默认日期格式缓存:Key =
sys:default:date:format,TTL = 24 小时 - DateTimeFormatter 缓存:使用 ConcurrentHashMap 缓存,永久缓存
放弃方案的原因:
方案 A:Caffeine 本地缓存
- 放弃原因:
- 本地缓存无法在分布式环境下共享,多实例数据不一致
- 需要引入额外依赖(caffeine)
- 缓存更新需要手动同步,实现复杂
方案 B:数据库缓存
- 放弃原因:
- 数据库查询响应时间 > 10ms,性能较差
- 高并发场景下数据库压力大,影响系统性能
- 无法自动过期,需要手动清理过期数据
决策 3:日期格式化层选择
选定方案:Service 层转换 + AOP 切面拦截
选择理由:
- 统一处理:在 Service 层统一处理日期格式化,避免代码重复
- AOP 切面:使用 AOP 切面拦截 Service 方法返回值,自动格式化日期字段,无需手动调用
- 覆盖面广:所有调用 Service 的地方都会触发格式化(Controller、定时任务、内部调用等)
- 业务逻辑分离:日期格式化逻辑与业务逻辑分离,代码清晰
- 易于维护:修改日期格式化逻辑只需修改切面代码,影响范围小
- 性能优化:AOP 切面在编译时织入,运行时性能损失小
- 自动识别:自动识别 Date、LocalDate、LocalDateTime 类型字段,减少手动标注的工作量
- 时区转换集成:与 TimeZoneConvertAspect 配合,先进行时区转换,再进行日期格式化
实现方案:
- 响应格式化:使用
@Around切面拦截 Service 方法返回值,自动格式化日期字段 - 注解支持:提供
@DateFormat注解,标记需要格式化的 Service 方法 - 递归处理:递归处理嵌套对象的日期字段
- 循环引用处理:避免循环引用导致的无限递归
- 多类型支持:支持 Date、LocalDate、LocalDateTime 等多种日期时间类型
放弃方案的原因:
方案 A:Controller 层格式化 + AOP 切面拦截
- 放弃原因:
- 只有接口请求会触发格式化,定时任务、内部调用等不会触发
- 如果有多个地方需要格式化,可能需要多个切面
- 格式化逻辑分散,难以统一管理
方案 B:手动调用格式化方法
- 放弃原因:
- 需要在每个需要格式化的地方手动调用,代码重复
- 容易遗漏,导致格式化不一致
- 维护成本高,修改格式化逻辑需要修改多处代码
决策 4:时区转换方案
选定方案:复用已实现的时区转换功能(TimeZoneConvertAspect)
选择理由:
- 避免重复开发:时区转换功能已在 2026-01-21-002-04 中实现,无需重复开发
- 功能完整:已实现的时区转换功能完整,满足跨时区日期格式化需求
- 代码复用:复用现有代码,减少开发成本和维护成本
- 一致性:与现有时区转换功能保持一致,避免功能不一致
- 集成简单:DateFormatAspect 与 TimeZoneConvertAspect 配合使用,集成简单
实现方案:
- 时区转换优先:TimeZoneConvertAspect 先进行时区转换,将 UTC 时间转换为用户时区
- 日期格式化后置:DateFormatAspect 后进行日期格式化,根据用户日期格式偏好格式化日期
- 切面顺序:使用 @Order 注解控制切面执行顺序,确保时区转换先于日期格式化
放弃方案的原因:
方案 A:在 DateFormatAspect 中实现时区转换
- 放弃原因:
- 重复开发时区转换功能,增加开发成本
- 与现有时区转换功能不一致,可能导致功能冲突
- 维护成本高,需要同时维护两套时区转换逻辑
方案 B:不进行时区转换
- 放弃原因:
- 无法满足跨时区日期格式化需求
- 不同时区的用户看到的日期时间不一致,影响用户体验
决策 5:多语言支持方案
选定方案:结合国际化功能,使用 Locale 进行本地化
选择理由:
- 避免重复开发:国际化功能已在 2026-01-21-002-03 中实现,无需重复开发
- 功能完整:已实现的国际化功能完整,满足多语言日期显示需求
- 代码复用:复用现有代码,减少开发成本和维护成本
- 一致性:与现有国际化功能保持一致,避免功能不一致
- 集成简单:DateFormatAspect 结合国际化功能,使用 Locale 进行本地化
实现方案:
- 语言偏好读取:从 SysUser 表读取用户语言偏好(lang 字段)
- Locale 创建:根据语言偏好创建 Locale 实例(例如:zh_CN、en_US)
- 本地化格式化:使用 DateTimeFormatter.withLocale() 方法设置语言环境
- 多语言支持:支持星期几、月份名称的本地化显示
放弃方案的原因:
方案 A:在 DateFormatAspect 中实现多语言支持
- 放弃原因:
- 重复开发多语言支持功能,增加开发成本
- 与现有国际化功能不一致,可能导致功能冲突
- 维护成本高,需要同时维护两套国际化逻辑
方案 B:不进行多语言支持
- 放弃原因:
- 无法满足多语言日期显示需求
- 不同语言环境的用户看到的日期显示不一致,影响用户体验
决策 6:数据类型选择
选定方案:Date、LocalDate、LocalDateTime
选择理由:
- 类型安全:LocalDate、LocalDateTime 是类型安全的日期时间类型,避免混淆
- 线程安全:LocalDate、LocalDateTime 是不可变的,线程安全
- API 优秀:Java 8+ 的日期时间 API 设计优秀,易于使用
- 与现有代码一致:项目中已有的日期数据使用 Date、LocalDate、LocalDateTime 类型,保持一致
- 功能完整:支持日期、日期时间、时区等多种场景
- 性能优秀:LocalDate、LocalDateTime 性能优秀,满足高性能要求
实现方案:
- 日期字段:使用 LocalDate 类型表示日期(例如:出生日期)
- 日期时间字段:使用 LocalDateTime 类型表示日期时间(例如:创建时间)
- 兼容旧代码:保留 Date 类型,兼容旧代码
- 类型转换:提供 Date、LocalDate、LocalDateTime 之间的转换方法
放弃方案的原因:
方案 A:仅使用 Date
- 放弃原因:
- Date 不是线程安全的,在多线程环境下使用需要同步
- Date 的 API 设计不佳,易于出错
- Date 不支持不可变性,容易导致数据不一致
方案 B:仅使用 LocalDate、LocalDateTime
- 放弃原因:
- 与现有代码不一致,需要大量重构
- 旧代码可能依赖 Date 类型,兼容性问题
方案 C:仅使用 Long(时间戳)
- 放弃原因:
- 不直观,可读性差
- 显示时需要格式化,增加处理逻辑
- 与现有代码不一致
后果
正面后果
- 无额外依赖:使用 Java 内置库和已集成的 Redis,无需引入额外依赖
- 高性能:日期格式化响应时间 < 10ms,满足性能要求
- 易维护:代码结构清晰,格式化逻辑统一,易于扩展和维护
- 数据准确:使用 DateTimeFormatter 保证格式化准确性
- 分布式支持:Redis 缓存支持分布式部署,多实例共享缓存
- 功能完整:支持多日期格式、时区转换、多语言显示等核心功能
- 代码复用:复用已实现的时区转换和国际化功能,减少开发成本
- 类型安全:使用 LocalDate、LocalDateTime 等类型安全的日期时间类型
负面后果
- AOP 学习成本:开发人员需要了解 AOP 的使用方式
- 切面顺序控制:需要控制 DateFormatAspect 和 TimeZoneConvertAspect 的执行顺序
- 缓存一致性:分布式环境下需要保证缓存一致性
- 多类型支持:需要支持 Date、LocalDate、LocalDateTime 等多种日期时间类型,增加实现复杂度