PAYATHON 2026

Java Stream API:如何在链式处理中记录日志并保持可读性?

技术老齐

这段校验逻辑建议保留 if 和提前返回,方便看清检查顺序、每一步的失败原因和退出位置。也可以使用链式写法,但为了记录日志而引入嵌套 Optional、额外拆包或复杂谓词,通常会让代码更难读。仅为这个需求,没有必要增加第三方依赖。

先分清 Optional 和 Stream

示例使用的是 Optional。它也有 map、filter 和 flatMap,但执行方式与 Stream 不同:

  • Optional 在调用这些方法时就处理已有值,不需要终止操作。
  • 某一步得到空 Optional 后,后续 filter 的谓词和 map 的映射函数不会执行。
  • Optional 只表达“有值或无值”,不会保存无值的原因。JDK Optional 文档

这里需要按顺序检查,遇到第一个失败条件时记录日志,并返回 Optional.empty()。

推荐写法:保留提前返回

每个条件都有各自的失败日志时,直接写出检查过程更容易维护:

import java.util.Optional;

public Optional<Address> getValidSupplierAddress(Contact contact) {
    if (!contact.hadRecentProjects()) {
        log.debug("contact didn't have any recent projects");
        return Optional.empty();
    }

    if (!contact.validatedByBoardOfCommerce()) {
        log.debug("contact is not validated by board of commerce");
        return Optional.empty();
    }

    Address address = contact.getAddress();
    if (address == null) {
        log.debug("no address specified for contact");
        return Optional.empty();
    }

    if (address.isPobox()) {
        log.debug("address is not valid, no po. box allowed");
        return Optional.empty();
    }

    return Optional.of(address);
}

这里假设 contact 非 null,且存在 hadRecentProjects() 方法。如果它实际是字段,应使用 contact.hadRecentProjects;Java 方法引用不能直接引用字段。

这种写法没有嵌套分支,新增校验时,在对应位置加入一个检查块即可。排查日志时,也能直接找到触发条件。

如果保留链式结构,控制辅助方法的数量

如果团队习惯使用 Optional 链,可以用一个辅助方法处理判断失败后的动作,再用单独的方法处理获取地址时的空值:

import java.util.Optional;
import java.util.function.Predicate;

public Optional<Address> getValidSupplierAddress(Contact contact) {
    return Optional.of(contact)
        .filter(onFailure(
            Contact::hadRecentProjects,
            () -> log.debug(
                "contact didn't have any recent projects")))
        .filter(onFailure(
            Contact::validatedByBoardOfCommerce,
            () -> log.debug(
                "contact is not validated by board of commerce")))
        .flatMap(this::addressWithLogging)
        .filter(onFailure(
            address -> !address.isPobox(),
            () -> log.debug(
                "address is not valid, no po. box allowed")));
}

private Optional<Address> addressWithLogging(Contact contact) {
    Address address = contact.getAddress();
    if (address == null) {
        log.debug("no address specified for contact");
    }
    return Optional.ofNullable(address);
}

private static <T> Predicate<T> onFailure(
        Predicate<T> condition,
        Runnable action) {

    return value -> {
        boolean accepted = condition.test(value);
        if (!accepted) {
            action.run();
        }
        return accepted;
    };
}

onFailure 每次只判断一次条件,并在返回 false 前执行日志动作。在没有异常的情况下,任一检查失败,后续检查都会跳过;所有检查通过时,不会调用失败日志。

原来的 condition.or(...) 也能通过短路求值实现相同行为,不过显式的 if 更容易看出动作的执行时机。这个辅助方法返回的谓词带有日志副作用;如果条件判断抛出异常,失败回调不会自动执行。

原链式代码需要修正什么?

下面的代码不能接收可能为 null 的地址:

Optional.of(contact.getAddress())

Optional.of(null) 会直接抛出 NullPointerException,后面的日志逻辑没有机会运行。允许空值时,应使用 Optional.ofNullable(...)。JDK Optional.of 文档

映射操作则要根据映射函数的返回类型选择:

// getAddress() 返回 Address,允许返回 null
optionalContact.map(Contact::getAddress)

// addressWithLogging() 返回 Optional<Address>
optionalContact.flatMap(this::addressWithLogging)

Optional.map 会把映射结果中的 null 转为空 Optional。如果映射函数已经返回 Optional,使用 flatMap 可以避免得到 Optional<Optional<Address>>。JDK map 与 flatMap 文档

原示例中,Optional.of(supplier.getAddress()) 内部的 filter 接收的是 Address,因此不能在这里使用 Optional::isPresent。修正类型关系后,也不需要再通过 isPresent() 和 get() 手动拆包。

空值日志的位置也需要注意。如果直接写:

optionalContact
    .map(Contact::getAddress)
    .filter(/* address predicate */);

地址为 null 时,后面的谓词不会执行,因而无法在其中记录“地址缺失”。单独使用 addressWithLogging,就是为了在获取地址时处理这条日志。

真正使用 Stream 时,peek 适合什么?

处理一批联系人时,可以用 peek 观察实际流经某个位置的元素:

contacts.stream()
    .filter(Contact::hadRecentProjects)
    .peek(contact -> log.debug("contact passed recent-project check"))
    .map(Contact::getAddress)
    .forEach(this::processAddress);

这里的 processAddress 需要自行定义,并处理可能的空地址。放在 filter 后面的 peek 只能看到通过检查的元素,无法记录被该过滤器排除的元素。

peek 主要用于调试观察。Stream 是惰性执行的,没有终止操作就不会开始遍历。短路操作可能只处理部分元素,某些优化也可能省略阶段。例如,能够直接确定数量的 count() 可能跳过 peek。并行流中的日志调用还可能来自不同线程,日志顺序也可能交错。JDK Stream.peek 文档

必须执行的业务记录或审计记录,应放在明确执行的处理步骤中。这里需要逐项校验并记录失败原因,直接调用校验方法更容易控制行为。

是否值得使用现有库?

只需要返回地址或空值时,JDK 的 Optional 已经够用。失败原因如何表达,仍需要单独设计,换一个链式 API 无法自动解决。

如果调用方也需要知道失败原因,可以考虑返回携带错误信息的结果类型。项目已经使用 Vavr 时,可以用 Either<FailureReason, Address> 表达失败原因或成功地址,按顺序组合检查后,在边界处统一记录日志。如果需要收集多个独立校验错误,Vavr 的 Validation 支持错误累积。Vavr 用户指南

选择时要区分这两种需求:当前代码遇到第一个失败就结束,收集全部错误则会改变执行范围。地址相关的检查仍需以地址存在为前提。仅为减少几个 if 引入一套结果类型,通常不划算。

备注:内容仅供参考。