← 返回

数据开放平台架构设计(二):SQL 转 API 引擎

上一篇: 数据开放平台(一):从 SQL2API 到数据开放平台的演进

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 的执行链路可以拆成下面这条主线,所有失败分支都要在审计里留下可追踪记录:

flowchart LR A["路由匹配"] --> B["读取 API 配置"] B --> C{"参数校验"} C -- "不通过" --> C1["400 参数错误"] C -- "通过" --> D["SQL DSL 解析"] D --> E{"SQL 安全校验"} E -- "拒绝" --> E1["403 风险 SQL"] E -- "放行" --> F["数据源路由"] F --> G["JdbcTemplate 执行"] G --> H["数据转换"] H --> I["审计与指标"] I --> J["统一响应"]

每一步都是可插拔的。比如不需要鉴权的公开 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 语法安全不代表执行安全,一个没有分页的查询照样能把连接池打满。


上一篇: 数据开放平台(一):从 SQL2API 到数据开放平台的演进
下一篇: 数据开放平台(三):鉴权与访问控制