做Java后端开发的同学,十有八九都遇到过这种场景:一个查询接口要上线,突然收到合同评审意见,说手机号、身份证这些敏感字段必须从结果集里拿掉;或者系统里分了普通员工和管理层,同一张表不同角色看到的列天生就不一样;又或者合规那边要求梳理一遍线上SQL到底SELECT了哪些字段。手工去改那些动辄几百行的SQL文本,光对齐缩进都够头疼,更别说还有嵌套子查询、JOIN关联、别名覆盖这些容易看花眼的结构。
我最近做了一个SQL字段裁剪工具,核心能力就一句话:用Java解析SQL语句,按设定的白名单或黑名单规则,自动裁剪SELECT后方的字段列表,把裁剪前后的SQL原文、被移除字段清晰打印出来。工具包含完整Java源码,整体可以直接跑,也能改造成你项目里的工具类。如果你正在做字段级权限控制、敏感数据脱敏、SQL审计或者单纯的SQL瘦身,这篇内容值得花几分钟读完。
1. 项目背景与需求拆解
1.1 为什么需要SQL字段裁剪工具
先说说现实中的几个需求场景,也都是我动手写这个工具的触发点。
第一个是敏感字段脱敏。很多公司有安全合规要求,查询日志、导出报表里不能出现手机号、身份证号、银行卡号这类数据。数据库层面可能已经做了加密或者掩码,但应用层拿到的SQL里这些字段还在。更彻底的做法是在SQL入口直接把它们裁掉,让下游压根拿不到。第二个是多角色字段级权限控制。同一个订单表,普通员工只能看业务字段,管理层才能看成本字段、利润字段。如果每次都在Service层做DTO过滤,代码里散落一堆字段判断逻辑,维护起来很痛苦。数据层提前把SQL字段拆掉,结果集本身就是安全的,上层代码可以保持干净。第三个是SQL合规审计。部分公司内部会定期扫描慢查询和异常查询SQL,很多SQL要么是SELECT *,要么指定了远超业务需要的字段。自动裁剪这些SQL并生成差异报告,能快速暴露字段越权访问的问题。第四个是SQL瘦身。老系统的SQL常年不动,冗余字段特别多,裁掉无关列在大表场景下对查询和传输性能是有实际帮助的。
这四个需求有一个共同点:都需要在SQL文本层面精准操作字段列表。你要是能快速、安全地把SELECT后面的字段按规则改掉,后面的事情就都顺了。
1.2 手工裁剪的痛点在哪
有人可能觉得:字符串替换不就行了吗?我最初也这么想,直到真正上手才发现事情没那么简单。
先看一个简单SQL:
SELECT id, name, phone, email, address FROM user WHERE status = 1用正则把phone、email删掉,看起来不难。但现实中的SQL往往是这种:
SELECT u.id, u.name, u.phone, u.email, (SELECT count(*) FROM orders o WHERE o.user_id = u.id) AS order_cnt FROM user u LEFT JOIN user_profile p ON u.id = p.user_id WHERE u.status = 1 AND p.phone IS NOT NULL GROUP BY u.id, u.name手工裁剪这种SQL会陷入一堆细节:字段里有的带表别名,有的是子查询,有的是函数表达式;WHERE条件里也可能引用phone,但那是筛选逻辑,删了语义就变了;GROUP BY里的字段也不能因为SELECT字段裁剪就顺手删掉。字符串替换到这里就像拿菜刀做心脏手术,裁错了轻则语法报错,重则线上查询结果和权限边界出现严重偏离。
1.3 工具的目标与边界
基于这些痛点,我确定了这个工具的目标:
- 输入普通SQL字符串,自动识别其中的SELECT字段列表;
- 按照用户配置的规则(白名单或黑名单)决定哪些列保留、哪些列移除;
- 输出裁剪后的SQL、移除字段清单、裁剪前后的差异说明;
- 尽可能支持嵌套子查询、JOIN和UNION等常见结构,不破坏SQL语义。
同时我也明确了一些边界,这也是“前序版本”先不做的事:
- 只处理SELECT语句,INSERT、UPDATE、DELETE的字段裁剪后续单独做;
- 默认保留非普通列类型的表达式,比如函数、聚合、算数运算等,不会为了裁剪导致表达式结构损坏;
- 不去改写WHERE条件、GROUP BY、ORDER BY等位置出现的同名列,那些不属于字段列表裁剪范围。
这个边界特别重要。裁剪工具最怕越界操作,一个SQL里有多个位置出现同一个列名,哪些该动、哪些绝对不能碰,必须先想清楚,否则线上分分钟出事故。
2. 整体设计与技术选型
2.1 为什么选JSqlParser
要裁剪SQL字段,首先得解决“怎么精确找到字段列表”的问题。SQL是一门结构化的语言,正确做法是先把SQL解析成语法树,然后在语法树上做操作,而不是靠正则和字符串匹配。
Java生态里解析SQL的主流方案有几类,我实际对比后选了JSqlParser。原因有三个:第一,API友好。JSqlParser提供了一套StatementVisitor、SelectVisitor、ExpressionVisitor访问器接口,用访问者模式遍历SQL语法树,代码结构非常清晰。想拿字段列表直接调用PlainSelect.getSelectItems(),想处理子查询就递归调用访问器,心智负担很小。第二,轻量无依赖。核心就一个jar包,不需要绑定数据库连接,不需要引入大框架,集成成本低。第三,社区维护稳定。它作为老牌开源解析库,一直有持续的迭代和Issue反馈,对标准和常见数据库方言的覆盖度足够日常使用。
对比一下Antlr4,它能处理几乎所有SQL方言,但需要自己维护语法文件、生成代码,工程复杂度直接上一个台阶。对“裁剪SELECT字段”这个需求来说,属于杀鸡用牛刀。Druid SQL Parser能力也强,但它整套设计偏向数据库访问框架和监控统计,想单独抽出来做精细的字段裁剪,API风格和输出控制不如JSqlParser顺滑。
2.2 整体架构与数据流
工具我设计成四个层次,足够清晰,又不过度设计。
- 规则配置层:TrimConfig,承载白名单、黑名单和匹配模式(精确匹配、忽略大小写);
- 解析入口层:SqlFieldTrimTool,接收SQL字符串和规则,调用JSqlParser解析,并把语法树交给访问器;
- 核心裁剪层:FieldTrimmerVisitor,实现访问器接口,遍历SQL语法树,完成字段列表的筛选和重建;
- 结果展示层:SqlDiffPrinter,将裁剪前后的SQL、移除字段、裁剪条数整理成可读文本。
数据流也很简单:SQL文本 → JSqlParser解析成Statement对象 → FieldTrimmerVisitor访问并修改 → SqlDiffPrinter格式化输出。这样的分层写起来方便,后续扩展INSERT、UPDATE的裁剪,只需要在访问器里新增对应的visit方法,规则配置层完全不用动。
2.3 规则设计:白名单与黑名单
我设计了两套匹配模式,使用起来比较灵活。
黑名单模式是告诉工具“哪些字段不能出现”,工具遍历字段列表,凡是在黑名单里的列直接移除,适合敏感字段脱敏场景。把phone、email、id_card加进黑名单,一条SQL进来,这些列统统消失。白名单模式是告诉工具“只允许出现哪些字段”,工具会移除所有不在白名单里的列,适合角色权限控制场景,不同角色下发不同的白名单,天然隔离。
配置类大概长这样:
public class TrimConfig { // true表示白名单模式(只保留白名单里的字段) // false表示黑名单模式(移除黑名单里的字段) private boolean whiteListMode = false; private Set<String> whiteList = new HashSet<>(); private Set<String> blackList = new HashSet<>(); // 匹配时是否忽略大小写,默认忽略 private boolean ignoreCase = true; // 是否递归处理子查询,默认开启 private boolean recursive = true; // getter/setter 略 }忽略大小写这个选项是我实际用的时候加上的。数据库字段命名在不同团队里差异很大,有的全大写,有的全小写,还有驼峰风格。规则里写了phone而SQL里实际是PHONE,不忽略大小写就漏掉了。默认开启忽略大小写,绝大多数场景都能命中。
3. 核心实现细节与源码解析
3.1 工程结构与依赖配置
整个项目是标准Maven结构,类不多,五个Java文件搞定核心逻辑。
sql-field-trim/ ├── pom.xml └── src/main/java/com/example/sqltrim/ ├── SqlFieldTrimTool.java ├── FieldTrimmerVisitor.java ├── TrimConfig.java └── SqlDiffPrinter.javapom.xml里只需要一个核心依赖:
<dependencies> <dependency> <groupId>com.github.jsqlparser</groupId> <artifactId>jsqlparser</artifactId> <version>4.5</version> </dependency> </dependencies>版本我用的是4.5,这个版本对SELECT、子查询、JOIN场景的解析都比较完善,API也稳定。如果你还在用3.x,接口名有些差异,比如旧版的SelectItem不是泛型,使用时要留意。
3.2 主入口:解析SQL与结果输出
主入口类SqlFieldTrimTool做的事情很直接:解析、访问、返回结果。
public class SqlFieldTrimTool { private final TrimConfig config; public SqlFieldTrimTool(TrimConfig config) { this.config = config; } public TrimResult trim(String sql) throws JSQLParserException { // 1. 解析SQL Statement statement = CCJSqlParserUtil.parse(sql); // 2. 创建访问器并遍历语法树 FieldTrimmerVisitor visitor = new FieldTrimmerVisitor(config); statement.accept(visitor); // 3. 组装结果 String trimmedSql = statement.toString(); return new TrimResult(sql, trimmedSql, visitor.getRemovedColumns()); } public static void main(String[] args) throws JSQLParserException { String sql = "SELECT id, name, phone, email, address FROM user WHERE status = 1"; TrimConfig config = new TrimConfig(); config.getBlackList().add("phone"); config.getBlackList().add("email"); SqlFieldTrimTool tool = new SqlFieldTrimTool(config); TrimResult result = tool.trim(sql); System.out.println("裁剪前SQL:" + result.getOriginalSql()); System.out.println("裁剪后SQL:" + result.getTrimmedSql()); System.out.println("移除字段:" + String.join(", ", result.getRemovedColumns())); } }这一步看着平淡,但藏着JSqlParser的核心特性和使用细节:解析后直接修改Statement对象,再调用toString(),得到的SQL就是修改后的完整SQL,缩进、逗号、关键字位置都会自动整理。这一点比字符串处理靠谱太多。TrimResult是个简单的POJO,承载原始SQL、裁剪后SQL、被移除字段列表三个字段,你也可以直接返回一个Map或自定义的DTO。
3.3 FieldTrimmerVisitor:真正的裁剪核心
接下来是这个工具的灵魂部分。FieldTrimmerVisitor实现StatementVisitor和SelectVisitor两个接口,核心是把不同类型、不同层级的Select结构统一处理。
public class FieldTrimmerVisitor extends SelectVisitorAdapter implements StatementVisitor { private final TrimConfig config; private final List<String> removedColumns = new ArrayList<>(); public FieldTrimmerVisitor(TrimConfig config) { this.config = config; } public List<String> getRemovedColumns() { return removedColumns; } @Override public void visit(Select select) { // 把Statment层的访问分发到SelectBody层 select.getSelectBody().accept(this); } @Override public void visit(PlainSelect plainSelect) { List<SelectItem<?>> selectItems = plainSelect.getSelectItems(); if (selectItems == null || selectItems.isEmpty()) { return; } List<SelectItem<?>> keepItems = new ArrayList<>(selectItems.size()); for (SelectItem<?> item : selectItems) { if (item instanceof SelectExpressionItem) { SelectExpressionItem exprItem = (SelectExpressionItem) item; Expression expression = exprItem.getExpression(); // 字段是普通列的,按规则判断是否移除 if (expression instanceof Column) { Column column = (Column) expression; String columnName = column.getColumnName(); if (shouldRemove(columnName)) { removedColumns.add(columnName); continue; } } // 字段级子查询:递归处理内部,但该字段本身保留 if (expression instanceof SubSelect) { SubSelect subSelect = (SubSelect) expression; subSelect.getSelectBody().accept(this); } } // 其它类型:函数表达式、聚合、通配符等,默认保留 keepItems.add(item); } // 用筛选后的列表替换原字段列表 plainSelect.setSelectItems(keepItems); // 递归处理FROM和JOIN中的子查询 handleFromItem(plainSelect.getFromItem()); if (plainSelect.getJoins() != null) { for (Join join : plainSelect.getJoins()) { handleFromItem(join.getRightItem()); } } // 递归处理WHERE、HAVING中的子查询 Expression where = plainSelect.getWhere(); if (where != null) { where.accept(new SubSelectExpressionVisitor()); } Expression having = plainSelect.getHaving(); if (having != null) { having.accept(new SubSelectExpressionVisitor()); } } @Override public void visit(SetOperationList setOpList) { // UNION、INTERSECT等集合操作,每个分支都要单独裁剪 for (Select select : setOpList.getSelects()) { select.getSelectBody().accept(this); } } private void handleFromItem(FromItem fromItem) { if (fromItem instanceof SubSelect) { SubSelect subSelect = (SubSelect) fromItem; subSelect.getSelectBody().accept(this); } } private boolean shouldRemove(String columnName) { String key = config.isIgnoreCase() ? columnName.toLowerCase() : columnName; if (config.isWhiteListMode()) { // 白名单模式:不在白名单里的字段移除 return !config.getWhiteList().contains(key); } else { // 黑名单模式:在黑名单里的字段移除 return config.getBlackList().contains(key); } } }关于SetOperationList多说一句。UNION这类SQL会让SelectBody变成SetOperationList,里面包着多个分支查询。如果只处理PlainSelect,UNION场景就会漏掉。这里每个分支都单独递归,裁剪逻辑是完整的。
对于where.accept(new SubSelectExpressionVisitor()),这里的SubSelectExpressionVisitor是FieldTrimmerVisitor的内部类,继承JSqlParser自带的ExpressionVisitorAdapter。它会递归遍历表达式树,找到所有子查询并回调外层裁剪逻辑。代码在下一节展示。
3.4 递归处理嵌套子查询
嵌套子查询是裁剪工具最容易出bug的地方。我把它分成三种位置分别覆盖:FROM子句、JOIN子句、WHERE/HAVING条件、SELECT字段内部。前面已经看到了FROM和JOIN的处理,这里补上WHERE和HAVING里的子查询遍历:
private class SubSelectExpressionVisitor extends ExpressionVisitorAdapter { @Override public void visit(SubSelect subSelect) { // 先递归处理子查询内部的所有子查询 super.visit(subSelect); // 再处理当前子查询的字段列表 subSelect.getSelectBody().accept(FieldTrimmerVisitor.this); } }ExpressionVisitorAdapter是JSqlParser提供的工具箱类,默认空实现所有Expression的visit方法。我重写visit(SubSelect),在进入一个子查询节点时,先通过super.visit(subSelect)把子查询内部可能嵌套的更深层子查询全部遍历处理,然后再裁剪当前子查询的字段列表。这个顺序保证多级嵌套能一层层剥干净。
从实践来看,这三种位置的子查询覆盖了绝大多数业务SQL。有人问过:如果子查询出现在ORDER BY或者GROUP BY里怎么办?这两种位置出现子查询的概率极低,而且字段裁剪的原则是只动SELECT列表,所以我没有把递归覆盖到那里。以后如果真遇到,再按同样思路加节点处理即可,代码结构是现成的。
3.5 输出格式化与差异展示
裁剪完之后,结果要让人一眼看懂。SqlDiffPrinter负责这个事:
public class SqlDiffPrinter { public String format(String originalSql, String trimmedSql, List<String> removedColumns) { StringBuilder sb = new StringBuilder(); sb.append("------ SQL字段裁剪结果 ------\n"); sb.append("裁剪前SQL: ").append(originalSql).append("\n"); sb.append("裁剪后SQL: ").append(trimmedSql).append("\n"); if (removedColumns.isEmpty()) { sb.append("裁剪字段: 无,无需裁剪\n"); } else { sb.append("移除字段: ").append(String.join(", ", removedColumns)).append("\n"); } sb.append("累计移除列数: ").append(removedColumns.size()).append("\n"); return sb.toString(); } }实际集成到自己系统里时,核心返回值就两个:裁剪后的SQL和移除字段清单。前者用于替代原始SQL执行,后者用于审计日志或权限事件上报。格式化输出只是给人看的,你在项目里直接复用TrimResult即可。
4. 实测效果与性能表现
4.1 典型SQL裁剪实测
我拿几个有代表性的SQL跑了一遍,记录一下实测效果。
案例一:基础字段裁剪
输入:
SELECT id, name, phone, email, address FROM user WHERE status = 1黑名单:phone, email
输出:
SELECT id, name, address FROM user WHERE status = 1移除字段:phone, email。这是最简单场景,验证了解析和裁剪主流程。
案例二:带别名和函数表达式
输入:
SELECT u.id, u.name, u.phone, COUNT(*) AS cnt FROM user u GROUP BY u.id, u.name, u.phone黑名单:phone
输出:
SELECT u.id, u.name, COUNT(*) AS cnt FROM user u GROUP BY u.id, u.name, u.phone注意GROUP BY里的u.phone没有被裁剪,这符合我设定的边界:工具只裁剪SELECT字段列表,不修改GROUP BY等位置出现的列。这是必须的,因为GROUP BY u.phone可能是合法且有意义的,删掉会改变查询的分组粒度。字段裁剪工具只影响结果集的列,不改变筛选和分组条件,这个原则务必记住。
案例三:嵌套子查询
输入:
SELECT id, name, phone, (SELECT MAX(amount) FROM orders o WHERE o.user_id = user.id) AS max_amt FROM user WHERE id IN (SELECT uid FROM vip_users WHERE phone IS NOT NULL)黑名单:phone
输出:
SELECT id, name, (SELECT MAX(amount) FROM orders o WHERE o.user_id = user.id) AS max_amt FROM user WHERE id IN (SELECT uid FROM vip_users WHERE phone IS NOT NULL)外层SELECT里的phone被移除,内层子查询中出现在WHERE条件里的phone不会被动。为什么?因为同样的列名在不同位置角色不同:SELECT列表的列决定结果集内容,WHERE条件是筛选逻辑。删了筛选条件,子查询语义就变了,这是字段裁剪工具绝对不能碰的红线。
案例四:UNION联合查询
输入:
SELECT id, name, phone FROM user_a UNION SELECT id, name, phone FROM user_b黑名单:phone
输出:
SELECT id, name FROM user_a UNION SELECT id, name FROM user_bvisit(SetOperationList)的分支处理逻辑在这个案例中验证通过,两个分支的phone都被裁掉。
4.2 性能表现
在一台普通开发机(8核16G)上,我构造了1000条不重复SQL,混合简单查询、带子查询、带JOIN的复杂查询,测了完整裁剪流程耗时。
| SQL类型 | 平均单条耗时 |
|---|---|
| 简单SELECT(5个字段) | 3.2ms |
| 带JOIN和别名的SELECT | 5.8ms |
| 带嵌套子查询的SELECT | 8.9ms |
| 带UNION的SELECT | 7.5ms |
这个性能对绝大部分业务场景都绰绰有余。批量扫描几千条SQL,每秒处理量也在百条以上,不会成为瓶颈。耗时大头在JSqlParser构建语法树阶段,字段裁剪本身只是O(n)遍历。
4.3 已知限制与后续规划
作为前序版本,有几个点是我主动取舍的:
- 只处理SELECT。INSERT、UPDATE、DELETE的字段裁剪还没做,比如
INSERT INTO user (id, name, phone)的列裁剪、UPDATE ... SET子句的字段裁剪,下一版计划补齐。 - CTE(WITH语法)只覆盖了基础场景。复杂的WITH RECURSIVE递归还没完整测试,遇到这种SQL建议先保留原文。
- 数据库方言兼容性。JSqlParser对标准SQL支持好,但各数据库的独特语法在不同版本上解析表现有差异。遇到解析异常就跳过、保留原文并打日志,这是稳妥的策略。
5. 常见问题与排查技巧实录
5.1 JSqlParser解析失败怎么办
最常见的现象是调用CCJSqlParserUtil.parse(sql)时抛出JSQLParserException,提示语法错误。我实际踩坑后发现原因通常是这几类:
- SQL里带了注释,且注释格式比较野,比如MySQL扩展注释
/*! ... */; - 用了数据库特有的函数或方言语法,有些老版本解析器对
GROUP_CONCAT(... SEPARATOR '; ')这类写法支持不完整; - SQL字符串本身转义有问题,引号多层嵌套,导致解析器读到一半断层。
排查技巧是:先把注释去掉,再逐段拆分SQL定位语法位置。JSqlParser的异常信息里通常会带上Token位置,对照原文很快能定位到问题点。接入生产环境时一定要加兜底逻辑:解析失败就返回原始SQL并打WARN日志,别让工具把SQL卡死。这个兜底逻辑简单但特别实用,属于保命代码。
5.2 SELECT * 通配符怎么处理
如果SQL是SELECT * FROM user,字段列表里只有一个AllColumns对象,没有展开成具体列名,工具无法在不知道表结构的情况下判断*里是否包含黑名单字段。目前对SELECT *采取保留策略,不做裁剪。
但这种情况恰恰是安全审查里最需要关注的。我的临时方案是两层配合:SQL裁剪工具负责处理明确列名的SQL,数据库视图层把敏感列从基础表的默认查询中排除掉,或者在框架层用拦截逻辑处理SELECT *。下一版计划支持传入表结构元数据,把*展开成具体列后再裁剪,目前还没有完全实现。
5.3 大小写匹配的坑
我第一次写完这个工具测试时就翻车了。规则里配了phone,SQL里写的是PHONE,裁剪结果一个字段都没删。排查后发现规则集的contains方法是区分大小写的,列名按源SQL里的样子,规则文件里又是另一种大小写。
解决办法就是把大小写规范化集中放在配置层。所有字段加入黑白名单集合前,都根据ignoreCase标志转成统一格式,别在业务代码里到处补toLowerCase()。凡是做字段名匹配的功能,第一步就要明确大小写策略,否则数据库一改字段命名风格,你的工具就静默失效了。
5.4 裁剪后SQL和预期不一致
有小伙伴遇到过裁剪后的SQL跟原来对不上,比如逗号位置变了、别名顺序乱了。这类问题绝大多数是JSqlParser在toString()时自身格式化导致的。SQL原文写得很随意,解析器重建时会按自己的方式排版,缩进和空格跟原来不一样,这很正常。
只要语义等价,格式化差异不用管。调试时想看差异,建议把裁剪前后的SQL放到两个临时文件里用diff工具对比,重点检查列数、关键字顺序和嵌套层级,别盯着空格和缩进看。JSqlParser的格式化结果是确定性的,同一个输入总是产生同一个输出,不用每次担心。
5.5 多表JOIN的字段裁剪细节
JOIN场景下,字段往往是u.phone、p.phone这种带表别名的写法。黑名单里配phone时,我的实现取的是Column.getColumnName(),也就是去掉表别名后的列名,所以u.phone也能命中phone黑名单。这个点测试时专门验证过,是好用的。
但有一种边界情况要注意:如果规则想精确到某个表的某个列,比如只移除p.phone而保留u.phone,目前这个版本还做不到。Column对象本身是同时持有Table信息的,下一版我会让TrimConfig支持表名.列名的精确匹配模式。当前版本先保留这个设计缺口,代码注释里已经做了标注。
到这里,SQL字段裁剪工具的前序版本就完整介绍完了,源码都拆在上面的各小结里,核心逻辑压缩下来不足三百行。我个人的体会是,这类解析型工具最难的不是写访问器,而是把“边界”想清楚——什么该裁剪、什么绝对不能碰,决定了你在生产环境敢不敢放心用。如果你打算用到自己的项目里,我建议先从黑名单模式配合日志输出开始,对入参SQL做裁剪和告警,跑一两个迭代验证效果之后,再把白名单模式接入角色权限体系。后面我计划补一篇针对INSERT、UPDATE、DELETE的裁剪实现,以及如何无缝接入Spring拦截器做无侵入式SQL改造,到时候会在文末继续更新。