查询对象模式:让数据库查询像搭积木一样优雅

说实话,我做了十几年后端,最头疼的就是那些又臭又长的SQL拼接。尤其是业务复杂起来,条件判断、排序、分页混在一起,代码简直没法看。后来接触到查询对象模式(Query Object),才算是找到了解药。

这个模式说白了就是:把查询条件封装成对象。你不需要关心SQL怎么拼,只需要告诉它「我要查什么条件」,它帮你搞定一切。

为什么需要查询对象模式?

我举个例子。你写过这样的代码吗?

String sql = "SELECT * FROM users WHERE 1=1";
if (name != null) {
    sql += " AND name = '" + name + "'";
}
if (age > 0) {
    sql += " AND age > " + age;
}
if (orderBy != null) {
    sql += " ORDER BY " + orderBy;
}

嗯,我年轻时也这么干。直到有一次,用户输入了一个特殊字符,直接把表给删了……那叫一个惨。

查询对象模式要解决的就是三个核心问题:

  • SQL注入——参数绑定帮你挡掉
  • 代码可读性——条件封装成对象,一眼看懂
  • 复用性——同一个查询条件,换个数据源照样用

核心三要素:查询构建器、条件封装、参数绑定

我个人习惯把查询对象模式拆成三个部分来理解。你想想看,就像盖房子:

  • 查询构建器(Builder)——负责搭框架,SELECT、FROM、WHERE这些骨架
  • 条件封装(Criteria)——负责填砖块,等于、大于、模糊匹配这些具体条件
  • 参数绑定(Parameter Binding)——负责安全加固,防止有人往砖缝里塞炸药

先看一个简单的Java实现

// 查询构建器
public class QueryBuilder {
    private String table;
    private List<Criterion> criteria = new ArrayList<>();
    private List<Object> params = new ArrayList<>();
    
    public QueryBuilder from(String table) {
        this.table = table;
        return this;
    }
    
    public QueryBuilder where(Criterion criterion) {
        this.criteria.add(criterion);
        return this;
    }
    
    public String build() {
        StringBuilder sql = new StringBuilder("SELECT * FROM ");
        sql.append(table);
        
        if (!criteria.isEmpty()) {
            sql.append(" WHERE ");
            for (int i = 0; i < criteria.size(); i++) {
                if (i > 0) sql.append(" AND ");
                sql.append(criteria.get(i).toSql());
                params.addAll(criteria.get(i).getParams());
            }
        }
        return sql.toString();
    }
    
    public List<Object> getParams() {
        return params;
    }
}

// 条件封装
public interface Criterion {
    String toSql();
    List<Object> getParams();
}

public class SimpleCriterion implements Criterion {
    private String field;
    private String operator;
    private Object value;
    
    public SimpleCriterion(String field, String operator, Object value) {
        this.field = field;
        this.operator = operator;
        this.value = value;
    }
    
    @Override
    public String toSql() {
        return field + " " + operator + " ?";
    }
    
    @Override
    public List<Object> getParams() {
        return Collections.singletonList(value);
    }
}

你看,这样写查询就清爽多了:

QueryBuilder qb = new QueryBuilder()
    .from("users")
    .where(new SimpleCriterion("name", "=", "张三"))
    .where(new SimpleCriterion("age", ">", 18));

String sql = qb.build();
List<Object> params = qb.getParams();
// sql = "SELECT * FROM users WHERE name = ? AND age > ?"
// params = ["张三", 18]

参数绑定自动处理了,SQL注入?不存在的。

C++版本:模板与多态的较量

做C++的朋友可能会问:Java有反射,C++怎么办?

嗯,我在一个C++项目中用过类似方案。C++没有反射,但我们可以用模板和虚函数来模拟:

#include <vector>
#include <string>
#include <sstream>

class Criterion {
public:
    virtual std::string toSql() const = 0;
    virtual std::vector<std::string> getParams() const = 0;
    virtual ~Criterion() = default;
};

class SimpleCriterion : public Criterion {
private:
    std::string field;
    std::string op;
    std::string value;
public:
    SimpleCriterion(const std::string& f, const std::string& o, const std::string& v)
        : field(f), op(o), value(v) {}
    
    std::string toSql() const override {
        return field + " " + op + " ?";
    }
    
    std::vector<std::string> getParams() const override {
        return {value};
    }
};

class QueryBuilder {
private:
    std::string table;
    std::vector<std::unique_ptr<Criterion>> criteria;
public:
    QueryBuilder& from(const std::string& t) {
        table = t;
        return *this;
    }
    
    QueryBuilder& where(std::unique_ptr<Criterion> c) {
        criteria.push_back(std::move(c));
        return *this;
    }
    
    std::string build() {
        std::ostringstream sql;
        sql << "SELECT * FROM " << table;
        
        if (!criteria.empty()) {
            sql << " WHERE ";
            for (size_t i = 0; i < criteria.size(); ++i) {
                if (i > 0) sql << " AND ";
                sql << criteria[i]->toSql();
            }
        }
        return sql.str();
    }
};

说实话,C++写起来比Java繁琐一些,但核心思想完全一样。把条件抽象成对象,构建器负责组装,参数绑定保证安全。

JPA Criteria API:工业级的查询对象模式

如果你用过JPA,那你其实已经在用查询对象模式了。JPA Criteria API就是官方提供的查询构建器。

我记得刚接触JPA Criteria时,觉得这玩意儿写起来真啰嗦。但用久了才发现,它解决了一个大问题:类型安全

// JPA Criteria API 示例
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<User> query = cb.createQuery(User.class);
Root<User> root = query.from(User.class);

// 构建条件
Predicate namePredicate = cb.equal(root.get("name"), "张三");
Predicate agePredicate = cb.greaterThan(root.get("age"), 18);

query.where(cb.and(namePredicate, agePredicate));

List<User> users = entityManager.createQuery(query).getResultList();

你看,这里没有字符串拼接,没有SQL注入风险。所有的字段名、操作符都是通过API调用的,编译期就能发现错误。

核心优势:JPA Criteria API把查询对象模式做到了极致——类型安全、动态组合、与ORM深度集成。

避坑指南:我曾经踩过的坑

我曾经在一个项目中,把查询对象模式用过了头。每个查询条件都封装成独立的类,结果搞出了几十个类,维护起来比直接写SQL还痛苦。

后来我总结了几条经验:

  • 不要过度抽象——简单的等于、大于条件,用SimpleCriterion就够了,没必要每个字段都建一个类
  • 注意性能——查询对象模式会带来一定的对象创建开销。高并发场景下,可以考虑对象池或缓存
  • 复杂查询要谨慎——多表关联、子查询、聚合函数,这些用查询对象模式写起来反而更复杂。我一般会退回到原生SQL

特别注意:查询对象模式不是银弹。如果你的查询全是简单的单表CRUD,直接用MyBatis Plus或Spring Data JPA的Query Methods就够了,没必要自己造轮子。

知识体系总览

下面这张图是我自己整理的查询对象模式知识结构,你可以对照着看:

查询对象模式知识体系 查询对象模式 查询构建器 条件封装 参数绑定 SQL拼接 链式调用 SimpleCriterion 复合条件 PreparedStatement 类型安全 应用场景:动态查询 · 报表系统 · API网关 · 多数据源适配 最佳实践:适度抽象 · 注意性能 · 复杂查询回归原生SQL

实际项目中的取舍

我在一个报表系统中用过查询对象模式。报表的查询条件特别多——时间范围、地区、品类、渠道……每个条件都可能组合。如果用传统方式,光if-else就得写上百行。

用了查询对象模式后,每个条件都是一个独立的对象,可以自由组合。而且因为参数绑定是自动的,再也不用担心SQL注入了。

但我也要提醒你:查询对象模式不是万能的。如果你的项目只有三五个查询,直接写SQL反而更快。这个模式的价值在于「动态组合」和「类型安全」,场景对了才值得用。

我的建议:先从简单的条件封装开始,不要一上来就搞复杂的构建器。等你的查询条件真的需要动态组合了,再逐步引入查询对象模式。步子迈大了,容易扯着蛋。

好了,关于查询对象模式就聊这么多。记住它的核心:把查询条件变成对象,让代码更安全、更可读、更可复用。下次写动态查询时,不妨试试这个思路。