设计模式预计阅读 19 分钟7442 字3801 次浏览

设计模式修炼之路 ⑧ 过滤器模式——与或非条件当积木

非 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 名单里,分类归属也乱——多数资料(含本系列目录篇)把它排在结构型里讲,也有人说它行为型味道更浓。编外选手,户口本乱很正常,记住它"筛集合、拼条件"的本事就行。

二、过滤器模式的优缺点

优点

  1. 条件复用:一个条件类写一次,哪里需要哪里 new,不用到处复制 if。
  2. 组合自由:与、或、非加嵌套,任意复杂的筛选逻辑都是积木拼接,表达能力从"一串 AND"升级为任意布尔表达式。
  3. 新增条件不改老代码:加"品牌"筛选 = 新建一个 BrandCriteria 完事,反面教材里"改签名、改方法体、全调用方跟着改"的事故现场不再重演——开闭原则。
  4. 条件成了"一等公民":条件可以当参数传、当返回值返、存进集合、运行时动态拼装——这套操作有个学名,叫行为参数化。

缺点

  1. 类数量膨胀:每个条件一个类,为三行 if 建一个类,简单场景纯属杀鸡用牛刀。
  2. Java 8 之后存在感下降:Stream + Predicate 把这套玩法内建进了 JDK,一个 lambda 顶一个类,第五节专门展开。
  3. 组合正确性全靠人肉:积木拼错了编译器不吭声,复杂组合的可读性全靠变量命名硬撑。

三、过滤器模式的应用场景

  • 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元,有货)

能跑,但三个雷已经埋下:

  1. "传 null 表示不筛"是隐式协议——签名上看不出哪个参数能空、空了是什么语义,全靠口口相传;
  2. 只会 AND——想筛"食品或图书"?这个方法无能为力,只能另写一个方法,或者往 if 里继续堆逻辑;
  3. 加维度全链路遭殃——哪天要按品牌筛:签名加参数、方法体加 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 顶一个类,还没有类膨胀问题。那手写版还剩什么用?三个场景:

  1. 讲解与面试——把模式拆开揉碎,才看得清 Predicate 为什么长这样、and/or/negate 三个默认方法在设计上抄了谁的作业;
  2. 老项目——还在 Java 7 及更早的项目没有 lambda,手写 Criteria 是唯一选择;
  3. 条件要"带走"的场景——lambda 没法存进数据库、没法跨服务传输、没法给运营改(lambda 序列化是个公认的 fragile 玩法);而规则引擎里的条件必须入库、回放、可视化编辑,所以它必须是真正的对象。手写 Criteria 本质上就是"可序列化、可配置的 Predicate"——这正是 Drools 这类规则引擎存在的理由之一。

写在最后

回顾这一篇:过滤器模式(Criteria)把每个筛选条件封装成实现同一接口的类,再配上与、或、非三个组合器,条件像积木一样自由拼接、合法套娃;反面教材"参数地狱"只会 AND、加维度全链路遭殃;它不在 GoF 名单里,却被 JDK 收编成了 Predicate + Stream——业务里优先用 Stream,条件需要入库配置时手写版依然是正解;另外记牢名字排雷:拦截过滤器是"请求过安检门",㉛ 再讲。

下一篇回到 GoF 正牌阵容,结构型第四位:组合模式(Composite)。这一篇 And 嵌 Or 拼出的条件树已经提前剧透了它的主题——把"一对多"组织成树形结构,让单片叶子和整棵树用同一个接口处理:文件夹与文件、菜单与子菜单、部门与员工,都是它的舞台。

花

花开空白

开发者 · 西安

相关文章

评论(0)

还没有评论,来抢沙发吧

发表评论