Mybatis之攔截器--獲取執行SQL實現多客戶端資料同步

atd681發表於2018-09-06

0. 前言

最近的一個專案是將J2EE環境打包安裝在客戶端(使用nwjs+NSIS製作安裝包)執行, 所有的業務操作在客戶端完成, 資料儲存在客戶端資料庫中. 伺服器端資料庫彙總各客戶端的資料進行分析. 其中客戶端ORM使用Mybatis. 通過Mybatis攔截器獲取所有在執行的SQL語句, 定期同步至伺服器.

本文通過在客戶端攔截SQL的操作介紹Mybatis攔截器的使用方法.

1. 專案需求

客戶分店較多且比較分散, 部分店內網路不穩定, 客戶要求每個分店在無網路的情況下也能正常使用系統, 同時所有店面資料需要進行彙總分析. 綜合客戶的需求, 專案架構如下:

Mybatis之攔截器--獲取執行SQL實現多客戶端資料同步

將WEB專案及其執行環境通過NSIS製作安裝包在各分店進行安裝, 每個分店是一個獨立的WEB服務, 這樣就保證店內在無網路(有區域網,無法訪問網際網路)的情況下也可以正常使用系統. 此時每個分店的資料庫儲存自己店內的運營資料, 各店之間的資料相互隔離.

但運營方無法分析所有店面的彙總資料(如商品整體銷售情況等), 因此需要將每個店面的資料定期同步至伺服器的資料庫中.

  • 由於店內可能無網路(無網時不能受資料同步影響,系統需正常執行), 實時同步方案被排除.
  • 為保證資料庫安全性, 伺服器資料庫不能對外暴露, 使用資料庫的同步機制方案被排除.
  • 部分業務需要記錄資料變化日誌(資料從1到0又到1, 需記錄過程), 增量同步方案被排除.

最終採用了將客戶端所有更新(增,刪,改)的SQL按照執行順序儲存至資料庫中, 定期同步並在伺服器的資料庫按照順序執行SQL, 以此來保證伺服器資料庫的資料是各客戶端資料的彙總.

2. 解決方案

專案採用Mybatis, Mapper中定義SQL時可以使用Mybatis的標籤及引數識別符號, Mybatis會解析標籤替換引數生成最終的SQL在資料庫中執行, 而我們需要的是最終在資料庫中執行的SQL.

Mybatis中SQL的寫法:

<insert id="insert">
    INSERT INTO atd681_mybatis_test ( dv ) VALUES ( #{dv} )
</insert>
複製程式碼

需要同步至伺服器執行的SQL:

INSERT INTO atd681_mybatis_test ( dv ) VALUES ( 'aaa' )
複製程式碼

3. 攔截器

3.1 什麼是攔截器

想這樣一個場景, 你做飯的時候可能需要以下步驟:

買菜 >> 洗菜 >> 切菜 >> 做菜 >> 上菜 >> 洗碗

  • 開始洗菜前, 買菜操作已經完成, 可以知道買了什麼菜.
  • 洗菜時還未開始做菜, 因此不知道菜是什麼口味的.
  • 在上菜前(此時做菜已經完成), 可以知道菜的口味.
  • 在上菜時不知道有沒有剩菜
  • 在洗碗時我們可以知道有沒有剩菜.

上面的做飯流程是按照步驟一步一步的進行, 我們既可以在其中的某個步驟中獲取前幾步的成果, 也可以在某個步驟開始之前做些額外的事情, 比如: 切菜前對菜稱重等.

Mybatis提供了這樣一個元件: 他可以在某個步驟執行之前先執行自定義的操作. 這個元件叫做攔截器. 所謂攔截器, 顧名思義: 需要定義攔截哪個操作步驟及攔截後做什麼事情.

3.2 定義攔截器

攔截器需要實現org.apache.ibatis.plugin.Interceptor介面並指定攔截的方法.

// 攔截器
@Intercepts(@Signature(type = StatementHandler.class, 
                       method = "update", 
                       args = Statement.class)
            )
public class SQLInterceptor implements Interceptor {

    // 攔截方法後執行的邏輯
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 繼續執行Mybatis原有的邏輯
        // proceed中通過反射執行被攔截的方法
        return invocation.proceed();
    }

    // 返回當前攔截的物件(StatementHandler)的動態代理
    // 當攔截物件的方法被執行時, 動態代理中執行攔截器intercept方法.
    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    // 設定屬性
    @Override
    public void setProperties(Properties properties) {
    }

}

複製程式碼
  • @Intercepts為Mybatis提供的攔截器註解, @Signature指定攔截的方法.
  • 如果一個攔截器攔截多個方法時, 在@Intercepts中配置多個@Signature(陣列)即可.
  • 由於JAVA的方法可以過載, 確定唯一方法需要指定類(type), 方法(method), 引數(args).
  • 攔截器可攔截Executor,ParameterHandler,ResultSetHandler,StatementHandler下的方法.

3.3 配置攔截器

在Spring配置檔案中, 宣告攔截器並將其配置到SqlSessionFactoryBeanplugins屬性中

// Mybatis攔截器
sqlInterceptor(SQLInterceptor)

// Mybatis配置
sqlSessionFactory(SqlSessionFactoryBean) {
    dataSource = ref("dataSource")
    mapperLocations = "classpath*:/com/atd681/mybatis/interceptor/*_mapper.xml"
    
    // 配置Mybatis攔截器
    plugins = [
        sqlInterceptor
    ] 
}
複製程式碼

4. 獲取並儲存SQL

Mybatis處理SQL的大致流程如下:

載入SQL >> 解析SQL >> 替換SQL引數 >> 執行SQL >> 獲取返回結果

攔截[執行SQL]操作, 此時Mybatis已經完成SQL解析及替換引數, 所得的SQL即為傳送資料庫執行的SQL. 我們只需要獲取該SQL並儲存至資料庫即可.

// Mybatis攔截器:攔截所有的增刪改SQL,將SQL保持至資料庫
// 攔截StatementHandler.update方法
@Intercepts(@Signature(type = StatementHandler.class, 
                       method = "update", 
                       args = Statement.class)
           )
public class SQLInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {

        // invocation.getArgs()可以獲取到被攔截方法的引數
        // StatementHandler.update(Statement s)的引數為Statement
        Statement s = (Statement) invocation.getArgs()[0];

        // 資料來源為DRUID, Statement為DRUID的Statement
        Statement stmt = ((DruidPooledPreparedStatement) s).getStatement();

        // 配置druid連線時使用filters: stat配置
        if (stmt instanceof PreparedStatementProxyImpl) {
            stmt = ((PreparedStatementProxyImpl) stmt).getRawObject();
        }

        // 資料庫提供的Statement可獲取引數替換後的SQL(JDBC和DRUID獲取的是帶?的)
        // 資料庫為MySQL,可以直接強制轉換為MySQL的PreparedStatement獲取SQL
        // SQL在書寫時為了格式容器閱讀會有換行符(多個空格)存在
        // 為了儲存和檢視方便去除SQL中的換行及多個空格
        String sql = ((com.mysql.jdbc.PreparedStatement) stmt).asSql().replaceAll("\\s+", " ");

        // 儲存SQL的操作必須和當前執行的SQL在同一事務中
        // 使用當前SQL所在的資料庫連線執行儲存操作即可
        // 目標sql成功時儲存sql的方法也同步成功
        Connection conn = stmt.getConnection();

        // 將SQL儲存至資料庫中
        PreparedStatement ps = null;

        try {
            ps = conn.prepareStatement("INSERT INTO atd681_mybatis_sql (v_sql) VALUES (?)");
            ps.setString(1, sql);

            // 因為和Mybatis的操作在同一事務中
            // 如果本次操作如果失敗, 所有操作都回滾
            ps.execute();
        }
        finally {
            if (ps != null) {
                ps.close();
            }
        }

        // 繼續執行StatementHandler.update方法
        return invocation.proceed();

    }

}

複製程式碼
  • 只有MySQL提供的PreparedStatement物件中可以獲取到最終的SQL.
  • 儲存SQL操作需要和Mybatis的操作在同一事務中, 必須同時成功或失敗.

5. 測試

在資料庫中建立兩張表:

  • atd681_mybatis_test: 儲存業務測試資料
  • atd681_mybatis_sql: 儲存業務操作的SQL

建立DAOMapper, 建立增加, 刪除, 修改的方法及SQL

// 資料DAO
@Repository
public interface DataDAO {

    // 新增資料
    void insert(String dv);

    // 更新資料
    void update(String dv);

    // 刪除資料
    void delete();

}
複製程式碼
<mapper namespace="com.atd681.mybatis.interceptor.DataDAO">
	
	<!-- 新增資料,內容為引數i的值 -->
	<insert id="insert">
		INSERT INTO atd681_mybatis_test ( dv ) VALUES ( #{dv} )
	</insert>
	
	<!-- 更新資料,更新為引數u的值 -->
	<update id="update">
		UPDATE atd681_mybatis_test1 SET dv = #{dv}
	</update>
	
	<!-- 刪除資料 -->
	<delete id="delete">
		DELETE FROM atd681_mybatis_test
	</delete>
	
</mapper> 
複製程式碼

控制器中新增方法, 依次呼叫刪除, 新增, 更新. 保證三個操作在同一個事務中.

@RestController
public class DataController {

    // 注入DAO
    @Autowired
    private DataDAO dao;

    // 分別執行刪除,插入,更新操作
    // 引數i: 插入時的字串
    // 引數u: 更新時的字串
    @GetMapping("/mybatis/test")
    @Transactional
    public String excuteSql(String i, String u) {

        // 刪除資料後將引數i的內容外掛資料庫,將資料更新成引數u的內容
        // 該方法新增了事務,3次資料庫操作會在同一個事務中執行.
        // Mybatis攔截器會捕獲三次資料庫SQL插入至資料庫中(詳見攔截器)
        dao.delete();
        dao.insert(i);
        dao.update(u);

        return "success";
    }

}
複製程式碼

啟動服務, 訪問http://localhost:3456/mybatis/test?i=insert&u=update

程式依次執行刪除、新增(內容為"insert")、更新(內容為"update")三個操作, 執行完成後資料庫中有一條記錄(內容為"update"). 由於配置了攔截器, 在每個操作執行前將SQL保持至資料庫中, 因此三條SQL也被儲存至資料庫中.

Mybatis之攔截器--獲取執行SQL實現多客戶端資料同步

上述過程中除了3次業務操作, 還有3次保持SQL的操作, 因此資料庫總共會執行6條SQL.

  1. 執行DELETE操作
  2. 儲存1中DELETE操作的SQL
  3. 執行INSERT SQL
  4. 儲存3中INSERT操作的SQL
  5. 執行UPDATE SQL
  6. 儲存5中UPDATE操作的SQL

上述6次資料庫操作必須在同一事務中, 否則一旦出現業務操作成功但儲存SQL失敗的情況. 伺服器端同步的資料就會與客戶端本地不一致.

6. 示例程式碼

雖然Mybatis沒有Spring的擴充套件性強, 但是攔截器的出現也可以幫助我們解決一些常見問題. 通過攔截器可以實現分頁, 統一設定引數等常見的功能.

  • 示例程式碼地址: https://github.com/atd681/alldemo
  • 示例專案名稱: atd681-mybatis-interceptor

相關文章