数据开放平台架构设计(二):SQL 转 API 引擎
SQL 转 API 引擎是整个数据开放平台的核心。用户在界面上写一条受控 SQL 模板,配好参数规则,系统自动生成一个可调用的 API。听起来简单,但要做到"运行时解析、校验、执行、审计"这一整套链路,细节其实不少。
下面看看这个引擎到底是怎么实现的。
一、为什么选 JdbcTemplate
结论:这个场景里,执行层放在 JdbcTemplate 上。
为什么不是 JPA?因为 JPA 要求编译期定义 Entity,SQL2API 的 SQL 在运行时才确定,核心不知道返回什么字段。JPA 的 EntityManager 也执行原生 SQL,但返回值绑死在 Entity 上,灵活性为零。
为什么不是 MyBatis?MyBatis 的 XML 是编译时写死的,要支持运行时动态 SQL 得 hack 进 Configuration 对象,线程不安全、内存泄漏、复杂度高。收益为零。
JdbcTemplate 的好处是足够薄:
// 运行时执行任意 SQL,返回 List<Map>
List<Map<String, Object>> result = jdbcTemplate.queryForList(
"SELECT * FROM user WHERE status = ? AND name LIKE ?",
status, "%" + name + "%"
);
// 结果直接是通用 Map,不用定义任何 Entity
// 连接自动归还,异常自动处理,参数自动绑定防注入它不要求提前定义 Entity,也不强行接管 SQL 生成过程。对于"运行时执行 SQL 模板,返回通用结构"这个场景,JdbcTemplate 的抽象刚刚好:连接释放、异常转换、参数绑定这些脏活它来做,SQL 的控制权仍然在平台手里。
二、SQL 模板引擎
用户写的并非纯 SQL,重点是带参数占位符的 SQL 模板。模板语法类似 MyBatis 的动态标签,但更轻量:
SELECT u.id, u.name, u.email, u.status
FROM user u
WHERE 1=1
<if test="status">AND u.status = #{status}</if>
<if test="name">AND u.name LIKE CONCAT('%', #{name}, '%')</if>
<if test="startDate">AND u.create_time >= #{startDate}</if>
<if test="endDate">AND u.create_time <= #{endDate}</if>
ORDER BY u.create_time DESC模板解析的核心逻辑分两步:解析 DSL 标签 和 替换参数占位符。
@Component
public class SqlDslParser {
/**
* 解析 SQL 模板,生成可执行的 SQL 和参数
*/
public ParsedSql parse(SqlApiConfig config, Map<String, Object> params) {
String template = config.getSqlTemplate();
// 第一步:处理 <if>、<choose>、<foreach> 等 DSL 标签
String processed = processDslTags(template, params);
// 第二步:替换 #{param} 占位符为 ?
List<Object> args = new ArrayList<>();
String finalSql = replacePlaceholders(processed, params, args);
return new ParsedSql(finalSql, args.toArray());
}
private String processDslTags(String template, Map<String, Object> params) {
// 处理 <if test="xxx">
Pattern ifPattern = Pattern.compile(
"<if\\s+test=\"(\\w+)\">(.+?)</if>", Pattern.DOTALL);
Matcher matcher = ifPattern.matcher(template);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String condition = matcher.group(1);
String content = matcher.group(2);
// 条件为 true 才保留内容
if (evaluateCondition(condition, params)) {
matcher.appendReplacement(sb, Matcher.quoteReplacement(content));
} else {
matcher.appendReplacement(sb, "");
}
}
matcher.appendTail(sb);
return sb.toString();
}
private String replacePlaceholders(String sql, Map<String, Object> params,
List<Object> args) {
Pattern pattern = Pattern.compile("#\\{(\\w+)}");
Matcher matcher = pattern.matcher(sql);
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String paramName = matcher.group(1);
Object value = params.get(paramName);
args.add(value);
matcher.appendReplacement(sb, "?");
}
matcher.appendTail(sb);
return sb.toString();
}
private boolean evaluateCondition(String paramName, Map<String, Object> params) {
Object value = params.get(paramName);
if (value == null) return false;
if (value instanceof String str) return !str.isEmpty();
if (value instanceof Collection<?> col) return !col.isEmpty();
return true;
}
}解析后的 ParsedSql 包含两部分:末尾可执行的 SQL(? 占位符)和对应的参数数组。直接传给 JdbcTemplate 执行。
支持的 DSL 标签
目前支持三种标签,覆盖了 90% 的动态 SQL 场景:
<if>:条件判断,参数不为空才包含 SQL 片段。更常用,上面的例子已经展示过了。
<choose>:多条件分支,类似 switch-case:
<choose>
<when test="sortBy == 'name'">ORDER BY u.name ASC</when>
<when test="sortBy == 'date'">ORDER BY u.create_time DESC</when>
<otherwise>ORDER BY u.id DESC</otherwise>
</choose><foreach>:遍历集合,用于 IN 查询:
SELECT * FROM user WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>这三个标签加上 #{param} 占位符,就能覆盖绝大多数动态查询需求。
三、动态数据源路由
数据开放平台需要连接多种数据库,不能把数据源写死在配置文件里。动态数据源路由的核心思路是:运行时根据 API 配置选择数据源。
@Component
public class DynamicRoutingDataSource extends AbstractRoutingDataSource {
private final Map<String, DataSource> dataSourceMap = new ConcurrentHashMap<>();
@Override
protected Object determineCurrentLookupKey() {
// 从 ThreadLocal 获取当前请求绑定的数据源名
return DataSourceContextHolder.get();
}
/**
* 注册数据源
*/
public void register(String name, DataSourceProperties props) {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(props.getUrl());
ds.setUsername(props.getUsername());
ds.setPassword(props.getPassword());
ds.setMaximumPoolSize(props.getMaxPoolSize() != null ?
props.getMaxPoolSize() : 10);
ds.setConnectionTimeout(3000);
dataSourceMap.put(name, ds);
}
/**
* 获取指定数据源
*/
public DataSource getDataSource(String name) {
DataSource ds = dataSourceMap.get(name);
if (ds == null) {
throw new BusinessException("数据源不存在: " + name);
}
return ds;
}
}使用时,在 Pipeline 里根据配置切换数据源:
// Pipeline 执行时
DataSourceContextHolder.set(config.getDatasourceName());
try {
JdbcTemplate jdbc = sqlExecutor.getJdbcTemplate(config.getDatasourceName());
List<Map<String, Object>> result = jdbc.queryForList(
parsed.getSql(), parsed.getArgs());
return result;
} finally {
DataSourceContextHolder.clear();
}每个数据源独立维护连接池(HikariCP),互不影响。某个数据源连接池打满不会影响其他数据源。
四、分页查询实现
分页是常见的查询模式。SQL2API 的分页需要兼容多种数据库的方言,不能写死 MySQL 的 LIMIT。
public PageResult<?> executePageQuery(JdbcTemplate jdbc,
ParsedSql parsed,
SqlApiConfig config,
Map<String, Object> params) {
int pageNum = getIntParam(params, "pageNum", 1);
int pageSize = getIntParam(params, "pageSize", 20);
int offset = (pageNum - 1) * pageSize;
// 1. 查总数
String countSql = "SELECT COUNT(*) FROM (" + parsed.getSql() + ") _t";
Long total = jdbc.queryForObject(countSql, Long.class, parsed.getArgs());
// 2. 拼分页 SQL(根据方言)
String pageSql = buildPageSql(parsed.getSql(), config.getDialect(),
offset, pageSize);
// 3. 查分页数据
List<Map<String, Object>> list = jdbc.queryForList(pageSql, parsed.getArgs());
// 4. 组装分页结果
return PageResult.builder()
.total(total != null ? total : 0)
.pageNum(pageNum)
.pageSize(pageSize)
.pages(total != null ? (int) Math.ceil((double) total / pageSize) : 0)
.list(list)
.build();
}
private String buildPageSql(String sql, String dialect, int offset, int limit) {
return switch (dialect.toLowerCase()) {
case "mysql", "postgresql" -> sql + " LIMIT " + limit + " OFFSET " + offset;
case "oracle" ->
"SELECT * FROM (SELECT _t.*, ROWNUM _rn FROM (" + sql +
") _t WHERE ROWNUM <= " + (offset + limit) +
") WHERE _rn > " + offset;
case "dm" -> sql + " LIMIT " + limit + " OFFSET " + offset;
case "sqlserver" ->
sql + " OFFSET " + offset + " ROWS FETCH NEXT " + limit + " ROWS ONLY";
default -> sql + " LIMIT " + limit + " OFFSET " + offset;
};
}分页结果统一包装成 PageResult,包含总数、当前页、每页条数、总页数、数据列表。调用方不用关心底层用的什么数据库方言。
五、请求全链路
把上面的模块串起来,一个动态 API 从请求到响应的完整链路:
SQL2API 的执行链路可以拆成下面这条主线,所有失败分支都要在审计里留下可追踪记录:
每一步都是可插拔的。比如不需要鉴权的公开 API,跳过第①步;不需要数据转换的简单查询,跳过第⑥步。Pipeline 的设计让扩展变得很容易,加一个缓存步骤?插在⑤和⑥之间就行。
@Component
public class ApiPipeline {
@Autowired private AuthHandler authHandler;
@Autowired private ParamValidator paramValidator;
@Autowired private SqlDslParser sqlDslParser;
@Autowired private DynamicRoutingDataSource routingDataSource;
@Autowired private DataTransformEngine transformEngine;
@Autowired private ResultPostProcessor resultPostProcessor;
public R<?> execute(SqlApiConfig config, Map<String, Object> params) {
// ① 鉴权
authHandler.verify(config);
// ② 参数校验
paramValidator.validate(config, params);
// ③ SQL 解析
ParsedSql parsed = sqlDslParser.parse(config, params);
// ④⑤ 数据源路由 + SQL 执行
DataSourceContextHolder.set(config.getDatasourceName());
List<Map<String, Object>> rawData;
try {
JdbcTemplate jdbc = sqlExecutor.getJdbcTemplate(config.getDatasourceName());
rawData = jdbc.queryForList(parsed.getSql(), parsed.getArgs());
} finally {
DataSourceContextHolder.clear();
}
// ⑥ 数据转换
List<Map<String, Object>> transformed = transformEngine
.transform(config.getTransformConfig(), rawData);
// ⑦ 结果包装
return resultPostProcessor.wrap(config, transformed);
}
}整个链路的核心思想是:SQL 在运行时动态解析,数据源在运行时动态选择,结果在运行时动态转换。 编译期不绑定具体业务表,运行期也不是裸跑 SQL,中间每一步都要留下校验、审计和指标。
六、安全性保障
SQL 转 API 引擎更怕的是 SQL 注入和失控查询。防护措施至少分三层:
第一层:参数绑定。 所有用户输入的参数都通过 JdbcTemplate 的 ? 占位符绑定,不拼接字符串。这是基础的防护,JDBC 驱动会自动做转义。
第二层:SQL 白名单校验。 保存 SQL 模板时,解析 SQL AST,检查是否包含危险操作:
@Component
public class SqlSafetyChecker {
private static final Set<String> FORBIDDEN_KEYWORDS = Set.of(
"DROP", "TRUNCATE", "ALTER", "CREATE", "GRANT", "REVOKE",
"EXEC", "EXECUTE", "INTO OUTFILE", "LOAD_FILE"
);
public void check(String sql) {
String upperSql = sql.toUpperCase();
for (String keyword : FORBIDDEN_KEYWORDS) {
if (upperSql.contains(keyword)) {
throw new BusinessException("SQL 包含禁止的关键字: " + keyword);
}
}
// 检查是否只读(SELECT 开头)
if (!upperSql.trim().startsWith("SELECT")) {
throw new BusinessException("仅支持 SELECT 查询");
}
}
}只允许 SELECT 语句,禁止 DDL 和 DML 操作。需要写入的场景(比如数据导入接口)单独走另一个流程,有更严格的审批和权限控制。
第三层:执行时保护。 每个 API 都要有超时时间、上限返回行数、慢查询阈值和数据源级并发限制。SQL 语法安全不代表执行安全,一个没有分页的查询照样能把连接池打满。