规则树:把一百行if-else拆成一棵树

0 阅读7分钟

发券、审批、风控、订单流转,这类需求有共同的结果:上线时只有三条规则,半年后变成三十条,最后没人敢碰那个方法😭


先看看我们遇到的问题

下面这段代码,做过营销活动的同学应该都写过:

if (login) {
  if (activityOpen) {
    int level = getLevel(uid);
    if (level == 0) send5();
    else if (level == 1) send10();
    else send20();
  }
}

只有三个规则的时候这样写非常简洁易懂,但当产品陆续说出“再加个黑名单拦截”、“新客要专享”、“每人每天限一次”等需求时,这段代码会长成前套一堆if-else的样子。这会导致以下让人难受的点:

  • 改一条规则要先读懂全部规则,因为你不确定这次改动会踩到哪个分支。

  • 没法单独测“等级1”这条路径,只能构造完整请求跑权链路。

  • 两个人同时加需求时必冲突。

这些问题的根源其实只有一个:一条条规则被压在一个方法里,它们之间看不见彼此的边界。规则树做的事,就是把这个嵌套结构重新展开,让每条规则变成一个独立的、可以单独编写和测试的节点。

一次请求是怎么走完的

光说概念没有意思,直接举一个具体场景:双十一发券,用户进页面,系统决定给他发哪张券。

假设用户小明已登录、活动进行中、会员等级 1,他会这样走完:

步骤节点干的活指的路
1RootNode确认已登录→ SwitchRootNode
2SwitchRootNode确认活动未结束→ AccountNode
3AccountNode查出等级 = 1→ MemberLevel1Node
4MemberLevel1Node发 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 画成了树再逐个节点实现。但它带来的改变很具体:规则从一个方法里的若干行变成了若干个形状相同的小类,于是它们可以被单独编写、单独测试、单独上线、单独加日志。

复杂系统的可维护性,往往就藏在这种"把大判断拆成小判断"的动作里。