尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java优雅判空实战:从Optional到卫语句告别空指针异常

Java优雅判空实战:从Optional到卫语句告别空指针异常 1. 先看看那些让人血压升高的判空写法不知道你有没有这样的经历接手一段老代码翻到某个业务方法映入眼帘的全是下面这种画面。if (user ! null) { if (user.getAddress() ! null) { if (user.getAddress().getCity() ! null) { System.out.println(user.getAddress().getCity()); } } }缩进一层套一层括号像俄罗斯套娃。你想改个逻辑得先数清楚这层 if 到底属于哪个对象。要是哪天产品经理说“加个需求用户没有地址时默认显示北京”你站在原地盯了屏幕五分钟愣是不知道这个 if 该加在哪。说实话这种代码能跑但维护它的人每一次改动都在消耗耐心。再举一个更常见的例子。你用 equals 判断字符串但没注意调用方可能是 nullif (name.equals(admin)) { // do something }如果 name 是 null这里直接抛 NullPointerException线上告警嗡嗡响凌晨两点的你从床上爬起来查日志查了半天发现就是少了判空。这种坑我踩过不止一次后来养成了习惯能调静态方法就不调实例方法能放在判断条件左边的就不放在右边。这些写法丑在哪不是说你没判空而是判空这件事做得太“裸”了。每判一次空代码就多一层缩进每个对象都判一遍业务逻辑就被淹没在 if 的海洋里。真正的优雅判空不是“不判了”而是“把判空这件事抽象出来让代码读起来像在描述业务而不是像在防守空指针”。考虑到不少朋友可能刚工作不久我把判空这件事拆开讲透。这篇内容以 Java 生态为主但思路是通用的Go、Python、JavaScript 里同样适用——只是工具不同思想完全一样。2. 字符串、集合、对象三类高频判空的优雅姿势2.1 字符串判空别再用 length() 0 了字符串判空是日常开发里出现频率最高的。很多人写代码时还在用str.length() 0其实 JDK 早就提供了更简洁的 API。我按推荐程度给你排个序。最基础的做法用或者isEmpty()// 判断是否为 null或者空串 if (str null || str.isEmpty()) { // do something }JDK 11 之后官方引入了isBlank()方法它比isEmpty()更强把空格、制表符这种不可见字符也算作“空”// 判断为 null或者空串或者全是空白字符 if (str null || str.isBlank()) { // do something }但是说真的日常业务开发中我推荐直接用 Apache Commons Lang 的StringUtils这个工具类已经经过了十几年生产环境检验不是花架子if (StringUtils.isBlank(str)) { // str 为 null、空串、或者全是空白字符时进入 }一行搞定null 安全不用手写||连接。isBlank()和isEmpty()的区别要记清楚isEmpty()只认长度为 0 的字符串而isBlank()连 、\t这种也算空。绝大多数业务场景里用户提交的表单数据你是希望把空格过滤掉的所以isBlank()更贴近真实需求。提示如果你不想引入外部依赖可以自己封装一个静态方法把str null || str.trim().isEmpty()包起来。注意trim()只处理半角空格全角空格处理不了需要可以用strip()JDK 11支持 Unicode 空白。2.2 集合判空isEmpty() 不等于判空集合判空是另一个重灾区。很多代码是这样写的if (list.size() 0) { // do something }这串代码隐藏着一个致命问题如果 list 本身是 nulllist.size()会直接抛空指针。我之前在线上就碰到过这种情况——接口返回的 list 因为上游数据问题变成 null而我们的代码只判断了size() 0结果就是接口大面积 500排查了半天才找到这行。正确的姿势是if (list null || list.isEmpty()) { // do something }或者使用 Spring 的CollectionUtils.isEmpty()方法if (CollectionUtils.isEmpty(list)) { // list 为 null 或 size 为 0 时进入 }这个方法只做一件事把 null 判断和 isEmpty 判断合并了。看起来只是省了几个字符但代码的语义从“我要先检查是不是 null再检查是不是空集合”变成了“我要判断这个集合是不是‘无内容’”理解成本低了很多。与之相对的还有一个高频场景判断集合不为空。很多人写list ! null list.size() 0用工具类就是CollectionUtils.isNotEmpty(list)语义非常直白。补充一个经验如果你用 Java 9List.of()创建的空集合是 immutable 的调用add()会抛UnsupportedOperationException。所以不要依赖“空集合就可以随便 add”这个假设该判空的地方还是得判。2.3 对象判空Objects.equals 和 Objects.requireNonNull对象判空最常见的场景有两个一个是判断两个对象是否相等一个是方法入参校验。判断相等时很多人写obj1.equals(obj2)但没意识到 obj1 可能为 null。换成Objects.equals(obj1, obj2)就安全了JDK 内部帮你处理了 null 的情况if (Objects.equals(a, b)) { // 即使 a 是 null 也不会抛异常 }方法入参校验方面Objects.requireNonNull是个被低估的 API。它可以在方法入口直接校验参数提前暴露问题而不是等运行到深处才炸public void process(User user) { Objects.requireNonNull(user, user 不能为空); // 后续代码可以放心使用 user }它和直接写if (user null) throw new IllegalArgumentException()的区别在于前者是声明式的读代码的人一眼就知道这是“参数约束”后者还得看 throw 那行才能反应过来。而且requireNonNull抛出的NullPointerException会带上你写的 message排查问题的时候日志里直接能看出是哪个参数出了问题。这里有个实战心得良好的参数校验是在“入口处”完成的不是在“使用处”完成的。方法一开始就校验完所有入参后续业务逻辑就不用到处穿插 if null 判断了。这比写一百个判空 if 要优雅得多因为它把问题前置了。3. 用 Optional 把判空写成链式告别嵌套地狱3.1 Optional 的核心用法orElse、orElseGet、mapJava 8 引入的Optional是判空领域的一个里程碑。它不是一个用来“消灭” null 的工具而是用来“约束”你处理 null 的方式。简单说Optional 强制你思考“这个值可能不存在那不存在时该怎么办”。我用一个最常见的场景来演示。以前获取用户的所在城市你得一层层判空User user findUser(); if (user ! null) { Address address user.getAddress(); if (address ! null) { City city address.getCity(); if (city ! null) { return city.getName(); } } } return 未知城市;用 Optional 之后可以写成这样return Optional.ofNullable(findUser()) .map(User::getAddress) .map(Address::getCity) .map(City::getName) .orElse(未知城市);两种写法对比第二种的意图非常清楚从 user 里一路取到 cityName取不到就给个默认值。map方法会自动处理中间某一步返回 null 的情况不会抛空指针。可读性和健壮性同时拉满。orElse和orElseGet的区别容易被忽略。orElse(默认值)无论前面的值是否存在都会先计算出默认值而orElseGet(Supplier)是懒加载的只有前面值不存在时才执行计算逻辑。如果默认值的创建成本比较高比如要查一次数据库、调一次接口一定要用orElseGet// 不推荐即使有值也会先 new 一个对象 return Optional.ofNullable(list) .map(List::size) .orElse(new ArrayList().size()); // 推荐没有值时才执行 return Optional.ofNullable(list) .map(List::size) .orElseGet(() - new ArrayList().size());那orElseThrow呢它的语义是“如果值为空就抛出指定异常”。这个在查询场景里非常有用——比如根据 ID 查用户正常情况下肯定存在不存在就是异常情况User user Optional.ofNullable(userRepository.findById(userId)) .orElseThrow(() - new BizException(用户不存在));这样写比“先查出 user再 if (user null) 抛异常”要简洁得多而且在读代码的时候一个orElseThrow就能传递出“这里不允许为空”的强约束信号。3.2 Optional 的进阶玩法ifPresent、filter、flatMap除了 map 和 orElse 这一组常用组合Optional 还有几个高阶操作。ifPresent适合“有值就消费没值就跳过”的场景配合 Consumer 接口使用Optional.ofNullable(user) .ifPresent(u - sendMessage(u.getPhone(), 欢迎回来));这相当于把if (user ! null) { ... }压缩成了一行而且不会污染外层代码的缩进。filter可以让你在链上直接做条件过滤省去一个 if 判断Optional.ofNullable(findOrder(orderId)) .filter(order - order.getStatus() ORDER_PAID) .ifPresent(order - refund(order));这段代码的意思是找到订单如果订单是已支付状态才执行退款。整个链路下来零个 if 语句但逻辑一点没少。flatMap解决的是“map 的返回结果本身是个 Optional”时的嵌套问题。比如某个方法返回OptionalAddress你用 map 处理时就会得到一个OptionalOptionalAddress非常别扭// map 的写法会嵌套 Optional OptionalOptionalAddress optionalOptional Optional.ofNullable(user) .map(User::findAddress); // findAddress 返回 OptionalAddress // flatMap 的写法避免嵌套 OptionalAddress address Optional.ofNullable(user) .flatMap(User::findAddress);这种场景在调用外部接口返回 Optional 时经常出现。遇到嵌套 Optional直接想到 flatMap 就对了。不过我要提醒一句Optional 是给开发者用的不是给实体类字段用的。别给实体类每个字段都包一层 Optional那样会导致序列化出问题Jackson 默认不支持直接序列化 Optional而且让代码变得特别啰嗦。Optional 适合做“链式处理的中间层”不适合做数据载体。这个坑我在项目里见得太多了。3.3 实战对比嵌套判空重构前后我把开头那个嵌套判空的例子完整重构一遍让你直观感受一下差异。重构前老写法public String getCityName(Long userId) { User user userService.findById(userId); if (user ! null) { Address address user.getAddress(); if (address ! null) { City city address.getCity(); if (city ! null) { return city.getName(); } } } return defaultCityName; }重构后Optional 链式public String getCityName(Long userId) { return Optional.ofNullable(userService.findById(userId)) .map(User::getAddress) .map(Address::getCity) .map(City::getName) .orElse(defaultCityName); }看着代码量确实差不多但维护体验完全不是一个级别。重构前你要追踪每一层 if 的边界找到 return 是在哪个条件下触发的重构后整条链从上往下读每一步都是从当前对象取下一个对象的映射心态完全不一样。而且这种写法天然让你把“取值的路径”和“取不到值怎么办”分开处理。路径用 map 描述兜底用 orElse 描述各管各的逻辑边界清晰。4. 进阶玩法自定义判空工具与断言式风格4.1 手写一个通用判空工具类用Optional和工具类能解决大部分场景但如果你所在项目里有大量复杂的判空需求还可以封装一个自己的判空工具类。不是重复造轮子而是把项目里反复出现的判空规则收敛到一处统一定义。比如下面这个工具类public final class CheckUtils { private CheckUtils() { } // 校验字符串是否为空null、空串、全空白 public static boolean isBlank(String str) { return str null || str.trim().isEmpty(); } public static boolean isNotBlank(String str) { return !isBlank(str); } // 校验集合是否为空null 或 size 为 0 public static boolean isEmpty(Collection? collection) { return collection null || collection.isEmpty(); } public static boolean isNotEmpty(Collection? collection) { return !isEmpty(collection); } // 校验对象是否为空并支持自定义异常信息 public static T T requireNonNull(T obj, String message) { if (obj null) { throw new IllegalArgumentException(message); } return obj; } }这个工具类的价值在于把“空”的定义统一了。比如项目里规定“用户输入的字符串如果全是空格也视为没填”那所有调CheckUtils.isBlank()的地方都会遵循同样的规则。你不需要在每个业务代码里都写一遍trim().isEmpty()更不用怕有人图省事直接写 。这里有个教训值得一提我之前看过一个项目字符串判空的方法散落着三四套——有的用StringUtils.isEmptycommons-lang3有的用StringUtils.isBlank同一个包不同方法有的直接用str null || str.length() 0。结果是不同人写的代码对“空”的定义不一致一个字符串 在某些模块被当作有效输入在另一些模块被当成空值丢弃线上数据就是这么被搞脏的。后来统一用工具类这种低级问题直接消失。4.2 断言式判空前置校验减少大量 if“断言式”是另一种优雅的思路。它的核心逻辑是既然这个值不可能是空的那我就在入口处断言它非空后面所有代码都不再需要判空。Spring 提供了Assert工具类用法很简洁public void createOrder(OrderRequest request) { Assert.hasText(request.getUserId(), userId 不能为空); Assert.hasText(request.getProductId(), productId 不能为空); Assert.notNull(request.getAmount(), amount 不能为空); // 下面代码中直接使用 request 的属性 }四个参数校验完方法体后面的代码就不再需要任何 if null 判断了。因为你知道如果参数不合法方法根本执行不到这里异常在入口就被拦住了。这种风格的代码读起来异常舒服——前面的校验像一道安检过了安检的人可以直接往前走不用每走三步被拦下来查一次证件。Guava 的Preconditions也是同样的思路在 Java 之外Python 的assert、Go 的panic/error其实都能做类似的事核心是先校验后使用。断言式的另一个好处是它把“异常处理”和“业务逻辑”分开了。校验不通过抛出的异常很明确——参数问题后续业务逻辑里出现异常那才是真正的业务逻辑问题。排查问题时日志里的异常类型直接告诉你故障发生在哪个阶段。如果你用的是 Spring BootAssert.hasText抛的是IllegalArgumentException配合全局异常处理器可以统一返回参数错误的信息。如果想要更细粒度的业务异常也可以封装自己的断言方法抛出自定义的BizException把错误码和 message 一起带出去。4.3 空对象模式一种反直觉但实战价值极高的方案这个模式比较冷门但在特定场景下非常好用。空对象模式的核心思想是不返回 null而是返回一个“什么都不做”的空对象。举个例子。你有个查询用户信息的接口用户不存在时以前返回 null调用方就要判空。如果改成返回一个“空用户”对象调用方就可以无脑调用不需要任何判空public class User { private String name; private ListOrder orders; // 一个静态的“空用户”实例 public static final User EMPTY new User(, Collections.emptyList()); public boolean isEmpty() { return this EMPTY; } }这样调用方的代码就变成了User user userService.findByName(张三); if (user.isEmpty()) { return 查无此人; } return user.getName();对比原先的if (user null)这种写法多了一层“语义化”。isEmpty()比 null更直观地表达了业务含义你要的不是“这个变量是不是空的引用”而是“这次查询是否查到了用户”。而且空对象内部还可以带上默认行为——比如空用户的订单列表是一个空集合调用方直接遍历也不会炸连空集合判断都省了。不过这个模式有个前提条件使用它的对象必须属性不多且不会频繁扩展。如果 User 有几十个字段空对象里的每个字段都得给默认值维护成本会迅速上升。另外如果团队里其他人习惯了判断 null看到你返回一个空对象可能反而困惑所以要在代码评审和团队规范里提前对齐。这个模式适合在内部接口、基础服务这些调用链比较稳定的地方用对外接口还是老老实实返回 null 或者用 Optional。5. 比判空更高级的境界从源头消灭判空5.1 设计层面避免空值产生讲了这么多判空技巧但我想说一个更本质的观点最好的判空是根本不需要判空。空指针问题的根源不是代码写得不好而是数据流里的 null 没有被约束住。如果你能从源头减少 null 的产生后面的判空代码自然而然就少了。举几个实战中的做法。第一数据库层面约束。建表时字段能加NOT NULL的尽量加上业务上“必填项”在数据库里就不要允许为 null。比如用户表里的手机号如果业务上注册必须有手机号那数据库字段就设 NOT NULL代码里永远不会出现“手机号为 null 的用户”。第二接口入参校验。对外提供的接口在入口处就用NotNull、NotBlank这类注解把参数校验掉。Spring Boot 里用Validated加持public class CreateUserRequest { NotBlank(message 用户名不能为空) private String username; NotNull(message 年龄不能为空) private Integer age; NotBlank(message 手机号不能为空) private String phone; }请求进来时框架自动校验不通过直接返回参数错误。你的业务代码里根本拿不到 username 为空的对象自然不用判空。第三方法实现层面能返回空集合就不要返回 null。很多人写“查询列表”的方法查不到数据时返回 null然后调用方就要判空。正确的做法是返回空集合// 不推荐 public ListOrder getOrders(Long userId) { ListOrder orders orderDao.findByUserId(userId); return orders.isEmpty() ? null : orders; } // 推荐 public ListOrder getOrders(Long userId) { return orderDao.findByUserId(userId); // 查无数据返回空集合 }调用方拿到空集合直接遍历一行判空都不用写。这是一个“上下同欲”的约定所有查询列表的方法约定返回空集合绝不返回 null。5.2 消灭嵌套的根本方案卫语句 早返回很多时候判空代码写的丑不是判空本身的问题而是用“层层嵌套”的方式在判。解决嵌套最好的方案不是 Optional而是卫语句。卫语句就是“不满足条件就提前返回”的写法// 反例层层嵌套 public String handleOrder(Order order) { if (order ! null) { if (order.getStatus() ORDER_PAID) { if (order.getUser() ! null) { return doBusiness(order); } } } return error; } // 正例卫语句 早返回 public String handleOrder(Order order) { if (order null) { return 订单不存在; } if (order.getStatus() ! ORDER_PAID) { return 订单未支付; } if (order.getUser() null) { return 订单用户不存在; } return doBusiness(order); }正例的代码是“扁平的”每个条件一行缩进永远控制在两层以内。读代码时顺着往下看遇到不满足的条件就返回逻辑非常直白。这个写法不需要任何工具类只需要改变一下编码习惯能不嵌套就不嵌套能用早返回就别用 else。我见过很多开发者写了三年代码还在用几十层的嵌套 if他们不是不会判空而是没有意识到“早返回”可以让代码结构发生质变。这条建议不需要依赖任何框架和工具今天看完代码明天就能用上。5.3 判空规范与 Code Review 检查点最后分享一个软件工程层面的建议。判空这种事的成本不在写代码那几秒钟而在后续的维护与排障。一个团队应该有统一的判空规范并在 Code Review 阶段把好关。我的团队在评审代码时重点关注以下几点所有入参来自外部接口、配置、数据库查询结果的地方是否存在未判空使用连续取多级属性a.getB().getC().getD()是否做了空指针保护集合遍历前是否确认了非空或者是否约定过“返回空集合不返回 null”字符串比较是否用了Objects.equals或把常量写在 equals 前面参数校验是否集中在方法入口而不是散落在业务逻辑中是否有人在实体类字段上滥用 Optional导致序列化问题这些检查点不用多每条都对应一类线上事故。我踩过的坑包括Kafka 消费者消费了一条关键字段为 null 的消息导致空指针、外部接口返回 null 导致批量任务中断、数据库查出的列表为 null 直接调用 stream 报错……每一个都是因为判空姿势不对或者根本没判空。把这些规范固化下来配合工具类统一使用两个月之后你会发现代码里的 if null 肉眼可见地变少了。这不是魔法是工程管理的价值。6. 判空实战常见问题和避坑速查6.1 高频踩坑记录我在实际项目里整理了一些比较典型的判空问题这里挑几个共享出来给读者朋友做个提醒。场景错误写法问题正确写法字符串相等判断name.equals(admin)name 为 null 时抛 NPEadmin.equals(name)或Objects.equals(name, admin)集合判空list.size() 0list 为 null 时抛 NPECollectionUtils.isEmpty(list)包装类比较Integer.compare(x, y) 0x 或 y 为 null 时抛 NPE提前判空或用Objects.equals(x, y)数组判空array.length 0数组为 null 时抛 NPEarray null || array.length 0多级属性取值user.getAddress().getCity()中间任何环节为 null 都崩溃Optional 链式取值第一行那个admin.equals(name)是最经典的写法把常量放前面完全是为了防 null。第二行是培训新人时必讲的点。第五行则是日常开发中最隐蔽的坑——一个链式调用里有三四个中间对象任何一个为 null 都够你排查半天用 Optional 链式处理能一口气把这条链保护起来。6.2 Optional 滥用最容易犯的三个错误前面说了 Optional 好用但滥用也会引入新的问题。我归纳了三个比较典型的错误。第一是给实体类字段加 Optional。这个问题前面提过这里再强调一次实体类是要被序列化、反序列化的Optional 不是一种可序列化的设计硬要加会导致 JSON 转换出错或者字段丢失。数据库字段的“可空性”应该由数据库约束表达程序里由参数校验表达不是靠 Optional。第二是直接用Optional.get()。get()方法在值为空时会抛NoSuchElementException这和空指针比只是换个名字继续崩。如果你一定要用get()前面最好先isPresent()否则就失去了 Optional 的意义// 反例用 Optional 但还在用 get() 冒险 OptionalString opt findName(); String name opt.get(); // 正例用 orElse 兜底 String name findName().orElse(默认名);第三是把 Optional 作为方法参数传进来。public void doSomething(OptionalString param)这种写法会让调用方被迫包一层 Optional而且无法区分“调用方没传”和“调用方传了空 Optional”。遇到这种情况直接改成传普通参数在方法内部判断即可。6.3 一个小而美的总结优雅判空的四层境界根据我个人经验判空这件事可以粗略分成四层境界。第一层是“疯狂嵌套”——所有判断都用 if 包起来能跑但是维护成本极高。第二层是“工具类护体”——学会用StringUtils、CollectionUtils、Objects.equals这些工具代码不再那么啰嗦。第三层是“Optional 链式 卫语句”——代码结构从嵌套改为链式/扁平业务逻辑清晰了很多。第四层是“从源头消灭空值”——数据库约束、入参校验、空对象、返回空集合让空值根本没机会出现。大多数人的提升路径是从第一层走到第三层这已经能解决 80% 的问题。第四层需要架构层面的设计意识不是一蹴而就的。我个人的建议是先把手头的代码从嵌套 if 改成卫语句和 Optional再逐步制定团队规范最后在系统设计时把“防止空值产生”作为一个默认原则。6.4 本机可复现的一套练习建议如果你想把判空玩明白我建议你找一个周末的下午做三个小练习。第一个练习从你的项目里随便找一个嵌套超过 3 层的 if 判断用 Optional 重构它。跑一遍测试确认逻辑没变。第二个练习选一个“查询列表”的方法查不到数据时改为返回空集合下游所有判断 null的地方全部改成直接遍历或isEmpty()判断。第三个练习把你项目里所有str.length() 0、list.size() 0的写法搜出来统计一下数量。如果超过 20 处说明项目在判空规范方面还有不少优化空间可以牵头做一次全局替换。这三个练习做完你对判空的理解会比看十篇文章都深刻。代码这东西光看是看不会的一定要动手改才知道什么叫“改前想吐改后真香”。最后再分享一个我实践中的心得那个让我印象最深的判空重构不是用了什么高深的技术而是把一个 300 行的“嵌套 if 防守地狱”变成了 80 行的“卫语句 Optional 链”。代码量减少了一大半Bug 数直接从每月两三个变成了零。那次之后我真正认同了一个说法——写代码不是写给编译器看的是写给下一个维护者包括三个月后的自己看的。优雅判空本质上是优雅做人。
返回列表