JSqlParser实战:从SQL解析到动态改写的Java开发指南
做Java后端写增删改查写久了早晚会碰上一类需求要读SQL、改SQL、判断一条SQL里到底碰了哪些表。早期我见过不少人用正则硬抠抠到注释和换行就崩条件一复杂直接摆烂。后来我换了专业解析SQL的Java库也就是JSqlParser从此这类活基本半小时内搞定。这篇文章不聊底层实现就把我实际用JSqlParser解析SQL语句的经验、源码级细节、踩过的坑一次性总结出来。1. JSqlParser是什么以及为什么你需要它1.1 一句话讲清楚这个库JSqlParser是一个纯Java实现的SQL解析库它的核心作用是把一段SQL文本解析成一棵Java对象树。打个比方SQL字符串在它眼里就像一段“源代码”它帮你做完了词法分析和语法分析最后给你一个Statement对象你通过这个对象就能随意访问SQL里的表、列、条件、排序、分组等结构。反过来你修改了这棵对象树之后再调用toString()它还能把树重新序列化成一条新的SQL文本。跟正则相比这完全是两个次元的工具。正则处理select * from user where id1还行遇到换行、注释、嵌套子查询、union、join正则写起来又长又脆。JSqlParser是真正理解SQL语法结构的所以不管SQL文本长什么样、缩进多乱、注释多少只要语法合法它都能解析成结构化的对象。1.2 哪些项目真正需要解析SQL我总结下来下面这些场景是JSqlParser的主场基本都有刚需数据权限系统最常见的用法用户请求SQL之后在服务端给原始SQL动态追加“部门IDxx”这类过滤条件实现行级权限控制又不破坏原SQL结构。SQL审核平台拦截开发提交的SQL判断是不是select *、有没有更新非目标表、有没有跨库查询这些都是靠解析语法树来判断比正则靠谱太多。数据脱敏服务自动识别SQL中涉及手机号、身份证、姓名的列把查询结果字段替换成掩码函数。数据血缘分析用SQL做数据仓库、数仓治理的时候需要分析一条SQL从哪些表取了哪些列输出了哪些表生成血缘关系图。SQL格式化与方言转换把风格混乱的SQL统一格式或者做简单的MySQL到PostgreSQL语法改写。慢查询归因拿到慢SQL列表之后批量解析统计哪些表、哪些列经常出现在慢查询里辅助索引优化。可以说凡是让SQL“可编程化”的需求基本都会落到技术选型上而JSqlParser就是Java生态里最成熟的那个选择。2. 环境准备与核心API概览2.1 Maven依赖和版本选择用Maven引入非常快在pom.xml加一段依赖就行dependency groupIdcom.github.jsqlparser/groupId artifactIdjsqlparser/artifactId version5.5/version /dependency这里有个容易踩的坑老项目里你会看到很多资料写的是net.sf.jsqlparser:jsqlparser这个坐标那一度是3.x、4.x时代常见的groupId。从4.x后期开始Maven Central上稳定推荐的是com.github.jsqlparser这个坐标但包名一直是net.sf.jsqlparser.*没变。所以切换坐标的时候引入的类路径不需要大改。版本怎么选呢我的建议是如果你的项目比较新直接用5.x语法解析能力、SQL方言兼容性都更好。如果你在维护老系统用的是3.x或4.x不要盲目升级。jsqlparser在版本升级时有过Visitor接口的方法调整升级后代码编译大概率会报错需要逐个改方法签名。一定要关注它支持的SQL方言版本。比如SQL Server的TOP (n)、Oracle的CONNECT BY、Hive的LATERAL VIEW这些在旧版本里支持不稳定新版本才逐步完善。你如果正好要解析这类方言选新版本能省很多事。2.2 解析入口与Statement对象模型JSqlParser的入口非常简洁绝大多数场景只需要一个方法import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.Statement; String sql select id, name from user where status 1; Statement statement CCJSqlParserUtil.parse(sql); System.out.println(statement.getClass()); // 输出class net.sf.jsqlparser.statement.select.SelectStatement是所有SQL语句的顶层接口往下分很多类型Select、Insert、Update、Delete、CreateTable、AlterTable、TruncateTable等等。日常解析任务里Select出现频率最高。拿到Select之后最常用的操作是把它转成PlainSelect这是普通查询的对象模型Select select (Select) statement; PlainSelect plainSelect (PlainSelect) select.getPlainSelect();PlainSelect里面几乎包含了SELECT语句的所有组成元素getSelectItems()查询列对应select后面的字段列表getFromItem()主表对应from后面的表getJoins()连接信息对应join部分getWhere()过滤条件对应where部分的表达式树getGroupBy()分组信息getOrderByElements()排序信息getLimit()分页信息这个模型设计得很直观你不需要去看SQL文本直接操作这些方法就能完成信息提取和改写。2.3 两种拿SQL信息的方式现成工具与Visitor解析SQL获取信息JSqlParser给你提供了两条路要分清楚否则代码会越写越绕。第一条路直接用它提供好的工具类。比如TablesNamesFinder一个方法就能把SQL里所有涉及的表名拿到包括子查询里的表。典型用法TablesNamesFinder finder new TablesNamesFinder(); ListString tableList finder.getTableList(statement);这条路线适合“只需要简单信息”的场景比如我只要知道这条SQL涉及哪些表别的不管。第二条路自己写Visitor遍历语法树。这是JSqlParser的精髓也是从“会用”到“会写”的分水岭。所谓Visitor就是你声明“我对语法树里哪种节点感兴趣”然后框架会在遍历到这种节点时回调你。比如我想处理所有Column节点就写expression.accept(new ExpressionVisitorAdapter() { Override public void visit(Column column) { System.out.println(列名 column.getColumnName()); } });Visitor的优势是你不用关心SQL的复杂结构框架自动帮你递归遍历整棵树你只处理自己关心的节点。子查询里的列、函数参数里的列、JOIN条件里的列全部能命中。实际项目里这两条路经常混用先用TablesNamesFinder快速拿到表清单再写Visitor去细挖列和条件。下面我分场景展开讲。3. 从SQL里高效提取信息表名、列名、查询条件3.1 提取表名与别名TablesNamesFinder与自定义Visitor提取表名是SQL解析里最基础、最高频的操作。比如审计平台要统计哪些表被查询得最频繁或者血缘分析要画表之间的依赖图第一步都是拿到表名列表。最简单的做法是直接用TablesNamesFinderimport net.sf.jsqlparser.util.TablesNamesFinder; String sql select a.*, b.name from user a join order b on a.id b.user_id where a.status 1 and exists (select 1 from logout_log l where l.user_id a.id); Statement statement CCJSqlParserUtil.parse(sql); TablesNamesFinder finder new TablesNamesFinder(); ListString tables finder.getTableList(statement); System.out.println(tables); // 输出[user, order, logout_log]这里要注意两点。第一返回的列表可能有重复。同一个表在SQL里出现两次比如自关联列表里就会有两条。如果你要做统计分析记得先new HashSet(list)去重。第二这个工具默认会递归进入子查询所以上面例子里的logout_log也被抓出来了这通常是好事但如果你只想要最外层表就麻烦了需要自己处理。我自己写过一版“只看主表”的提取逻辑做法是拿PlainSelect.getFromItem()和getJoins()逐个判断类型遇到Table直接取名字遇到SubSelect或ParenthesedSelect就不继续往下翻PlainSelect ps (PlainSelect) select.getPlainSelect(); ListString outerTables new ArrayList(); collectFromItem(ps.getFromItem(), outerTables); for (Join join : ps.getJoins()) { collectFromItem(join.getRightItem(), outerTables); }如果SQL里有复杂嵌套比如从子查询里再select这种“只取外层表”的逻辑还要递归处理所有层级。实际工作中我建议先明确需求是要全部表还是只要主查询的表再选择工具还是自定义Visitor否则很容易在后续处理时被多余的子查询表干扰。3.2 提取查询列与嵌套表达式中的列提取查询字段看起来简单getSelectItems()遍历一下就行但坑其实不少。一个SELECT项可能是纯列、带别名的列、函数表达式、*通配符或者t.*这种带表前缀的通配符。不处理这些特殊情况代码就不可用。我用的通用提取逻辑是这样的ListString columns new ArrayList(); for (SelectItem item : plainSelect.getSelectItems()) { if (item instanceof AllColumns) { // select * 的情况 columns.add(*); } else if (item instanceof AllTableColumns) { // select t.* 的情况 columns.add(((AllTableColumns) item).getTable().getName() .*); } else if (item instanceof SelectExpressionItem) { SelectExpressionItem sei (SelectExpressionItem) item; Expression expr sei.getExpression(); expr.accept(new ExpressionVisitorAdapter() { Override public void visit(Column column) { columns.add(column.getColumnName()); } }); } }用ExpressionVisitorAdapter的好处是即使字段是concat(a.name, b.name)这类函数表达式它也只会回调真正的Column节点把a.name和b.name都提取出来而不是给你一个没用的Function对象。有人会问select *到底要不要展开成具体列名这取决于业务场景。做数据血缘时*必须展开成物理表的全部字段否则血缘关系不完整。实现展开逻辑时要拿到表结构元数据代码里还是要走一遍”查表结构“的流程解析库本身只负责告诉你这里是个*。3.3 解析WHERE条件拿到完整查询约束WHERE条件的提取是JSqlParser里最有价值的场景之一也是很多“SQL解析教程”没讲透的部分。很多人的需求不仅仅是拿到表名和列名而是想搞清楚这条SQL的查询约束是什么。比如做SQL风险评估要判断有没有把主键条件带上做SQL改写要确定原来的WHERE结构能不能安全地插入新条件。PlainSelect.getWhere()返回的是一棵Expression表达式树。这棵树的组成很典型EqualsTo等值判断对应a bGreaterThan、MinorThan大小比较AndExpression、OrExpression逻辑与、逻辑或Column列引用StringValue、LongValue、DoubleValue常量值InExpression对应IN和NOT INIsNullExpression对应IS NULLBetween对应BETWEEN ... AND ...想完整遍历条件树最省力的方式是继承ExpressionVisitorAdapterWhereCollector collector new WhereCollector(); plainSelect.getWhere().accept(collector);然后在回调里记录你关心的条件类型。比如我要判断这条SQL是不是全表扫描就关注LongValue有没有出现在EqualsTo的右侧或者WHERE是不是直接为空。注意如果getWhere()返回null说明没有过滤条件这是select * from user这种全表查询权限控制里一般直接拦掉。另一个容易忽略的点条件里的列不一定都带表名前缀。where user.name x里的列对象getTable()有值但where name x里的列对象getTable()就是null。在写数据权限过滤器时处理这种列要跟解析出来的主表名做关联否则就会漏判。我的做法是先拿到主表名遇到table null的列就默认归属主表。4. 动态改写SQL条件注入、脱敏与SQL重建4.1 在WHERE后面追加一个过滤条件动态追加WHERE条件是JSqlParser里最实用的技能典型的场景是Saas系统的行级权限。用户登录后看到的数据范围不应该由前端传参决定而应该由后端在SQL上强制追加“当前登录用户的组织IDxxx”。追加条件的实现思路是解析原SQL拿到WHERE表达式把新条件用AndExpression拼接到后面最后把修改后的Statement序列化回字符串。我直接给出一段可以落地的代码String sql select * from t_order where amount 100; Select select (Select) CCJSqlParserUtil.parse(sql); PlainSelect plainSelect (PlainSelect) select.getPlainSelect(); // 构造新条件org_id 1001 EqualsTo filter new EqualsTo(); filter.setLeftExpression(new Column(null, org_id)); filter.setRightExpression(new LongValue(1001)); Expression originalWhere plainSelect.getWhere(); if (originalWhere null) { // 原来没有where直接设置 plainSelect.setWhere(filter); } else { // 原来是and条件把新条件拼上去 plainSelect.setWhere(new AndExpression(originalWhere, filter)); } String newSql select.toString(); System.out.println(newSql); // 输出示例SELECT org_id, amount ... 实际上会有格式变化这段代码关键点有三个。第一用EqualsTo对象构造条件而不是字符串拼接。为什么因为JSqlParser底层序列化的时候会按语法树规则决定是否加括号。你如果直接用字符串硬拼遇到where a1 or b2这种条件拼出来的where a1 or b2 and org_id1001会改变原SQL的语义因为AND优先级高于OR直接改变了条件组合。而用AndExpression包装JSqlParser会把原条件括号化生成where (a1 or b2) and org_id1001语义完全正确。这个坑凡是做过条件注入的人应该都踩过。第二new Column(null, org_id)的第一个参数是表名传null表示不指定表序列化出来就是干净的org_id。第三原来的where如果为空直接setWhere(filter)不要画蛇添足先new AndExpression。4.2 把敏感列替换为函数表达式做脱敏脱敏需求也很常见查出来的手机号不能明文展示要在SQL层面直接替换成concat(left(phone,3), ****, right(phone,4))之类的掩码格式。用JSqlParser做这件事的思路是遍历查询列找到目标列把它整体替换成一个Function表达式。示例代码骨架如下以select name from user为例PlainSelect ps (PlainSelect) select.getPlainSelect(); ListSelectItem items ps.getSelectItems(); for (SelectItem item : items) { if (!(item instanceof SelectExpressionItem)) { continue; } SelectExpressionItem sei (SelectExpressionItem) item; Expression expr sei.getExpression(); if (expr instanceof Column) { Column column (Column) expr; if (name.equalsIgnoreCase(column.getColumnName())) { // 构造 concat(left(name,1), ****) 函数 Function left new Function(); left.setName(left); left.setParameters(new ExpressionList(column, new LongValue(1))); Function concat new Function(); concat.setName(concat); concat.setParameters(new ExpressionList(left, new StringValue(****))); sei.setExpression(concat); } } }这一段代码最需要注意的是ExpressionList的构造。我早期在这里翻过车参数少加一个、类型传错序列化出来的SQL就莫名其妙。建议每次构建完函数后先String s select.toString()打印出来看看确认函数嵌套的括号顺序正确。你要清楚这种脱敏改写是把“查询结果集”在数据库层就做掉了对应用层完全透明后面接JDBC直接拿到的就是脱敏后的值。但要注意一点如果SQL里同时有WHERE name xxx这种条件你光替换SELECT列是不够的WHERE里的列也要处理否则数据库查询条件仍按明文匹配。实际项目中脱敏通常要配合条件字段白名单或黑名单一起做。4.3 toString还原SQL时的格式化问题我见过很多人第一次用JSqlParser改写SQL改完发现”SQL怎么变了“。这里的核心知识点是Statement对象序列化出来的SQL不等于原始SQL文本。JSqlParser的toString()是根据语法树重新生成的所以会有这些变化关键字统一变成大写比如SELECT、WHERE空格、换行、缩进被标准化别名、表名的大小写可能被保留或变化没有必要的括号会根据优先级重新组织原来的注释全部丢失这其实不是bug而是正常行为。你把它当成“格式化工具”反而是够用的但如果你想“在原SQL基础上做最小改动”那JSqlParser做不到文本级别的保留。我实际项目中是怎么处理的呢如果是内部系统SQL本来就是程序生成的我直接接受它的格式化输出。如果是用户自定义SQL我会把改写过后的SQL再走一遍格式化展示让用户看到的是整洁的新SQL而不是纠结于“怎么和原来不一样了”。如果你想保留原始SQL建议解析后把原始文本作为字段单独存起来改写后的SQL作为另一个字段两边的对应关系用SQL主键或系统生成的ID关联。5. 实战踩坑记录与工程化建议5.1 高频坑UNION、子查询、关键字冲突先说UNION。很多新手第一次解析select * from a union select * from b写(PlainSelect) select.getPlainSelect()直接报ClassCastException因为UNION类型的Select不是PlainSelect而是SetOperationList。正确处理方式是先判断类型if (select instanceof PlainSelect) { ... } else if (select instanceof SetOperationList) { SetOperationList setOps (SetOperationList) select; for (Select innerSelect : setOps.getSelects()) { // innerSelect里面继续判断PlainSelect还是嵌套的SetOperationList } }子查询也是类似问题。from后面的表可能会是SubSelect而不是Table你在提取表名时要用TablesNamesFinder或者写递归判断而不是直接强转Table。再说关键字冲突。SQL解析里order、group、status这类词在部分数据库里是保留字。JSqlParser处理它们一般没问题但当你自己构造Column对象时如果列名恰好是保留字序列化出来的SQL可能会缺反引号或引号。我有一个偷懒但有效的应对办法尽量不从零构造SQL而是解析一条“模板SQL”在语法树层面做替换。这样保留字的处理完全交给框架不需要自己操心。5.2 版本差异与线程安全问题在网上搜JSqlParser的教程会发现同一种写法在不同版本里表现不一样。我踩过最典型的一个坑3.x版本里AdditiveExpression还是单独一个类4.x之后它被合并到BinaryExpression体系里了ExpressionVisitor接口也在4.x版本里新增过方法。所以如果你在网上找到一段能用的Demo大概率是某个特定版本下的写法粘到自己项目里先看看版本对不对再跑测试。线程安全这块我的结论是CCJSqlParserUtil.parse()本身是线程安全的因为每次解析都会创建独立的解析器实例你可以在多线程环境下放心解析不同的SQL字符串。但是解析出来的Statement对象不是线程安全的它本质上是可变对象。如果你在多线程环境共享同一个Statement对象并做修改会有并发问题。我的工程实践是只读场景比如分析表名、列名解析结果可以缓存复用要改写的场景每次请求都重新parse一次反正一次解析通常是亚毫秒到毫秒级别性能完全扛得住。5.3 性能优化与合理使用姿势说几个我压测过的数据帮大家建立直觉一条几十行的普通SELECTJSqlParser解析耗时大概在0.5ms到2ms之间。所以如果你只是做管理后台的SQL审核每秒处理几十条SQL完全不用担心性能。真正要担心的是两个地方第一批量解析大量SQL时会产生大量临时对象GC压力会上升。处理几千条SQL这种量级没问题但如果做全量SQL采集解析建议用生产者消费者模型解析线程池控制在CPU核心数上下避免一次创建太多对象。第二Statement.toString()的耗时比解析还要高不少尤其是复杂SQL序列化一多性能就下来了。如果只是分析不改写没必要调用toString()直接对语法树做只读遍历就行。另外一个常见误区试图用JSqlParser判断SQL“合法不合法”。它确实能抛ParseException但更多时候数据库方言的特定写法它会宽松处理或者直接忽略。真正要做合法性校验建议用数据库自己的EXPLAIN解析库只用来做结构分析。写在最后说点个人经验我从第一次用JSqlParser解析一个SELECT COUNT(*) FROM t到后来在权限系统里全面铺开中间踩过的坑基本都写在上面的章节里了。如果只让我给一条建议那就是先别急着写代码把SQL文本拆成你能接触到的所有对象模型PlainSelect、Expression、Column的关系搞清楚后面所有需求都是基于这棵树做增删改查。你手边也可以常备一个调试小工具随时把Statement.toString()打出来看格式肉眼观察序列化结果比翻文档管用得多。SQL解析这个领域入门容易做深了全是细节但JSqlParser确实是Java生态里值得你投入时间的那一个库。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →