☰
JSqlParser实战:Java后端SQL解析、改造与行级权限拦截
2026/10/2 8:27:24 网站建设 项目流程

做Java后端这几年,凡是跟SQL沾边的系统,早晚都会遇到一个问题:怎么在代码里去“读懂”一条SQL。用户传过来的查询语句、配置中心的动态脚本、低代码平台的报表条件、数据权限的过滤逻辑——这些东西本质都是字符串,但你想在运行时拆出它查了哪些表、过滤了哪些条件、改动了哪些字段,靠正则手写解析基本是给自己挖坑。JSqlParser就是专门干这件事的Java库,它能把SQL语句解析成一棵结构化的语法树,然后你就可以像操作普通Java对象一样去读取、修改、甚至重新拼接SQL。这篇文章把我用JSqlParser做SQL解析、拼接行级权限、排查线上问题的经验完整总结一遍,适合用过但不够熟的人,也适合正准备引入这个库的团队参考。

1. 为什么需要JSqlParser:它到底解决了什么问题

1.1 手写解析SQL是在给自己挖坑

很多开发者的第一反应是:我不就取个表名、加个where条件吗,用正则不就行了?我早期也这么干过,比如:

Pattern.compile("from\\s+(\\w+)", Pattern.CASE_INSENSITIVE)

这条正则处理select * from user没问题,但遇到下面这些情况就开始连环炸:

  • SELECT id, name FROM t_user AS u LEFT JOIN t_order o ON u.id = o.user_id
  • SELECT * FROM (SELECT id FROM t_user) t WHERE t.id > 100
  • DELETE FROM t_user WHERE id IN (SELECT user_id FROM t_order)
  • 字段名或者别名就叫from、where、order的关键字冲突
  • SQL里带注释、带反引号、带换行和缩进

正则在这些场景下要么漏匹配,要么误匹配,你只能不停补规则,最后补出一个自己都维护不了的怪物代码。而且正则只能“匹配”文本,你没法方便地“修改”SQL——比如在where后面追加一个组织过滤条件,用正则替换会改错位置,极容易改到子查询的条件里去。

1.2 JSqlParser是什么?解决什么问题

JSqlParser是一个基于JavaCC的SQL语法解析库。它的核心产出是一个AST(抽象语法树):输入一条SQL字符串,输出一个Statement对象。这个对象下面的每个节点都对应SQL里的语法单元,比如Table表示表、Column表示列、EqualsTo表示等值比较、PlainSelect表示一个普通SELECT块。

它能解决的问题集中在三个方向:

第一,读取SQL的关键信息。比如提取SQL涉及的所有表名、提取查询列、提取where条件的比较字段。这是SQL审计、SQL血缘分析、数据字典联动的基础能力。

第二,动态改造SQL。你可以在保留原始SQL格式语义的前提下,往SELECT语句里追加where条件、替换查询列、改写表名。我在做行级权限时就是靠它把过滤条件“织”进原始SQL的,这个后面会详细说。

第三,SQL合法性预校验。系统里保存复杂查询条件之前,先用JSqlParser解析一遍,解析不过就直接拒绝,避免脏SQL落到执行层才报错。

1.3 一条SQL是怎么变成一棵树的

理解JSqlParser的工作机制不需要研究JavaCC的全部细节,但建议了解它的大致链路。SQL文本进入解析器后,会经过词法分析、语法分析两个阶段。词法分析把字符流切成Token,比如SELECT、id、,、FROM这些;语法分析再根据SQL语法规则把这些Token组合成层级结构。

举个例子:

SELECT id, name FROM t_user WHERE age > 18

解析后会形成类似这样的对象结构:

  • Statement(实际运行时是Select类型)
    • SelectBody(内部是PlainSelect)
      • SelectItems:id列、name列,各自是SelectExpressionItem
      • FromItem:Table(名称为t_user)
      • Where:GreaterThan表达式,左值是Column(age),右值是LongValue(18)

你操作JSqlParser的过程,本质就是在这棵树上做查找和修改。这比正则匹配可靠的根因在于:解析器是真正按SQL语法规则推进的,它知道FROM后面跟的是表还是子查询,知道括号里的条件作用域在哪一层。

2. 环境准备与快速上手:10分钟跑通第一个解析Demo

2.1 Maven依赖引入与版本选型

使用JSqlParser的第一步是把依赖加进去,Maven坐标是:

<dependency> <groupId>com.github.jsqlparser</groupId> <artifactId>jsqlparser</artifactId> <version>4.6</version> </dependency>

这里要提醒一件特别容易踩坑的事:网上很多老教程写的是net.sf.jsqlparser这个groupId,对应的是2.x甚至1.x的老版本。4.0以后坐标统一改成了com.github.jsqlparser,包名没变,还是net.sf.jsqlparser.*。如果你照着老博客复制依赖,很可能拉到远古版本,然后发现API对不上。

版本选型上我的建议是:没有特殊兼容性要求就直接上4.6或者更新的4.x版本。4.0和4.3之间有一些API变化,比如有些方法从List<String>变成带泛型的类型,还有部分访问者方法签名调整。4.5之后整体比较稳定,网上资料也多。如果你的项目里还在用3.x,除非有庞大的历史代码没法迁移,否则建议升级,老版本的表达式访问器设计确实不如新版好用。

2.2 最小的解析Demo:从字符串到Statement

引入依赖后,一行代码就能完成解析:

import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.select.Select; public class Demo { public static void main(String[] args) throws JSQLParserException { String sql = "SELECT id, name FROM t_user WHERE age > 18"; Statement statement = CCJSqlParserUtil.parse(sql); System.out.println(statement.getClass()); // 输出: class net.sf.jsqlparser.statement.select.Select System.out.println(statement.toString()); // 输出: SELECT id, name FROM t_user WHERE age > 18 } }

注意statement.toString()会按照JSqlParser内部的格式化规则重新生成SQL,和你原始输入不一定逐字符相同,但语义一致。这个特性很有用:当你修改语法树之后,直接toString就能拿到改造后的完整SQL,不需要自己维护字符串拼接。

2.3 三个常用快捷入口,按场景选用

JSqlParser在CCJSqlParserUtil里提供了几个解析入口,我用得最多的是这三个:

// 1. 解析完整语句,支持SELECT/INSERT/UPDATE/DELETE等 Statement stmt = CCJSqlParserUtil.parse(sql); // 2. 只解析一个表达式,比如条件片段 "age > 18" Expression expr = CCJSqlParserUtil.parseExpression("age > 18"); // 3. 解析条件表达式,用于where片段拼接 Expression cond = CCJSqlParserUtil.parseCondExpression("dept_id = 100");

parse是通用入口,绝大多数场景用它。当你只需要处理一个表达式(比如前端传过来的过滤条件串)时,用parseExpression和parseCondExpression更轻量。我在权限模块里就是先拿到用户自定义的过滤条件字符串,用parseCondExpression解析成Expression对象,再往主查询的where树上挂,避免全程用字符串拼接再重复解析。

3. 核心API拆解:查询、表名、条件、增删改的解析要点

3.1 Statement继承体系:先搞清楚对象层次

解析返回值类型是Statement接口,实际运行时根据SQL类型不同,会new出不同的实现类。常用的对应关系如下:

SQL类型实际类型说明
SELECTSelect内部再通过getSelectBody()拿查询体
INSERTInsert包含表、列、值或SELECT子句
UPDATEUpdate包含目标表、SET语句、WHERE条件
DELETEDelete包含目标表、WHERE条件

写代码时尤其要注意:Select只是一个壳子,真正的查询内容是SelectBody。SelectBody可能是PlainSelect(普通查询),也可能是SetOperationList(UNION、INTERSECT这类集合查询)。你如果直接把select.getSelectBody()强转成PlainSelect,遇到UNION查询就会抛ClassCastException。稳妥的做法是先instanceof判断,或者递归处理。

Select select = (Select) statement; SelectBody selectBody = select.getSelectBody(); if (selectBody instanceof PlainSelect) { PlainSelect plainSelect = (PlainSelect) selectBody; // 处理普通查询 } else if (selectBody instanceof SetOperationList) { SetOperationList setOpList = (SetOperationList) selectBody; // 逐个处理其中的SelectBody for (SelectBody body : setOpList.getSelects()) { // 继续递归 } }

3.2 提取SQL涉及的表名:TablesNamesFinder与别名问题并行

提取表名是JSqlParser最常用的能力之一,官方提供了现成的TablesNamesFinder:

import net.sf.jsqlparser.util.TablesNamesFinder; TablesNamesFinder finder = new TablesNamesFinder(); List<String> tableList = finder.getTableList(statement);

这条逻辑对SELECT、INSERT、UPDATE、DELETE都有效,而且它会把子查询、JOIN里出现的表也一并找出来。比如:

SELECT u.id FROM t_user u LEFT JOIN t_order o ON u.id = o.user_id WHERE o.status IN (SELECT status FROM t_order_status)

得到的tableList顺序大约会是:t_user、t_order、t_order_status。这里有个隐藏细节:有些老教程里会写stmt.getTablesNames(),那是当时API的名字。新版我建议直接用TablesNamesFinder,因为它是独立的工具类,不依赖你当前拿到的Statement具体类型。

另一个容易被忽略的点是别名。Table对象可以通过getAlias()拿到别名,但TablesNamesFinder默认只返回真实表名,不返回别名。如果你要做血缘关系或影响面分析,建议自己遍历语法树,同时取table.getName()和table.getAlias().getName(),两者结合起来用。

3.3 where条件解析:表达式树的遍历套路

where条件是SQL里信息密度最高的部分,也是踩坑最多的地方。PlainSelect.getWhere()返回一个Expression对象,这个对象可能是EqualsTo、AndExpression、InExpression、LikeExpression、Parenthesis等几十种类型之一。

最通用的遍历姿势是访问者模式:

import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; import net.sf.jsqlparser.expression.operators.relational.EqualsTo; Expression where = plainSelect.getWhere(); where.accept(new ExpressionVisitorAdapter() { @Override public void visit(Column column) { System.out.println("发现字段引用: " + column.getFullyQualifiedName()); } @Override public void visit(EqualsTo expr) { System.out.println("发现等值条件: " + expr); super.visit(expr); } });

ExpressionVisitorAdapter的妙处在于:你不重写某个方法时,它会按默认逻辑递归遍历子节点。你只需要关注感兴趣的节点类型就行。比如我想找出where条件里所有属于某张表的字段,可以在visit(Column)里判断column.getTable().getName()是不是目标表。

但这里有个大坑我必须强调:访问者会递归进入子查询的表达式树。假设where里有EXISTS (SELECT 1 FROM t_order WHERE t_order.user_id = t_user.id),你在visit(Column)里会同时看到t_user.id和t_order.user_id两个字段。如果你的目的是找“主查询的字段”,需要自己在遍历时维护层级标记,比如重写visit(SubSelect)方法,在进子查询时加一层状态,出子查询时恢复。

3.4 INSERT、UPDATE、DELETE的解析差异

不要以为JSqlParser只是“SELECT解析器”。我做权限控制时经常要拦截UPDATE和DELETE,它们的解析结构跟SELECT差异很大。

UPDATE语句的关键点是set部分和where部分:

Update update = (Update) statement; Table targetTable = update.getTable(); System.out.println("目标表: " + targetTable.getName()); List<UpdateSet> updateSets = update.getUpdateSets(); for (UpdateSet updateSet : updateSets) { System.out.println("更新列: " + updateSet.getColumns()); } Expression where = update.getWhere();

**INSERT语句要区分两种形态:**一种是INSERT INTO t_user(id, name) VALUES (1, 'zhangsan'),另一种是INSERT INTO t_user SELECT ...。前者的取值在ItemsList里,后者则嵌了一个Select。处理后者时,需要把嵌套的Select当成独立查询来遍历。

DELETE语句相对最简单,只有一个目标表和where条件:

Delete delete = (Delete) statement; Table table = delete.getTable(); Expression where = delete.getWhere();

我处理UPDATE和DELETE时最谨慎的地方在于:改SQL字段容易,改错影响范围可就大了。比如实现行级权限时,你往DELETE语句追加一个数据权限条件,如果追加的位置不对,可能把一个部门删除操作变成全表删除操作。这种风险在后面实战部分我会给出相对安全的承接方案。

4. 实战案例:用JSqlParser实现行级权限拦截器

4.1 业务场景与整体方案

行级权限在Java后端系统里是绕不开的需求。典型的场景是这样:一张t_order订单表,业务人员登录后只能看自己部门的数据。如果所有查询、修改、删除都在SQL里硬编码WHERE dept_id = ?,那所有业务代码都得跟着改一遍,侵入性太高。

更合理的做法是在持久层框架(MyBatis、JPA等)上面做一个统一的拦截层:拦截到原始SQL,解析出语法树,在where条件下追加一条数据权限过滤条件,再放行执行。这个过程用JSqlParser做非常顺,也是我向团队推荐这个库的原因。

整体方案分为三步:

  1. 根据当前登录用户查出数据权限范围,比如允许访问的部门ID集合
  2. 拦截SQL字符串,解析成Statement
  3. 找到最外层查询的where条件,追加AND dept_id IN ('d001', 'd002')这样的过滤条件,重新toString()得到改造后的SQL

4.2 核心实现:在正确的位置拼接数据权限条件

初步实现看起来很简单,但里面有几个关键的“做对”细节:

import net.sf.jsqlparser.expression.operators.conditional.AndExpression; import net.sf.jsqlparser.expression.operators.relational.InExpression; import net.sf.jsqlparser.expression.StringValue; import net.sf.jsqlparser.schema.Column; import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.statement.select.Select; public String addDataScope(String originalSql, List<String> allowedDeptIds) throws JSQLParserException { Statement statement = CCJSqlParserUtil.parse(originalSql); if (!(statement instanceof Select)) { // 简单版本只处理SELECT;UPDATE/DELETE需要单独的防御逻辑 return originalSql; } Select select = (Select) statement; SelectBody selectBody = select.getSelectBody(); if (!(selectBody instanceof PlainSelect)) { // UNION这种集合查询扔给上层特殊处理 return originalSql; } PlainSelect plainSelect = (PlainSelect) selectBody; // 构造数据权限条件: dept_id IN ('d001', 'd002') InExpression deptFilter = new InExpression(); deptFilter.setLeftExpression(new Column("dept_id")); // 简单起见用字符串列表,实际请用ExpressionList for (String deptId : allowedDeptIds) { deptFilter.getRightItemsList().add(new StringValue(deptId)); // 伪代码示意 } Expression where = plainSelect.getWhere(); if (where == null) { plainSelect.setWhere(deptFilter); } else { plainSelect.setWhere(new AndExpression(where, deptFilter)); } return statement.toString(); }

这段代码能跑通基础场景,但它有几个不完善的地方,我这里专门摊开讲:

**第一,dept_id字段的归属必须明确。**如果原始SQL是SELECT * FROM t_order o JOIN t_user u ON ...,那dept_id到底是t_order的还是t_user的,不加表名前缀会有歧义。我实际项目里会通过权限配置指定“数据权限字段属于哪张表”,构造Column时显式带上表名:

Column deptColumn = new Column(new Table("t_order"), "dept_id");

**第二,括号和运算符优先级。**如果原始where是status = 1 OR status = 2,直接AND新的权限条件,得到的语义是正确的——SQL里AND优先级高于OR。但如果原始where本身用OR连接了复杂业务条件,且权限条件比较重,建议把新增和原有条件分别用Parenthesis包一层再AND,避免边界情况下的歧义。这不是必须的,但是防御性编程的好习惯:

AndExpression and = new AndExpression(new Parenthesis(where), new Parenthesis(deptFilter));

**第三,表达式修改后toString的格式变化。**JSqlParser重新输出的SQL会规范化大小写和空格,比如某些方言的Hint可能被调整。在绝大多数ORM框架里这没问题,但如果你用的是对SQL文本有强校验的分库分表中间件,需要先在测试环境验证一遍。

4.3 防御边界:UNION、JOIN、子查询、UPDATE与DELETE

上面那段代码对最简单SELECT有效,真实的业务SQL远不止这些形态。我在生产环境落地时,给自己定了几条防御边界,分享给各位:

UNION集合查询。UNION对应的SetOperationList里有多个SelectBody,你需要决定权限条件加在哪一层。通常应该对每个分支都加,否则可能通过UNION绕过过滤。如果一时间做不了完全正确,宁可返回原始SQL,也不要只改一半导致数据越权。

**JOIN和子查询。**权限条件应该加在最外层查询的where上,不能加进子查询里。要判断“最外层”,得从Select对象往下取第一个PlainSelect。如果最外层其实是个子查询包了一层,比如SELECT * FROM (SELECT * FROM t_order) t,这时候plainSelect.getFromItem()是SubSelect类型,你就得决定加外套还是加内层。我的经验是:如果查询主体本身就来自子查询,权限条件加在外层会对不上字段;加内层需要找到真正查询t_order的那一层,实现上要递归。为了第一期快速上线,团队可以先支持显式表名的普通查询,子查询包外层这类场景直接放行并记录告警日志,后面迭代再补。

**UPDATE和DELETE改造。**给UPDATE语句追加数据权限条件时要特别小心。UPDATE的语法是UPDATE t_order SET status = 2 WHERE id = ?,逻辑上可以追加AND dept_id IN (...),但一旦表名不带AS别名,dept_id的归属也要显式绑定。DELETE同理。最稳妥的方案是:当目标表是t_order且原始SQL中没有子查询修改同一张表时,才允许自动追加条件;否则返回原始SQL并打告警。

4.4 性能与缓存:解析器不能成为接口瓶颈

JSqlParser的解析过程是有CPU开销的。一个简单的SELECT解析大概在几十微秒到一两百微秒,平时看起来不痛不痒。但线上接口如果每秒几千次请求,每次都解析SQL,GC压力一定会上来。

我采用的方案是按SQL文本做内存缓存:

private final ConcurrentHashMap<String, Statement> statementCache = new ConcurrentHashMap<>(); private Statement parseCached(String sql) throws JSQLParserException { Statement cached = statementCache.get(sql); if (cached != null) { return cached; } Statement parsed = CCJSqlParserUtil.parse(sql); Statement prev = statementCache.putIfAbsent(sql, parsed); return prev != null ? prev : parsed; }

这里有几个细节:

  • **缓存的是Statement对象,不是改造后的SQL字符串。**因为不同用户进来权限范围不同,改造结果不同,但语法树结构是一样的,可以共享。
  • **注意控制缓存容量。**动态拼接的SQL如果每个用户都不一样,缓存会无限膨胀。我设置了一个上限,比如最多缓存5000条,超过之后按插入序淘汰。实现可以用ConcurrentHashMap.newKeySet()加一个有序队列,或者直接用Caffeine。
  • **不要在锁里做解析。**ConcurrentHashMap的putIfAbsent可以避免并发重复解析的锁竞争,建议用这个模式而不是先get再put。

5. 常见问题与排查技巧实录

5.1 版本混乱:同一个库,API换了三副面孔

我见过太多人栽在JSqlParser的版本上。最典型的一个问题:网上教程里写Statement.getTablesNames(),你在4.6里一跑就编译不过去。另一个是AndExpression的构造器签名,早期版本是AndExpression(Expression left, Expression right),新版本里还加了with...的链式写法。还有ExpressionList的泛型从裸类型变带了ExpressionList<?>。

遇到编译错误,不要跟老代码较劲,去查当前版本的官方Javadoc。JSqlParser的API文档在GitHub上很全,直接看PlainSelect、ExpressionVisitorAdapter、CCJSqlParserUtil这几个类的说明就够覆盖日常使用了。

5.2 解析失败:哪些SQL是JSqlParser搞不定的

JSqlParser支持主流SQL语法,但并非所有数据库方言都全覆盖。我踩过的解析失败场景包括:

  • MySQL特有的某些语法,比如REPLACE INTO、ON DUPLICATE KEY UPDATE,看版本支持情况,有些能解析有些不行
  • PostgreSQL的RETURNING、WITH递归CTE在某些老版本上解析不稳定
  • SQL Server的TOP、WITH(NOLOCK)这种提示,部分写法会直接抛JSQLParserException

处理策略是:解析失败不要硬撑。在拦截器链路上,如果CCJSqlParserUtil.parse抛异常,捕获后直接放行原始SQL,同时记录一条WARN日志,让DBA和开发人工介入检查。这样最多损失个别SQL的权限增强能力,不会把正常查询全部拦死。

5.3 别名、反引号、Schema:三个最不起眼的坑

这三个问题单独拎出来都不复杂,但组合在一起就是灾难。

**别名问题。**当你给表起了别名,JSqlParser里Column的getTable().getName()返回的是别名而不是真实表名。如果你用column.getTable().getName().equals("t_order")去判断归属,就会漏掉SELECT o.id FROM t_order o这种写法。正确做法是先取出FromItem的真实表名与别名做映射,再遍历列时查表判断。

**MySQL反引号。**JSqlParser默认支持反引号包裹的表名和字段名,比如SELECTidFROMt_user``。但解析出来的getName()会保留反引号还是去掉,取决于版本。你在判断表名时最好做一层归一化,把反引号、双引号都去掉再比较。

Schema前缀。SELECT * FROM db1.t_user这样的写法,Table.getName()返回的是t_user,getSchemaName()返回的是db1。如果你忽略schema,可能在多租户系统里把不同库的同名表当成一张表。建议用table.getFullyQualifiedName()来判断完整表名,这个方法会把schema和table拼到一起返回。

5.4 性能监控:解析耗时在线上真的不能忽略

最后提醒一下,引入JSqlParser之后一定要加监控。我见过一个团队上线SQL解析功能后接口P99从50ms涨到900ms,原因就是每条SQL在拦截器里被解析了三次:一次取表名、一次验权、一次做脱敏,每次都是重新parse。

养成一个习惯:一次解析,多处复用。拦截器里解析得到Statement对象后,把表名提取、条件修改、字段遍历都基于同一个对象完成,不要反复调用CCJSqlParserUtil.parse。另外,对执行频率很高的固定SQL(比如MyBatis的MappedStatement对应的SQL模板),解析结果一定要走缓存,改造后的SQL也缓存,这样可以有效把拦截过程的耗时压到微秒级。

6. 写在最后:我的一些体会

做了几个月的JSqlParser接入之后,我最深的感受是:这类语法解析库的“下限”很低,一行代码就能跑通Demo;但“上限”很高,真正用好它需要对SQL语法、AST遍历和业务边界都有清晰的认识。

如果团队准备在项目里引入JSqlParser,我的建议是先别急着铺开做全量拦截。找一个具体的业务痛点切入,比如行级权限或者SQL审计,先跑通核心链路,把防御边界、缓存策略、异常降级都设计好,再逐步扩展场景。我在实际项目里就是先从“只给最简单的SELECT加数据权限”开始,跑了一段稳定之后,才慢慢覆盖UPDATE、DELETE和子查询场景。另外,JSqlParser的社区版本迭代不算慢,线上依赖升级前一定要看CHANGELOG,重点检查解析器的语法支持和表达式API变化,这能帮你避开不少升级带来的隐性坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询