ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java提取字符串日期年份:从substring到LocalDate的完整方案

Java提取字符串日期年份:从substring到LocalDate的完整方案

上周做报表需求时碰到一个很不起眼的问题:数据源从老系统导出来,日期字段全是String类型,规范统一是"2004-07-11"这种写法。统计口径要按年份分组,所以第一步就变成了一道经典 Java 小题——从 String 类型的"2004-07-11"提取年份2004,并且以Integer返回。

需求听起来很简单,真落到代码上才发现,实现方式至少有四五种,每种背后对应不同的格式假设、异常策略和性能取舍。这篇文章就当一次完整复盘,把从最朴素的substring到规范的java.time解析方案都梳理一遍,也会聊几个实际踩过的坑。

1. 拿到“2004-07-11”这种字符串,先别急着写 substring

1.1 这种字符串是怎么来的

很多刚接触的同学会以为日期就是Date或LocalDate类型,现实里大量系统为了兼容性,把日期直接存成String。常见的来源有三个:

  • 老系统的数据库字段是varchar,存的就是"2004-07-11",换库换表的时候没人敢动它。
  • 外部接口返回的 JSON 里没有日期类型,前端拿到的就是字符串。
  • Excel、CSV 文件导入时,单元格格式本身就是文本,程序读出来必然是String。

在这些场景里,你没法要求上游“先转好类型再给我”,只能自己处理字符串。也正是因为字符串的不可控性,提取年份这件事才需要认真想清楚。

1.2 动手前先问自己三个问题

接到需求第一反应是substring(0, 4)没问题,但我会先确认三个问题,避免白写:

  1. 输入格式是不是永远是"yyyy-MM-dd"?
  2. 有没有可能出现null、空字符串、前后空格?
  3. 年份提取失败时,是返回null还是抛异常?

这三个问题直接决定你选哪种写法。如果业务方一口咬定“格式绝对固定”,substring最省事;如果数据来自不可控的上游,那么规范的LocalDate.parse或者一个封装好的工具方法会稳妥得多。很多线上问题都出在“我以为格式固定”上,所以开头多问一句,比后续写一堆修补代码值。

2. 最直接的 substring 方案:三行代码,但前提是格式必须听话

2.1 代码确实简单

先看最直白的写法:

String dateStr = "2004-07-11"; String yearStr = dateStr.substring(0, 4); // "2004" int yearInt = Integer.parseInt(yearStr); // 2004 Integer year = Integer.valueOf(yearInt); // Integer 对象

也可以合并成一行:

Integer year = Integer.valueOf(dateStr.substring(0, 4));

为什么能这么写?因为"2004-07-11"这个格式里,年份永远占据前 4 个字符,而且这 4 个字符都是数字。这是最直接的字符串剪切思路,不需要引入任何额外对象,代码量最少,执行也快。

2.2 格式一变,substring 就崩给你看

substring最大的问题是它根本不知道自己在处理日期,它只是“切字符串”,所以数据的真实结构一旦发生变化,结果就不可控了。我给你列几个真实可能出现的变体:

输入内容substring(0, 4) 结果后续 parseInt 表现
"2004-07-11""2004"正常得到 2004
"2004/07/11""2004"碰巧正常,但语义上你已经不再校验格式了
"2004年07月11日""2004年"抛出NumberFormatException
"born:2004-07-11""born"抛出NumberFormatException
"2004-7-11""2004"碰巧正常,但后续日期部分格式已经被忽略
"2004-13-99""2004"碰巧正常,但明显是非法日期

这表格里的后几种情况,substring都能“成功”提取出年份,但整个日期可能是错的。如果你只是做粗粒度统计,要的可能就是这四位数字;如果后面还有其他同学接手这段逻辑,他看到"2004-13-99"居然也能返回 2004,大概率会以为业务上允许这种脏数据。这个隐形风险比异常本身更麻烦。

2.3 Integer.parseInt 和 Integer.valueOf 的区别

在这个例子里有个小细节值得单独说一句:Integer.parseInt(String)返回的是基础类型int,而Integer.valueOf(String)返回的是包装类型Integer。题目要求返回Integer,所以直接写Integer.valueOf(dateStr.substring(0, 4))是语法上最贴近需求的。

但注意,Integer.valueOf(String)内部也会做一次解析,如果传入的不是纯数字字符串,它一样会抛NumberFormatException。它只负责“字符串到 Integer 的转换”,不负责“这个字符串是不是合法年份”。测试的时候可以自己写两行 main 验证一下:

System.out.println(Integer.valueOf("2004")); // 2004 System.out.println(Integer.valueOf("2004年")); // NumberFormatException

第二个输出会直接抛异常,这就是把字符串处理和日期语义混在一起的最大代价。

3. LocalDate.parse + getYear:规范做法,但也要记住格式限制

3.1 为什么我更推荐 java.time

Java 8 引入java.time包之后,日期解析这件事就应当优先用它。LocalDate.parse默认使用ISO_LOCAL_DATE格式,也就是"yyyy-MM-dd",正好匹配"2004-07-11":

LocalDate date = LocalDate.parse("2004-07-11"); int year = date.getYear(); // 2004 Integer yearBoxed = Integer.valueOf(year);

这段代码最大的价值在于:它不只完成了“截取前四位”,而是真正把整个字符串当作日期去解析。"2004-13-11"这种非法月份会被直接拦截,"2005-02-29"这种不存在的日期也不会放行。因为底层是完整的日期解析,读代码的人一眼就能明白:先把字符串转成日期对象,再从日期对象里取年份。语义清晰,不容易误导后来维护的人。

类似的,LocalDateTime.parse处理"2004-07-11T00:00:00"这种带时间的字符串也很方便,取年份的逻辑一样。

3.2 当“2004-7-11”出现时,默认解析会失败

LocalDate.parse默认要求月和日都是两位数。拿"2004-7-11"或"2004-07-1"去解析,会直接抛DateTimeParseException,因为默认格式里月份是两位、日期也是两位。这在某些手工录入的场景很常见,比如用户填表时写成了"2004-7-1"。

遇到这种需求,可以自己定义一个宽松一点的DateTimeFormatter:

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("uuuu-M-d", Locale.ROOT); LocalDate date = LocalDate.parse("2004-7-11", formatter); Integer year = Integer.valueOf(date.getYear());

注意我用的是uuuu。uuuu和yyyy在解析公元年份时基本没有差别,但从语言规范角度,uuuu是“proleptic year”,更贴近底层日期计算的语义,社区里越来越多人推荐在日期解析时用uuuu而不是yyyy。新手可以直接记住:解析日期,写uuuu更保险。

3.3 带时间、带格式后缀怎么办

实际数据里还经常出现"2004-07-11 00:00:00"这种带空格带时间的写法。此时LocalDate.parse直接解析会失败,有两个方向:

// 方向一:按 LocalDateTime 解析再取日期 LocalDateTime dateTime = LocalDateTime.parse("2004-07-11T00:00:00"); Integer year = Integer.valueOf(dateTime.getYear()); // 方向二:自定义格式,只取日期部分 DateTimeFormatter formatter = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss", Locale.ROOT); LocalDateTime dateTime2 = LocalDateTime.parse("2004-07-11 00:00:00", formatter); Integer year2 = Integer.valueOf(dateTime2.getYear());

很多人在这里会纠结“多一个 T 少一个空格”的问题。判断标准很简单:先看上游文档,确认到底有没有时间部分、分隔符是T还是空格。如果确实存在多种变体,可以准备两三个DateTimeFormatter依次尝试,而不是写一段脆弱的字符串剪切。

4. 其它“能跑”的方案:正则、SimpleDateFormat、split

4.1 split("-")[0]:取巧但同样依赖分隔符

还有一个常见的写法是:

String dateStr = "2004-07-11"; Integer year = Integer.valueOf(dateStr.split("-")[0]);

这种方案比substring(0, 4)更进了一步:不假设年份固定在前四位,而是假设第一段是年份。对于"2004-07-11"它能正确返回,对于"2004/07/11"它会得到一整段"2004/07/11"然后解析失败。

它的问题在于:split是按分隔符切分,不是按日期语义切分。如果字符串里年份前有前缀,比如"born-2004-07-11",它取到的就是"born"。所以这种写法适合你已经确定数据“就是三段,以-分隔”,且不会变化的场景,本质上是substring的变种。

4.2 正则表达式:校验和提取一起做

正则适合“既要判断格式对不对,又要提取年份”的场景。比如:

private static final Pattern YEAR_PATTERN = Pattern.compile("^(\\d{4})-(\\d{2})-(\\d{2})$"); public static Integer extractYear(String dateStr) { Matcher matcher = YEAR_PATTERN.matcher(dateStr); if (matcher.matches()) { return Integer.valueOf(matcher.group(1)); } return null; }

这段代码有两个好处:第一,它要求整个字符串完全匹配"yyyy-MM-dd",格式不对就返回null,不会把"2004-13-99"当合法数据处理;第二,捕获组直接提取第一段年份,不存在越界问题。

缺点是代码量偏大,而且正则只校验了结构,并没有校验日期的真实合法性,比如"2004-02-30"用上面这个正则也能匹配成功。如果你既想要正则的“格式校验”,又想要日期的“业务校验”,就必须在正则匹配通过后再调用LocalDate.parse。正则更适合做“预检”,不适合做最终裁决。

4.3 SimpleDateFormat + Calendar:老代码里经常见,坑也不少

在一些老项目里会看到这种做法:

SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); Date date = sdf.parse("2004-07-11"); Calendar calendar = Calendar.getInstance(); calendar.setTime(date); Integer year = calendar.get(Calendar.YEAR);

它的核心思路是:先解析成Date,再用Calendar取出年份。这个方案能跑,但我不推荐在新代码里用。原因有三个:

  • SimpleDateFormat不是线程安全的,把它定义成static后多线程并发解析会出现诡异结果,比如年份错乱、时间漂移。
  • SimpleDateFormat默认是宽松解析模式,"2004-13-11"这种数据可能会被自动“进位”解析成2005-01-11,年份直接从 2004 变成 2005。
  • 代码里塞进一个Calendar对象,结构上明显比LocalDate.getYear()笨重。

唯一建议使用它的场景是:项目还在用 Java 7 或更早版本,没法引入java.time,又不想额外加依赖。否则一律优先java.time。

4.4 方案横向对比

方案代码量能否校验日期合法性线程安全推荐度
substring+parseInt少不能安全(无共享状态)格式绝对固定时可用
split("-")[0]少不能安全不如 substring 直观
正则表达式中只能校验结构安全(Pattern 可复用)适合做预检
SimpleDateFormat中宽松模式有坑不安全老项目维护再用
LocalDate.parse+getYear中能安全默认推荐

这里说的“线程安全”,在substring和split方案里其实是指代码本身不依赖共享可变对象;而在SimpleDateFormat方案里,如果你把它定义为静态字段,风险就会冒出来。DateTimeFormatter相反,它是线程安全的,定义成static final复用完全没问题。

5. 封装一个可以直接抄的工具方法

5.1 常规工具方法长这样

项目里如果多处都要提取年份,不要每次到处写substring或者LocalDate.parse,封装一个工具方法是最好的选择。我提供一个可以直接抄的版本:

import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.Locale; public final class ExtractYearUtil { private static final DateTimeFormatter FLEXIBLE_FORMATTER = DateTimeFormatter.ofPattern("uuuu-M-d", Locale.ROOT); private ExtractYearUtil() { } public static Integer getYear(String dateStr) { if (dateStr == null) { return null; } String text = dateStr.trim(); if (text.isEmpty()) { return null; } LocalDate date; try { date = LocalDate.parse(text); } catch (DateTimeParseException e) { try { date = LocalDate.parse(text, FLEXIBLE_FORMATTER); } catch (DateTimeParseException ex) { return null; } } return Integer.valueOf(date.getYear()); } }

逻辑并不复杂:先处理空值和前后空格,再尝试标准格式"2004-07-11",失败后尝试"2004-7-1"这类宽松格式,都失败就返回null。两个DateTimeFormatter都没有共享可变状态,工具类整体线程安全。

5.2 异常策略怎么定

上面工具方法选择返回null,这是我在统计类需求里的习惯:上游有一两条脏数据,不应该让整个批量任务崩溃。但如果你的业务要求“解析失败就是接口调用失败,必须让调用方感知”,那返回null就不合适,应该改抛IllegalArgumentException,把原始字符串和异常信息一起带出去:

throw new IllegalArgumentException("无法解析的日期字符串: " + dateStr, e);

没有绝对正确的策略,只有适合当前业务的策略。封装时最好把这个决策暴露给调用方,比如再加一个getYearOrThrow方法。

5.3 简单验证用例

不引入测试框架,直接写main也能验证:

public static void main(String[] args) { System.out.println(ExtractYearUtil.getYear("2004-07-11")); // 2004 System.out.println(ExtractYearUtil.getYear("2004-7-11")); // 2004 System.out.println(ExtractYearUtil.getYear(" 2004-07-11 ")); // 2004 System.out.println(ExtractYearUtil.getYear("")); // null System.out.println(ExtractYearUtil.getYear(null)); // null System.out.println(ExtractYearUtil.getYear("2004-13-11")); // null System.out.println(ExtractYearUtil.getYear("2005-02-29")); // null }

最后一个用例是关键:2005-02-29不是合法日期,因为 2005 不是闰年。工具方法返回null而不是 2005,这正是java.time方案相对substring方案的核心优势。

5.4 为什么我看到有人用 Integer 而不是 int

题目明确要求返回Integer,但很多项目代码里根本不需要包装类型。这里有个容易混淆的点:int year = date.getYear()返回的是基本类型,Integer.valueOf(date.getYear())返回的是包装类型。如果你的业务逻辑里没有集合泛型、没有数据库映射等强制要求,用int更轻量;只有当你要把年份放进List<Integer>、Map<String, Integer>,或者框架要求返回值非空时,才需要Integer。

还有一点要提醒:如果getYear()设计成在解析失败时返回null,那返回类型必须是Integer,因为int装不下null。这两者不是一个“心情好选哪个都行”的问题,而是由空值策略决定的。

6. 实际使用中的隐藏坑:前导零、性能、时区、维护

6.1 年份可能不是四位数

我遇到过从旧系统导出的数据,年份只有两位,比如"04-07-11"。用substring(0, 4)会取到"04-0"这种怪东西,直接炸;用LocalDate.parse默认格式也会因为年份不足四位而出问题。

这类数据要先确认业务口径:两位年份"04"到底代表 2004 还是 1904?如果你能确认它是 2004,可以自行补零:

String dateStr = "04-07-11"; String normalized = "20" + dateStr; // 简单拼接 LocalDate date = LocalDate.parse(normalized); Integer year = Integer.valueOf(date.getYear()); // 2004

补零逻辑看起来粗暴,但至少它把“数据不规范”这件事暴露在代码里了。等将来数据源修复了,这里可以整体删掉,不会留下模糊不清的年份偏移。

6.2 解析性能:不是所有场景都应该上 LocalDate.parse

LocalDate.parse看起来很美,但它内部要做完整的格式化校验和字段解析,在高频循环里确实比单纯的substring + parseInt慢。我自己本地跑过简单对比,两者差距能到数倍甚至更多,但这个结论要看环境,不要拿它当金科玉律。

我的取舍标准很简单:

  • 数据量小、格式乱、希望校验严格:用LocalDate.parse或工具方法。
  • 数据量极大、格式绝对固定、且已经确认上游稳定:用substring(0, 4) + Integer.parseInt。
  • 中等规模且上游有变数:写工具方法,先substring快速路径,再兜底LocalDate.parse,把你的性能追求和健壮性要求组合起来。

多数业务场景里,真正的瓶颈在网络和数据库,解析省出来的那点时间可以忽略不计。只有在纯内存批量处理海量数据时,性能优化才有意义。

6.3 时区问题:字符串本身和“年份”的歧义

有人觉得字符串"2004-07-11"已经固定了,时区有什么关系?关系在于这个字符串是怎么生成的。如果字符串来自一个 UTC 时间戳,比如2004-07-11T16:00:00Z,在某个东八区系统里显示已经是2004-07-12,这时按字符串提取年份拿到的是2004,但如果你在代码里把它转成Instant,再按其他时区取年份,就可能变成2005或2004,取决于时区转换规则。

所以处理带时间部分的日期字符串时,必须明确:这个字符串是哪个时区的本地时间?如果上游文档说“所有时间都是服务器本地时间”,那解析时用ZoneId.systemDefault()即可;如果上游说“这是 UTC 时间”,你就得先按OffsetDateTime.parse解析,再转换到目标时区再取年份。最怕的是不明确时区约定,代码里随便写一个ZoneId,最终年份可能和业务预期差一年。

6.4 我的最终建议

如果是面试场景,给出substring方案可以体现基础功底,但紧接着展示LocalDate方案,并说明两者的边界差异,才是更完整的回答。

如果是生产代码,我会按这个顺序决策:

  1. 先确认上游格式和脏数据比例。
  2. 确认是否存在null、空串、前后空格、单月单日、带时间等变体。
  3. 日常首选LocalDate.parse + getYear,必要时配一个宽松DateTimeFormatter。
  4. 性能敏感且格式铁板钉钉,再用substring,但一定要在代码注释里写清“不要乱动,依赖固定宽度”。
  5. 所有解析集中到一个工具方法里,避免团队里每个人都写一套自己的提取逻辑。

个人最深的体会是,这种小需求如果不把边界说清楚,看起来三行代码的事,后面一定会变成若干个if和异常补丁。把格式假设、空值策略、失败策略一次定下来,反而省心。下次你再遇到类似需求,希望能直接照这个思路落地,少走几步弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进