Administrator
发布于 2019-06-11 / 876 阅读
15

Optional 的正确用法,别把它当成判空工具

review 的时候,我看到一段"用 Optional 写的判空"

5 月底做代码评审,看到同事写了这么一段:

Optional<User> userOpt = userMapper.findById(userId);
if (userOpt.isPresent()) {
    User user = userOpt.get();
    String name = user.getName();
    ...
} else {
    throw new BizException("用户不存在");
}

Java 8 都发布五年了,Optional 还是被写成了 if (x != null) 的另一种写法,而且比直接判空还多两行。这不能怪他,我刚学 Optional 的时候也是这么写的,以为它就是个"官方的判空工具"。

后来我踩了个坑,才想明白 Optional 到底该怎么用。

orElse 里的代码,总会执行

那是做一个用户信息的本地缓存,我写的代码:

public User getUser(Long id) {
    return Optional.ofNullable(localCache.get(id))
                   .orElse(loadFromDb(id));       // 缓存没有就查库
}

private User loadFromDb(Long id) {
    log.info("查询数据库, id={}", id);
    return userMapper.selectById(id);
}

上线之后 DBA 找过来了:用户库的 QPS 从 800 涨到 1720,翻了一倍多。查日志发现,缓存命中的时候也打了"查询数据库"

原因很基础,但当时我完全没意识到:orElse 的参数是一个已经求值完毕的对象。Java 的方法调用是先算参数再进方法,所以 loadFromDb(id)Optional.ofNullable(...) 执行之前就已经跑完了。Optional 内部的 orElse 只是二选一返回,它没法让已经执行的语句倒回去。

写个测试验证:

public class OptionalTrap {
    static User loadFromDb(Long id) {
        System.out.println("执行了 loadFromDb, id=" + id);
        return new User(id, "张三");
    }

    public static void main(String[] args) {
        User cached = new User(1L, "缓存里的李四");

        System.out.println("--- orElse ---");
        User u1 = Optional.of(cached).orElse(loadFromDb(1L));

        System.out.println("--- orElseGet ---");
        User u2 = Optional.of(cached).orElseGet(() -> loadFromDb(1L));
    }
}

输出:

--- orElse ---
执行了 loadFromDb, id=1        ← 明明有值,还是执行了
--- orElseGet ---

orElseGet 的参数是 Supplier,是个 lambda,只有需要的时候才会被调用。看一眼源码就很清楚:

public T orElse(T other) {
    return value != null ? value : other;         // other 早就算好了
}

public T orElseGet(Supplier<? extends T> other) {
    return value != null ? value : other.get();   // 需要时才调用
}

改成 orElseGet 之后,用户库 QPS 从 1720 回到 810。我们缓存命中率 92%,那 920 次多余的查询就是这么来的。

判断标准其实很直观:orElse 的参数如果是常量或已经存在的对象,用 orElse;如果是一次方法调用、一次 RPC、一次数据库查询,一律用 orElseGet

Optional 的正确位置:作为返回值

Optional 的设计意图在它的 javadoc 第一句就写明了:它是一个"可能为空的容器对象",主要用途是作为方法返回值,用来明确告诉调用方"这个方法可能没有结果"。

// 好:签名本身就说明了"可能查不到"
public Optional<User> findByMobile(String mobile) {
    return Optional.ofNullable(userMapper.selectByMobile(mobile));
}

调用方看到这个签名,编译期就会被提醒要处理空的情况,比在注释里写一句"可能为 null"有效得多。

反过来,这几种用法都是不推荐的:

不要作为方法参数

// 不推荐
public void updateUser(Optional<User> userOpt) { ... }

调用方被迫先包装一层,而方法内部还是得 isPresent 判断,等于把处理成本转嫁给了调用方。而且 Optional 没有实现 Serializable,如果这是个 Dubbo 或者远程接口,序列化直接报错:

java.io.NotSerializableException: java.util.Optional
    at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1184)

我们就有过一次,把 Optional<Order> 放进了 RPC 的返回 DTO 里,本地测试全过,一发布到测试环境就报这个错。

不要作为类的字段

同样的理由:不能序列化,而且 MyBatis、Jackson 这些框架对 Optional 字段的支持都很别扭。

不要在集合里放 Optional

// 毫无意义,集合本身就有"空"的概念
List<Optional<User>> users = ...;

链式判空:从 12 行到 6 行

这才是 Optional 真正好用的地方。改造前,取"订单所属用户的收货城市":

String city = "未知";
if (order != null) {
    User user = order.getUser();
    if (user != null) {
        Address address = user.getAddress();
        if (address != null) {
            String c = address.getCity();
            if (c != null && !c.isEmpty()) {
                city = c;
            }
        }
    }
}

用 Optional 之后:

String city = Optional.ofNullable(order)
        .map(Order::getUser)
        .map(User::getAddress)
        .map(Address::getCity)
        .filter(c -> !c.isEmpty())
        .orElse("未知");

每一行对应一次可能的空值,任何一个环节返回 null,整条链就直接短路到 orElse,不会抛 NPE。读代码的时候视线一路往下,不用在大括号里数层级。

它的实现其实非常简单:map 会把函数的结果再包一层 Optional,函数返回 null 就得到 Optional.empty()

public <U> Optional<U> map(Function<? super T, ? extends U> mapper) {
    Objects.requireNonNull(mapper);
    if (!isPresent())
        return empty();
    else {
        return Optional.ofNullable(mapper.apply(value));   // 结果可能是 empty
    }
}

有个细节要注意:如果中间某一环本身返回的也是 Optional,要用 flatMap 而不是 map,否则会得到 Optional<Optional<T>>

Optional<Address> findDefaultAddress(User user) { ... }

// 错:得到 Optional<Optional<Address>>,编译不过
Optional<Optional<Address>> bad = userOpt.map(u -> findDefaultAddress(u));

// 对:flatMap 会把两层拍平
Optional<Address> good = userOpt.flatMap(u -> findDefaultAddress(u));

还有几个要注意的

get() 之前一定要确认有值

Optional.get() 在值为空时抛的是 NoSuchElementException,比 NPE 还难排查,因为堆栈里看不出是哪个字段空了:

java.util.NoSuchElementException: No value present
    at java.util.Optional.get(Optional.java:135)

orElseorElseGetorElseThrow 替代 get()orElseThrow 在找不到值要抛业务异常时特别合适:

User user = findByMobile(mobile)
        .orElseThrow(() -> new BizException("用户不存在: " + mobile));

别用 of() 包可能为 null 的值

Optional.of(null) 直接抛 NPE。不确定是不是 null 就用 ofNullable。我们有个地方从 Map 里取值然后 Optional.of(map.get(key)),key 不存在的时候直接炸了。

性能:业务代码放心用,极致热路径要留意

Optional 是个对象,每次 map 都要创建新的 Optional 实例加一次函数调用。用 JMH 跑 1000 万次链式取值:

写法平均单次
嵌套 if 判空0.8 ns
Optional 链(4 层)2.1 ns

差 2.6 倍,但绝对值都是纳秒级。业务代码里一次请求调几十次,多出来的开销是几百纳秒,完全无所谓。我们的规则是:业务代码随便用,自己写的基础工具类(每秒可能被调几十万次的那种)用回 if。

JDK 9 才有的几个方法

ifPresentOrElseorstream 这几个是 JDK 9 加的。我们项目还是 JDK 8,看到网上的例子用了这些方法,编译不过别以为是自己写错了。

小结

  • orElse 的参数会被无条件求值,只要里面是方法调用、RPC、数据库查询,就要换成 orElseGet。这是我踩过的最实在的一个坑。
  • Optional 主要用作方法返回值,表达"可能没有结果"。不要用作参数、字段、集合元素,它不可序列化。
  • 它的价值在链式调用:map 串起来的每一层都是一次空值短路,替代层层嵌套的 if。中间返回 Optional 的方法要用 flatMap
  • 别用 isPresent() + get() 那套,那是把 Optional 用成了更啰嗦的判空。

我现在的习惯是:写查询方法时如果结果可能为空,返回值就声明成 Optional,逼着调用方处理。我们重构过的几个模块里,NPE 类的线上问题从每月四五次降到了几乎没有。

参考