一、背景
根据信创国产适配要求,针对业务系统迁移至达梦数据库,保持系统稳定、可靠。 当前系统技术栈:springboot+mybatis(3.5.9)+mybaits plus 我们采用的是拦截器方案,结合JavaAgent字节码增强技术,实现最小化代码改动。
二、迁移方案
- 通过拦截器实现
- 通过mybatis自带的databaseId 多数据源机制
三、梳理现有使用方式
| 使用方式 | demoMapperXml | demoMapperAnno | demoMapper | demoBeanService | queryWrap |
|---|
四、方案实现
3.1 拦截器方式实现
主要实现两个拦截器:
拦截器 作用 备注 mapper调用方法级别拦截器 拦截复杂sql的执行方法 精确到单个方法的拦截,仅对达梦不支持的方法进行更改 sql函数拦截器 简单的函数替换例如(group_concat->WM_CONCA )
3.1.1 方法拦截器
实现原理:
在MyBatis调用Mapper方法时进行拦截 自动将原方法调用重定向到对应的DM版本方法 示例:
// 原方法
@Select("SELECT * FROM user WHERE id = #{id}")
User selectById(@Param("id") Long id);
// DM专用方法(仅当原SQL不兼容时需实现)
@Select("SELECT * FROM user WHERE id = #{id}") // 修改后的DM兼容SQL
User selectByIdDM(@Param("id") Long id);
拦截逻辑:
检查是否存在原方法名+DM后缀的方法 存在则调用DM版本方法,否则调用原方法
3.1.2 sql函数拦截器
对sql的进行拦截并处理,处理简单函数转换
例如:
group_concat → WM_CONCAT 迁移SQL分类处理
| 类别 | 处理方式 | 描述 | 示例 |
|---|---|---|---|
| 无需更改 | 直接执行 | DM 数据库原生兼容的 SQL 语法,无需任何转换。 | SELECT * FROM employees WHERE id = 1; |
| 仅替换函数 | 函数拦截器处理 | 仅需将源数据库特有的函数名一对一映射为 DM 的等效函数。 | GROUP_CONCAT(name) → WM_CONCAT(name) |
| 需要重写SQL | 方法拦截器 + DM 方法 | SQL 的语义或语法结构不同,需用 DM 的特定语法重写整条语句。 | REPLACE INTO table ... → MERGE INTO table ... |
| 重写SQL + 替换函数 | 方法拦截器 + DM 方法 | 复杂的 SQL 语句,其结构需要重写,并且其中包含需要替换的特殊函数。 | 包含 GROUP_CONCAT 和 LIMIT 分页的复杂查询,需重写为使用 WM_CONCAT 和 ROWNUM 的 DM 语法。 |
代码整体情况
方法拦截器代码
import cn.hutool.json.JSONUtil;
import java.util.Properties;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
// 定义拦截器签名
@Intercepts({
@Signature(
type = Executor.class,
method = "update",
args = {MappedStatement.class, Object.class}),
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class})
})
//@Component
@Slf4j
public class MethodInterceptor implements Interceptor {
@Value("${task.dbType:mysql}")
private String dbType; // 数据库类型配置
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this); // 包装目标对象
}
@Override
public void setProperties(Properties properties) {} // 设置属性方法
@Override
public Object intercept(Invocation invocation) throws Throwable {
System.out.println("intercept-开始执行方法-" + invocation.getMethod().getName());
// 获取StatementHandler
Object[] args = invocation.getArgs(); // 方法参数
MappedStatement ms = (MappedStatement) args[0]; // 映射语句
String methodId = ms.getId(); // 方法ID
RowBounds rowBounds = (RowBounds)args[2];
// 如果是DM数据库且方法名不带Dm后缀
if ("dm".equals(dbType) && !methodId.endsWith("DM")) {
try {
// 尝试查找对应的Dm方法
Configuration configuration = ms.getConfiguration();
// 处理 namespace 逻辑
String fullMethodId = methodId;
String noFullMethodId = methodId;
if (!methodId.contains(".")) {
// 如果不包含点(.),说明是简短的方法ID,需要加上namespace
String namespace = ms.getResource().replace(".", "/"); // 获取namespace
fullMethodId = namespace + "." + methodId;
}else{
noFullMethodId = methodId.substring(methodId.lastIndexOf(".") + 1);
}
String dmFullMethodId = fullMethodId + "DM";
String dmNoFullMethodId = noFullMethodId + "DM";
String dmMethodId = dmFullMethodId;
MappedStatement dmMs = getDmMappedStatement(configuration, dmFullMethodId);
if(dmMs == null){
dmMethodId = dmNoFullMethodId;
dmMs = getDmMappedStatement(configuration, dmNoFullMethodId);
}
Object parameter = args[1];
// 如果找到Dm方法则替换执行
if (dmMs != null) {
BoundSql newBoundSql = dmMs.getBoundSql(parameter);
log.info("intercept执行的方法是-" + dmMethodId);
args[0] = dmMs; // 替换为DM方法
if (args.length == 4) {
args[2] = newBoundSql;
} else {
args[5] = newBoundSql;
}
}
} catch (Exception e) {
log.error("intercept-没有找到对应的Dm方法-继续执行原方法-或尝试使用sql替换-" + methodId,e);
}
}
Object proceed = invocation.proceed(); // 执行拦截方法
log.info(JSONUtil.parse(proceed).toStringPretty()); // 记录执行结果
return proceed;
}
public MappedStatement getDmMappedStatement(Configuration configuration, String methodId) {
return configuration.getMappedStatement(methodId);
}
}
sql拦截器代码
@Intercepts({
@Signature(
type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class})
})
@Log
public class ReWriteInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 获取 StatementHandler
StatementHandler handler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = handler.getBoundSql();
String originalSql = boundSql.getSql();
log.info("OriginalSql: " + originalSql.replaceAll("\n"," "));
String rewrittenSql = rewriteSql(originalSql);
// 用反射重新设置 SQL
Field sqlField = boundSql.getClass().getDeclaredField("sql");
sqlField.setAccessible(true);
sqlField.set(boundSql, rewrittenSql);
Object proceed = invocation.proceed();
return proceed;
}
private String rewriteSql(String originalSql) {
// mysql IFNULL(col1,col2) => NVL(col1,col2) dm
originalSql = originalSql.replaceAll("(?i)GROUP_CONCAT", "WM_CONCAT");
originalSql = originalSql.replaceAll("(?i)!JSON_CONTAINS", " not JSON_CONTAINS ");
originalSql = originalSql.replaceAll("(?i)INSERT\\s+IGNORE\\s+INTO", "INSERT INTO ");
originalSql = convertCastToToChar(originalSql);
log.info("OriginalSql: " + originalSql+"\nRewrittenSql: " + originalSql);
return originalSql;
}
/**
* 将cast(... as char)转换为to_char(...)格式
* @param sql
* @return {@link String}
*/
public static String convertCastToToChar(String sql) {
// 正则表达式匹配CAST(... AS CHAR)模式
Pattern pattern = Pattern.compile(
"CONVERT\\s*\\(\\s*(.+?)\\s*,\\s*(?:CHAR|VARCHAR|VARCHAR2|TEXT)\\s*(?:\\(\\d+\\))?\\s*\\)",
Pattern.CASE_INSENSITIVE
);
Matcher matcher = pattern.matcher(sql);
StringBuffer result = new StringBuffer();
while (matcher.find()) {
// 获取CAST内部的表达式
String innerExpression = matcher.group(1).trim();
// 替换为TO_CHAR格式
matcher.appendReplacement(result, "TO_CHAR(" + innerExpression + ")");
}
matcher.appendTail(result);
return result.toString();
}
mybatis2dmAutoConfiguration 代码
@Configuration
@ConditionalOnExpression("'${spring.datasource.url}'.startsWith('jdbc:dm:')")//必须是达梦的驱动才生效
public class mybatis2dmAutoConfiguration {
@Bean
public DmMethodRouterInterceptor dmMethodRouterInterceptor() {
return new DmMethodRouterInterceptor();
}
@Bean
public ReWriteInterceptor reWriteInterceptor() {
return new ReWriteInterceptor();
}
@Bean
public ConfigurationCustomizer mybatisConfigurationCustomizer() {
return configuration -> {
configuration.addInterceptor(new DmMethodRouterInterceptor());
configuration.addInterceptor(new ReWriteInterceptor());
};
}
}
3.2 通过mybatis自带的databaseId 多数据源机制
话不多说,直接上代码。
MyBatisConfig 代码
@Configuration
public class MyBatisConfig {
@Bean
public DatabaseIdProvider databaseIdProvider() {
VendorDatabaseIdProvider databaseIdProvider = new VendorDatabaseIdProvider();
Properties properties = new Properties();
properties.setProperty("MySQL", "mysql");
properties.setProperty("DM DBMS", "dm");
databaseIdProvider.setProperties(properties);
return databaseIdProvider;
}
}
xml代码
<!-- 通用SQL(所有数据库都会尝试使用) -->
<select id="selectUser" resultType="User">
SELECT * FROM user
</select>
<!-- MySQL专用SQL(仅MySQL使用) -->
<select id="selectUser" resultType="User" databaseId="mysql">
SELECT * FROM user LIMIT 1
</select>
<!-- DM专用SQL(仅达梦使用) -->
<select id="selectUser" resultType="User" databaseId="dm">
SELECT TOP 1 * FROM user
</select>
在运行代码过程中,会根据databaseId来选择数据源运行对象的sql。
方案对比
| 方案特性 | 拦截器方式 | 原生 MyBatis DatabaseId |
|---|---|---|
| 实现原理 | 通过自定义 MyBatis 拦截器(及JavaAgent字节码增强)动态解析和重写SQL | 依赖 MyBatis 内置的 <databaseIdProvider> 和 XML 中 databaseId 属性 |
| 支持的映射方式 | ||
- XML 映射文件 (demoMapperXml) | ✅ 支持 | ✅ 支持 |
- 注解方式 (demoMapperAnno) | ✅ 支持 | ❌ 不支持 |
- MyBatis-Plus 等扩展 (demoMapper) | ✅ 支持 | ❌ 不支持 |
- Service/Bean 中的直接SQL (demoBeanService) | ✅ 支持 | ❌ 不支持 |
- 查询包装器 (queryWrap) | ✅ 支持 | ❌ 不支持 |
| 灵活性 | 高:可处理任意位置的SQL,支持复杂逻辑和函数替换 | 低:仅限XML中定义的语句,无法处理运行时生成的SQL |
| 侵入性 | 低:对业务代码基本无侵入,通过配置和字节码增强实现 | 中:需要在所有XML语句中显式定义 databaseId 属性 |
| 维护成本 | 集中维护拦截规则,一改全改 | 分散维护,每个XML语句都需要适配不同数据库的版本 |
实施步骤
- 准备工作: 对SQL及其mapper方法进行分类标注
- 开发拦截器: 实现方法级别拦截逻辑 配置函数替换规则
- JavaAgent集成: 打包拦截器为JavaAgent 配置JVM启动参数加载Agent
- 部署上线: 添加JavaAgent到生产环境启动参数 代码在最后面,具体的工程我后续会上传至:
优势
最小化代码改动:仅需为不兼容的方法添加DM版本 非侵入式:通过字节码增强避免直接修改业务代码 灵活可控:可精确控制哪些方法需要特殊处理 易于维护:DM相关修改集中管理,与主逻辑分离 补充点: 事务都是支持的
最终实现代码
整体工程结构
RewriteAgent
public class RewriteAgent {
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("mysql2dm agent start");
AgentBuilder agentBuilder = new AgentBuilder.Default()
.with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)
.with(AgentBuilder.Listener.StreamWriting.toSystemOut().withTransformationsOnly());
// 拦截 BaseExecutor.query 和 update
agentBuilder = agentBuilder
.type(ElementMatchers.named("org.apache.ibatis.executor.BaseExecutor"))
.transform((builder, typeDescription, classLoader, module) ->
builder
.method(ElementMatchers.named("query").or(ElementMatchers.named("update")))
.intercept(Advice.to(ExecutorAdvice.class))
)
// ⚠️ 注意:下面这个拦截器是追加,而不是覆盖上面
.type(ElementMatchers.named("org.apache.ibatis.executor.statement.RoutingStatementHandler"))
.transform((builder, typeDescription, classLoader, module) ->
builder
.method(ElementMatchers.named("prepare"))
.intercept(Advice.to(SqlRewriteAdvice.class))
);
agentBuilder.installOn(inst);
}
}
BoundSqlHolder
public class BoundSqlHolder {
private static final ThreadLocal<BoundSql> holder = new ThreadLocal<>();
public static void setBoundSql(BoundSql boundSql) {
holder.set(boundSql);
}
public static BoundSql getBoundSql() {
return holder.get();
}
public static void clear() {
holder.remove();
}
}
ExecutorAdvice
public class ExecutorAdvice {
@Advice.OnMethodEnter
public static void enter(@Advice.AllArguments Object[] args) {
try {
MappedStatement originalMs = (MappedStatement) args[0];
String methodId = originalMs.getId();
if (methodId != null && !methodId.endsWith("DM")) {
Configuration config = originalMs.getConfiguration();
String dmMethodId = methodId + "DM";
if (config.hasStatement(dmMethodId)) {
MappedStatement dmMs = config.getMappedStatement(dmMethodId);
System.out.println("[ExecutorAdvice] 替换为 DM 方法: " + dmMethodId);
args[0] = dmMs;
Object parameter = args[1];
BoundSql newBoundSql = dmMs.getBoundSql(parameter);
BoundSqlHolder.setBoundSql(newBoundSql);
System.out.println("[ExecutorAdvice] 替换后的 SQL: " + newBoundSql.getSql());
}
}
} catch (Exception e) {
System.err.println("[ExecutorAdvice] 替换失败: " + e.getMessage());
}
}
}
SqlRewriteAdvice
public class SqlRewriteAdvice {
@Advice.OnMethodEnter
public static void enter(@Advice.This Object handlerObj,
@Advice.Origin String method,
@Advice.AllArguments Object[] args) {
try {
StatementHandler handler = (StatementHandler) handlerObj; // 手动强转(运行时)
Field delegateField = handler.getClass().getDeclaredField("delegate");
delegateField.setAccessible(true);
StatementHandler realHandler = (StatementHandler) delegateField.get(handler);
BoundSql boundSql = handler.getBoundSql();
BoundSql newBoundSql = BoundSqlHolder.getBoundSql();
if (newBoundSql != null) {
String originalSql = newBoundSql.getSql();
System.out.println("Original SQL: " + originalSql);
// 重写 SQL
String rewrittenSql = rewriteSql(originalSql);
Field boundSqlField = realHandler.getClass().getSuperclass().getDeclaredField("boundSql");
boundSqlField.setAccessible(true);
boundSqlField.set(realHandler, newBoundSql);
System.out.println("Rewritten SQL: " + rewrittenSql);
}else {
String originalSql = boundSql.getSql();
System.out.println("Original SQL: " + originalSql);
String rewrittenSql = rewriteSql(originalSql);
Field sqlField = boundSql.getClass().getDeclaredField("sql");
sqlField.setAccessible(true);
sqlField.set(boundSql, rewrittenSql);
System.out.println("Rewritten SQL: " + rewrittenSql);
}
} catch (Exception e) {
System.out.println("SQL Rewrite Error: " + e);
}finally {
// 清理,防止内存泄漏
BoundSqlHolder.clear();
}
}
public static String rewriteSql(String originalSql) {
originalSql = originalSql.replaceAll("(?i)GROUP_CONCAT", "WM_CONCAT");
originalSql = originalSql.replaceAll("(?i)!JSON_CONTAINS", " not JSON_CONTAINS ");
originalSql = originalSql.replaceAll("(?i)INSERT\\s+IGNORE\\s+INTO", "INSERT INTO ");
originalSql = originalSql.replaceAll("(?i)GROUP_CONCAT", "WM_CONCAT");
return originalSql;
}
在启动方法参数上添加:
-javaagent:E:\\cbimWorkSpace\\mybatis2dm-agent\\target\\mybatis2dm-agent-1.0-SNAPSHOT.jar
完结,撒花!