先说结论
把 SQL 方法和数据源配置存到数据库里,通过 REST API 动态执行。改了立刻生效,不用重启。
一张图看懂架构:
scss
┌──────────────────────────────────────────┐
│ REST API (/dynamic-sql) │
│ execute | method/update | ds/create ... │
└──────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ DynamicMethodEngine │
│ (调度中心) │
└─────────┬─────────────────┬─────────────┘
│ │
┌────────────▼──────┐ ┌───────▼──────────────┐
│ DynamicMethodCache│ │DynamicDataSourceManager│
│ (Caffeine 缓存) │ │ (Caffeine + HikariCP) │
└────────┬──────────┘ └────────┬──────────────┘
│ │
┌───────────▼──────────┐ ┌────────▼───────────┐
│ MyBatisXmlCompiler │ │ JdbcTemplate │
│ XML → SqlSource │ │ (每库独立连接池) │
└───────────┬──────────┘ └────────┬───────────┘
│ │
└───────────┬───────────┘
▼
┌───────────────┐
│ SqlExecutor │
│ 安全校验 + 执行 │
└───────────────┘
核心思路:MyBatis 的 SqlSource 只做 SQL 解析和参数绑定,JdbcTemplate 负责执行,两者结合实现了"动态 SQL + 多数据源"。
一、建两张元数据表
SQL 方法和数据源配置都存在 MySQL 里,应用启动时不需要提前知道有哪些库、哪些方法。
sql
-- 数据源配置表
CREATE TABLE dynamic_datasource_config (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT '数据源名称',
db_type VARCHAR(30) NOT NULL COMMENT '数据库类型',
jdbc_url VARCHAR(500) NOT NULL COMMENT 'JDBC连接地址',
username VARCHAR(100) NOT NULL,
password VARCHAR(200) NOT NULL,
driver_class_name VARCHAR(200) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- SQL 方法定义表
CREATE TABLE dynamic_sql_method (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
method_id VARCHAR(128) NOT NULL COMMENT '方法唯一标识',
name VARCHAR(100) COMMENT '方法名称',
command_type VARCHAR(20) NOT NULL COMMENT 'SELECT/INSERT/UPDATE/DELETE',
xml_fragment TEXT NOT NULL COMMENT 'MyBatis XML片段',
enabled TINYINT NOT NULL DEFAULT 1,
version BIGINT NOT NULL DEFAULT 1,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_method_id (method_id)
);
往里插一条数据源和一条方法,就能用了。
二、最核心的一行:执行入口
整个引擎的调度就这几行,核心逻辑非常清晰:
java
@Service
public class DynamicMethodEngine {
private final DynamicMethodCache methodCache;
private final DynamicDataSourceManager dataSourceManager;
private final SqlExecutor sqlExecutor;
public Object execute(Long dataSourceId, String methodId, Map<String, Object> params) {
// 1. 用 methodId 拿编译好的 SqlSource(带 Caffeine 缓存)
CompiledDynamicMethod method = methodCache.get(methodId);
// 2. 传入参数,让 MyBatis 动态生成最终 SQL(处理 if/where/foreach 等)
BoundSql boundSql = method.getSqlSource().getBoundSql(params);
// 3. 用 dataSourceId 拿 JdbcTemplate(带 Caffeine 缓存的独立连接池)
JdbcTemplate jdbcTemplate = dataSourceManager.getJdbcTemplate(dataSourceId);
// 4. 安全校验 + 执行
return sqlExecutor.execute(jdbcTemplate, method.getCommandType(), boundSql, params);
}
}
四个步骤,每个步骤拆成一个独立模块。下面逐个讲。
三、XML 编译:把 MyBatis XML 片段变成 SqlSource
这是引擎最关键的部分,也是踩坑最多的地方。
3.1 完整代码
java
@Component
public class MyBatisXmlCompiler {
private final Configuration configuration;
private final XMLLanguageDriver languageDriver = new XMLLanguageDriver();
public MyBatisXmlCompiler(SqlSessionFactory sqlSessionFactory) {
this.configuration = sqlSessionFactory.getConfiguration();
}
public CompiledDynamicMethod compile(DynamicSqlMethod method) {
// 校验 + 提取根标签内部内容
String scriptContent = validateAndExtract(method.getXmlFragment(), method.getCommandType());
// 关键:必须用 <script> 包裹,否则动态标签不生效
String script = "<script>" + scriptContent + "</script>";
SqlSource sqlSource = languageDriver.createSqlSource(configuration, script, Map.class);
return new CompiledDynamicMethod(
method.getMethodId(),
method.getCommandType().toUpperCase(Locale.ROOT),
method.getVersion(),
sqlSource
);
}
private String validateAndExtract(String xmlFragment, String commandType) {
// 用 Jsoup 做格式校验(只校验,不序列化)
Document document = Jsoup.parse(xmlFragment, "", Parser.xmlParser());
Element root = document.children().first();
if (root == null) {
throw new IllegalArgumentException("XML 不能为空");
}
String tagName = root.tagName().toLowerCase(Locale.ROOT);
if (!Set.of("select", "insert", "update", "delete").contains(tagName)) {
throw new IllegalArgumentException("只支持 select/insert/update/delete");
}
if (!tagName.equalsIgnoreCase(commandType)) {
throw new IllegalArgumentException("XML 标签类型和 commandType 不一致");
}
if (xmlFragment.contains("${")) {
throw new IllegalArgumentException("禁止使用 ${},请使用 #{}");
}
// 从原始字符串截取根标签内部内容(不用 Jsoup 序列化,避免破坏动态标签属性)
int tagEnd = xmlFragment.indexOf('>', xmlFragment.indexOf("<" + tagName));
int closeStart = xmlFragment.lastIndexOf("</" + tagName + ">");
return xmlFragment.substring(tagEnd + 1, closeStart);
}
}
3.2 为什么要用 <script> 包裹?
这是整个引擎最大的坑。XMLLanguageDriver.createSqlSource() 有两种模式:
不加 <script> ------静态模式,只替换 #{} 占位符,所有 XML 标签原样输出:
xml
输入: "SELECT * FROM user WHERE id = #{id} <if test='name!=null'>AND name=#{name}</if>"
输出: "SELECT * FROM user WHERE id = ? <if test='name!=null'>AND name=?</if>"
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
<if> 标签直接出现在 SQL 里!
加了 <script> ------动态模式,解析 <if>、<where>、<set>、<foreach> 等所有标签:
ini
输入: "<script>SELECT * FROM user WHERE id = #{id} <if test='name!=null'>AND name=#{name}</if></script>"
name=null 时输出: "SELECT * FROM user WHERE id = ?"
name="张三"时输出: "SELECT * FROM user WHERE id = ? AND name = ?"
所以编译时要:提取根标签(<select>)内部的内容 → 包裹 <script> → 传给 MyBatis。
3.3 为什么不用 Jsoup 的 html() 做序列化?
第二个坑。<if test="name != null"> 经过 Jsoup 的 html() 输出,test 属性可能被破坏或丢失,因为 Jsoup 本质是 HTML 解析器,对非标准属性处理不可靠。
解法:Jsoup 只做校验(标签名、安全性),内容提取直接操作原始字符串,零篡改。
四、方法缓存:Caffeine + 编译结果
java
@Component
public class DynamicMethodCache {
private final DynamicMethodRepository repository;
private final MyBatisXmlCompiler compiler;
private final Cache<String, CompiledDynamicMethod> cache = Caffeine.newBuilder()
.maximumSize(5000) // 最多缓存 5000 个方法
.expireAfterAccess(Duration.ofHours(1)) // 1 小时未访问自动过期
.build();
public CompiledDynamicMethod get(String methodId) {
// 缓存命中直接返回,未命中则从数据库加载 + 编译
return cache.get(methodId, this::loadAndCompile);
}
public void invalidate(String methodId) {
// 方法更新时手动失效,下次访问重新编译
cache.invalidate(methodId);
}
private CompiledDynamicMethod loadAndCompile(String methodId) {
DynamicSqlMethod method = repository.findEnabledByMethodId(methodId);
return compiler.compile(method);
}
}
为什么不用 ConcurrentHashMap?因为 Caffeine 有自动过期和淘汰,方法改了不用手动清理,过期后自动重新加载。
五、动态数据源:每个库一个 HikariCP 连接池
java
@Component
public class DynamicDataSourceManager {
private final DynamicDataSourceRepository repository;
private final Cache<Long, JdbcTemplate> cache = Caffeine.newBuilder()
.maximumSize(200) // 最多 200 个数据源
.expireAfterAccess(Duration.ofMinutes(30)) // 30 分钟没访问自动关闭
.removalListener((Long id, JdbcTemplate template, RemovalCause cause) -> {
// 连接池过期时,主动关闭 HikariDataSource,不泄漏
if (template != null && template.getDataSource() instanceof HikariDataSource ds) {
ds.close();
}
})
.build();
public JdbcTemplate getJdbcTemplate(Long dataSourceId) {
// 缓存命中返回已有 JdbcTemplate,未命中则从数据库读配置创建新连接池
return cache.get(dataSourceId, this::createJdbcTemplate);
}
public void invalidate(Long dataSourceId) {
// 密码改了?调这个接口,旧连接池立刻关闭
cache.invalidate(dataSourceId);
}
private JdbcTemplate createJdbcTemplate(Long dataSourceId) {
DbConfig config = repository.findEnabledById(dataSourceId);
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(config.getJdbcUrl());
ds.setUsername(config.getUsername());
ds.setPassword(config.getPassword());
ds.setDriverClassName(config.getDriverClassName());
ds.setPoolName("dynamic-ds-" + dataSourceId);
ds.setMaximumPoolSize(3); // 动态数据源不需要大池子
ds.setMinimumIdle(0);
ds.setIdleTimeout(60_000);
ds.setMaxLifetime(10 * 60_000);
ds.setConnectionTimeout(5_000);
return new JdbcTemplate(ds);
}
}
关键设计:
- 按需创建:第一次查询某个库时才建连接池,不用不建
- 自动回收:30 分钟没访问,连接池自动关闭,释放数据库连接
- removalListener :过期时主动调
HikariDataSource.close(),避免连接泄漏
六、SQL 执行器:参数绑定 + 安全校验
java
@Slf4j
@Component
public class SqlExecutor {
private final Configuration configuration;
private final SqlSecurityValidator validator;
public SqlExecutor(SqlSessionFactory sqlSessionFactory, SqlSecurityValidator validator) {
this.configuration = sqlSessionFactory.getConfiguration();
this.validator = validator;
}
public Object execute(JdbcTemplate jdbcTemplate, String commandType,
BoundSql boundSql, Map<String, Object> params) {
String sql = boundSql.getSql(); // MyBatis 动态生成的最终 SQL
Object[] args = buildArgs(boundSql, params); // 按 ParameterMapping 顺序取参数值
validator.validate(sql, commandType); // 安全校验
log.info("执行sql={}, args={}", sql, args);
return switch (commandType) {
case "SELECT" -> jdbcTemplate.queryForList(sql, args); // → List<Map>
case "INSERT", "UPDATE", "DELETE" -> jdbcTemplate.update(sql, args); // → 影响行数
default -> throw new IllegalArgumentException("不支持的 SQL 类型:" + commandType);
};
}
private Object[] buildArgs(BoundSql boundSql, Map<String, Object> params) {
List<ParameterMapping> mappings = boundSql.getParameterMappings();
MetaObject metaObject = configuration.newMetaObject(params);
return mappings.stream()
.map(mapping -> getParameterValue(boundSql, metaObject, mapping))
.toArray();
}
private Object getParameterValue(BoundSql boundSql, MetaObject metaObject,
ParameterMapping mapping) {
String property = mapping.getProperty();
// foreach 产生的内部变量走 additionalParameter
if (boundSql.hasAdditionalParameter(property)) {
return boundSql.getAdditionalParameter(property);
}
return metaObject.getValue(property);
}
}
参数绑定的关键:MyBatis 的 BoundSql 已经根据 <if>、<foreach> 等标签动态生成了最终 SQL 和参数映射列表。SqlExecutor 只需要按映射列表的顺序从 params Map 中取值,填到 ? 占位符里。
七、安全校验
java
@Component
public class SqlSecurityValidator {
public void validate(String sql, String commandType) {
String trimSql = sql.trim();
String lower = " " + trimSql.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ") + " ";
// 1. 禁止危险操作
if (lower.contains(" drop ") || lower.contains(" truncate ")
|| lower.contains(" alter ") || lower.contains(" create ")
|| lower.contains(" grant ") || lower.contains(" revoke ")) {
throw new IllegalArgumentException("SQL 包含危险操作");
}
// 2. SQL 首个关键字必须与声明的 commandType 一致
String first = trimSql.split("\\s+")[0].toUpperCase(Locale.ROOT);
if (!first.equals(commandType)) {
throw new IllegalArgumentException("SQL 类型不匹配,期望:" + commandType + ",实际:" + first);
}
// 3. SELECT 禁止 for update
if ("SELECT".equals(commandType) && lower.contains(" for update ")) {
throw new IllegalArgumentException("SELECT 禁止使用 for update");
}
}
}
再加上编译期就禁止的 ${},三层防护:编译期防注入 → 运行期防危险操作 → 运行期校验类型一致性。
八、REST 接口
java
@RestController
@RequestMapping("/dynamic-sql")
public class DynamicSqlController {
private final DynamicMethodEngine engine;
private final DynamicMethodRepository methodRepository;
private final DynamicDataSourceRepository dataSourceRepository;
// ── 执行 SQL ──
@PostMapping("/execute")
public Object execute(@RequestBody ExecuteRequest request) {
return engine.execute(request.getDataSourceId(), request.getMethodId(), request.getParams());
}
// ── 方法管理 ──
@GetMapping("/method/list")
public List<DynamicSqlMethod> listMethods() { return methodRepository.findAll(); }
@PostMapping("/method/update")
public String updateMethod(@RequestBody UpdateMethodRequest request) {
methodRepository.saveOrUpdate(request.getMethodId(), request.getCommandType(), request.getXmlFragment());
engine.invalidateMethod(request.getMethodId()); // 更新后自动失效缓存
return "ok";
}
@PostMapping("/method/{id}/toggle")
public String toggleMethod(@PathVariable Long id) { methodRepository.toggleEnabled(id); return "ok"; }
// ── 数据源管理 ──
@GetMapping("/datasource/list")
public List<DbConfig> listDataSources() { return dataSourceRepository.findAll(); }
@PostMapping("/datasource/create")
public String createDataSource(@RequestBody CreateDataSourceRequest request) {
DbConfig config = new DbConfig();
config.setName(request.getName());
config.setDbType(request.getDbType());
config.setJdbcUrl(request.getJdbcUrl());
config.setUsername(request.getUsername());
config.setPassword(request.getPassword());
config.setDriverClassName(request.getDriverClassName());
dataSourceRepository.save(config);
return "ok";
}
@PostMapping("/datasource/{id}/refresh")
public String refreshDataSource(@PathVariable Long id) {
engine.invalidateDataSource(id); // 关闭旧连接池,下次访问重建
return "ok";
}
@PostMapping("/datasource/{id}/toggle")
public String toggleDataSource(@PathVariable Long id) { dataSourceRepository.toggleEnabled(id); return "ok"; }
}
九、curl 调用示例
新增一个数据源
bash
curl -X POST http://localhost:8080/dynamic-sql/datasource/create \
-H 'Content-Type: application/json' \
-d '{
"name": "用户中心库",
"dbType": "MySQL",
"jdbcUrl": "jdbc:mysql://10.0.0.5:3306/user_center?useUnicode=true&characterEncoding=utf8",
"username": "readonly",
"password": "readonly123",
"driverClassName": "com.mysql.cj.jdbc.Driver"
}'
新增一个带动态条件的查询方法
bash
curl -X POST http://localhost:8080/dynamic-sql/method/update \
-H 'Content-Type: application/json' \
-d '{
"methodId": "searchUser",
"commandType": "SELECT",
"xmlFragment": "<select id=\"searchUser\">SELECT id, name, phone, status FROM t_user<where><if test=\"name != null\">AND name LIKE CONCAT(\"%\", #{name}, \"%\")</if><if test=\"status != null\">AND status = #{status}</if></where>ORDER BY id DESC LIMIT 100</select>"
}'
执行查询
按名字搜:
bash
curl -X POST http://localhost:8080/dynamic-sql/execute \
-H 'Content-Type: application/json' \
-d '{"dataSourceId": 1, "methodId": "searchUser", "params": {"name": "张"}}'
不传条件查全部:
bash
curl -X POST http://localhost:8080/dynamic-sql/execute \
-H 'Content-Type: application/json' \
-d '{"dataSourceId": 1, "methodId": "searchUser", "params": {}}'
十一、为什么不直接用 MyBatis 的 SqlSession?
每个动态数据源是独立的 HikariCP 连接池,不在 SqlSessionFactory 管理范围内。
这个引擎的解法是拆开用:
- SqlSource:只做 SQL 解析和参数绑定(纯计算,不连数据库)
- JdbcTemplate:负责真正的 SQL 执行(每库一个独立连接池)
这样既享受了 MyBatis 动态 SQL 的全部能力,又不被 SqlSession 的单数据源限制绑住。
十二、依赖
xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.12</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
Java 21 + Spring Boot 3.3.5,拿去就能跑。