動手擼一個SQL規范檢查工具


背景

近幾年公司人員規模快速增長,超過半數開發人員均為近兩年入職的新員工,開發技能與經驗欠缺,之前踩坑的經驗也未能完全了解,出現了幾起因慢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文件,后續根據需要,再編寫其它的實現。

關注公眾號“程序員順仔和他的朋友們”,帶你了解更多開發和架構知識。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM