項目日誌優化(一)——優化druid日誌打印

說實話,日誌難看,在排查問題時會不爽。

線上出現問題,經常需要看數據庫裏面的數據有沒有問題,是不是sql查詢的時候就查錯了,所以需要查看sql語句的日誌,但是mybatis打印的日誌都是佔位符,還有很多換行符,非常不友好,druid默認的statementLogEnabled參數配置也有這個問題。

一開始爲了解決這個痛點,沒想到druid有提供可以打印可執行的sql的配置,功夫花在mybatis插件開發上,代碼基於倉頡大佬博客中提到的內容,解決了使用參數值代替佔位符時,實際參數字段有可能是ParameterObject的父類的這種情況,無法打印可執行SQL的問題,

Myabtis插件優化SQL語句輸出

通過Myabtis插件優化SQL語句輸出,具體代碼如下:

import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.ParameterMapping;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.defaults.DefaultSqlSession.StrictMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.lang.reflect.Field;
import java.sql.Statement;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Properties;

/**
 * Sql執行時間記錄攔截器
 */
@Intercepts({@Signature(type = StatementHandler.class, method = "query", args = {Statement.class, ResultHandler.class}),
        @Signature(type = StatementHandler.class, method = "update", args = {Statement.class}),
        @Signature(type = StatementHandler.class, method = "batch", args = { Statement.class })})
public class SqlCostInterceptor implements Interceptor {
    private static final Logger logger = LoggerFactory.getLogger(SqlCostInterceptor.class);

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object target = invocation.getTarget();

        long startTime = System.currentTimeMillis();
        StatementHandler statementHandler = (StatementHandler)target;
        try {
            return invocation.proceed();
        } finally {
            long endTime = System.currentTimeMillis();
            long sqlCost = endTime - startTime;

            BoundSql boundSql = statementHandler.getBoundSql();
            String sql = boundSql.getSql();
            Object parameterObject = boundSql.getParameterObject();
            List<ParameterMapping> parameterMappingList = boundSql.getParameterMappings();

            // 格式化Sql語句,去除換行符,替換參數
            sql = formatSql(sql, parameterObject, parameterMappingList);

            logger.info("SQL:[" + sql + "]執行耗時[" + sqlCost + "ms]");
        }
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {

    }

    @SuppressWarnings("unchecked")
    private String formatSql(String sql, Object parameterObject, List<ParameterMapping> parameterMappingList) {
        // 輸入sql字符串空判斷
        if (sql == null || sql.length() == 0) {
            return "";
        }

        // 美化sql
        sql = beautifySql(sql);

        // 不傳參數的場景,直接把Sql美化一下返回出去
        if (parameterObject == null || parameterMappingList == null || parameterMappingList.size() == 0) {
            return sql;
        }

        // 定義一個沒有替換過佔位符的sql,用於出異常時返回
        String sqlWithoutReplacePlaceholder = sql;

        try {
            if (parameterMappingList != null) {
                Class<?> parameterObjectClass = parameterObject.getClass();

                // 如果參數是StrictMap且Value類型爲Collection,獲取key="list"的屬性,這裏主要是爲了處理<foreach>循環時傳入List這種參數的佔位符替換
                // 例如select * from xxx where id in <foreach collection="list">...</foreach>
                if (isStrictMap(parameterObjectClass)) {
                    StrictMap<Collection<?>> strictMap = (StrictMap<Collection<?>>)parameterObject;

                    if (isList(strictMap.get("list").getClass())) {
                        sql = handleListParameter(sql, strictMap.get("list"));
                    }
                } else if (isMap(parameterObjectClass)) {
                    // 如果參數是Map則直接強轉,通過map.get(key)方法獲取真正的屬性值
                    // 這裏主要是爲了處理<insert>、<delete>、<update>、<select>時傳入parameterType爲map的場景
                    Map<?, ?> paramMap = (Map<?, ?>) parameterObject;
                    sql = handleMapParameter(sql, paramMap, parameterMappingList);
                } else {
                    // 通用場景,比如傳的是一個自定義的對象或者八種基本數據類型之一或者String
                    sql = handleCommonParameter(sql, parameterMappingList, parameterObjectClass, parameterObject);
                }
            }
        } catch (Exception e) {
            // 佔位符替換過程中出現異常,則返回沒有替換過佔位符但是格式美化過的sql,這樣至少保證sql語句比BoundSql中的sql更好看
            return sqlWithoutReplacePlaceholder;
        }

        return sql;
    }

    /**
     * 美化Sql
     */
    private String beautifySql(String sql) {
        // sql = sql.replace("\n", "").replace("\t", "").replace("  ", " ").replace("( ", "(").replace(" )", ")").replace(" ,", ",");
        sql = sql.replaceAll("[\\s\n ]+"," ");
        return sql;
    }

    /**
     * 處理參數爲List的場景
     */
    private String handleListParameter(String sql, Collection<?> col) {
        if (col != null && col.size() != 0) {
            for (Object obj : col) {
                String value = null;
                Class<?> objClass = obj.getClass();

                // 只處理基本數據類型、基本數據類型的包裝類、String這三種
                // 如果是複合類型也是可以的,不過複雜點且這種場景較少,寫代碼的時候要判斷一下要拿到的是複合類型中的哪個屬性
                if (isPrimitiveOrPrimitiveWrapper(objClass)) {
                    value = obj.toString();
                } else if (objClass.isAssignableFrom(String.class)) {
                    value = "\"" + obj.toString() + "\"";
                }

                sql = sql.replaceFirst("\\?", value);
            }
        }

        return sql;
    }

    /**
     * 處理參數爲Map的場景
     */
    private String handleMapParameter(String sql, Map<?, ?> paramMap, List<ParameterMapping> parameterMappingList) {
        for (ParameterMapping parameterMapping : parameterMappingList) {
            Object propertyName = parameterMapping.getProperty();
            Object propertyValue = paramMap.get(propertyName);
            if (propertyValue != null) {
                if (propertyValue.getClass().isAssignableFrom(String.class)) {
                    propertyValue = "\"" + propertyValue + "\"";
                }

                sql = sql.replaceFirst("\\?", propertyValue.toString());
            }
        }

        return sql;
    }

    /**
     * 處理通用的場景
     */
    private String handleCommonParameter(String sql, List<ParameterMapping> parameterMappingList, Class<?> parameterObjectClass,
                                         Object parameterObject) throws Exception {
        for (ParameterMapping parameterMapping : parameterMappingList) {
            String propertyValue = null;
            // 基本數據類型或者基本數據類型的包裝類,直接toString即可獲取其真正的參數值,其餘直接取paramterMapping中的property屬性即可
            if (isPrimitiveOrPrimitiveWrapper(parameterObjectClass)) {
                propertyValue = parameterObject.toString();
            } else {
                String propertyName = parameterMapping.getProperty();
                Field field = null;
                try{
                    field = parameterObjectClass.getDeclaredField(propertyName);
                }catch (NoSuchFieldException e){
                    //如果當前類或者父類是Object,快速拋出錯誤
                    if(parameterObjectClass.isAssignableFrom(Object.class)
                            || parameterObjectClass.getSuperclass().isAssignableFrom(Object.class)){
                        throw e;
                    }
                    field = getField(parameterObjectClass, propertyName, field);
                }
                // 要獲取Field中的屬性值,這裏必須將私有屬性的accessible設置爲true
                field.setAccessible(true);
                propertyValue = String.valueOf(field.get(parameterObject));
                if (parameterMapping.getJavaType().isAssignableFrom(String.class)) {
                    propertyValue = "\"" + propertyValue + "\"";
                }
            }

            sql = sql.replaceFirst("\\?", propertyValue);
        }

        return sql;
    }

    //處理當propertyName是parameterObjectClass的父類時不生效的情況
    private Field getField(Class<?> parameterObjectClass, String propertyName, Field field) throws NoSuchFieldException {
        //獲取當前類的父類
        Class superClass = parameterObjectClass.getSuperclass();
        if(superClass.isAssignableFrom(Object.class)){
            throw new NoSuchFieldException();
        }
        //遍歷父類的字段
        for(Field f : superClass.getDeclaredFields()){
            if(f.getName().equals(propertyName)){
                //如果父類的屬性名找到了,賦值給field,結束循環,返回field
                field = f;
                break;
            }
        }
        if(field == null){
            //如果循環父類沒有找到,遞歸調用當前方法,直到找到爲止,找到頂級父類Object還是找不到,會拋出NoSuchFieldException
            getField(superClass,propertyName,field);
        }
        return field;
    }

    /**
     * 是否基本數據類型或者基本數據類型的包裝類
     */
    private boolean isPrimitiveOrPrimitiveWrapper(Class<?> parameterObjectClass) {
        return parameterObjectClass.isPrimitive() ||
                (parameterObjectClass.isAssignableFrom(Byte.class) || parameterObjectClass.isAssignableFrom(Short.class) ||
                        parameterObjectClass.isAssignableFrom(Integer.class) || parameterObjectClass.isAssignableFrom(Long.class) ||
                        parameterObjectClass.isAssignableFrom(Double.class) || parameterObjectClass.isAssignableFrom(Float.class) ||
                        parameterObjectClass.isAssignableFrom(Character.class) || parameterObjectClass.isAssignableFrom(Boolean.class));
    }

    /**
     * 是否DefaultSqlSession的內部類StrictMap
     */
    private boolean isStrictMap(Class<?> parameterObjectClass) {
        return parameterObjectClass.isAssignableFrom(StrictMap.class);
    }

    /**
     * 是否List的實現類
     */
    private boolean isList(Class<?> clazz) {
        Class<?>[] interfaceClasses = clazz.getInterfaces();
        for (Class<?> interfaceClass : interfaceClasses) {
            if (interfaceClass.isAssignableFrom(List.class)) {
                return true;
            }
        }

        return false;
    }

    /**
     * 是否Map的實現類
     */
    private boolean isMap(Class<?> parameterObjectClass) {
        Class<?>[] interfaceClasses = parameterObjectClass.getInterfaces();
        for (Class<?> interfaceClass : interfaceClasses) {
            if (interfaceClass.isAssignableFrom(Map.class)) {
                return true;
            }
        }

        return false;
    }

    /**
     * 獲取對象中的字段
     * @param obj which object you want to find filed
     * @param fieldName the field name you want to find
     * @return the field you want tofind
     * @throws Throwable
     * @throws NoSuchFieldException
     */
    protected Field getField(Object obj,String fieldName) throws NoSuchFieldException {
        Class clzz = obj.getClass();
        Field[] fields = clzz.getDeclaredFields();
        Field dest = null;
        while (!hasField(fields,fieldName) && !clzz.getName().equalsIgnoreCase("java.lang.Object")) {
            clzz = clzz.getSuperclass();
            fields = clzz.getDeclaredFields();
        }
        if (hasField(fields,fieldName)) {
            dest = clzz.getDeclaredField(fieldName);
        } else {
            throw new NoSuchFieldException("類中沒有此字段");
        }

        return dest;
    }
    /**
     * 判斷對象中是否有要找的字段
     * @param fields the fields which you want to find
     * @param fieldName the field name you want to find
     * @return if the field in field return true else return false
     */
    private boolean hasField(Field[] fields, String fieldName) {
        for (int i = 0; i < fields.length ;i ++) {
            if (fields[i].getName().equals(fieldName)) {
                return true;
            }
        }
        return false;
    }

}

煩人的問題:爲什麼會有兩條帶佔位符的SQL語句

當我寫的插件可以打印可執行SQL後,看到在可執行SQL前後,還是會有帶佔位符的SQL語句打印(原來項目中的),即現在系統裏,執行一條SQL語句,會打印兩條帶佔位符的語句,和一條可執行SQL語句,總共打印三條SQL語句。

爲什麼會有兩條帶佔位符的SQL語句???通過後面對Druid源碼的分析。默認druid的statementLogEnabled參數是true,如果配置了log4j的filter,在sql執行的時候會打印一條帶佔位符的SQL,在SQL執行完,druid又會打印一次帶佔位符的SQL。

Druid的SQL打印邏輯

Druid的SQL打印邏輯所在的類如下:

FilterEventAdapter類中:

 LogFilter類中:

第一次配置修改

那兩條佔位符的SQL對我們看日誌來說沒什麼用,於是想着怎麼過濾掉不顯示,根據官網wiki的提示,加了如下配置:

<bean id="log-filter" class="com.alibaba.druid.filter.logging.Log4jFilter">
	<property name="resultSetLogEnabled" value="false" />
	<property name="statementLogEnabled" value="false"/>
	<property name="statementExecuteAfterLogEnabled" value="false"/>
	<property name="statementExecutableSqlLogEnable" value="true"/>
</bean>

 將過濾器加到數據源配置中:

<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"
	init-method="init" destroy-method="close">
	省略其他屬性配置
	<property name="proxyFilters">
		<list>
			<ref bean="log-filter"/>
		</list>
	</property>	
</bean>

將mybatis插件註釋掉,不需要用了:

這樣啓動項目後,每次執行一條SQL,還是會打印兩條帶佔位符和一條可執行的SQL語句,那一條可執行的SQL語句說明druid的statementExecutableSqlLogEnable配置生效了,但是爲什麼還是會打印兩條帶佔位符的?

數據源中的原配置搞的鬼

先直接看爲什麼,因爲數據源配置中配置了這個:

<property name="filters" value="stat,log4j,config" />

配置了三個過濾器,其中一個就是log4j。。。。

stat是監控,config是爲了數據庫加密

在debug追源碼發現,DruidDataSource中的filter是4個,兩個Log4jFilter:

兩個Log4j,一個是上面的默認配置,一個是自定義的log4j配置。

最終配置修改:

所以解決方法就是把默認的配置去掉就可以了:

<property name="filters" value="stat,config" />

目前的SQl日誌可以看了。

如果遇到線上問題需要排查,直接複製到plsql中執行就行了,不用替換佔位符了。

 

總結:

1、這次的問題解決,首先自己關注了下項目中對於開發不友好的部分,SQL打印難看,給定位問題帶來一些不便。

2、在解決這個問題的時候,主要資源是官網wiki和源碼。在之前花了很多時間分析過Spring源碼、Mybatis源碼等後,分析Druid源碼還是很輕鬆的。源碼中找到的答案是最真實的。

3、debug能力更加鞏固了,依然確信,源碼分析沒有捷徑,就是自己debug,網上的分析的都是別人的,不是自己的,兩個效果不一樣。

4、準備有時間把Druid源碼的運行邏輯和一些思路記錄下,畢竟花了寶貴的時間,放腦子裏保質期太短,不利於總結提煉。(****)

5、項目中的日誌打印,除了SQL,其他的還是很亂,後續繼續優化。(****)

6、打算專門寫一篇,Druid中責任鏈模式的應用。(*****)

 

延伸閱讀:

DruidDataSource源碼解析

這篇文章一開始在介紹字段屬性,然後我就沒看了,發現自己debug找答案更快。但是等我回頭看這篇文章,還是有很多共鳴的,總之可以參考,但是一定要先自己debug看源碼。

作者的其他文章看起來也不錯,日後閒暇翻閱

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章