设计模式修炼之路 ⑧ 过滤器模式——与或非条件当积木
非 GoF、与或非条件当积木、Predicate 是 JDK 收编版
目录
结构型模式的前两位——适配器、桥接——都是 GoF 23 人名单里的正牌选手。今天这第三位是位编外人员:过滤器模式(Filter Pattern),也叫 Criteria 模式(标准模式),GoF 名单里没有它,是后世教程的"增补选手",但论日常业务里的出场率,它未必输给几位正牌——列表页的多条件筛选、风控规则的组合判断、权限系统的条件匹配,背后都是它的影子。上一篇预告的"条件像积木一样自由拼接",这一篇兑现。
一、什么是过滤器模式?
先看定义:
用不同的标准(Criteria)过滤一组对象,通过逻辑运算(与、或、非)把标准组合起来,实现灵活的条件筛选。
翻译成人话:把每个筛选条件封装成一个类,条件之间用"与、或、非"拼积木,要什么组合,现场拼。
生活类比是 HR 筛简历:"本科以上""五年经验""会 Java",每条都是一个独立的筛子;真实招聘需求往往是"本科以上且五年经验",或者"会 Java 或 会 Go"——HR 不会为每种组合发明一套新流程,只是把筛子叠着用、换着用。过滤器模式就是把"叠筛子"这件事代码化。
它有三个角色:
- Criteria(筛选条件接口):所有筛子的统一接口,
meetCriteria(List) → List,进一批、出一批; - ConcreteCriteria(具体条件):品类、价格、库存……一个维度一个类;
- 组合条件(AndCriteria / OrCriteria / NotCriteria):与、或、非三个组合器。
精髓在最后半句:组合器自己也实现 Criteria 接口。正因为它自己也是个"筛子",And 里才能嵌 Or、Or 里嵌 Not,最后拼出一棵条件树——这个味道是不是很熟悉?And/Or 套娃出来的结构,已经隐隐是第九篇组合模式里那棵"树"了。
顺带交代户口问题:它不在 GoF 名单里,分类归属也乱——多数资料(含本系列目录篇)把它排在结构型里讲,也有人说它行为型味道更浓。编外选手,户口本乱很正常,记住它"筛集合、拼条件"的本事就行。
二、过滤器模式的优缺点
优点
- 条件复用:一个条件类写一次,哪里需要哪里 new,不用到处复制 if。
- 组合自由:与、或、非加嵌套,任意复杂的筛选逻辑都是积木拼接,表达能力从"一串 AND"升级为任意布尔表达式。
- 新增条件不改老代码:加"品牌"筛选 = 新建一个 BrandCriteria 完事,反面教材里"改签名、改方法体、全调用方跟着改"的事故现场不再重演——开闭原则。
- 条件成了"一等公民":条件可以当参数传、当返回值返、存进集合、运行时动态拼装——这套操作有个学名,叫行为参数化。
缺点
- 类数量膨胀:每个条件一个类,为三行 if 建一个类,简单场景纯属杀鸡用牛刀。
- Java 8 之后存在感下降:Stream + Predicate 把这套玩法内建进了 JDK,一个 lambda 顶一个类,第五节专门展开。
- 组合正确性全靠人肉:积木拼错了编译器不吭声,复杂组合的可读性全靠变量命名硬撑。
三、过滤器模式的应用场景
- JDK(Java 8+):这个模式最提气的地方——它被官方收编了。
java.util.function.Predicate就是 Criteria 接口的 JDK 版,and() / or() / negate()三个默认方法就是三个组合器,Stream.filter()就是 meetCriteria。某种意义上,全世界的 Java 工程师每天都在用它。 - MyBatis-Plus:
QueryWrapper.eq("category", "数码").lt("price", 4000)链式拼条件,或条件用or()/nested()接——把"条件对象"拼装好交给 SQL,思路同款。 - 风控与规则引擎:"(单笔 > 5000 且 夜间交易)或 命中黑名单"——规则本身就是条件的与或非组合,很多规则引擎的核心就是一棵可配置的条件树。
- 后台列表页:多条件搜索把前端传来的参数动态拼成组合条件再查库,是它最常见的日常形态。
什么时候该用:要按多个条件筛一批对象,条件会持续增加、组合方式五花八门(不止 AND)时。要是永远只有一个固定条件,一个 if 就够了,别请它出场。
顺带排个雷:Spring 的 Filter、Servlet 的 FilterChain 是拦截过滤器模式(J2EE 模式,本系列㉛ 专讲)——那是"请求过安检门",和这里"筛对象集合"是两回事,只是共用了 Filter 这个名字,别被带偏。
四、过滤器模式的实现方式
示例代码在 design-pattern 项目的 filter 包里:
design-pattern/src/main/java
└── filter // 过滤器模式
├── Product.java // 被筛选的商品
├── BadFilter.java // 反面教材:参数地狱
├── Criteria.java // 筛选条件接口
├── CategoryCriteria.java // 具体条件:按品类
├── PriceBelowCriteria.java // 具体条件:价格低于
├── InStockCriteria.java // 具体条件:有货
├── AndCriteria.java // 组合条件:与
├── OrCriteria.java // 组合条件:或
├── NotCriteria.java // 组合条件:非
└── FilterTest.java // 测试入口
场景:电商列表页,商品按品类、价格、库存筛选。先看用"万能方法"硬扛的下场,再看 Criteria 怎么救。
1. 反面教材:参数地狱
最直觉的写法:一个静态方法,每个维度一个参数,"不筛就传 null":
// 反面教材:每加一个筛选维度,签名和所有调用方都得跟着改
public class BadFilter {
public static List<Product> filter(List<Product> products, String category, Double maxPrice, Boolean inStock) {
List<Product> result = new ArrayList<>();
for (Product p : products) {
if (category != null && !category.equals(p.getCategory())) {
continue;
}
if (maxPrice != null && p.getPrice() > maxPrice) {
continue;
}
if (inStock != null && p.isInStock() != inStock) {
continue;
}
result.add(p);
}
return result;
}
}
筛"数码 且 价格低于 4000":
List<Product> result = BadFilter.filter(products, "数码", 4000.0, null);
运行结果:
===== 反面教材:参数地狱 =====
小米手环(数码,249.0元,有货)
能跑,但三个雷已经埋下:
- "传 null 表示不筛"是隐式协议——签名上看不出哪个参数能空、空了是什么语义,全靠口口相传;
- 只会 AND——想筛"食品或图书"?这个方法无能为力,只能另写一个方法,或者往 if 里继续堆逻辑;
- 加维度全链路遭殃——哪天要按品牌筛:签名加参数、方法体加 if、所有调用方跟着改、测试全部重跑——开闭原则碎了一地。
2. Criteria 版:条件做成积木
先立筛子接口,这是全模式的定海神针:
// 筛选条件接口:具体条件和组合器都实现它
public interface Criteria {
List<Product> meetCriteria(List<Product> products);
}
具体条件,一个维度一个类。按品类筛:
// 具体条件:按品类筛
public class CategoryCriteria implements Criteria {
private final String category;
public CategoryCriteria(String category) {
this.category = category;
}
@Override
public List<Product> meetCriteria(List<Product> products) {
List<Product> result = new ArrayList<>();
for (Product p : products) {
if (category.equals(p.getCategory())) {
result.add(p);
}
}
return result;
}
}
PriceBelowCriteria(价格低于指定值)和 InStockCriteria(只留有货的)结构完全一样,只是 if 的条件不同,就不贴了。
重头戏是三个组合器。与——把第一个条件筛完的结果,交给第二个条件接着筛:
// 组合条件:两个条件都满足才留下
public class AndCriteria implements Criteria {
private final Criteria first;
private final Criteria second;
public AndCriteria(Criteria first, Criteria second) {
this.first = first;
this.second = second;
}
@Override
public List<Product> meetCriteria(List<Product> products) {
List<Product> firstResult = first.meetCriteria(products);
return second.meetCriteria(firstResult);
}
}
或——两边各筛一遍,第二边的成果里去掉重复的并进来:
// 组合条件:满足任一条件就留下
public class OrCriteria implements Criteria {
private final Criteria first;
private final Criteria second;
public OrCriteria(Criteria first, Criteria second) {
this.first = first;
this.second = second;
}
@Override
public List<Product> meetCriteria(List<Product> products) {
List<Product> firstResult = first.meetCriteria(products);
List<Product> secondResult = second.meetCriteria(products);
for (Product p : secondResult) {
if (!firstResult.contains(p)) {
firstResult.add(p);
}
}
return firstResult;
}
}
非——先算出要排除的,剩下的全留下:
// 组合条件:反着筛,留下不满足条件的
public class NotCriteria implements Criteria {
private final Criteria criteria;
public NotCriteria(Criteria criteria) {
this.criteria = criteria;
}
@Override
public List<Product> meetCriteria(List<Product> products) {
List<Product> excluded = criteria.meetCriteria(products);
List<Product> result = new ArrayList<>();
for (Product p : products) {
if (!excluded.contains(p)) {
result.add(p);
}
}
return result;
}
}
注意三个组合器的构造参数:装的不是商品,是 Criteria。组合器自己实现了 Criteria,所以它也能被装进别的组合器——套娃合法,这就是"积木"的全部秘密。
看测试代码。五个商品先备好,从单条件到组合到嵌套,层层递进:
private static void combine(List<Product> products) {
Criteria digital = new CategoryCriteria("数码");
Criteria cheap = new PriceBelowCriteria(4000);
System.out.println("--- 数码 且 价格<4000 ---");
print(new AndCriteria(digital, cheap).meetCriteria(products));
System.out.println("--- 食品 或 图书 ---");
print(new OrCriteria(new CategoryCriteria("食品"), new CategoryCriteria("图书")).meetCriteria(products));
System.out.println("--- 没货的(非:有货) ---");
print(new NotCriteria(new InStockCriteria()).meetCriteria(products));
}
private static void nest(List<Product> products) {
Criteria cheapDigital = new AndCriteria(new CategoryCriteria("数码"), new PriceBelowCriteria(4000));
System.out.println("--- (数码 且 价格<4000) 或 图书 ---");
print(new OrCriteria(cheapDigital, new CategoryCriteria("图书")).meetCriteria(products));
}
运行结果(单条件部分略,完整输出见项目):
===== 与、或、非组合 =====
--- 数码 且 价格<4000 ---
小米手环(数码,249.0元,有货)
--- 食品 或 图书 ---
薯片(食品,9.9元,有货)
三体(图书,59.0元,有货)
--- 没货的(非:有货) ---
iPhone 15(数码,4999.0元,缺货)
洗衣机(家电,2999.0元,缺货)
===== 条件当积木:套娃组合 =====
--- (数码 且 价格<4000) 或 图书 ---
小米手环(数码,249.0元,有货)
三体(图书,59.0元,有货)
对照反面教材逐条验收:
- 反面教材唯一的组合"数码 且 价格<4000",Criteria 版用
AndCriteria(digital, cheap)平替,还不用背 null 协议; - "食品或图书""没货的"这两个需求,BadFilter 直接投降,Criteria 版各一行拼完;
- 套娃组合"(数码 且 价格<4000) 或 图书"——AndCriteria 的产物被塞进 OrCriteria,因为组合器自己也是 Criteria,积木的接口统一了,无限嵌套就是自然结果;
- 哪天要按品牌筛:新建 BrandCriteria 一个类,老代码零改动——对照反面教材的"签名、方法体、调用方三处改"。
这套"组合器也实现同一接口"的设计,和桥接篇"两棵树用组合相连"、适配器篇"组合优于继承"一脉相承——接口统一 + 组合,是结构型模式反复出现的底牌。
五、Java 8 之后:Predicate 是官方收编版
写到这里必须回答一个灵魂拷问:都 2025 年了,谁还手写这套?同一套筛选,Stream 版长这样:
Predicate<Product> digital = p -> "数码".equals(p.getCategory());
Predicate<Product> cheap = p -> p.getPrice() < 4000;
Predicate<Product> book = p -> "图书".equals(p.getCategory());
// (数码 且 价格<4000) 或 图书
List<Product> result = products.stream()
.filter(digital.and(cheap).or(book))
.collect(Collectors.toList());
逐项对账,几乎一一对应:
| Criteria 手写版 | Stream / Predicate 版 |
|---|---|
| Criteria 接口 | `Predicate` 函数式接口 |
| CategoryCriteria("数码") | `p -> "数码".equals(p.getCategory())` |
| AndCriteria / OrCriteria / NotCriteria | `and()` / `or()` / `negate()` |
| meetCriteria(list) | `list.stream().filter(...).collect(...)` |
所以业务代码里的结论很直接:能用 Stream 就用 Stream,一个 lambda 顶一个类,还没有类膨胀问题。那手写版还剩什么用?三个场景:
- 讲解与面试——把模式拆开揉碎,才看得清 Predicate 为什么长这样、and/or/negate 三个默认方法在设计上抄了谁的作业;
- 老项目——还在 Java 7 及更早的项目没有 lambda,手写 Criteria 是唯一选择;
- 条件要"带走"的场景——lambda 没法存进数据库、没法跨服务传输、没法给运营改(lambda 序列化是个公认的 fragile 玩法);而规则引擎里的条件必须入库、回放、可视化编辑,所以它必须是真正的对象。手写 Criteria 本质上就是"可序列化、可配置的 Predicate"——这正是 Drools 这类规则引擎存在的理由之一。
写在最后
回顾这一篇:过滤器模式(Criteria)把每个筛选条件封装成实现同一接口的类,再配上与、或、非三个组合器,条件像积木一样自由拼接、合法套娃;反面教材"参数地狱"只会 AND、加维度全链路遭殃;它不在 GoF 名单里,却被 JDK 收编成了 Predicate + Stream——业务里优先用 Stream,条件需要入库配置时手写版依然是正解;另外记牢名字排雷:拦截过滤器是"请求过安检门",㉛ 再讲。
下一篇回到 GoF 正牌阵容,结构型第四位:组合模式(Composite)。这一篇 And 嵌 Or 拼出的条件树已经提前剧透了它的主题——把"一对多"组织成树形结构,让单片叶子和整棵树用同一个接口处理:文件夹与文件、菜单与子菜单、部门与员工,都是它的舞台。