Administrator
发布于 2022-01-09 / 5590 阅读
63

switch 模式匹配的演进与实战

重构一段 140 行的 if-else 链

1 月初接手支付网关的回调处理模块,打开 NotifyDispatcher 一看,是一个 140 行的 if-else 链。我们新服务用的是 JDK 17(ZGC + G1 都在测),正好可以试试新的语法。

原始代码长这样

public NotifyResult dispatch(NotifyEvent event) {
    Object payload = event.getPayload();

    if (payload instanceof AlipayNotify) {
        AlipayNotify n = (AlipayNotify) payload;
        if (n.getTradeStatus().equals("TRADE_SUCCESS")) {
            return handleAlipaySuccess(n);
        } else if (n.getTradeStatus().equals("TRADE_CLOSED")) {
            return handleAlipayClosed(n);
        } else {
            return NotifyResult.ignored();
        }
    } else if (payload instanceof WechatNotify) {
        WechatNotify n = (WechatNotify) payload;
        if (n.getResultCode().equals("SUCCESS") && n.getTotalFee() > 0) {
            return handleWechatSuccess(n);
        } else {
            return NotifyResult.ignored();
        }
    } else if (payload instanceof UnionpayNotify) {
        UnionpayNotify n = (UnionpayNotify) payload;
        ...
    }
    return NotifyResult.unsupported();
}

三个毛病:类型转换重复写一遍、每个分支都要重新声明局部变量、分支逻辑和类型判断纠缠在一起。

第一步:instanceof 模式匹配(Java 16 转正)

Java 16 起,instanceof 后面可以直接跟一个绑定变量,类型判断和转型合并:

// 之前
if (obj instanceof String) {
    String s = (String) obj;
    return s.length();
}

// 之后
if (obj instanceof String s) {
    return s.length();      // s 在这里已经完成转型
}

这个变量的作用域是由编译器做流分析得出的,不是简单的"if 块内"。几个容易懵的写法:

if (obj instanceof String s && s.length() > 5) {   // OK,&& 短路保证 s 已绑定
    ...
}

if (obj instanceof String s || s.length() > 5) {   // 编译错误,|| 左侧为 false 时 s 未绑定
    ...
}

if (!(obj instanceof String s)) {
    return;               // 这里 return 之后
}
System.out.println(s.length());   // OK,编译器知道 s 一定绑定了

第二个例子会报错 variable s might not have been initialized,因为 || 左侧不成立时右侧也会执行,而那时 s 还没绑定。这种"负向判断 + 提前返回"的写法(第三个例子)在实际代码里很常用,编译器能正确推导。

第二步:switch 表达式(Java 14 转正)

支付方式路由这种场景,用 switch 表达式更干净:

// 语句形式,老写法
String channel;
switch (payType) {
    case "ALIPAY":
        channel = "ch_001";
        break;               // 忘了 break 就穿透
    case "WECHAT":
        channel = "ch_002";
        break;
    default:
        throw new IllegalArgumentException("不支持的支付方式: " + payType);
}

// 表达式形式
String channel = switch (payType) {
    case "ALIPAY" -> "ch_001";
    case "WECHAT" -> "ch_002";
    default -> throw new IllegalArgumentException("不支持的支付方式: " + payType);
};

区别不只是少写 break

  • 箭头语法 -> 不会穿透,不需要 break。想穿透必须显式写多个 casecase "ALIPAY", "ALIPAY_H5" -> ...
  • 它是表达式,有返回值,可以直接赋值。
  • 穷尽性检查:用作语句时,如果没写 default,编译器不管;用作表达式时,如果没覆盖所有情况且没有 default,直接编译错误。

需要多行的分支用 yield 返回值:

int fee = switch (payType) {
    case "ALIPAY" -> {
        int base = calcBase(amount);
        log.info("alipay fee base={}", base);
        yield base * 6 / 1000;          // yield 是 switch 表达式的返回
    }
    case "WECHAT" -> amount * 6 / 1000;
    default -> 0;
};

yield 是为了和 return 区分开——return 是从方法返回,yield 只是从这个 switch 分支产生值。

第三步:switch 类型模式与守卫(JDK 17 预览)

回到开头那段回调分发。前两步只能解决"类型判断 + 转型"和"值分支",但没法把类型作为 switch 的 case。这个能力在 JDK 17 里是 JEP 406,预览特性,必须加 --enable-preview 才能用:

public NotifyResult dispatch(NotifyEvent event) {
    return switch (event.getPayload()) {
        case AlipayNotify n when "TRADE_SUCCESS".equals(n.getTradeStatus()) -> handleAlipaySuccess(n);
        case AlipayNotify n when "TRADE_CLOSED".equals(n.getTradeStatus())   -> handleAlipayClosed(n);
        case WechatNotify n when n.getTotalFee() > 0                          -> handleWechatSuccess(n);
        case UnionpayNotify n                                                 -> handleUnionpay(n);
        case null -> NotifyResult.empty();
        default -> NotifyResult.ignored();
    };
}

三个新东西:

类型模式 case AlipayNotify n

case 标签不再只能是常量,可以是类型 + 绑定变量。运行时按 case 顺序依次做 instanceof 判断,第一个匹配的执行。

守卫 when

类型匹配之后还要再加条件的话,用 when 跟上一个布尔表达式,叫 guard(守卫)。守卫为 false 时继续往下匹配下一个 case,而不是直接走 default。

注意 JDK 17 里守卫关键字是 when,后来的预览版本改成了 &&。这是预览特性的正常演化,别照着新文档往 17 上写。

case null

老 switch 遇到 null 直接抛 NullPointerException。模式匹配 switch 允许显式写 case null,不写的话默认仍然抛 NPE,但会是一个更有信息的 NullPointerException(带 "Cannot invoke ... because the switch selector is null" 之类)。

编译和运行

预览特性要显式打开。Maven 配置:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <version>3.8.1</version>
  <configuration>
    <release>17</release>
    <compilerArgs>--enable-preview</compilerArgs>
  </configuration>
</plugin>

运行时也要加:

$ java --enable-preview -jar payment-gateway.jar
WARNING: Using incubator modules: jdk.incubator.concurrent

命令行直接跑单文件的,java --enable-preview --source 17 Test.java 即可。

编译之后会有一个警告:

[WARNING] switch 中的模式匹配是预览功能,可能会在未来版本中删除。
  case AlipayNotify n when "TRADE_SUCCESS".equals(n.getTradeStatus()) ->
       ^

我的建议很明确:预览特性不要上生产。这次重构我在分支里写了完整的实现,但合入主干时用的是 instanceof + switch 表达式的版本——那两个已经是正式特性。类型模式的版本我留在一个 feature 分支里,等它转正(JDK 21)再切过去。

改完之后的数据

NotifyDispatcher.java
  改前:142 行,8 个分支,圈复杂度 24
  改后:67 行,8 个分支,圈复杂度 11

SonarQube 上的 Cognitive Complexity 从 24 降到 9。可读性提升主要来自两点:少写了 8 次强制转型,以及每个分支的目标方法一眼可见。

写在后面

现在回头看,《switch 模式匹配的演进与实战》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。

参考