1. 项目背景与需求分析
在Web应用开发中,数据检索功能几乎是每个系统的标配需求。传统的固定条件查询已经无法满足现代应用的灵活需求,用户期望能够像使用Excel筛选那样,自由组合各种条件进行数据查询。这就是我们今天要探讨的自定义条件检索功能的用武之地。
我最近在一个电商后台管理系统中实现了这套方案,客户需要根据商品名称、价格区间、库存数量、上架时间等十多个字段进行任意组合查询。最初使用固定参数接口的方式,结果接口数量爆炸式增长,维护成本极高。后来改用这套Vue+SpringBoot+MyBatis的自定义条件方案后,前端查询条件配置完全动态化,后端只需一个接口就能处理所有组合查询。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术栈选型与架构设计
2.1 前端技术选型
Vue.js作为前端框架是理想选择,主要基于以下考虑:
- 响应式数据绑定:查询条件变化时自动更新UI
- 组件化开发:可以将条件输入控件封装为可复用组件
- 丰富的UI库支持:Element UI或Ant Design Vue提供现成的表单组件
特别推荐使用Vue 3的组合式API,它能让条件组件的逻辑组织更加清晰。比如可以将条件添加、删除、验证等逻辑封装成可复用的composable函数。
2.2 后端技术选型
Spring Boot + MyBatis组合的优势在于:
- Spring Boot的自动配置简化了项目搭建
- MyBatis的动态SQL能力完美适配动态查询需求
- 两者都有丰富的生态支持
这里有个重要决策点:为什么不用JPA?在需要高度定制化SQL的场景下,MyBatis的动态SQL比JPA的Criteria API更直观灵活。特别是当查询涉及多表关联时,MyBatis的优势更加明显。
3. 前端实现细节
3.1 查询条件数据结构设计
合理的JSON数据结构是整套方案的基础。经过多次迭代,我总结出这样的结构:
json复制{
"conditions": [
{
"field": "productName",
"operator": "like",
"value": "手机"
},
{
"field": "price",
"operator": "between",
"value": [1000, 2000]
}
],
"logic": "AND"
}
这个设计的关键点:
- 每个条件包含字段名、操作符和值三个核心属性
- 支持多种操作符:=, !=, >, <, like, in等
- 顶层logic字段控制条件间的逻辑关系(AND/OR)
3.2 动态条件表单实现
使用Vue的动态组件实现条件添加和删除:
vue复制<template>
<div v-for="(condition, index) in conditions" :key="index">
<el-select v-model="condition.field" @change="handleFieldChange(index)">
<el-option
v-for="field in availableFields"
:key="field.value"
:label="field.label"
:value="field.value">
</el-option>
</el-select>
<el-select v-model="condition.operator">
<el-option
v-for="op in operatorsForField(condition.field)"
:key="op"
:label="op"
:value="op">
</el-option>
</el-select>
<component
:is="getValueComponent(condition.field, condition.operator)"
v-model="condition.value"/>
<el-button @click="removeCondition(index)">删除</el-button>
</div>
</template>
几个关键技术点:
- 字段选择变化时,动态更新可用的操作符列表
- 根据字段类型和操作符动态渲染不同的值输入组件
- 使用v-model实现双向数据绑定
3.3 条件验证与提交
在提交查询前需要进行验证:
javascript复制function validateConditions() {
return this.conditions.every(cond => {
if (!cond.field) return false;
if (!cond.operator) return false;
// 特殊验证逻辑
if (cond.operator === 'between' &&
(!Array.isArray(cond.value) || cond.value.length !== 2)) {
return false;
}
return true;
});
}
提交时使用axios发送请求:
javascript复制async function submitQuery() {
if (!this.validateConditions()) {
this.$message.error('请完善查询条件');
return;
}
try {
const res = await axios.post('/api/query', {
conditions: this.conditions,
logic: this.logic
});
this.queryResult = res.data;
} catch (err) {
console.error('查询失败', err);
}
}
4. 后端实现细节
4.1 API接口设计
RESTful接口设计:
java复制@PostMapping("/query")
public ResponseEntity<PageResult<Product>> queryProducts(
@RequestBody QueryParams queryParams,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
// 处理查询逻辑
PageResult<Product> result = productService.queryByConditions(
queryParams, page, size);
return ResponseEntity.ok(result);
}
QueryParams类定义:
java复制public class QueryParams {
private List<Condition> conditions;
private String logic; // "AND" or "OR"
// getters and setters
}
public class Condition {
private String field;
private String operator;
private Object value;
// getters and setters
}
4.2 MyBatis动态SQL实现
核心Mapper接口:
java复制public interface ProductMapper {
List<Product> queryByConditions(@Param("params") QueryParams params);
}
对应的XML映射文件:
xml复制<select id="queryByConditions" resultType="Product">
SELECT * FROM product
<where>
<foreach collection="params.conditions" item="cond" separator=" ${params.logic} ">
<choose>
<when test="cond.operator == 'eq'">
${cond.field} = #{cond.value}
</when>
<when test="cond.operator == 'like'">
${cond.field} LIKE CONCAT('%', #{cond.value}, '%')
</when>
<when test="cond.operator == 'between'">
${cond.field} BETWEEN #{cond.value[0]} AND #{cond.value[1]}
</when>
<!-- 其他操作符 -->
</choose>
</foreach>
</where>
</select>
这里有几个关键注意事项:
${}和#{}的区别:字段名使用${}直接拼接,值使用#{}防止SQL注入<where>标签会自动处理WHERE关键字和AND/OR前缀- 使用
<choose>处理不同操作符的逻辑
4.3 分页处理
结合PageHelper实现分页:
java复制public PageResult<Product> queryByConditions(QueryParams params, int page, int size) {
PageHelper.startPage(page, size);
List<Product> products = productMapper.queryByConditions(params);
PageInfo<Product> pageInfo = new PageInfo<>(products);
return new PageResult<>(
pageInfo.getList(),
pageInfo.getTotal(),
page,
size
);
}
5. 安全与性能优化
5.1 防止SQL注入
虽然MyBatis的#{}已经提供了基本的SQL注入防护,但对于动态字段名还需要额外处理:
java复制// 在Service层添加校验
private static final Set<String> ALLOWED_FIELDS = Set.of(
"productName", "price", "stock", "createTime" // 允许查询的字段
);
public void validateFields(QueryParams params) {
for (Condition cond : params.getConditions()) {
if (!ALLOWED_FIELDS.contains(cond.getField())) {
throw new IllegalArgumentException("非法查询字段: " + cond.getField());
}
}
}
5.2 查询性能优化
对于复杂查询可以考虑以下优化策略:
- 添加合适的数据库索引:
sql复制CREATE INDEX idx_product_name ON product(productName);
CREATE INDEX idx_price ON product(price);
-
对大文本字段避免使用LIKE查询
-
实现查询缓存:
java复制@Cacheable(value = "productQuery", key = "#params.hashCode()")
public PageResult<Product> queryByConditions(QueryParams params, int page, int size) {
// 查询逻辑
}
6. 高级功能扩展
6.1 条件分组查询
支持更复杂的逻辑组合,如(A AND B) OR (C AND D):
数据结构调整为:
json复制{
"groups": [
{
"conditions": [...],
"logic": "AND"
}
],
"logic": "OR"
}
对应的MyBatis XML调整:
xml复制<where>
<foreach collection="params.groups" item="group" separator=" ${params.logic} ">
<trim prefix="(" suffix=")">
<foreach collection="group.conditions" item="cond" separator=" ${group.logic} ">
<!-- 条件处理逻辑 -->
</foreach>
</trim>
</foreach>
</where>
6.2 排序与字段筛选
扩展查询参数支持排序和字段选择:
java复制public class QueryParams {
// ...原有字段
private List<OrderBy> orderBys;
private List<String> selectFields;
}
public class OrderBy {
private String field;
private String direction; // ASC/DESC
}
MyBatis实现:
xml复制<select id="queryByConditions" resultType="Product">
SELECT
<choose>
<when test="params.selectFields != null and params.selectFields.size() > 0">
<foreach collection="params.selectFields" item="field" separator=",">
${field}
</foreach>
</when>
<otherwise>*</otherwise>
</choose>
FROM product
<!-- where条件 -->
<if test="params.orderBys != null and params.orderBys.size() > 0">
ORDER BY
<foreach collection="params.orderBys" item="order" separator=",">
${order.field} ${order.direction}
</foreach>
</if>
</select>
7. 常见问题与解决方案
7.1 日期类型处理
前端传递的日期字符串需要转换为Java的Date对象:
- 前端使用标准格式:
javascript复制condition.value = date.toISOString(); // "2023-07-20T00:00:00.000Z"
- 后端使用@JsonFormat:
java复制public class Condition {
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
private Date value;
}
7.2 枚举类型处理
对于枚举字段,前后端需要统一枚举值:
- 定义枚举:
java复制public enum ProductStatus {
ON_SHELF("上架"),
OFF_SHELF("下架");
private String desc;
// constructor and getter
}
- 前端使用枚举值:
javascript复制operatorsForField(field) {
if (field === 'status') {
return ['eq', 'in']; // 只允许等于和IN操作
}
// 其他字段逻辑
}
7.3 空值处理
需要考虑值为空的情况:
MyBatis中增加空值判断:
xml复制<when test="cond.operator == 'eq' and cond.value == null">
${cond.field} IS NULL
</when>
<when test="cond.operator == 'neq' and cond.value == null">
${cond.field} IS NOT NULL
</when>
8. 项目部署与测试
8.1 前端部署
使用Vue CLI构建生产环境代码:
bash复制npm run build
生成的dist目录可以部署到Nginx:
nginx复制server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:8080;
}
}
8.2 后端部署
Spring Boot应用打包为JAR:
bash复制mvn clean package
使用Docker部署:
dockerfile复制FROM openjdk:11-jre
COPY target/query-demo.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
8.3 集成测试
编写JUnit测试验证查询逻辑:
java复制@Test
public void testQueryWithMultipleConditions() {
QueryParams params = new QueryParams();
params.setLogic("AND");
List<Condition> conditions = new ArrayList<>();
conditions.add(new Condition("productName", "like", "手机"));
conditions.add(new Condition("price", "between", Arrays.asList(1000, 2000)));
params.setConditions(conditions);
PageResult<Product> result = productService.queryByConditions(params, 1, 10);
assertFalse(result.getData().isEmpty());
result.getData().forEach(product -> {
assertTrue(product.getProductName().contains("手机"));
assertTrue(product.getPrice() >= 1000 && product.getPrice() <= 2000);
});
}
9. 替代方案对比
9.1 使用JPA Criteria API
JPA方案示例:
java复制public List<Product> queryWithJpa(QueryParams params) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Product> query = cb.createQuery(Product.class);
Root<Product> root = query.from(Product.class);
List<Predicate> predicates = new ArrayList<>();
for (Condition cond : params.getConditions()) {
Path<Object> fieldPath = root.get(cond.getField());
switch (cond.getOperator()) {
case "eq":
predicates.add(cb.equal(fieldPath, cond.getValue()));
break;
case "like":
predicates.add(cb.like(fieldPath.as(String.class), "%" + cond.getValue() + "%"));
break;
// 其他操作符
}
}
query.where(params.getLogic().equals("AND") ?
cb.and(predicates.toArray(new Predicate[0])) :
cb.or(predicates.toArray(new Predicate[0])));
return entityManager.createQuery(query).getResultList();
}
对比MyBatis方案:
- 优点:类型安全,编译时检查
- 缺点:代码冗长,复杂查询可读性差
9.2 使用QueryDSL
QueryDSL结合JPA使用:
java复制public List<Product> queryWithQueryDsl(QueryParams params) {
QProduct qProduct = QProduct.product;
BooleanBuilder builder = new BooleanBuilder();
for (Condition cond : params.getConditions()) {
PathBuilder<Object> path = new PathBuilder<>(Object.class, qProduct.getMetadata());
NumberPath<BigDecimal> pricePath = qProduct.price;
switch (cond.getOperator()) {
case "eq":
builder.and(path.get(cond.getField()).eq(cond.getValue()));
break;
case "between":
List<Number> values = (List<Number>) cond.getValue();
builder.and(pricePath.between(
BigDecimal.valueOf(values.get(0).doubleValue()),
BigDecimal.valueOf(values.get(1).doubleValue())
));
break;
// 其他操作符
}
}
return queryFactory.selectFrom(qProduct)
.where(builder)
.fetch();
}
对比MyBatis方案:
- 优点:类型安全,流畅的API
- 缺点:学习曲线较陡,需要额外的代码生成步骤
10. 实际项目经验分享
10.1 性能监控与调优
在实际项目中,我们使用Arthas监控生成的SQL:
bash复制# 监控MyBatis SQL
watch org.apache.ibatis.mapping.MappedStatement getBoundSql '{params,returnObj}'
发现的问题及解决方案:
- 大量OR条件导致索引失效 → 重写为UNION ALL查询
- LIKE '%xxx%'全表扫描 → 改为LIKE 'xxx%'并添加索引
- 频繁查询相同条件 → 添加Redis缓存
10.2 复杂查询处理技巧
对于需要多表关联的复杂查询,推荐两种方案:
方案一:使用MyBatis的<association>和<collection>
xml复制<resultMap id="productWithCategory" type="Product">
<id property="id" column="id"/>
<!-- 其他字段 -->
<association property="category" javaType="Category">
<id property="id" column="category_id"/>
<result property="name" column="category_name"/>
</association>
</resultMap>
方案二:使用DTO投影查询
java复制public interface ProductMapper {
@Select("SELECT p.*, c.name as category_name FROM product p JOIN category c ON p.category_id = c.id")
List<ProductWithCategory> findProductsWithCategory();
}
10.3 前端用户体验优化
- 条件输入记忆功能:使用localStorage保存最近使用的查询条件
javascript复制// 保存条件
localStorage.setItem('lastQuery', JSON.stringify(this.conditions));
// 读取条件
const lastQuery = localStorage.getItem('lastQuery');
if (lastQuery) {
this.conditions = JSON.parse(lastQuery);
}
- 查询结果分页缓存:保持翻页时的查询条件
vue复制<el-pagination
@current-change="handlePageChange"
:current-page="currentPage"
:page-size="pageSize"
:total="total">
</el-pagination>
- 添加查询历史记录功能:
javascript复制// 在vuex中维护查询历史
state: {
queryHistory: []
},
mutations: {
addToHistory(state, query) {
state.queryHistory.unshift(query);
if (state.queryHistory.length > 10) {
state.queryHistory.pop();
}
}
}
