看数是企业里的普遍需求,从 CEO 到普通员工都要靠数据做决策,所以 Superset 这类 BI 工具已经很成熟:数据准备好后,写 SQL 就能配出各种图表和报表。

但要做定制化的数据产品,就得提供 API。这就走出了数据开发的舒适区:除了懂数据,还要懂接口设计、服务治理、SLA 保障这些后端工程能力。在降本增效的大背景下,如果能像配图表一样,用 SQL 拖拉拽地生成接口,把后端工程的细节屏蔽掉,似乎能大幅提升效率。这篇回答一个问题:数据接口能不能也只写 SQL?能做到哪一步,边界又在哪里。

案例:一个电影列表接口

以 B 站的电影索引页为例,看看一个简单的数据接口需要哪些能力:

页面上的排序、筛选、翻页各自变成一个请求参数,一个数据接口要支持的就是这三件事

在浏览器开发者工具里可以看到背后的请求(当然,这个接口肯定不是数据开发同学写的):

http
# 按地区筛选(area=3)、按播放数降序(order=2)、第 1 页、每页 20 条
GET /pgc/season/index/result?area=3&order=2&sort=0&page=1&pagesize=20

返回的结果大致是这样(只保留了一部电影的主要字段):

json
{
    "code": 0,
    "data": {
        "has_next": 1,
        "list": [
            {
                "title": "坏蛋联盟",
                "cover": "http://i0.hdslb.com/bfs/bangumi/image/7d63615d0749d8fe2ce161d155ac1c8198869591.png",
                "badge": "大会员",
                "index_show": "2022年3月17日上映",
                "order": "1.1亿次播放",
                "score": "9.6"
            }
        ],
        "num": 1,
        "size": 20,
        "total": 1979
    },
    "message": "success"
}

拆开来看,一个这样的接口要做三件事:

一个数据接口做三件事:参数转 SQL、查询、结果转 JSON

第一步:跑起来

在 QFlink 仓库根目录启动服务,第一次运行会编译代码,要一两分钟:

shell
tutorials/sql2api.sh          # 默认端口 8090,Ctrl-C 结束

换一个终端,请求「美国电影按播放量降序的前两部」(area 是 URL 编码后的「美国」):

shell
curl 'http://127.0.0.1:8090/api/movie/list?area=%E7%BE%8E%E5%9B%BD&order=play_cnt%20DESC&limit=2'
json
{
  "code" : 0,
  "message" : "success",
  "data" : [ {
    "title" : "坏蛋联盟",
    "cover" : "https://example.com/cover/1.png",
    "area" : "美国",
    "score" : 9.6,
    "play_cnt" : 110000000
  }, {
    "title" : "星际穿越",
    "cover" : "https://example.com/cover/2.png",
    "area" : "美国",
    "score" : 9.4,
    "play_cnt" : 86000000
  } ]
}

这个接口没有一行专门为它写的 Java 代码,它只由三个文件定义。下面一个个看。

第二步:一张表、一段模板、一份参数声明

电影表和几行示例数据在 movie.sql:

sql
CREATE TABLE movie (
    title     VARCHAR COMMENT '电影名称',
    cover     VARCHAR COMMENT '电影封面',
    area      VARCHAR COMMENT '电影地区',
    style     VARCHAR COMMENT '电影风格',
    score     DOUBLE  COMMENT '电影评分',
    play_cnt  BIGINT  COMMENT '电影播放次数'
);

「美国电影按播放量降序的第一页」写成 SQL 是这样。把会变化的部分换成 {{$参数}},就得到了一个 SQL 模板,也就是 movie_list.sql:

sql
SELECT title, cover, area, AVG(score) AS score, SUM(play_cnt) AS play_cnt
FROM movie
WHERE area = {{$area}}
GROUP BY title, cover, area
ORDER BY {{$order}}
LIMIT {{$limit}} OFFSET {{$offset}}

光有模板还不够:每个参数能取什么值、缺省时用什么,要在 movie_list.json 里声明:

json
{
    "path": "/api/movie/list",
    "sql": "movie_list.sql",
    "params": {
        "area": {"kind": "VALUE"},
        "order": {
            "kind": "IDENTIFIER",
            "default": "play_cnt DESC",
            "allowed": ["play_cnt DESC", "play_cnt ASC", "score DESC", "score ASC"]
        },
        "limit": {"kind": "NUMBER", "default": "20", "max": 100},
        "offset": {"kind": "NUMBER", "default": "0", "max": 10000}
    }
}

参数分三类,这是整个例子的关键,下一步会看到为什么:

类型

例子

怎么进 SQL

VALUE

area

变成 ?,由数据库驱动绑定,用户传什么都只是一个值

NUMBER

limit、offset

也是绑定的 ?,先检查是整数、不超过上限

IDENTIFIER

order

驱动没法绑定排序字段,只有白名单里的值能原样写进 SQL

只要开发平台提供参数解析、SQL 生成和结果封装这些基础能力,再给一个写 SQL 的界面,就实现了 SQL2API,比如阿里云 Dataphin 的数据服务。SQL 又能开发接口了,「SQL Is All Your Need」诚不欺我。

开发者只写 SQL 模板和参数声明;解析参数、生成 SQL、绑定执行、封装 JSON 都由平台完成

第三步:模板怎样变成 SQL

渲染模板的是 SqlTemplate。它逐个找到 {{$参数}},按参数的类型决定写进去的是什么:

java
switch (param.kind) {
    case VALUE:
        args.add(raw);
        replacement = "?";
        break;
    case NUMBER:
        args.add(number(name, raw, param.max));
        replacement = "?";
        break;
    case IDENTIFIER:
        if (!param.allowed.contains(raw)) {
            throw new IllegalArgumentException(
                    name + " must be one of " + param.allowed + ", got " + raw);
        }
        replacement = raw;
        break;
}

上面那个请求渲染出来,SQL 是 WHERE area = ? … ORDER BY play_cnt DESC LIMIT ? OFFSET ?,三个 ? 依次绑定「美国」、2、0,再交给 JDBC 的预编译语句执行。结果的每一行转成一个 JSON 对象,套上 code 和 message 返回。

现在亲手试一下。地区传一个注入串:

shell
curl "http://127.0.0.1:8090/api/movie/list?area=%27%20OR%20%271%27%3D%271"
json
{
  "code" : 0,
  "message" : "success",
  "data" : [ ]
}

' OR '1'='1 被当成一个地区名,当然什么也查不到,而不是查出全表。排序里夹一句 SQL,或者一次要十万行:

shell
curl "http://127.0.0.1:8090/api/movie/list?area=%E7%BE%8E%E5%9B%BD&order=title%3BDROP%20TABLE%20movie"
curl "http://127.0.0.1:8090/api/movie/list?area=%E7%BE%8E%E5%9B%BD&limit=100000"
json
{
  "code" : 400,
  "message" : "order must be one of [play_cnt DESC, play_cnt ASC, score DESC, score ASC], got title;DROP TABLE movie"
}
{
  "code" : 400,
  "message" : "limit must be between 0 and 100"
}

第四步:统一的请求协议

模板接口每个都要写一段 SQL。再进一步,筛选、排序、分页都作为请求里的数据传进来,接口只声明能用哪张表、哪些字段(movie_query.json),连 SQL 都不用写:

shell
curl -X POST http://127.0.0.1:8090/api/movie/query -d '{
  "filters": {"condition_operator": "AND",
              "filters": [{"field": "area", "filter_operator": "EQUAL", "value": "美国"}]},
  "order_by": [{"order_field": "play_cnt", "order_type": "DESC"}],
  "limit": 1, "offset": 0
}'
json
{
  "code" : 0,
  "message" : "success",
  "data" : [ {
    "title" : "坏蛋联盟",
    "cover" : "https://example.com/cover/1.png",
    "area" : "美国",
    "style" : "喜剧",
    "score" : 9.6,
    "play_cnt" : 110000000
  } ],
  "total" : 3
}

生成 SQL 的是 QueryProtocol。规矩和模板一样:字段要在声明过的列表里,筛选操作符只能从一张表里挑,每个操作符对应一段带 ? 的 SQL,值一律绑定:

java
private static final Map<String, String> OPERATORS =
        Map.of(
                "EQUAL", "= ?",
                "NOT_EQUAL", "<> ?",
                "GREATER", "> ?",
                "GREATER_EQUAL", ">= ?",
                "LESS", "< ?",
                "LESS_EQUAL", "<= ?",
                "LIKE", "LIKE ?");

total 是用同样的筛选条件另查一次的总数,供前端分页。字段名里藏一句 SQL,同样会被拒绝:

json
{
  "code" : 400,
  "message" : "field must be one of [title, cover, area, style, score, play_cnt], got 1=1 OR title"
}

再进一步,把 group_by、额外字段等也做成请求参数,接口就可以完全由配置生成,连 SQL 都不用写了。但更复杂的业务逻辑,就需要编排了。

第五步:动手加一个排序方式

现在的接口只能按播放量或评分排序。在 movie_list.json 的 allowed 里加上 "title ASC",重新编译并启动:

shell
REBUILD=1 tutorials/sql2api.sh

再请求 order=title%20ASC,三部美国电影就按标题排好了。整个过程没有改一行 Java 代码:新接口、新参数、新的取值,都只是在改定义。仓库里的测试覆盖了上面每一种情况,可以这样跑:

shell
./mvnw -pl qflink-sql/qflink-sql-api -am test -Dtest=Sql2ApiTest -Dsurefire.failIfNoSpecifiedTests=false

DAG 编排:组合出复杂逻辑

复杂接口拆成节点编排:前置查询给出变量,两路 SQL 分别查 OLAP 和 OLTP,在服务层关联,再由 UDF 补上 SQL 做不了的事

把接口拆成若干节点,按依赖关系组成一个有向无环图(DAG):

节点

做什么

例子

前置节点

先查出后续步骤需要的配置,作为变量传下去

先查出这个页面允许展示的电影范围

SQL 节点

执行 SQL 模板,可以用模板语法实现循环、函数

按请求中的维度和指标动态生成 SELECT

关联节点

把不同系统的查询结果在服务层关联

OLAP 里的电影指标 + OLTP 里的大会员标记样式

UDF 节点

用代码实现 SQL 不好表达的逻辑

按 ID 调用外部接口获取封面图

其中 SQL 节点的变量替换只能实现简单逻辑,可以引入模板语法。比如用 Go template 按请求动态生成维度和指标:

sql
SELECT
    1 AS placeholder
    {{range $i, $dim := .dimensions}}
        ,{{$dim}}
    {{end}}
    {{range $i, $metric := .metrics}}
        ,{{$metric}}
    {{end}}
FROM movie
WHERE area = {{.area}}
GROUP BY
    1
    {{range $i, $dim := .dimensions}}
        ,{{$dim}}
    {{end}}
ORDER BY {{.order}}
LIMIT {{.limit}} OFFSET {{.offset}};

同样地,这里的维度、指标和排序字段都必须经过白名单校验。

关联节点的意义在于:分析数据一般存在 OLAP 系统里,配置信息可能在 OLTP 系统里(比如封面上「大会员」角标的颜色)。在服务层关联,就省掉了两个系统之间的数据同步。

至于 UDF 节点,似乎和「SQL Is All Your Need」的口号冲突了。其实写成 SQL UDF 也不是不行。

提供了各种节点之后,把它们组合编排起来,就能实现相当复杂的业务逻辑。连数据开发都能把后端的活干了,全干工程师的未来已来?

这个例子没有实现 DAG 编排,原因就在下一节。

边界:数据服务 vs 业务服务

我的结论是:简单的数据服务确实可以用 SQL2API 实现,开发效率也确实能提升;但如果把重心放在用 DAG 编排业务逻辑上,就得不偿失了。


数据服务

业务服务

核心

以数据查询为核心

业务功能和状态变化

逻辑

轻:筛选、排序、聚合、分页

重:流程、规则、事务

关注点

数据质量、口径、查询性能

高并发、一致性、可用性

适合

数据开发 + SQL2API

后端开发 + 常规服务框架

数据和后端的职责需要划分清晰:数据开发负责以数据查询为核心、轻业务逻辑的数据服务接口,后端开发负责包含复杂业务逻辑、状态变更和事务的业务服务接口。这个度要把握好,两种都想做,最后可能两种都做不好。这也是我在数据产品项目里的亲身体会,见 我的职业生涯。

总结

回到开头的问题:数据接口能不能也只写 SQL?能,但有前提。

  • 参数按类型进 SQL:值一律绑定,排序这类不能绑定的部分只认白名单,分页有上限。做不到这一条,SQL2API 就是一个 SQL 注入的入口。

  • 定义和执行分开:开发者只写 SQL 模板和参数声明,解析、生成、执行、封装由平台统一做,安全规则也只需要在平台里写一遍。

  • 只做数据服务:以查询为核心、逻辑轻的接口交给 SQL2API;有流程、规则、事务和状态变更的接口交给后端。

判断一个接口该不该用 SQL2API,可以问一句:它的全部逻辑,能不能写成「筛选、排序、聚合、分页」?能,就用模板或统一协议;不能,就别靠 DAG 硬编排。想动手改一改,可以从 QFlink 教程第 08 章 开始;数据接口在整条数据链路里的位置,见 Life of a Data 的「服务」一站。

参考资料


全文评论

0

正在加载评论…