兄弟们聊个每家项目里都逃不掉的东西——DateUtil 日期工具类。这篇文章不整虚的就说清楚一件事一个能撑住生产环境的DateUtil到底该怎么设计、怎么写、怎么测、怎么避坑。做后端几年我几乎在每个项目里都能看到至少一版“自制”DateUtil。有的三五行代码放那儿当摆设有的功能臃肿得像个日期全家桶但真正落到生产环境遇到跨时区部署、并发调用、跨年统计的时候能稳稳扛住的没几个。这篇就把我在某项目里从零封装DateUtil的全过程、踩过的坑、查阅过的资料全部摊开讲代码直接可抄思路比代码更重要。1. 为什么每个项目都离不开DateUtil先看清问题的本质1.1 你真的只是封装一个日期格式吗刚工作那会儿我以为DateUtil就是处理“yyyy-MM-dd”转字符串的。后来某跨平台系统对接了三个不同业务方的数据才发现日期处理根本是脏活累活的大集合上游某个接口传来的是时间戳字符串某张Excel导入出来的是“2024/01/15 14:30:22”这种自定义格式还有一批老系统用的是两位年份“24/01/15”再碰上客户要求的报表要按自然周汇总、按又一个财年季度分组所有逻辑都东一处西一处散落在业务代码里。这种散乱直接导致两个恶果。其一同样的转换逻辑在十几个类里各写一遍改格式规则要全局搜索替换漏一处就出一次线上bug。其二每个开发对日期处理的方式都不一样有人用SimpleDateFormat裸奔有人直接拼字符串有人用Date的getMonth()乱七八糟代码评审都懒得看。所以DateUtil存在的意义不是让你“少写几行代码”而是把项目中所有日期操作收口到一条受控的巷子里。所有的格式、时区、解析逻辑只能从这一个类进出规范统一出问题时也只有一个类需要排查。1.2 从某次线上事故说起不规范的日期处理有多贵说个我印象很深的例子。某个统计服务的季度报表在该季度最后一天晚上多跑了一小时跑出来的数据和人工核对差了整整一天。查了一宿最后定位到核心原因某平台统计时把“当前季度的最后一天”算成了“最后一天的当前时刻”LocalDate和LocalDateTime混用时间维度多了23小时59分59秒。这个bug的根不是“程序员不懂日期”而是没有任何统一的日期工具约束每个开发都在随手new对象。如果从第一天就强制用DateUtil的endOfQuarter()、startOfQuarter()这类语义明确的方法这种低级事故根本不会发生。从那之后我形成了一个原则项目里宁可多封装几个冷门方法也不让业务代码直接碰底层时间API。这也是下面这套DateUtil设计哲学的开端。2. DateUtil的设计思路与API规划2.1 工具类的职责边界该做什么、不该做什么设计DateUtil最容易犯的毛病是“什么都往里塞”。我见过有人把dateToString、stringToDate、addDays、getMonth这种基础方法放一个类里又把星座判断、生肖判断、干支纪年也塞进来最后这个类几千行改谁都怕。我的做法是职责收敛。DateUtil只做“日期时间处理”具体拆成五大类类型转换Date、LocalDate、LocalDateTime、Instant、时间戳之间互相转换格式化解析统一格式常量的定义线程安全的格式化与解析日期计算加减天/月/年季度初末、月初月末、上/下个工作日判定区间判断判断某天是否在区间内、是否闰年、是否同一天、跨年跨月处理默认值兜底解析失败时的安全回退避免到处try-catch凡是这五类之外的比如时区名列表展示、多语言星期显示我都建议单独建类不要污染核心工具。工具类最怕的是职责蔓延保持单一职责后面维护的人才会感激你。从Java 8之后我强烈建议所有DateUtil内部一律基于java.time包来实现。java.util.Date和SimpleDateFormat遗留问题太多线程安全、可变性、设计缺陷都是坑新代码没有理由再用它们。团队用Java 8以下版本的情况另外说那种环境建议直接用现成的第三方库别自己造轮子。2.2 统一格式常量不把魔法字符串散落各处一个很容易被忽略但极其重要的设计格式化模式必须集中定义成常量。很多项目的日期bug都起源于有人随手写了个YYYY-MM-dd hh:mm:ss这个模式里有两个致命错误——大写的YYYY代表“周年份”跟实际年份只差一个跨年周就翻车小写的hh代表12小时制晚上8点会被格式化成“08点”。后面章节我会专门展示这个坑。我项目里固定的常量大致长这样public static final String PATTERN_DATE yyyy-MM-dd; public static final String PATTERN_DATETIME yyyy-MM-dd HH:mm:ss; public static final String PATTERN_DATETIME_MS yyyy-MM-dd HH:mm:ss.SSS; public static final String PATTERN_DATE_COMPACT yyyyMMdd; public static final String PATTERN_DATETIME_COMPACT yyyyMMddHHmmss; public static final String PATTERN_DATE_SLASH yyyy/MM/dd;这些常量放在DateUtil内部作为public static final业务层只管引用。注意这里全是小写yyyy、大写MM、小写dd、24小时制HH一个字符都不能错。关于格式模式再分享一个小经验。时间戳转换时我建议所有对外接口统一使用标准ISO格式。DateTimeFormatter.ISO_LOCAL_DATE_TIME这类内置格式可以优先用它处理的可读性比自定义模式好而且能天然规避模式字符串拼写错误。2.3 API命名一眼看懂是基本素养DateUtil的方法命名我遵循一个直觉第一的规则看到方法名不用查文档就知道返回什么。参考惯例format系列返回字符串parse系列返回时间对象getXxx返回具体属性isXxx返回布尔判断addXxx表示在原对象上增加然后返回新对象。项目里最终稳定下来的方法签名叫这些public static String format(LocalDateTime dateTime, String pattern) public static LocalDateTime parse(String dateTimeStr, String pattern) public static LocalDateTime parseOrNow(String dateTimeStr, String pattern) // 兜底 public static String formatTimestamp(long timestamp, String pattern) public static long toTimestamp(LocalDateTime dateTime) public static LocalDate toLocalDate(Object dateObj) // 兼容各种类型 public static LocalDateTime startOfDay(LocalDate date) public static LocalDateTime endOfDay(LocalDate date) public static LocalDateTime startOfMonth(LocalDate date) public static LocalDateTime endOfMonth(LocalDate date) public static LocalDateTime startOfQuarter(LocalDate date) public static boolean isWeekend(LocalDate date) public static long daysBetween(LocalDate start, LocalDate end) public static int ageOf(LocalDate birthDate)强调一下方法不要重载得眼花缭乱。同一个语义能用两个参数解决的不要搞五个参数把可读性放在第一位。3. 核心方法实现与细节讲解3.1 线程安全的格式化与解析所有并发问题的根源远古时期大家用SimpleDateFormat有一个著名的坑它不是线程安全的多个线程共用同一个SimpleDateFormat实例解析日期会偶发解析出完全错误的时间甚至直接抛NumberFormatException。等并发量上来这种bug呈现“偶发性”极难排查。既然底层基于java.timeDateTimeFormatter本身就是不可变且线程安全的我们可以放心地把格式化器定义成static final。不过还是那句话如果需要自定义格式最佳实践是每次调用都创建新的DateTimeFormatter或者缓存不涉及可变状态的实例。看我的核心实现public static final DateTimeFormatter DATE_FORMATTER DateTimeFormatter.ofPattern(PATTERN_DATE); public static final DateTimeFormatter DATETIME_FORMATTER DateTimeFormatter.ofPattern(PATTERN_DATETIME); public static String format(LocalDateTime dateTime, String pattern) { if (dateTime null) { return ; } return dateTime.format(DateTimeFormatter.ofPattern(pattern)); } public static LocalDateTime parse(String dateTimeStr, String pattern) throws DateTimeParseException { final String trimmed dateTimeStr null ? : dateTimeStr.trim(); if (trimmed.isEmpty()) { throw new DateTimeParseException(Empty date string, , 0); } return LocalDateTime.parse(trimmed, DateTimeFormatter.ofPattern(pattern)); }上面这段代码里有两个容易被新手忽略的细节。第一是null返回值我统一返回空字符串而不是返回null这样业务侧拿到值可以直接拼接或者入库避免一堆空指针判断。第二是trim()上游传过来的时间字符串经常带空格不处理干净后面解析必然出幺蛾子。解析方法抛DateTimeParseException很多人觉得麻烦但我故意不吞异常。工具类不负责业务异常语义吞掉了业务侧就没法感知“数据到底错在哪里”。如果业务希望容错我单独提供兜底版本public static LocalDateTime parseOrDefault( String dateTimeStr, String pattern, LocalDateTime defaultValue) { try { return parse(dateTimeStr, pattern); } catch (DateTimeParseException ex) { return defaultValue; } }这是我来回调整几次之后才定下来的优雅结构核心方法保持严格扩展方法提供宽恕。3.2 日期加减与区间计算别小看月末和闰年LocalDateTime本身自带plusDays()、minusMonths()这类方法DateUtil封装它们的意义主要在“收口语义”。企业应用中最常见的三个业务场景是月底结算、季度统计、跨周期判断。先看月初和月末的实现。用LocalDate做干净利落public static LocalDate firstDayOfMonth(LocalDate date) { return date.withDayOfMonth(1); } public static LocalDate lastDayOfMonth(LocalDate date) { return date.withDayOfMonth(date.lengthOfMonth()); }注意lengthOfMonth()返回的是该月实际天数2月会自动区分28还是29。用with(TemporalAdjusters.lastDayOfMonth())也行但显式写lengthOfMonth()逻辑更直白新人看代码也容易理解。季度初末是业务报表的高频需求。季度结束日那三行代码比较绕我给了很详细的注释public static LocalDate firstDayOfQuarter(LocalDate date) { int quarterMonth ((date.getMonthValue() - 1) / 3) * 3 1; return LocalDate.of(date.getYear(), quarterMonth, 1); } public static LocalDate lastDayOfQuarter(LocalDate date) { LocalDate firstDay firstDayOfQuarter(date); // 下季度的第一天再减一天就是当前季度的最后一天 return firstDay.plusMonths(3).minusDays(1); }这里核心的取季度首月公式(月份-1)/3*31。比如8月(8-1)/322*3177月就是第三季度的开始月这逻辑放到哪个月都对。天数差计算则要注意“区间是开还是闭”。业务里“从10月1日到10月3日”到底算2天还是3天每个团队都有自己的规矩工具类必须在命名上说清楚public static long daysBetween(LocalDate start, LocalDate end) { return ChronoUnit.DAYS.between(start, end); // 结果是左闭右开不含end当天 } public static long daysInclusive(LocalDate start, LocalDate end) { return ChronoUnit.DAYS.between(start, end) 1L; // 头尾都算 }这个设计细节让我少挨了好几次骂。统计活跃天数、计算休假周期经常要“含头含尾”用错了整个业务数字对不上。3.3 工作日判断、年龄计算那些“看着很简单”的方法isWeekend()看起来容易真正做的时候要注意时区上下文。默认情况用DayOfWeek判断周六周日就够了public static boolean isWeekend(LocalDate date) { DayOfWeek day date.getDayOfWeek(); return day DayOfWeek.SATURDAY || day DayOfWeek.SUNDAY; }但很多业务要求“考虑法定节假日调休”比如国庆假期之后的周六要上班。这不是DateUtil能解决的因为节假日每年都由相关部门公布没有固定算法。我的做法是DateUtil只提供isWeekend额外提供isHoliday(LocalDate, SetLocalDate)这样一个接口让业务方自己注入节假日集合。节假日变化频繁适合做成可配置数据而不是固化在代码里。年龄计算是另一个暗坑。如果直接用Period.between(birthday, today).getYears()来处理生日还没到的场景结果天然正确但很多人以前用Date算年龄时是怎么算的直接减毫秒数除以年度毫秒数这种算法在闰年面前完全不可靠。public static int ageOf(LocalDate birthDate) { if (birthDate null) { return 0; } LocalDate today LocalDate.now(); int age today.getYear() - birthDate.getYear(); // 当前日期还没到达今年的生日则年龄减一 if (today.getDayOfYear() birthDate.withYear(today.getYear()).getDayOfYear()) { age--; } return age; }注意这段代码里比较用的是getDayOfYear()而不是getMonthValue()加getDayOfMonth()组合判断原因是前者一次调用就能搞定“月日序数”逻辑更紧凑。不过这里有潜在边界如果生日是2月29日而今年不是闰年birthDate.withYear(today.getYear())会直接报错。所以稳妥写法要换成MonthDaypublic static int ageOfSafe(LocalDate birthDate) { LocalDate today LocalDate.now(); int age today.getYear() - birthDate.getYear(); MonthDay birthMonthDay MonthDay.from(birthDate); MonthDay todayMonthDay MonthDay.from(today); if (todayMonthDay.isBefore(birthMonthDay)) { age--; } return age; }这个MonthDay方案彻底绕开了2月29日问题目前是团队内部推荐版本。3.4 时区问题一个DateUtil藏得最深的雷单机开发时很少意识到时区是个问题一旦服务器跨地域部署或者数据库连接串带serverTimezone参数日期就悄悄歪掉。最典型的场景是服务器在时区A数据库在时区B日志打印的是本地时刻用户看到的时间跟数据库存储的差了8小时谁看谁心态爆炸。我的DateUtil里对时间戳转换定了一条铁律凡是从外部传入时间戳或字符串必须显式指定时区默认不信任服务器本地时区。public static final ZoneId DEFAULT_ZONE ZoneId.of(Asia/Shanghai); public static String formatTimestamp(long timestamp, String pattern, ZoneId zone) { ZoneId targetZone zone ! null ? zone : DEFAULT_ZONE; LocalDateTime dateTime LocalDateTime.ofInstant( Instant.ofEpochMilli(timestamp), targetZone); return dateTime.format(DateTimeFormatter.ofPattern(pattern)); } public static long toTimestamp(LocalDateTime localDateTime, ZoneId zone) { ZoneId targetZone zone ! null ? zone : DEFAULT_ZONE; return localDateTime.atZone(targetZone).toInstant().toEpochMilli(); }这里用ofInstant而不是new Date(timestamp)是有意的因为Instant更贴合“时间轴上的一瞬间”这个语义配合ZoneId显式转成哪个时区的“墙上时间”完全由我控制。有一段时间我们还做过A/B验证同一批时间戳用本地时区和用固定时区各算一遍结果日志显示的数量级差异直接帮我们定位了一个延迟统计全错的问题。从那以后团队的所有时间存储约定全部统一为UTC时间戳 展示侧区域转换。这里再分享一个判断什么时候该把时区参数暴露给调用方什么时候直接隐藏我的原则是“读写外部数据的方法暴露内部计算的方法隐藏”。比如从第三方拉取时间戳时能拿到对方时区就必须显式传两个日期相减算差值跟时区无关就封装成根本不接收时区的参数减少调用方心智负担。4. 测试驱动为DateUtil写一份靠谱的测试4.1 边界条件测试设计你的方法不是为“正常情况”写的DateUtil这种基础工具正常路径的测试写了等于没写真正的价值全在边界。我测试时重点关注五类场景闰年的2月29日解析、加减、年龄、月末都要过一遍跨年与跨月加减1天跨月加减1个月跨年季度初末跨年时间边界00:00:00、23:59:59.999、UTC时间整点边界空值与非法值null、空串、半角空格、格式错乱冬令时/夏令时地区切换如果业务有海外用户这个必须测下面展示一组核心测试注意我测的全是容易出错的角落Test void testLastDayOfMonth_leapYear() { LocalDate date LocalDate.of(2024, 2, 1); LocalDate lastDay DateUtil.lastDayOfMonth(date); assertEquals(LocalDate.of(2024, 2, 29), lastDay); } Test void testLastDayOfQuarter_crossYear() { LocalDate date LocalDate.of(2024, 12, 15); LocalDate lastDay DateUtil.lastDayOfQuarter(date); assertEquals(LocalDate.of(2024, 12, 31), lastDay); } Test void testParseDateTime_trimSpace() { assertEquals( LocalDateTime.of(2024, 5, 6, 7, 8, 9), DateUtil.parse( 2024-05-06 07:08:09 , DateUtil.PATTERN_DATETIME) ); } Test void testParsePattern_WrongYYYY() { // 2024-12-29这个日期祝用YYYY的人好运 assertThrows(DateTimeParseException.class, () - { DateUtil.parse(2024-12-29, YYYY-MM-dd); }); }最后一个测试的评论是故意写的。YYYY解析2024-12-29碰到的坑不是解析异常而是它会给出完全不同的“周年份”。这个专题下一节单独讲。4.2 测试用例不是多就好场景化组合才有效很多团队测DateUtil一个方法写一个正常用例就收工。我建议按“业务场景组合”来测不然各方法分开看都对串起来就出事。比如某个统计需求是“上个季度最后一天的结束时刻”按场景串起来Test void testEndOfLastQuarter_endOfDay() { LocalDate today LocalDate.of(2024, 1, 20); // 一季度内 LocalDate lastQuarterLastDay DateUtil.lastDayOfQuarter(today.minusMonths(3)); LocalDateTime endTime DateUtil.endOfDay(lastQuarterLastDay); // 2023-12-31 23:59:59.999... assertEquals(LocalDate.of(2023, 12, 31), endTime.toLocalDate()); assertEquals(LocalTime.MAX, endTime.toLocalTime()); }这里用LocalTime.MAX而不是手写23:59:59是因为LocalTime.MAX精确到纳秒23:59:59.999999999自带边界意义。写测试的时候优先用JDK的常量比自己在测试里造数字可靠。测试数据这块我很建议做一个“日期测试数据生成器”辅助工具专门生成所有边界日期集合比如闰年列表、每月最后一天列表、跨年周列表直接喂给参数化测试。手写几十条assertEquals是对生命的不尊重。5. 常见问题与排查技巧实录5.1 第一个大坑SimpleDateFormat线程安全回溯现在代码都用DateTimeFormatter了但存量老系统里还有大把SimpleDateFormat尸体。如果你接手老项目发现偶发性乱时间第一反应就查有没有共享的SimpleDateFormat实例。排查思路很简单全局搜索SimpleDateFormat字段定义看是不是static如果是再看有没有被多线程共用出现NumberFormatException、A java.text.ParseException: Unparseable date这类日志时往这个方向查准没错修复方案有两个。简单方案每次调用都new SimpleDateFormat并发安全但浪费对象推荐方案换成DateTimeFormatter线程安全实例或使用ThreadLocal包裹旧实例做过渡。老系统迁移不能一蹴而就时我用过ThreadLocal方案平滑过渡了一两周再把所有调用点换到新工具。5.2 第二个大坑yyyy还是YYYY一个字符毁了一季度的报表这是我在项目中栽过最无语的跟头。某次季度报表的汇总统计几乎整整一周的跨年数据全部归到了错误的年份里。一开始没人怀疑格式后来查到一个查重接口里写的是YYYY-MM-dd。yyyy是“纪元年份”YYYY是“Week Based Year”周周年份。在ISO-8601定义中每个周一作为一周的开始一年的第一个周四所在的周算第一周。“YYYY-MM-dd”在12月29日到12月31日之间完全可能因为“这一周属于下一年”而显示成下一年的日期。排查这类问题最直接的手段用DateTimeFormatter跑一遍2024-12-29到2025-01-04的格式化输出肉眼就能看出分裂2024-12-30 - yyyy: 2024, YYYY: 2025 2025-01-01 - yyyy: 2025, YYYY: 2025血泪教训写进团队规范任何时间格式化模式里年份只能用小写yyyy星期相关的YYYY只有在你需要周维度报表时才用。这个字符问题每年跨年都要坑一波人我专门把它写进了DateUtil的注释头条防止后来者改代码时顺手复制错误示例。5.3 第三个大坑时区配置混乱导致的时间偏移有位兄弟遇到过这么个事接口返回给前端的时间总是比数据库多8小时后来发现数据库连接串上没有指定时区而数据库默认时区是UTC程序在本地时区组装完时间后存进去再按UTC取出来显示直接偏移。这类问题的排查我一般按三步走先确认“存进去的时间和查出来的时间是否一致”不一致基本是时区或驱动问题再打点打印数据库连接串的serverTimezone参数以及JVM默认时区最后看ORM框架有没有做自动时间转换很多框架默认把切到UTC或本地时区根治办法就是我在DateUtil里强制显式指定时区同时规范入库格式统一用UTC时间戳。一旦DateUtil成了访问时间的唯一入口时区问题就等于在门口堵住了不会再到处渗透。5.4 第四个争议工具类要不要全静态、要不要搞单例很多教材说“工具类不要实例化私有构造器就好全用静态方法”。这个说法没毛病但我建议在DateUtil身上投一个额外的思想尽量无状态。静态方法的类必须是纯函数式的入参进来结果出去不依赖任何全局可变配置。团队里有同事曾经给DateUtil加过一个“全局默认格式”的可变静态字段支持运行时修改。上线后果然出事A服务动态改了默认格式B服务老数据解析全乱。后来我把可变字段全部挪走所有格式都要求从入参或者常量里取。经过这件事DateUtil彻底保持纯净项目里任何全局配置都藏到配置中心去不会放在工具类的静态字段里。如果你特别在意性能可以把常用的DateTimeFormatter作为static final缓存。因为DateTimeFormatter不可变这样做既是安全的又免了频繁创建。但像ofPattern(pattern)这种自定义格式能传常量就传常量不要在一个高并发循环里new一万次。实测下来缓存常用格式和不缓存之间的差距在高频日志场景下是肉眼可见的。5.5 我的避坑操作清单直接抄把这几年的经验浓缩成一份清单开发提测前对着自查一遍格式化年份一律小写yyyy周年份一律大写YYYY并明确用途24小时制用大写HH12小时制用小写hh分清场景不要信任服务器默认时区跨时区部署必须显式传ZoneId解析用户输入前先trim()空串分两个策略抛异常或者返回null但要用parseOrDefault包一层高并发代码里不要反复创建格式化器优先用static final计算日期区间时先确认业务含不含尾命名区分daysBetween和daysInclusive年龄计算不用Period.between和withYear处理闰日用MonthDay清单不长但每一条背后都对应过至少一次生产事故。6. 这套DateUtil还能怎么扩展核心工具稳定后我在这套DateUtil外又延伸了几个小功能都不往主类里塞而是各自独立成类。一个是FiscalDateUtil财年工具类。某财务系统没有用自然年做结算周期而是从每年4月1日起算财年季度切割完全不同于自然季度。这种逻辑如果混进DateUtil会让工具类被一个业务场景绑架。我单独建类处理DateUtil保持通用。另一个是HolidayCalendar可配置节假日。前面说的法定节假日调休我把它做成基于配置文件的SetLocalDate一年更新一次配置不用发版。DateUtil只提供isHoliday(LocalDate, SetLocalDate)的纯函数式方法不吃外部状态。还有个经验如果项目里有Excel导入导出日期工具最好配合单元格格式统一处理。我在某数据迁移项目里发现Excel日期序列号比如44927代表某个日期直接解析成文本后极其反直觉后来单独写了个转换方法处理测试时把1899年这种跨世纪边界也算上了。这些扩展的目的只有一个让DateUtil守住“日期通用能力”的边界所有偏业务的规则都流出去避免工具类腐化成业务类的垃圾桶。最后再说一点体会纯粹个人风格的选择。DateUtil这种工具类是最容易被低估的代码因为它到处都在用人人都能调但写得好不好决定了一个团队处理时间问题的整体底线。我做完那轮重构后最大的感受不是代码跑得多顺而是下游同事提bug的次数明显少了排查问题的时间也压缩了。如果你准备在项目里重做DateUtil别急着写方法先跟团队把时间规范、时区约定、格式标准定清楚代码是最不着急的一环。
阅读完成 · 觉得有帮助?