最近的一个项目是将J2EE环境打包安装在客户端(使用 nwjs
+ NSIS
制作安装包)运行, 所有的业务操作在客户端完成, 数据存储在客户端数据库中. 服务器端数据库汇总各客户端的数据进行分析. 其中客户端ORM使用Mybatis. 通过Mybatis拦截器获取所有在执行的SQL语句, 定期同步至服务器.
本文通过在客户端拦截SQL的操作介绍Mybatis拦截器的使用方法.
客户分店较多且比较分散, 部分店内网络不稳定, 客户要求每个分店在无网络的情况下也能正常使用系统, 同时所有店面数据需要进行汇总分析. 综合客户的需求, 项目架构如下:
将WEB项目及其运行环境通过NSIS制作安装包在各分店进行安装, 每个分店是一个独立的WEB服务, 这样就保证店内在无网络(有局域网,无法访问互联网)的情况下也可以正常使用系统. 此时每个分店的数据库保存自己店内的运营数据, 各店之间的数据相互隔离.
但运营方无法分析所有店面的汇总数据(如商品整体销售情况等), 因此需要将每个店面的数据定期同步至服务器的数据库中.
最终采用了将客户端所有更新(增,删,改)的SQL按照执行顺序保存至数据库中, 定期同步并在服务器的数据库按照顺序执行SQL, 以此来保证服务器数据库的数据是各客户端数据的汇总.
项目采用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' ) 复制代码
想这样一个场景, 你做饭的时候可能需要以下步骤:
买菜>> 洗菜 >> 切菜 >> 做菜 >> 上菜 >> 洗碗
上面的做饭流程是按照步骤一步一步的进行, 我们既可以在其中的某个步骤中获取前几步的成果, 也可以在某个步骤开始之前做些额外的事情, 比如: 切菜前对菜称重等.
Mybatis提供了这样一个组件: 他可以在某个步骤执行之前先执行自定义的操作. 这个组件叫做 拦截器 . 所谓拦截器, 顾名思义: 需要定义拦截哪个操作步骤及拦截后做什么事情.
拦截器需要实现 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
(数组)即可. Executor
, ParameterHandler
, ResultSetHandler
, StatementHandler
下的方法. 在Spring配置文件中, 声明拦截器并将其配置到 SqlSessionFactoryBean
中 plugins
属性中
// Mybatis拦截器 sqlInterceptor(SQLInterceptor) // Mybatis配置 sqlSessionFactory(SqlSessionFactoryBean) { dataSource = ref("dataSource") mapperLocations = "classpath*:/com/atd681/mybatis/interceptor/*_mapper.xml" // 配置Mybatis拦截器 plugins = [ sqlInterceptor ] } 复制代码
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(); } } 复制代码
在数据库中创建两张表:
atd681_mybatis_test atd681_mybatis_sql
创建 DAO
和 Mapper
, 创建增加, 删除, 修改的方法及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也被保存至数据库中.
上述过程中除了3次业务操作, 还有3次保持SQL的操作, 因此数据库总共会执行6条SQL.
上述6次数据库操作必须在同一事务中, 否则一旦出现业务操作成功但保存SQL失败的情况. 服务器端同步的数据就会与客户端本地不一致.