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

atd681發表於2019-03-02

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

相關文章