新闻详情

新闻详情

首页 / 资讯中心 / 详情

JSqlParser实战:Java SQL解析与AST改写指南

发布时间:2026/9/26 20:17:58来源:尧图网络
JSqlParser实战:Java SQL解析与AST改写指南
做过 Java 后端开发的同学十有八九都遇到过这类需求要分析线上慢 SQL、要做一个动态查询引擎、要根据用户角色自动改写查询条件或者要给历史 SQL 做字段脱敏。手动截字符串永远是第一步但也是最不靠谱的一步——一个括号、一个嵌套子查询就能让你怀疑人生。如果你们项目里正好遇到了“读 SQL、改 SQL、生成 SQL”这类场景而且用的是 Java 技术栈那 JSqlParser 绝对是你值得认真研究的库。JSqlParser 是一个纯 Java 实现的 SQL 解析器它能帮我们把一条完整的 SQL 语句解析成结构化的 Java 对象模型也能反过来把 Java 对象模型重新生成 SQL 语句。它支持的方言够用覆盖了 Oracle、SQL Server、MySQL、PostgreSQL 的常见语法对于绝大多数业务场景来说完全够用。本篇文章我会结合自己的实际使用经验把 JSqlParser 的核心 API、解析原理、常见改写场景、以及我在项目中踩过的坑一次性给你梳理清楚。不管是做 SQL 审核平台、数据权限拦截还是面试前突击准备这篇都能帮到你。1. 整体思路拆解为什么要用 JSqlParser而不是自己拼正则很多人第一次遇到 SQL 解析需求时第一反应是用正则去匹配表名、去截取 where 条件。我早期也这么干过比如从一条 select 里取表名用FROM后面跟一段字符串去过滤。在小范围、可控格式的场景下勉强能跑但只要线上 SQL 稍微复杂一点比如嵌套子查询、join 多张表、带括号的复杂条件正则方案基本就失控了。1.1 JSqlParser 的本质把 SQL 变成一棵对象树JSqlParser 做的事情本质上和我们写编译器时的“语法分析”是一样的它把 SQL 字符串拆成 token然后按照语法规则构建成一棵抽象语法树也就是 AST。每一个语法单元都对应一个 Java 类比如Select、PlainSelect、Table、Column、EqualsTo、LongValue等等。这个设计带来的最大好处是你不再需要去“猜”一段字符串中间某个部分代表什么含义而是直接通过对象的属性和层级关系就能拿到信息。比如一条 select 语句里有哪些查询项、哪些表、哪些 join 条件、哪些 where 表达式库都给封装好了整个解析过程对于开发者来说几乎是透明的。1.2 和正则/String 截取方案的对比我用过很长一段时间的正则方案在这里直接说结论如果你的需求只是“判断一条 SQL 里有没有某个关键字”那正则够用但如果要做“取出所有涉及的物理表名”“给查询自动追加过滤条件”“把 A 表的字段改成 B 表字段”这类结构化操作正则方案在维护性和扩展性上是完败的。举一个很简单的例子一条多表 join 的查询表名可能出现在FROM、JOIN、INNER JOIN、LEFT JOIN甚至子查询里面。用正则匹配写出来的 Pattern 会越来越长万一线上出现带注释的 SQL、带反引号的表名匹配结果直接就飘了。而 JSqlParser 方案里表名的获取就是fromItem和join集合里逐个取对象拿名字逻辑完全线性没有分支判断的复杂度。String sql SELECT u.id, u.name, o.order_no FROM t_user u LEFT JOIN t_order o ON u.id o.user_id; Select select (Select) CCJSqlParserUtil.parse(sql); PlainSelect plainSelect select.getSelectBody(); System.out.println(plainSelect.getFromItem()); // 输出 t_user u1.3 我把话说在前面的劝退提示有一点必须提前说明JSqlParser 不是万能的。它对 MySQL 的某些特有语法支持得不够完美比如ON DUPLICATE KEY UPDATE在某些版本上解析会报错涉及WITH递归 CTE 的 SQL 也偶尔会翻车。如果你的项目里大量使用极其冷门的数据库方言那需要谨慎评估一下或者先在测试环境写好单测跑一遍再决定是否引入。2. 核心模型与解析 API快速入门的关键结构我用这个库也差不多三年了一开始就是对着 GitHub 文档硬啃啃完之后发现其实最核心的就是两三个入口类和几个高频模型。把这几个东西搞明白80% 的需求都能解决了。2.1 入口CCJSqlParserUtil 和手动构造 ParserJSqlParser 提供了两个风格的解析入口。日常最简单、最常用的就是CCJSqlParserUtil.parse(String)这个方法可以直接把一条 SQL 字符串解析成Statement对象。我这里说的Statement是接口它下面的实现类基本对应 SQL 的各类语句Select、Update、Insert、Delete、Merge、CreateTable等。Statement statement CCJSqlParserUtil.parse(SELECT * FROM t_user WHERE id 1); System.out.println(statement.getClass());如果是那种一个文件里包含多条 SQL 的情况CCJSqlParserUtil.parseStatements(String)就派上用场了它内部会对输入的多个语句逐个解析返回一个Statements集合。还有一个比较偏门但实用的入口是直接new CCJSqlParser(String)然后循环调用parseStatement()适合处理带自定义分隔符的 SQL 文本。我的经验是优先用工具类真遇到格式特殊的场景再手动造 Parser不必一上来就搞复杂方案。2.2 Statement 模型家族挑最重要的几个说不同的 SQL 操作对应不同的 Java 类型这点和 JPA 或者 MyBatis 的 MappedStatement 概念有点像。我用一个表把最常见的对应关系列出来SQL 类型对应 Java 类核心获取方法SELECTSelectgetSelectBody()UPDATEUpdategetTable()、getUpdateSets()、getWhere()INSERTInsertgetTable()、getColumns()、getItemsList()DELETEDeletegetTable()、getWhere()举例来说如果我想知道一条 Update 语句改了哪张表、设置了哪些字段直接Update对象就能拿到。这里有个容易忽略的点Select类的getSelectBody()返回的是SelectBody接口实际对象可能是PlainSelect、SetOperationList或WithItem。处理的时候最好先判断类型再强转免得直接强转PlainSelect在遇到UNION语句时报ClassCastException。if (select.getSelectBody() instanceof PlainSelect) { PlainSelect plainSelect (PlainSelect) select.getSelectBody(); // 处理普通查询 }2.3 Select 核心结构PlainSelect 就是主战场PlainSelect是用的最多的核心类。它包含了 SQL 查询的重点部分从代码层面看它主要有这些成员selectItems查询列、fromItem主表、joinsjoin 表集合、where条件表达式、groupBy、having、orderBy、limit。我第一次真刀真枪用 JSqlParser 就是做一个动态租户隔离的需求要根据当前登录用户自动给所有 select 语句追加tenant_id ?条件。当时需要做的就是遍历fromItem和joins拿到所有涉及的表然后判断表的位置再往where表达式树里追加一个AndExpression。PlainSelect plainSelect (PlainSelect) select.getSelectBody(); Expression where plainSelect.getWhere(); EqualsTo tenantCondition new EqualsTo(); tenantCondition.setLeftExpression(new Column(tenant_id)); tenantCondition.setRightExpression(new LongValue(1024)); // 如果原来没有 where直接用新的条件 if (where null) { plainSelect.setWhere(tenantCondition); } else { // 原有条件和新条件用 AND 连接 plainSelect.setWhere(new AndExpression(where, tenantCondition)); }这个逻辑看起来简单但实际里有一层很重要的细节where是一个嵌套的表达式树。AndExpression本身可以继续包含AndExpression而在 JSqlParser 内部AndExpression的构造器会自动处理左右表达式的合并问题不需要手动加括号。这也是后来我在处理复杂查询时发现它比字符串拼接省心太多的原因。2.4 表达式体系用访问器模式遍历不要自己判断类型了解 JSqlParser 之后你就会发现最难啃的反而不是入口 API而是Expression这一整套体系。EqualsTo等值判断、GreaterThan大于、IsNullExpressionIS NULL、LikeExpressionLIKE、InExpressionIN等等它们都继承自Expression接口。如果你要深度修改 where 条件最忌讳的就是从上到下写十几个instanceof判断。正确做法是实现ExpressionVisitorAdapter用访问器模式去遍历表达式树。这个Adapter的好处在于它内部已经做了递归遍历你只需要重写关心的节点类型方法就行了。public class TableNameVisitor extends ExpressionVisitorAdapter { Override public void visit(Column column) { // 这里会拿到 SQL 里所有的列包括带表名前缀的 System.out.println(column.getColumnName()); } }这段代码可以应用于很多场景比如做列级数据权限遍历一条 SQL 涉及的所有列再和权限配置比对。如果在这个 Visitor 里访问表名、列名时发现没有权限就可以直接抛异常拦截住这条 SQL 的执行实现一个轻量级的 SQL 防火墙。3. 实操过程与核心环节实现四类最常用的改写场景理论看再多不如直接上手写代码。我挑四个高频需求把完整的实现过程和思考细节都展示出来每个场景我都在项目里实际用过不是那种只能跑 Demo 的玩具代码。3.1 场景一SELECT 语句自动补充数据权限条件这种需求在 SaaS 系统中尤其常见每个租户只能查自己的数据但又不想在业务代码里每个查询都手动去拼条件。用 JSqlParser 做统一拦截其实是让所有 SQL 在进入数据库前都过一遍这个“增强器”。完整实现步骤如下第一步解析 SQL 为Select对象 第二步判断SelectBody的类型拿到PlainSelect 第三步遍历fromItem和joins确认需要追加条件的表是否在 SQL 中出现 第四步构建新的条件合并进现有 where。这里有一个很重要的点如果 SQL 原本没有 where 条件直接setWhere(condition)就行如果原本有 where要把原有表达式和新表达式包一层AndExpression。但是如果你是在原有复杂表达式基础上追加一个条件建议用AndExpression(condition, where)而不是AndExpression(where, condition)因为前者的生成效果更接近人类的书写习惯后面如果再继续解析或者打印可读性更好。我把这个工具类写成静态方法用起来非常方便public static String addTenantCondition(String sql, String tenantId) throws JSQLParserException { Statement statement CCJSqlParserUtil.parse(sql); if (statement instanceof Select) { Select select (Select) statement; SelectBody selectBody select.getSelectBody(); if (selectBody instanceof PlainSelect) { PlainSelect plainSelect (PlainSelect) selectBody; Expression where plainSelect.getWhere(); EqualsTo tenantCond new EqualsTo(); tenantCond.setLeftExpression(new Column(tenant_id)); tenantCond.setRightExpression(new StringValue(tenantId)); if (where null) { plainSelect.setWhere(tenantCond); } else { plainSelect.setWhere(new AndExpression(where, tenantCond)); } return select.toString(); } } return sql; }这个方法返回的是经过 JSqlParser 重新序列化后的 SQL 字符串所以你会注意到 SQL 里的引号、大小写、空格都可能会被规范化。如果你们公司的 SQL 规范要求保留原换行和缩进那这个方案还需要再加工一下但大部分场景下这个输出都是可接受的。3.2 场景二UPDATE 语句自动填充更新时间有一个需求特别常见所有 UPDATE 操作都自动带上update_time NOW()防止开发同学在写 SQL 的时候忘记更新这个字段。基于 JSqlParser我们可以实现在 DAO 层或者 MyBatis 拦截器层统一处理。关键是要正确操作Update对象的getUpdateSets()方法它返回的是一个ListUpdateSet每个UpdateSet内部包含 columns 和 expressions 两部分。如果要判断某列是否已经在 SET 中出现过直接遍历判断列名即可。public static String fillUpdateTime(String sql) throws JSQLParserException { Statement statement CCJSqlParserUtil.parse(sql); if (statement instanceof Update) { Update update (Update) statement; ListUpdateSet updateSets update.getUpdateSets(); boolean hasUpdateTime false; for (UpdateSet updateSet : updateSets) { for (Column column : updateSet.getColumns()) { if (update_time.equalsIgnoreCase(column.getColumnName())) { hasUpdateTime true; break; } } } if (!hasUpdateTime) { UpdateSet updateSet new UpdateSet(); updateSet.addColumn(new Column(update_time)); updateSet.addExpression(new Function(NOW)); updateSets.add(updateSet); } return update.toString(); } return sql; }这里关于Function(NOW)有一点要补充JSqlParser 生成 SQL 时默认会把它输出为NOW()所以不需要自己去拼字符串。这种通过对象模型构造函数的方式也避免了手写字符串时容易把NOW()写成NOW()或者漏掉括号的问题。我在实际项目里也见过有人直接new HexValue(NOW())来绕过解析但那是邪道不推荐因为一旦 SQL 要经过二次改写那种方式生成出来的 SQL 别人很难维护。3.3 场景三从复杂 SQL 中提取所有涉及的表名提取表名这个需求在数据血缘分析、SQL 审核平台里非常常用。如果只拿FROM后的第一个表名遇到子查询或者 UNION 就会漏掉。正确做法是实现一个FromItemVisitorAdapter。public class TableExtractor extends FromItemVisitorAdapter { private final ListString tableNames new ArrayList(); Override public void visit(Table table) { tableNames.add(table.getFullyQualifiedName()); } public ListString getTableNames() { return tableNames; } }使用时把fromItem和每个join的右侧都丢进这个 Visitor。关键是如果遇到子查询作为 FROM 项你需要递归处理在SubSelect的visit方法里再取出它内部的SelectBody继续遍历。public static ListString extractTables(String sql) throws JSQLParserException { Statement statement CCJSqlParserUtil.parse(sql); ListString result new ArrayList(); if (statement instanceof Select) { SelectBody body ((Select) statement).getSelectBody(); if (body instanceof PlainSelect) { PlainSelect ps (PlainSelect) body; TableExtractor extractor new TableExtractor(); ps.getFromItem().accept(extractor); result.addAll(extractor.getTableNames()); if (ps.getJoins() ! null) { for (Join join : ps.getJoins()) { TableExtractor joinExtractor new TableExtractor(); join.getRightItem().accept(joinExtractor); result.addAll(joinExtractor.getTableNames()); } } } } return result.stream().distinct().collect(Collectors.toList()); }这套代码在绝大多数场景下都能正常工作。如果你解析的 SQL 里还有UNION ALL要注意UNION ALL解析的结果是SetOperationList里面包含多个PlainSelect或SelectBody需要逐个遍历处理。我好多同事第一次写extractTables工具类时都栽在UNION上返回了空列表原因就是没处理SetOperationList。3.4 场景四LIMIT 安全校验与改写还有一类偏安全风控的需求禁止不带LIMIT的查询或者强制把不带LIMIT的查询改成LIMIT 1000。这在后管理系统的列表查询接口里很有用能防止有人误操作一次性拉全表数据把数据库打满。基于 JSqlParser这个需求实现起来很简单归根结底就是判断PlainSelect.getLimit()是否为空。if (plainSelect.getLimit() null) { Limit limit new Limit(); limit.setRowCount(new LongValue(1000)); plainSelect.setLimit(limit); }同样的思路还可以延伸到读取已有 LIMIT 的数值如果超出阈值就自动改成阈值。这种“改写而不是报错”的策略在线上比较友好毕竟直接报错会把正在操作的用户搞蒙自动兜底反而既安全又无感。4. 项目实战中遇到的典型问题与排查思路讲完了有意思的改写场景接下来的内容也很重要因为这都是在真实项目中我摔过跤换来的经验排查起来相当耗时间能列出来的我都列出来了。4.1 问题一解析报错——No value specified for parameter这个问题非常经典你拿着 SQL 字符串像平时一样调用CCJSqlParserUtil.parse(sql)结果解析器报错而且错误信息不是特别友好。我第一次遇到时懵了很久满脑子问号。后来才发现原来是因为 SQL 里使用了问号占位符比如WHERE id ?。解决办法有两个一个是用CCJSqlParserUtil.parse(sql, true)这个重载方法会自动把?当作一个合法的参数占位符解析另一个更严谨的方案是先用ReplaceString之类的手段把?替换成一个不冲突的变量名比如:param1解析后再替换回来。我在 MyBatis 拦截器场景下就是用的第一种方法实测下来最省事。Statement statement CCJSqlParserUtil.parse(SELECT * FROM t_user WHERE id ?, true);4.2 问题二别名信息丢失这个坑藏得比较深。获取Column对象时我用column.getColumnName()拿到了实际列名但当我们试图通过column.getTable()获取表名时发现有时候是null有时候又是对的。原因是如果 SQL 中没有显式写表前缀比如SELECT name FROM t_user那么Column对象的表名属性本身就是空的。这不是库的 bug而是 SQL 表达本身就没写清楚库只能如实反映。在权限校验场景里如果需要判定某列属于哪张表就得做一步“列归属推导”先看列名带不带表前缀不带就去当前遍历的 FROM 表集合里匹配如果匹配不上可以直接按安全策略处理比如默认放行或抛异常。这个处理逻辑需要业务方自己定义JSqlParser 给不了现成的答案。4.3 问题三toSQL 后格式变化与兼容问题最后提醒一个很实际的点JSqlParser 的toString()输出不保证和原始 SQL 完全一致。它会重新格式化比如在运算符两边加空格、把表名的反引号去掉取决于配置甚至会把LEFT JOIN大小写改变。如果是做 SQL 美化的系统这个特性反而是优点但如果要做“原样转发”的 SQL 防火墙那一定要先验证输出 SQL 在目标数据库上能正常执行。我碰到过一次比较隐蔽的问题原始 SQL 里给表名加了反引号JSqlParser 默认解析后可能就丢了反引号。如果表名是普通命名还好但如果包含保留关键字比如order、group丢反引号后生成的 SQL 到 MySQL 上就会直接报语法错误。解决办法是可以启用 JSqlParser 的withLowercase或相关配置或者在生成 SQL 后对关键字加引号这块需要靠单测把关。4.4 问题四依赖冲突与版本兼容JSqlParser 底层依赖了com.github.jsqlparser:jsqlparser核心包同时它隐含依赖了classmate这个库。如果你的项目里已经引入了classmate的旧版本并且因为某些框架比如 Jackson 的某些模块传递依赖冲突会碰到NoClassDefFoundError。排查起来其实也不难用mvn dependency:tree看看依赖树把 JSqlParser 版本和classmate版本对齐一下就行。还有一个容易被忽略的问题不同大版本的Update相关 API 有差异。比如较老的版本里Update.getUpdateSets()直接返回ListExpression而新版本改成了ListUpdateSet。如果你照着网上的老教程抄代码编译直接报错。我个人建议引入新项目时直接使用 4.6 以上版本并去 GitHub 拉最新的 Javadoc避免这种差异带来的不必要排查时间。5. 常见问题速查与经验总结我把实际工作中经常被同事问的几个问题整理成了一个速查表方便以后在使用过程里遇到对应问题直接定位。常见问题可能原因解决方案解析带?的 SQL 报错解析器默认不识别问号占位符调用 parse(sql, true)获取列所属表名为 nullSQL 本身未使用表名前缀从 FROM/JOIN 的上下文推导归属解析ON DUPLICATE KEY UPDATE报错某些版本对 MySQL 特有语法支持不完善升级版本或做语法裁剪toSql 后保留字不带反引号JSqlParser 默认不做方言转义开启对应方言配置或手动处理保留字处理 UNION 语句时强转 PlainSelect 异常顶层 SelectBody 实际是 SetOperationList先判断语句类型再逐段处理多语句文件只能解析第一条用错了入口 API改用 parseStatements一些比较零碎的经验我放在这里一并分享第一遇到非常复杂的业务 SQL建议先写单元测试把常见 SQL 样例跑一遍再决定方案。我见过好几个项目功能做完后才发现连最普通的子查询都没法解析最后不得不推翻重来。第二如果你要在 MyBatis 的 Interceptor 里使用 JSqlParser注意拦截器每个 SQL 都要反射出BoundSql这里的性能开销确实存在。实测下来一条中等复杂度的 select 解析耗时大约在 1~3 毫秒左右对于绝大多数业务系统是完全可接受的没必要过度在意性能除非你的系统 QPS 真的极其夸张。第三JSqlParser 的表达式构造器是一个宝库但很多人不常用。它提供了一堆带前缀的方法比如EqualsTo构造器可以连成链式写法能明显减少样板代码。我自己写工具类时就喜欢用构造器风格代码看起来更紧凑也更容易读懂意图。EqualsTo eq new EqualsTo() .withLeftExpression(new Column(status)) .withRightExpression(new LongValue(1));第四多在toString()上做测试。解析正确不算完序列化输出后 SQL 依然正确才算真的处理完事这是很多新手容易忽视的最后一公里。如果接下来你想深入一点建议按照“解析一条 SQL 并打印模型结构”为练习再把“改写 where 条件”作为进阶最后写一个完整的 SQL 拦截器集成到 Spring Boot 里这样一套做下来基本上 JSqlParser 的核心用法就能熟练掌握了。同时模拟一些相关面试题自测一下比如 “说说 JSqlParser 的工作原理” 或 “基于它实现数据权限的方案”也是不错的自己检验成果的方式。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

无网环境 Docker 部署 Hermes Agent 番外篇:TaoToken 统一 Key 接入全功能沙箱的 config.toml 骨架与验证实录(Docker + Python 3.12 + 2026/9/26 21:53:40

无网环境 Docker 部署 Hermes Agent 番外篇:TaoToken 统一 Key 接入全功能沙箱的 config.toml 骨架与验证实录(Docker + Python 3.12 +

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
ax编排实战:Agent、K8s与CLI三层架构与避坑指南 2026/9/26 21:53:28

ax编排实战:Agent、K8s与CLI三层架构与避坑指南

1. 从"ax"这个标题说起:一个被低估的编排入口第一次看到"ax"这个标题,很多人会以为是某个命令行工具的缩写,或者某个内部代号。但结合热搜词里的 agentic、orchestrator、Kubernetes、CLI 这几个关键词,基本可…

阅读更多 →
SpringBoot+Vue医疗服务系统源码实战:从环境搭建到挂号并发避坑 2026/9/26 21:53:28

SpringBoot+Vue医疗服务系统源码实战:从环境搭建到挂号并发避坑

简介:这是一套基于SpringBootVue的医疗服务系统完整源码与数据库,面向计算机、通信、人工智能、自动化等相关专业的在校学生与教师,尤其适合作为毕业设计、期末课程设计或课程大作业的参考方案。项目为个人毕设作品,答辩评审分达9…

阅读更多 →
桌面沟通型CRM实战拆解:从客户管理到销售漏斗的落地指南 2026/9/26 21:53:21

桌面沟通型CRM实战拆解:从客户管理到销售漏斗的落地指南

1. 项目概述:DeskcommCRM到底是什么做团队管理和客户跟进这些年,我越来越觉得一个道理:工具不在多,而在顺不顺手。很多团队买了一套CRM,结果用了三个月就闲置,业务员天天拿Excel记客户,管理者想…

阅读更多 →
MySQL 5.7社区版审计插件部署指南:等保合规与性能调优 2026/9/26 21:53:21

MySQL 5.7社区版审计插件部署指南:等保合规与性能调优

简介:该资源为面向 Linux 64 位环境的 MySQL 5.7 社区版安全审计插件安装包,适合数据库管理员、运维工程师及有合规审计需求的技术人员使用,用于记录数据库活动、追踪 SQL 操作并满足安全合规要求。压缩包共 6 个文件,约 568KB&am…

阅读更多 →
Atlas 300V推理加速卡实战:YOLO模型部署全流程解析 2026/9/26 21:53:21

Atlas 300V推理加速卡实战:YOLO模型部署全流程解析

最近在好几个技术群里都看到有人在问 Atlas 300V,其中一条热搜问题让我印象很深:“atlas 300v 24g 是运算加速卡吗”。单看这个说法,其实没有回答到位。它确实是一张加速卡,但它和很多人熟悉的 GPU 加速卡在工作方式和使用思路上有…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉