背景
近幾年公司人員規模快速增長,超過半數開發人員均為近兩年入職的新員工,開發技能與經驗欠缺,之前踩坑的經驗也未能完全了解,出現了幾起因慢SQL而引發的生產性能問題。
為了更好地指導產品SQL設計及開發,避免不恰當的設計、開發帶來問題和隱患,同時為了提升開發人員對SQL相關知識的掌握程度, 我們組織了技術專家依據現狀,整理了一份SQL開發規范, 通過明確的規則指導編寫合理、高效的SQL語句。
然而,在實踐過程中發現,即使我們做了大量的宣講、培訓,但是各項目組反饋還是難以將規范落地。因為規范有數十條規則,完全靠人工的檢查與落實,難免會有遺漏,而且增大了代碼review的難度。
基於此,我們動手擼了一個SQL規范檢查工具,用來自動化的檢查出不符合規范的SQL語句。HTML輸出效果如下圖:

詳細設計
整個工具分為3部分,3個模塊相互獨立,可以有多種實現:
- SQL獲取:該部分主要用來獲取SQL語句,可以有多種實現方式,比如從項目的mapper.xml中解析獲取,也可以由文本文件中獲取。目前我們實現了利用maven插件,在mvn:compile編譯期間解析項目代碼中的mapper.xml文件,獲取SQL。這部分不是本次介紹的重點,后面再寫單獨的文章專門介紹。
- SQL檢查:是整個工具的核心部分。入參是獲取到的SQL語句集合,出參為檢查報告集合。下文會詳細介紹該部分。
- 報告渲染:該部分主要用來渲染檢查報告,如可將報告渲染為json文件、通過freemarker等工具生成HTML、等樣式方便查看,也可以通過jenkins流水線生成報告。該部分本次也不做詳細介紹。
本次只詳細介紹下SQL檢查模塊的設計與實現。
SQL檢查模塊設計
每個SQL都由查詢項、表名、where條件、join條件、limit條件等特定的幾部分構成,以下面這個SQL語句為例,查詢項為a.*,b.name,表名為a,b,where條件為a.id=b.id。
select a.*,b.name from a,b where a.id=b.id
SQL檢查的核心流程簡單來說,就是入參為單個SQL語句,輸出為檢查報告。分為以下幾個具體步驟:
-
1.將SQL解析成語法樹,可以從語法樹中獲取SQL的各個部分,如查詢項、關聯表、where條件等。
-
2.根據SQL的類型,匹配規則檢查器。如SELECT、UPDATE、DELETE等分別有不同的檢查器。
-
3.根據規則,檢查語法樹的各個部分,並生成檢查報告。如有個規則為“必須寫明查詢條件,不能出現select *”,這個重點檢查查詢語句和子查詢的查詢項部分。
-
4.將檢查報告輸出為特定樣式,供開發人員查看。
根據以上流程,設計幾個核心的接口和類:
- Analyzer,語法分析器,用來將SQL語句解析成語法樹
/**
* SQL語法分析器
*/
public interface Analyzer {
/**
* @param sql sql語句
* @return sql語法樹
*/
AST analyze(String sql);
}
- AST,抽象語法樹,用來獲取SQL的各個部分
/**
* SQL抽象語法樹
*/
public interface AST {
/**
* 獲取語法樹對應SQL的SQL類型
* @return SQL類型枚舉
*/
SqlTypes getSqlType();
String getSql();
Expression getWhere();
GroupByElement getGroupBy();
List<SelectItem> getSelects();
List<Column> getColumns();
List<Join> getJoins();
Limit getLimit();
List<OrderByElement> getOrderByElement();
}
- Checker,抽象類,所有規則檢查器的基類,check()方法用來遍歷規則集並檢查
/**
* 規則檢查器
*/
public abstract class Checker {
/**
* @return 規則檢查器的名稱
*/
public abstract String getName();
/**
* 規則集
*/
protected List<CheckRule> rules = new ArrayList<>();
public void registeRule(CheckRule rule){
this.rules.add(rule);
}
/**
* @param tree 抽象語法樹
* @return 規則檢查報告
*/
public List<Report> check(AST tree){
List<Report> reports = new ArrayList<>();
for(CheckRule rule : rules){
Report report = rule.match(tree);
if (report != null){
reports.add(report);
}
}
return reports;
}
}
- CheckRule,具體的檢查規則,每個規則器里有多個檢查規則,如select類型的SQL語句會有多個檢查規則
/**
* 具體的檢查規則
*/
public interface CheckRule {
/**
* @param tree 抽象語法樹
* @return 規則檢查報告
*/
Report match(AST tree);
/**
* 規則作用域,SELECT、DELETE等
* @return
*/
List<SqlTypes> scope();
}
- Report,檢查報告,每條規則檢查后都會生成一條報告
/**
* 檢查報告
*/
public class Report {
private boolean pass; //通過標識
private String desc; //錯誤提示
private String sql;//sql語句
private Level level;//報告等級
private String sample;//正例,每個報告在輸出時,除了報告錯誤外,還需展示正例,告訴用戶正確的寫法是什么
public enum Level{
WARNING("wanring"),
ERROR("error"),
INFO("info");
private String value;
Level(String value){
this.value = value;
}
}
}
- Appender,用於輸出報告,可以定義不同的實現類,輸出不同的樣式
/**
* 用於輸出報告,不同的輸出樣式,定義不同的Appender實現類
*/
public interface Appender {
void print(List<Report> reports);
}
- CheckerHolder,用來注冊Checker,所有的Checker都必須注冊在CheckerHolder才能生效
public class CheckerHolder {
private static Map<String,Checker> checkers = new ConcurrentHashMap<>(16);
public static void registeChecker(Checker checker){
checkers.putIfAbsent(checker.getName(),checker);
}
public static void unRegisteChecker(Checker checker){
checkers.remove(checker.getName());
}
public static Map<String,Checker> getCheckers(){
return checkers;
}
}
有了以上接口和類,可以編寫主流程的測試代碼了:
public void test(){
String sql = "select * from test";
//sql語法分析器
Analyzer analyzer = new DefaultAnalyzer();
//注冊select規則解析器和規則
Checker selectChecker = new SelectCheck();
CheckRule writeClearlySelectFieldRule = new WriteClearlySelectFieldRule();
selectChecker.registeRule(writeClearlySelectFieldRule);
CheckerHolder.registeChecker(selectChecker);
//注冊insert規則解析器和規則
Checker insertChecker = new InsertCheck();
CheckRule clearTableRule = new ClearTableRule();
insertChecker.registeRule(clearTableRule);
CheckerHolder.registeChecker(insertChecker);
Appender appender = new DefaultAppender();
//解析成抽象語法樹
AST tree = analyzer.analyze(sql);
//遍歷規則檢查器,開始檢查
for (Checker checker : CheckerHolder.getCheckers().values()){
//每個規則生成一個報告
List<Report> reports = checker.check(tree);
//輸出
appender.print(reports);
}
}
以上便是整個SQL檢查模塊的設計,每個接口都有具體的實現,我們使用JSqlParser作為SQL解析的實現。代碼如下:
- 語法樹的實現JSqlParseAst,因重點檢查SELECT類型的語句,因此其他類型的實現暫時為null
public class JSqlParseAst implements AST {
private Statement statement;
private String sql;
public JSqlParseAst(Statement statement, String sql) {
this.statement = statement;
this.sql = sql;
}
@Override
public SqlTypes getSqlType() {
if (statement instanceof Select) {
return SqlTypes.SELECT;
} else if (statement instanceof Update) {
return SqlTypes.UPDATE;
} else if (statement instanceof Delete) {
return SqlTypes.DELETE;
} else if (statement instanceof Insert) {
return SqlTypes.INSERT;
} else if (statement instanceof Replace) {
return SqlTypes.REPLACE;
} else if(statement instanceof GrammarErrStatement){
return SqlTypes.ERROR;
}
else {
return SqlTypes.OTHER;
}
}
@Override
public String getSql() {
return this.sql;
}
@Override
public Expression getWhere() {
switch (this.getSqlType()) {
case SELECT:
Select select = (Select) statement;
return ((PlainSelect) select.getSelectBody()).getWhere();
case UPDATE:
Update update = (Update) statement;
return update.getWhere();
case DELETE:
Delete delete = (Delete) statement;
return delete.getWhere();
default:
return null;
}
}
@Override
public GroupByElement getGroupBy() {
switch (this.getSqlType()) {
case SELECT:
Select select = (Select) statement;
return ((PlainSelect) select.getSelectBody()).getGroupBy();
default:
return null;
}
}
@Override
public List<SelectItem> getSelects() {
switch (this.getSqlType()) {
case SELECT:
Select select = (Select) statement;
return ((PlainSelect) select.getSelectBody()).getSelectItems();
default:
return null;
}
}
@Override
public List<Column> getColumns() {
switch (this.getSqlType()) {
case INSERT:
Insert insert = (Insert) statement;
return insert.getColumns();
default:
return null;
}
}
@Override
public List<Join> getJoins() {
switch (this.getSqlType()) {
case SELECT:
Select select = (Select) statement;
return ((PlainSelect) select.getSelectBody()).getJoins();
default:
return null;
}
}
@Override
public Limit getLimit() {
if (SqlTypes.SELECT == getSqlType()) {
Select select = (Select) statement;
return ((PlainSelect) select.getSelectBody()).getLimit();
} else {
return null;
}
}
@Override
public List<OrderByElement> getOrderByElement() {
if (SqlTypes.SELECT == getSqlType()) {
Select select = (Select) statement;
return ((PlainSelect) select.getSelectBody()).getOrderByElements();
} else {
return null;
}
}
}
- 解析器的實現JSqlParseAnalyzer
/**
* SQL語法解析
*/
public class JSqlParseAnalyzer implements Analyzer {
@Override
public AST analyze(String sql) {
JSqlParseAst ast = null;
try {
Statement statement = CCJSqlParserUtil.parse(sql);
ast = new JSqlParseAst(statement, sql);
} catch (Exception e) {
ast = new JSqlParseAst(new GrammarErrStatement(), sql);
}
return ast;
}
}
- Checker的實現比較簡單,因為大部分邏輯都已包含在基類中,子類只需要提供一個name即可,用來標識Checker的類型。SelectChecker實現如下:
public class SelectChecker extends Checker {
@Override
public String getName() {
return "SELECT";
}
}
- CheckRule的一個具體實現WriteClearlySelectFieldRule,檢查SQL中不能出現SELECT *
/**
* 寫明查詢字段,不要使用select *
*/
public class WriteClearlySelectFieldRule implements CheckRule {
@Override
public Report match(AST tree) {
Report report = new Report(tree.getSql());
report.setPass(true);
List<SelectItem> selectItems = tree.getSelects();
//查詢體中是否有*號
if(checkAsterisk(selectItems)){
report.setDesc("請寫明查詢字段,不要使用select *");
report.setPass(false);
report.setLevel(Report.Level.ERROR);
return report;
}
//join子查詢中是否有*號,有則報錯
List<Join> joins = tree.getJoins();
if(joins == null || joins.size() <1){
return report;
}
for(Join join : joins){
//如果是子查詢
if(join.getRightItem() instanceof SubSelect){
//獲取子查詢
SelectBody selectBody = ((SubSelect) join.getRightItem()).getSelectBody();
if(selectBody instanceof PlainSelect){
//檢查是否有*號
if(checkAsterisk(((PlainSelect) selectBody).getSelectItems())){
report.setDesc("請寫明查詢字段,不要使用select *");
report.setPass(false);
report.setLevel(Report.Level.ERROR);
return report;
}
}
}
}
//where子查詢中是否有*號
Expression where = tree.getWhere();
ExpressionVisitorAdapter adapter = new ExpressionVisitorAdapter();
adapter.setSelectVisitor( new MySelectVisitor(report));
where.accept(adapter);
return report;
}
@Override
public List<SqlTypes> scope() {
return Arrays.asList(SqlTypes.SELECT);
}
}
- Appender的實現類,DefaultAppender,默認往控制台輸出報告
public class DefaultAppender implements Appender {
@Override
public void print(List<Report> result) {
if (result == null || result.size() < 1){
return;
}
System.out.println("========報告如下=========");
for (Report report : result){
//不通過才打印
if (!report.isPass()){
System.out.println(report);
System.out.println();
}
}
}
}
以上代碼測試結果如下:
Report{pass=false, desc='請寫明查詢字段,不要使用select *', sql='select * from test', level=ERROR, sample='null'}
擴展性設計
因為規則較多,需要多個人協作共同完成。在剛剛的示例代碼中,每個規則實現后,都需要注冊才能生效。
//注冊select規則解析器和規則
Checker selectChecker = new SelectCheck();
CheckRule writeClearlySelectFieldRule = new WriteClearlySelectFieldRule();
selectChecker.registeRule(writeClearlySelectFieldRule);
CheckerHolder.registeChecker(selectChecker);
//注冊insert規則解析器和規則
Checker insertChecker = new InsertCheck();
CheckRule clearTableRule = new ClearTableRule();
insertChecker.registeRule(clearTableRule);
CheckerHolder.registeChecker(insertChecker);
當規則很多的時候,注冊相關的代碼就需要重復寫很多遍,作為一個“優秀”的程序猿,怎么能容忍這樣的事情發生呢,因此我們采用了java的SPI機制。具體原理介紹請參照之前的文章“搞懂SPI擴展機制”。
從上述代碼可以看出,有兩類實現需要注冊,一類是Checker實現類,一類是CheckRule實現類。因此在META-INF/services目錄下,新建兩個文件,文件名分別為兩個接口的全路徑,如下:

Checker文件內容為:

CheckRule文件內容為:

有了這兩個文件后,還需要使用ServiceLoader將所有實現類加載,並注冊在程序中,代碼如下:
/**
* java spi注冊checker和rule
*/
static {
ServiceLoader<Checker> checkers = ServiceLoader.load(Checker.class);
Iterator<Checker> iteratorChecker = checkers.iterator();
while (iteratorChecker.hasNext()) {
Checker checker = iteratorChecker.next();
CheckerHolder.registeChecker(checker);
}
ServiceLoader<CheckRule> services = ServiceLoader.load(CheckRule.class);
Iterator<CheckRule> iteratorCheckRule = services.iterator();
while (iteratorCheckRule.hasNext()) {
CheckRule rule = iteratorCheckRule.next();
List<SqlTypes> scopes = rule.scope();
for (SqlTypes scope : scopes) {
CheckerHolder.getCheckers().get(scope.toString()).registeRule(rule);
}
}
}
以上便是整個SQL檢查模塊的完整實現。
總結
整個工具由SQL獲取、SQL檢查、報告渲染三部分構成。SQL可從程序的mapper.xml中獲取,也可在應用程序運行過程中輸出到文本日志,從文本日志中讀取。SQL檢查可使用JSqlParser實現,也可使用Antrl、Druid等工具實現。報告渲染可根據需要輸出至HTML、數據庫、PDF等。
目前我們只實現了從mapper.xml中獲取Sql,使用JsqlParser解析SQL檢查,結果輸出至控制台和Html文件,后續根據需要,再編寫其它的實現。
關注公眾號“程序員順仔和他的朋友們”,帶你了解更多開發和架構知識。
