发券、审批、风控、订单流转,这类需求有共同的结果:上线时只有三条规则,半年后变成三十条,最后没人敢碰那个方法😭
先看看我们遇到的问题
下面这段代码,做过营销活动的同学应该都写过:
if (login) {
if (activityOpen) {
int level = getLevel(uid);
if (level == 0) send5();
else if (level == 1) send10();
else send20();
}
}
只有三个规则的时候这样写非常简洁易懂,但当产品陆续说出“再加个黑名单拦截”、“新客要专享”、“每人每天限一次”等需求时,这段代码会长成前套一堆if-else的样子。这会导致以下让人难受的点:
-
改一条规则要先读懂全部规则,因为你不确定这次改动会踩到哪个分支。
-
没法单独测“等级1”这条路径,只能构造完整请求跑权链路。
-
两个人同时加需求时必冲突。
这些问题的根源其实只有一个:一条条规则被压在一个方法里,它们之间看不见彼此的边界。规则树做的事,就是把这个嵌套结构重新展开,让每条规则变成一个独立的、可以单独编写和测试的节点。
一次请求是怎么走完的
光说概念没有意思,直接举一个具体场景:双十一发券,用户进页面,系统决定给他发哪张券。
假设用户小明已登录、活动进行中、会员等级 1,他会这样走完:
| 步骤 | 节点 | 干的活 | 指的路 |
|---|---|---|---|
| 1 | RootNode | 确认已登录 | → SwitchRootNode |
| 2 | SwitchRootNode | 确认活动未结束 | → AccountNode |
| 3 | AccountNode | 查出等级 = 1 | → MemberLevel1Node |
| 4 | MemberLevel1Node | 发 10 元券 | → null(到此为止) |
整棵树里,每个节点只回答两个问题:我该干什么,以及我干完之后下一个该找谁。它不知道前面是谁,也不关心总共有多少节点。
节点内部:一个处理器(handler),一个映射器(mapper)
既然每个节点只做两件事,那它在代码里就是两个方法——一个负责干活,一个负责指路。落在同一个抽象类中(AbstractStrategyRouter),一个节点同时实现了这两个接口。
这张图是整个模式的基础,它说明了为什么这套结构可以无限扩展:每个节点的形状完全一样,差别只在方法体里那几行判断。所以加一条规则,就是加一个形状相同的新类。
不是所有节点都在分叉
刚接触时容易有个误解,以为树上每个节点都存在分支。实际上RootNode和SwitchRoot的next()只有两种答案,下一个节点或者null。它们的分支只有一个出口,作用只是拦截。
这才是我觉得这个模式最巧妙的地方:它用一条极简的约定 "next() 返回 null 就终止",把"拦截"和"选路"统一成了同一种动作。于是守卫和分流器可以串在同一条链上,用同一份引擎跑,不需要两套机制。
代码事例
1.上下文:全程随身携带
所有节点共享一个对象,每个节点把自己查到的信息存储到该对象中,后面的节点直接读,不用重复查库。
public class Ctx {
public Long uid;
public String scene;
public boolean passed = true; // 是否还允许继续
public String reason; // 被拦下的原因
public int level;
public boolean frozen;
public String coupon;
}
2.引擎:所有节点共享的那一份while
public abstract class AbstractStrategyRouter<T> {
/** 干活:处理当前这一步,必要时往上下文里写数据 */
protected abstract T handle(T ctx);
/** 指路:返回下一个节点;返回 null 表示到此为止 */
protected abstract AbstractStrategyRouter<T> next(T ctx);
/** 入口:从当前节点开始,一路走到 null */
public final T route(T ctx) {
AbstractStrategyRouter<T> node = this;
while (node != null) {
ctx = node.handle(ctx);
node = node.next(ctx);
}
return ctx;
}
}
整个模式的核心其实就是这个 while:反复做两件事,handle 然后取 next,直到 next 返回 null。它写成 final,是因为子类不应该重写流程,只能填空。
3.根节点:只负责能不能进来
public class RootNode extends AbstractStrategyRouter<Ctx> {
protected Ctx handle(Ctx ctx) {
if (ctx.uid == null) {
ctx.passed = false;
ctx.reason = "未登录";
}
return ctx;
}
protected AbstractStrategyRouter<Ctx> next(Ctx ctx) {
if (!ctx.passed) return null;
return new SwitchRootNode("ACT_2026_11_COUPON"); // 只有一条路,没得选
}
}
4.开关节点:一个能一键关停的总闸
public class SwitchRootNode extends AbstractStrategyRouter<Ctx> {
private final String switchKey; // 开关名,构造时传入 → 任何活动都能复用
public SwitchRootNode(String switchKey) {
this.switchKey = switchKey;
}
protected Ctx handle(Ctx ctx) {
if (!configService.isOn(switchKey)) { // 读缓存,一次 hash 查找
ctx.passed = false;
ctx.reason = "活动已下线";
}
return ctx;
}
protected AbstractStrategyRouter<Ctx> next(Ctx ctx) {
return ctx.passed ? new AccountNode() : null;
}
}
5.账户节点:分流器
public class AccountNode extends AbstractStrategyRouter<Ctx> {
protected Ctx handle(Ctx ctx) {
if (!ctx.passed) return ctx;
ctx.level = userService.getLevel(ctx.uid);
ctx.frozen = userService.isFrozen(ctx.uid);
return ctx;
}
protected AbstractStrategyRouter<Ctx> next(Ctx ctx) {
if (!ctx.passed || ctx.frozen) return null;
switch (ctx.level) {
case 0: return new MemberLevel0Node();
case 1: return new MemberLevel1Node();
default: return new MemberLevel2Node();
}
}
}
6.叶子节点:干完就断
public class MemberLevel1Node extends AbstractStrategyRouter<Ctx> {
protected Ctx handle(Ctx ctx) {
ctx.coupon = couponService.grant(ctx.uid, 10);
return ctx;
}
protected AbstractStrategyRouter<Ctx> next(Ctx ctx) {
return null; // 我是终点
}
}
7.调用方:只有这一处知道起点是谁
Ctx ctx = new Ctx();
ctx.uid = 10086L;
ctx.scene = "double11";
new RootNode().route(ctx); // route 里 node = this,所以谁调用谁是起点
return ctx.passed
? Result.ok(ctx.coupon)
: Result.fail(ctx.reason);
注意最后这段:业务代码完全不关心中间走了几个节点、走了哪些分支,它只拿到一个填满了的 Ctx。这就是"上下文数据封装"的意义——流程对调用方是黑盒,结果对调用方是白盒。
加一条规则到底要改多少
假设现在产品说:黑名单用户不发。用 if-else 你得小心翼翼地往嵌套里再插一层。用规则树,你新增一个类,然后改一行的引用:
public class BlacklistNode extends AbstractStrategyRouter<Ctx> {
protected Ctx handle(Ctx ctx) {
if (riskService.inBlacklist(ctx.uid)) {
ctx.passed = false;
ctx.reason = "风控拦截";
}
return ctx;
}
protected AbstractStrategyRouter<Ctx> next(Ctx ctx) {
return ctx.passed ? new AccountNode() : null;
}
}
设计上的取舍
为什么守卫要放在最前面
登录判断只读一次 token,开关判断只读一次缓存,都是纳秒级。而 AccountNode 一放行,后面就要查用户中心、查风控、可能还要连两次 DB。把最快速的判断放最前面,让不合格的请求在前 1 毫秒就被挡回去。
为什么不把所有节点都做成"分叉"
有些实现会让 next() 返回一个列表,由引擎去遍历。那样确实更"树",但也更重:每个节点都要处理"多个下游"的语义,共用的守卫节点就没法自然挂上去了。我这版选择单一 next,牺牲了一点表达力,换来形状极简,实践中 99% 的场景够用。
passed 要不要提到引擎里
你可能会觉得每个节点判一次 !ctx.passed 很啰嗦,确实。可以在 route() 里加一行统一短路:
while (node != null) {
ctx = node.handle(ctx);
if (!ctx.passed) break; // 统一短路
node = node.next(ctx);
}
代价是引擎开始理解业务状态了。我倾向于保留这种啰嗦——引擎只懂"next 是不是 null",业务语义全部留在节点里,边界更干净。
什么场景该设计成这样
| 适合 | 不适合 |
|---|---|
| 营销发券、权益领取 | 只有 2~3 个 if 的小逻辑 |
| 风控审批、额度计算 | 规则之间强耦合、要来回跳转的 |
| 订单状态流转、工单分派 | 单次执行、用完即弃的脚本 |
| 规则多、会变、要分层看日志的链路 | 团队里没人维护得动抽象的人(😁) |
判断标准很简单:如果你已经在同一个方法里写过第三层嵌套,并且预感它还会长,那就拆。
反过来,三个 if 就上规则树,是把三个类变成八个类,纯自嗨。
还有个隐性成本必须说:规则树把"代码可读性"换成了"结构可读性"。新人打开项目,看到 RootNode 不知道后面还有什么,得顺着 next() 一个个点下去,所以落地时务必配一张流程图(就像图 1),并且在 next() 里把走向写成一目了然的 switch 或三元表达式,别藏在深层条件里。
回头看,规则树并不是什么新东西——它是责任链和多态分发的组合,甚至可以说是把嵌套 if 画成了树再逐个节点实现。但它带来的改变很具体:规则从一个方法里的若干行变成了若干个形状相同的小类,于是它们可以被单独编写、单独测试、单独上线、单独加日志。
复杂系统的可维护性,往往就藏在这种"把大判断拆成小判断"的动作里。