說實話,日誌難看,在排查問題時會不爽。
線上出現問題,經常需要看數據庫裏面的數據有沒有問題,是不是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中責任鏈模式的應用。(*****)
延伸閱讀:
這篇文章一開始在介紹字段屬性,然後我就沒看了,發現自己debug找答案更快。但是等我回頭看這篇文章,還是有很多共鳴的,總之可以參考,但是一定要先自己debug看源碼。
作者的其他文章看起來也不錯,日後閒暇翻閱