ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

commons-lang3实战指南:用StringUtils等工具类告别重复样板代码

commons-lang3实战指南:用StringUtils等工具类告别重复样板代码

开发Java至少五年以上的朋友,应该对下面这句代码一点都不陌生:

if (str != null && !str.isEmpty() && !str.trim().isEmpty()) { // 继续业务逻辑 }

就这么一个字符串非空判断,业务里到处都能看到它的身影。写的人倒不觉得累,但读的人看多了是真的头大。后来我彻底换成commons-lang3之后,同样的逻辑一行搞定:

if (StringUtils.isNotBlank(str)) { // 继续业务逻辑 }

就这么个不起眼的类库,让我在无数个项目里少敲了几万行重复代码。commons-lang3是Apache Commons家族里最经典的一员,定位就是给JDK内置的java.lang包做扩展补充。它不解决业务问题,但能把你日常开发中频繁使用的基础操作,比如字符串判空、数组处理、对象比较、随机数生成、异常堆栈转换这些,整理成一套标准、稳定、坑少的工具方法。

今天这篇东西,我打算以一个普通Java开发者的视角,把我这几年在项目里真正高频用到的commons-lang3方法做一次系统梳理,不追求把API文档抄一遍,只挑选能实实在在提升效率的部分,配上踩过的坑和优化过的写法。无论你是刚入门的新手,还是写了多年业务代码的老手,如果还没系统用上这个库,这篇文章应该能帮你把工具类用得更顺手。

1. 为什么要用commons-lang3:它解决的从来不是"有没有"的问题

很多同学一开始会问:JDK里不是已经有String类了吗?Java 8不是也有java.time包了吗?为什么还要引入一个第三方工具库?

这个问题的答案,往深了说其实是一个工程化思维的转变。JDK提供的是语言的基础能力,它追求的是通用和稳定,不会把每个业务开发中常见的操作都封装成现成的工具方法。举几个最简单的例子:判断一个字符串是不是空白、把一个字符串数组转成用逗号分隔的长字符串、获取一个对象的默认值、比较两个对象是否相等同时考虑null的情况。这些操作JDK类库本身能实现,但需要你写好几行样板代码,而且每换一个项目、每换一个开发者,写法可能都不一样。

这就是工具库的价值所在:把重复的样板代码收敛成标准化的方法调用,项目里所有人都走同一条路,代码的可读性和维护性自然就上来了。

commons-lang3的前身是commons-lang 2.x,到3.x版本时包名从org.apache.commons.lang改成了org.apache.commons.lang3,而且底层依赖Java 8+,在泛型推断、异常处理、空值安全方面做了一轮大升级。目前绝大多数新项目直接用3.x就对了,你完全不用回头去关注2.x的写法。

引入方式很简单,Maven项目加依赖:

<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.14.0</version> </dependency>

Gradle项目则写:

implementation 'org.apache.commons:commons-lang3:3.14.0'

这个库里最核心的几个工具类我需要先点一下名:StringUtils、ObjectUtils、ArrayUtils、NumberUtils、BooleanUtils、RandomUtils、DateUtils、DateFormatUtils、ExceptionUtils、ClassUtils、SystemUtils。往下我会按使用频率和实际价值逐个展开。

2. StringUtils:我把手写判空逻辑改成工具方法后,代码行数少了一半

如果要在commons-lang3里选一个使用率最高的类,那一定是StringUtils,没有之一。这个类几乎覆盖了字符串操作的全场景:判空、截取、拼接、替换、包含判断、去除空白、大小写转换等等。而且它有一个贯穿始终的设计理念,就是空安全——大多数方法接收null都不会抛空指针,而是返回一个合理的默认值,这背后其实是把"防御性编程"内化到了工具层面,你写业务代码时不需要在每一个字符串操作前都加一个null判断。

2.1 判空:isEmpty、isBlank、isNotEmpty、isNotBlank

这几个方法是最基础也最容易搞混的,值得先说清楚。

方法判断逻辑典型场景
isEmpty(null) / isEmpty("")返回true判断字符串是否为null或空串
isBlank(null) / isBlank("") / isBlank(" ")返回true判断是否为null、空串或纯空白
isNotEmpty / isNotBlank上述逻辑取反业务校验中更常用

我第一次用commons-lang3时犯过一个错,就是把isBlank和isEmpty混用,结果在表单校验场景里,用户输入了一串空格居然能通过校验。后来我规定自己:凡是"用户输入内容"这种场景,一律用isBlank来判,空串和纯空格都不应该被当成有效输入。而像"某个字段是否被赋值"这种场景,用isEmpty更贴近语义。

顺带提一句:3.5版本之后,StringUtils里还加了isAllBlank、isNoneBlank这类多参数方法,一次判断N个字符串,非常方便。比如登录时用户名、密码、验证码三个都不能为空,一行写法就是:

if (StringUtils.isAnyBlank(username, password, verifyCode)) { throw new IllegalArgumentException("关键参数不能为空"); }

2.2 拼接与切割:join、split的工程细节

在Java 8的String.join出现之前,StringUtils.join一直是项目里拼接字符串的标配。现在JDK虽然补上了这个能力,但StringUtils.join还是有它的独特价值——它支持迭代器、数组、各种分隔符,并且内部对null做了处理,返回空字符串而不是把null拼进去。

String[] arr = {"java", "python", "go"}; String result = StringUtils.join(arr, ", "); // 输出: java, python, go

和Java 8的String.join对比有一个容易被忽略的差异:当数组元素中包含null时,String.join会输出null这个字符串,而StringUtils.join默认会把null当成空串处理。如果你在拼SQL、拼日志、拼文件路径,这个差异可能会直接影响输出内容,选择时务必留意。

split方法也有讲究。JDK内置的String.split接收的是正则表达式,而StringUtils.split默认按字符切分。举个例子:

"a,b;c".split("[,;]") // JDK:支持正则 StringUtils.split("a,b;c", ",;") // 第三个参数是分隔符字符集,按任意一个字符切分

我实际用下来,StringUtils.split有个更好的体验是:它会自动忽略切分产生的首尾空字符串。也就是说"a,,b"用","切,JDK的split会得到["a", "", "b"],而StringUtils.split得到的是["a", "b"]。在某些业务场景里(比如解析CSV时想跳过空字段),这个行为反而是我们想要的。

2.3 substring、truncate与contains的实用细节

子串操作上,StringUtils提供了多个重载方法。比如substring(str, start, end)和JDK的substring基本一致,但它兼容了start和end越界的情况,不会因为参数越界抛异常。默认的JDK实现要是下标传错了,直接StringIndexOutOfBoundsException,在边界较多的解析场景里特别容易炸,换成StringUtils后清爽很多。

还有一个更高频的方法:truncate。它和substring的差别在于,truncate是从字符串开头截取指定长度,超出的部分丢弃,常用于列表页截断超长文本。比如:

StringUtils.truncate("这是一段很长的描述文案", 6); // 返回: 这是一段很长的描

这类方法虽然简单,但胜在语义明确,团队里用起来沟通成本很低。

contains家族也值得一提。contains(str, searchStr)是普通的包含判断,containsIgnoreCase则忽略大小写,containsAny可以判断一个字符串是否包含字符数组中的任意一个字符。之前做过一个需求,判断用户上传的文件名是否属于允许的图片扩展名列表,我就直接用了:

if (!StringUtils.containsAny(fileName, "png", "jpg", "jpeg", "gif", "bmp")) { throw new RuntimeException("仅支持图片文件"); }

2.4 其他高频方法:defaultString、strip、leftPad

defaultString是另一个让我又爱又恨的方法。它做的事情很简单:如果字符串是null,返回默认值:

String displayName = StringUtils.defaultString(user.getName(), "匿名用户");

这个方法我用得非常多,尤其是在组装展示层数据时,避免频密的null判断。注意它区分null和空串:null会走默认值,空串不会。如果你想空串也走默认值,还得搭配isBlank来写:

String displayName = StringUtils.isBlank(user.getName()) ? "匿名用户" : user.getName();

strip则是去除字符串两端的空白字符,和trim的作用类似,但strip除了能去掉标准空格,还能去掉全角空格这类特殊空白,国际化场景下比trim更靠谱。我处理多语言文案时遇到过字符串首尾带着全角空格的情况,trim完全无效,换strip就正常了。

leftPad往字符串左侧补指定字符到目标长度,典型场景是订单号、流水号的补零:

String orderNo = StringUtils.leftPad(String.valueOf(10086), 10, '0'); // 输出: 00000010086

3. ObjectUtils与BooleanUtils:处理null和布尔值的三态问题,代码立刻干净

字符串工具类用得最多,但真正让我觉得"这库太值了"的时刻,其实来自ObjectUtils和BooleanUtils。

3.1 ObjectUtils:默认值、比较与toString

ObjectUtils中最常用的方法有三个:defaultIfNull、equals、toString。

defaultIfNull方法直接用泛型返回对象,配合演示代码如下:

User realUser = userService.getUser(userId); User safeUser = ObjectUtils.defaultIfNull(realUser, new User()); // 即使realUser为null,后续代码也不会NPE

这个方法在写一些"查询结果可能为空,但空对象也有业务含义"的代码时非常好用。比如你查一个用户的扩展配置,查到就返回配置,查不到就返回默认配置,一行代码就搞定,不用再写if判断。

equals方法解决的是对象比较时的null安全问题。JDK自带的Objects.equals其实已经有了,但ObjectUtils.equals的优势在于,它的内部实现是"第一个参数为null时返回false",并且更早的版本里就兼顾了不同类型对象的比较。实际上两者差别不大,用哪个看团队习惯。不过有一个细节值得一说:ObjectUtils.equals会比较数组内容,而普通equals比较的是数组引用地址。如果你在代码里需要比较两个字节数组的内容是否相同,直接用ObjectUtils.equals可以避免自己写循环。

toString这里有个隐藏细节,JDK的String.valueOf(Object)在参数为null时返回的是字符串"null"(四个字符),而ObjectUtils.toString(obj)默认返回的是空字符串。在做日志输出、审计记录时,这两者差别还是挺明显的。

Object obj = null; String s1 = String.valueOf(obj); // "null" String s2 = ObjectUtils.toString(obj); // ""

3.2 BooleanUtils:三态布尔的正确打开方式

Java的boolean只有true和false两种取值,但Boolean封装类却有三种状态:true、false、null。很多业务字段在数据库里就是可空的布尔值,比如"是否删除"、"是否启用",读出来是Boolean对象,很可能为null。如果直接拿它做条件判断,容易在null时走错分支。

BooleanUtils提供的核心价值就是安全地处理这种三态布尔。我用得最多的是跟数字之间的转换:

// Boolean转数字,true=1,false=0 int flag = BooleanUtils.toInteger(isEnabled); // 数字转Boolean,null安全 Boolean isEnabled = BooleanUtils.toBooleanObject(flag);

还有toBooleanDefaultIfNull,允许你给null指定默认值:

boolean isVip = BooleanUtils.toBooleanDefaultIfNull(user.getVipFlag(), false);

这行代码比手写三元表达式清晰多了。另外一个常用场景是BooleanUtils.toString方法,将true/false映射成自定义字符串,比如"Y"/"N",这在对接外部系统接口时相当高频:

String yesNo = BooleanUtils.toString(isActive, "Y", "N", "N"); // 第三个参数是null时的默认值

4. ArrayUtils与NumberUtils:数组操作和数字转换的隐藏强者

4.1 ArrayUtils:判空、追加、反转和元素查找

如果说StringUtils是使用频率之王,ArrayUtils就是最容易被人忽略的强者。它在处理数组时的优势,完全不输给Java的Collections或Stream API,而且写法更直接。

最常用的toString方法就比JDK原生体验好很多。打印一个int数组时,直接使用数组的toString会得到形如[I@5e481248的地址,而ArrayUtils.toString会输出{1, 2, 3}这种可读的格式。调试的时候非常有用:

int[] nums = {1, 2, 3}; System.out.println(ArrayUtils.toString(nums)); // 输出: {1,2,3}

判空方法isNotEmpty则是数组场景下最常见的工具方法。数组不像集合有isEmpty方法,判断null后又得判断length,ArrayUtils帮你合并成一步:

if (ArrayUtils.isNotEmpty(items)) { // 继续业务处理 }

数组扩容和复制操作也是ArrayUtils的强项。我在项目里用add和remove方法做过一个动态维护标签列表的功能,比起手动用System.arraycopy,代码可读性好太多:

String[] arr = {"a", "b"}; String[] newArr = ArrayUtils.add(arr, "c"); // {a,b,c} String[] shrunk = ArrayUtils.remove(newArr, 0); // {b,c}

还有反转数组的reverse、查找元素下标的indexOf、判断是否包含某元素的contains。从工程角度来看,arraycopy这类原生操作应该永远被封装在底层SDK里,业务代码直接用ArrayUtils足够。

4.2 NumberUtils:数字转换不再为异常发愁

NumberUtils主要解决两个问题:字符串与数字的安全转换、数字之间的比较。

数字转换时,我最早常用的方法是toInt(String, int defaultValue),字符串不是合法数字时直接返回默认值,而不会像Integer.parseInt那样抛NumberFormatException。类似还有toLong、toDouble、toFloat配合默认值的重载方法。解析外部接口返回的字符串字段时,这种"出错不给异常,只给兜底值"的机制非常实用:

int pageSize = NumberUtils.toInt(request.getParameter("pageSize"), 20);

另一个高频工具是createNumber和createBigDecimal,前者能识别字符串里的进制前缀、十六进制、八进制等格式,后者专门用于解决浮点数计算精度问题。项目里算金额时推荐用NumberUtils.createBigDecimal将字符串转为BigDecimal,避免直接用Double做精确计算。

数字比较上,NumberUtils.compare方法是跨类型比较数字的安全选择。但实际项目中我更常直接用compareTo方法,尤其注意float和double的比较,因为包装类型比较时会有精度坑,用NumberUtils提供的compare方法可以规避浮点精度引发的问题。

4.3 字符串转数字的底层坑:为什么我不直接推荐Integer.parseInt

这里顺便展开说一个经验。在大部分场景中Integer.parseInt看起来直接简单,但它有两个很现实的问题:对null字符串直接抛NumberFormatException,对空字符串也抛。所以每做一次转换都要先判空、再try-catch。而NumberUtils.toInt把这个防御性逻辑封装在内部,返回默认值,让代码干净了一个层次。

当然也要辩证看待。如果业务需求是"转换失败时要有明确提示,不能静默用默认值",那就应该用Integer.parseInt并主动捕获异常。工具方法只提供能力,最终行为取决于你的业务语义。

5. RandomUtils、日期与异常处理:容易被忽略但很能打的辅助类

5.1 RandomUtils:别再自己封装随机数方法了

很长一段时间里,我的项目里每个团队都会自己封装一个RandomUtil。有的是用UUID,有的是用Math.random,写法五花八门且容易踩坑。后来我统一换成了commons-lang3的RandomUtils,省了很多事。

常用方法如下:

// 生成[0, 100)之间的随机int int randInt = RandomUtils.nextInt(0, 100); // 生成[0.0, 1.0)之间的随机double double randDouble = RandomUtils.nextDouble(0.0, 1.0); // 随机取一个枚举值 RandomEnum randomEnum = RandomUtils.nextEnum(RandomEnum.class);

RandomUtils封装了线程安全性。在Java 8以后,底层使用ThreadLocalRandom实现,在高并发场景下比单纯使用Math.random要安全。我自己在生成短信验证码、随机抽奖、模拟测试数据这些场景里都用它,没有再重复造过轮子。

5.2 日期工具:DateUtils在Java 8时代还值得用吗?

这个问题我在很多场合被人问过,回答要分两层。

Java 8之后,项目里新代码应该优先使用java.time包下的LocalDateTime、ZonedDateTime这些类,这是趋势。但如果你维护的是老项目,或者要跟数据库里的java.util.Date交互,那commons-lang3的DateUtils和DateFormatUtils依然是救命稻草。

DateUtils最常用的方法之一是parseDate,它可以接收多组日期格式,依次尝试解析:

Date date = DateUtils.parseDate("2024/03/15", "yyyy/MM/dd", "yyyy-MM-dd", "yyyyMMdd");

另一个高频操作是时间偏移计算。比如计算当前时间加7天、减3小时,DateUtils的addDays、addHours方法一行搞定,不用自己去操作Calendar。

Date expireTime = DateUtils.addDays(new Date(), 7);

但我必须强调:不要在新项目中用DateUtils做复杂的日期计算。换成LocalDateTime.plusDays()更直观也更安全。

DateFormatUtils则多用于将Date格式化成字符串。最常用的格式化方式是:

String now = DateFormatUtils.format(new Date(), "yyyy-MM-dd HH:mm:ss");

这套方法胜在写法简单,参数没有多余的累赘。

5.3 ExceptionUtils:异常堆栈的获取与转换

异常处理是Java开发里绕不开的话题。ExceptionUtils给了一个非常有用的能力:直接获取异常的完整堆栈字符串,以及递归查找根异常。

try { riskyCall(); } catch (Exception e) { String stackTrace = ExceptionUtils.getStackTrace(e); log.error("业务执行异常: {}", stackTrace); }

在打印错误日志时,这种方式展示的堆栈格式与异常原生打印的堆栈完全一致,但可以方便地存入日志系统或消息队列。另一个常用方法是getRootCause,当异常层层包装时,它能直接帮你找到最底层的根因异常,排查问题时非常有效。

Throwable root = ExceptionUtils.getRootCause(e);

这里有一个实战心得:项目里做接口调用失败的重试判断时,我经常配合getRootCause使用。只有根因是网络超时、连接中断这类可重试的异常才触发重试,而不是看最外层的包装异常。

5.4 ClassUtils:反射场景下的辅助利器

ClassUtils在反射工具类里相对更加底层,但我实际做框架封装时经常用到。比如getShortClassName能返回短的类名,isAssignable可以做安全的类型赋值判断,在泛型擦除、接口实现判断的场景下非常有用。写复杂SPI加载逻辑或者字段拷贝工具时,ClassUtils能帮上不少忙。

6. 避坑指南:关于版本、兼容性与使用禁忌的六大经验

6.1 版本选择的血泪教训

commons-lang3从3.0到3.14,API基本稳定,可以直接上最新稳定版。但要注意包名与旧版的关系:如果你在依赖里同时看到commons-lang和commons-lang3,一定检查一下是不是某个老依赖传递引入了旧版包,旧版的org.apache.commons.lang和新的org.apache.commons.lang3是两个完全不同的类路径。这种依赖冲突在大型项目里最常见,排查起来非常折腾,建议统一在构建配置中排除旧版本依赖。

6.2 不要为了工具方法而引入整个依赖

commons-lang3的jar包本身很小,依赖引入的成本几乎可以忽略不计。真正需要注意的是,尽量只使用稳定版本,避免使用过旧版本或特意使用新加的API。有些老旧线上项目锁定了开源依赖的版本后,如果强行升级到最新版,可能带来其他依赖的传递性变化,容易引发意想不到的编译问题。

6.3 不要在循环里用toString拼接大字符串

StringUtils.join对集合和数组的效率还不错,但如果你是在循环里反复拼接超长字符串,仍然建议用StringBuilder。工具类只是帮你封装了写法,并没有魔术般的性能提升。性能敏感的大批量字符串拼接场景,还是要自己掌握底层数据结构。

6.4 方法命名相似,语义差异巨大

isBlank和isEmpty的区别我已经在前面讲过了。需要特别提醒的是,项目中尽量约定统一,避免有人用isBlank、有人用isNotEmpty,看着都差不多,实际处理空白字符时的行为不一样,容易引发隐性bug。我的建议:凡是校验用户输入的,统一用isBlank和isNotBlank;凡是判断状态字段是否赋值,用isEmpty和isNotEmpty。

6.5 null安全不等于业务正确

有同学会用StringUtils.defaultString来防止NPE,却忽略了空串可能给下游带来的影响。工具方法的null安全,本质是帮你避免了空指针异常,但业务上对空值的处理仍然是你的责任。把null转成空串传入业务校验逻辑,有时候反而会掩盖了"参数缺失"这种真实问题。我见过很多案例,排查到最后,发现就是defaultString把null转成了"", 导致下游一直拿到空串做业务判断,根本无法区分到底是没传还是传了空值。

6.6 团队内使用建议

最后是三条实际的团队建议。第一,工具类的使用要沉淀成团队编码规范,明确哪些工具类统一用commons-lang3,避免各自为政。第二,阅读源码,尤其是StringUtils这类核心类的源码,你会发现里面大量用到了三目表达式、空值兜底的设计思想,对自己写代码也有很大帮助。第三,不要和Guava的功能重复叠加。commons-lang3和Guava有不少功能重叠的地方,比如字符串判空、集合判空、默认值等,团队最好确定一个主选方案,避免风格混乱。

7. 我的使用习惯与后续扩展方向

写到这里,大部分我认为值得推荐的内容都覆盖了。最后分享一些个人的使用习惯:我一般在项目的基础设施层,比如通用响应体封装、全局异常处理、日期格式化组件这些地方,都会大量使用commons-lang3的工具方法,这些位置往往跨多个业务模块,使用工具类收敛逻辑的价值最高。业务层中,则更倾向于在复杂字符串处理和入参校验时使用,其他场景不强求。

另外,commons-lang3本身也在持续演进。3.x系列后期版本加入了更多对Java 8+特性的适配,比如更便捷的泛型方法、更丰富的空值处理工具。如果项目里还没有引入,真心建议从今天开始在新建的类里引入一下,先从StringUtils和ObjectUtils用起来,习惯培养起来以后,你会发现代码的整体气质不太一样。

RELATED READING

延伸阅读

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