ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JSqlParser实战:Java后端SQL解析、改造与行级权限拦截

JSqlParser实战:Java后端SQL解析、改造与行级权限拦截 做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_idSELECT * FROM (SELECT id FROM t_user) t WHERE t.id 100DELETE 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内部是PlainSelectSelectItemsid列、name列各自是SelectExpressionItemFromItemTable名称为t_userWhereGreaterThan表达式左值是Columnage右值是LongValue18你操作JSqlParser的过程本质就是在这棵树上做查找和修改。这比正则匹配可靠的根因在于解析器是真正按SQL语法规则推进的它知道FROM后面跟的是表还是子查询知道括号里的条件作用域在哪一层。2. 环境准备与快速上手10分钟跑通第一个解析Demo2.1 Maven依赖引入与版本选型使用JSqlParser的第一步是把依赖加进去Maven坐标是dependency groupIdcom.github.jsqlparser/groupId artifactIdjsqlparser/artifactId version4.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变化比如有些方法从ListString变成带泛型的类型还有部分访问者方法签名调整。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普通查询也可能是SetOperationListUNION、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最常用的能力之一官方提供了现成的TablesNamesFinderimport net.sf.jsqlparser.util.TablesNamesFinder; TablesNamesFinder finder new TablesNamesFinder(); ListString 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()); ListUpdateSet 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做非常顺也是我向团队推荐这个库的原因。整体方案分为三步根据当前登录用户查出数据权限范围比如允许访问的部门ID集合拦截SQL字符串解析成Statement找到最外层查询的where条件追加AND dept_id IN (d001, d002)这样的过滤条件重新toString()得到改造后的SQL4.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, ListString allowedDeptIds) throws JSQLParserException { Statement statement CCJSqlParserUtil.parse(originalSql); if (!(statement instanceof Select)) { // 简单版本只处理SELECTUPDATE/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解析大概在几十微秒到一两百微秒平时看起来不痛不痒。但线上接口如果每秒几千次请求每次都解析SQLGC压力一定会上来。我采用的方案是按SQL文本做内存缓存private final ConcurrentHashMapString, 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_usergetSchemaName()返回的是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变化这能帮你避开不少升级带来的隐性坑。
返回列表