在传统报表系统开发中,最让人头疼的就是处理五花八门的查询条件组合。每次业务方提出"能不能加个按部门筛选"、"能否同时按日期和金额范围查询"这类需求,开发人员就得修改SQL、调整接口参数。这个项目正是为了解决这个痛点而生——通过动态SQL和条件编排器的组合拳,让前端可以像搭积木一样自由组合查询条件。
我在金融行业做数据平台时,曾遇到过一个月内修改了17次同一张报表的查询逻辑。正是这种经历让我意识到,必须设计一套能灵活应对条件变化的解决方案。现在这套方案已经在生产环境稳定运行3年,支撑日均20万+的查询请求。
选择SpringBoot作为基础框架是考虑到其快速启动和约定优于配置的特性。对于持久层,我们没有用JPA而是坚持MyBatis,原因很实际:
动态SQL的实现采用了MyBatis原生的OGNL表达式,比拼接字符串更安全优雅。条件编排器则是自主设计的DSL(领域特定语言),通过JSON结构描述条件关系。
code复制前端
│
└─提交 JSON条件描述
│
v
Controller层
│
└─验证&转换条件DSL
│
v
Service层
│
├─条件编排器解析
│ │
│ └─生成查询元数据
│ │
│ v
└─动态SQL构造器 ←─ MyBatis映射文件
│
v
数据库查询
这个流程的关键在于条件DSL的设计,我们采用了类似GraphQL的嵌套结构:
json复制{
"conditions": [
{
"field": "createTime",
"operator": "BETWEEN",
"value": ["2023-01-01", "2023-12-31"]
},
{
"logic": "OR",
"conditions": [
{"field": "department", "operator": "IN", "value": ["finance", "hr"]},
{"field": "amount", "operator": "GT", "value": 10000}
]
}
]
}
基础的
xml复制<select id="queryReport" resultMap="reportResult">
SELECT * FROM financial_data
<where>
<foreach collection="paramGroups" item="group" separator=" OR ">
<trim prefix="(" suffix=")" prefixOverrides="AND |OR ">
<include refid="conditionFragment"/>
</trim>
</foreach>
<if test="securityFilter != null">
AND ${securityFilter} <!-- 注意$和#的区别 -->
</if>
</where>
ORDER BY
<choose>
<when test="sortField == 'amount'">abs_amount</when>
<otherwise>create_time</otherwise>
</choose>
</select>
几个关键点:
核心类图:
code复制ConditionBuilder
├─ parse(json): QueryMeta
└─ validate(schema)
QueryMeta
├─ whereClause: String
├─ parameters: Map
└─ orderBy: String
FieldMetaRegistry
├─ fieldDefinitions: Map
└─ registerValidator()
实现时的注意事项:
测试发现频繁解析条件DSL会成为性能瓶颈。我们的解决方案是:
java复制// 使用Guava Cache缓存编译后的查询模板
LoadingCache<String, QueryTemplate> templateCache = CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build(new TemplateLoader());
// 关键代码:生成缓存键时包含条件结构指纹
String cacheKey = md5(conditionJson + fieldMetaVersion);
当遇到深度分页时(如pageNum>1000),采用延迟关联优化:
xml复制<select id="queryLargeReport" resultMap="reportResult">
SELECT t1.* FROM financial_data t1
JOIN (
SELECT id FROM financial_data
<include refid="whereClause"/>
ORDER BY create_time DESC
LIMIT #{offset}, #{pageSize}
) t2 ON t1.id = t2.id
</select>
除了常规的预编译语句,我们还增加了:
字段级权限控制:用户只能查询有权限的字段
java复制// 在解析阶段过滤无权限字段
conditionJson.getConditions().removeIf(
c -> !securityContext.hasFieldAccess(c.getField()));
操作符白名单:限制只能用=、>、<等安全操作符
值范围校验:数字类型检查合理范围,字符串限制长度
初期遇到时区问题导致查询结果不准。解决方案:
java复制// 统一使用Java8时间API处理
DateTimeFormatter formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
ZonedDateTime zdt = ZonedDateTime.parse(input, formatter);
Instant instant = zdt.toInstant();
对于前端传null值的不同语义:
我们最终采用显式声明策略:
json复制{
"field": "approver",
"operator": "IS_NULL" // 或"IS_NOT_NULL"
}
通过在条件DSL中增加function节点实现:
json复制{
"field": "amount",
"operator": "GT",
"function": {
"name": "exchangeRate",
"params": ["USD", "CNY"]
}
}
对应的SQL生成:
sql复制amount > exchange_rate('USD', 'CNY')
通过抽象条件解析器接口,可以支持不同数据库方言:
java复制public interface DialectAdapter {
String handlePagination(String originSql);
String handleFunction(String funcName, List<String> params);
}
// 针对MySQL的实现
@Component
@Profile("mysql")
public class MySqlAdapter implements DialectAdapter {
// 实现分页语法差异等
}
在动态SQL执行阶段埋点:
java复制@Around("execution(* com..*Mapper.*(..))")
public Object monitorQuery(ProceedingJoinPoint pjp) {
long start = System.currentTimeMillis();
try {
return pjp.proceed();
} finally {
long cost = System.currentTimeMillis() - start;
if (cost > 1000) { // 超过1秒记录
log.warn("Slow query: {} - {}ms",
pjp.getSignature(), cost);
// 上报到监控系统
}
}
}
开发了自动EXPLAIN工具,当检测到复杂条件时:
sql复制EXPLAIN SELECT * FROM ... WHERE [动态条件]
将执行计划结果与查询参数一起记录,便于后续优化。
基于Vue实现的交互式条件生成器:
javascript复制// 动态渲染字段选择器
<select v-model="currentField" @change="updateOperators">
<option v-for="field in allowedFields" :value="field.name">
{{ field.label }}
</option>
</select>
// 根据字段类型显示不同值输入组件
<component :is="valueComponent" v-model="currentValue"/>
用户可以将组合好的条件保存为模板:
java复制@Entity
public class QueryTemplate {
@Id
private String id;
@Lob
private String conditionJson;
private String createdBy;
// 其他元数据...
}
字段设计原则:
性能调优步骤:
版本兼容策略: