看数是企业里的普遍需求,从 CEO 到普通员工都要靠数据做决策,所以 Superset 这类 BI 工具已经很成熟:数据准备好后,写 SQL 就能配出各种图表和报表。
但要做定制化的数据产品,就得提供 API。这就走出了数据开发的舒适区:除了懂数据,还要懂接口设计、服务治理、SLA 保障这些后端工程能力。在降本增效的大背景下,如果能像配图表一样,用 SQL 拖拉拽地生成接口,把后端工程的细节屏蔽掉,似乎能大幅提升效率。这篇回答一个问题:数据接口能不能也只写 SQL?能做到哪一步,边界又在哪里。
案例:一个电影列表接口
以 B 站的电影索引页为例,看看一个简单的数据接口需要哪些能力:
在浏览器开发者工具里可以看到背后的请求(当然,这个接口肯定不是数据开发同学写的):
# 按地区筛选(area=3)、按播放数降序(order=2)、第 1 页、每页 20 条
GET /pgc/season/index/result?area=3&order=2&sort=0&page=1&pagesize=20返回的结果大致是这样(只保留了一部电影的主要字段):
{
"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"
}拆开来看,一个这样的接口要做三件事:
第一步:跑起来
在 QFlink 仓库根目录启动服务,第一次运行会编译代码,要一两分钟:
tutorials/sql2api.sh # 默认端口 8090,Ctrl-C 结束换一个终端,请求「美国电影按播放量降序的前两部」(area 是 URL 编码后的「美国」):
curl 'http://127.0.0.1:8090/api/movie/list?area=%E7%BE%8E%E5%9B%BD&order=play_cnt%20DESC&limit=2'{
"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:
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:
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 里声明:
{
"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 |
|---|---|---|
|
| 变成 |
|
| 也是绑定的 |
|
| 驱动没法绑定排序字段,只有白名单里的值能原样写进 SQL |
只要开发平台提供参数解析、SQL 生成和结果封装这些基础能力,再给一个写 SQL 的界面,就实现了 SQL2API,比如阿里云 Dataphin 的数据服务。SQL 又能开发接口了,「SQL Is All Your Need」诚不欺我。
第三步:模板怎样变成 SQL
渲染模板的是 SqlTemplate。它逐个找到 {{$参数}},按参数的类型决定写进去的是什么:
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 返回。
现在亲手试一下。地区传一个注入串:
curl "http://127.0.0.1:8090/api/movie/list?area=%27%20OR%20%271%27%3D%271"{
"code" : 0,
"message" : "success",
"data" : [ ]
}' OR '1'='1 被当成一个地区名,当然什么也查不到,而不是查出全表。排序里夹一句 SQL,或者一次要十万行:
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"{
"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 都不用写:
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
}'{
"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,值一律绑定:
private static final Map<String, String> OPERATORS =
Map.of(
"EQUAL", "= ?",
"NOT_EQUAL", "<> ?",
"GREATER", "> ?",
"GREATER_EQUAL", ">= ?",
"LESS", "< ?",
"LESS_EQUAL", "<= ?",
"LIKE", "LIKE ?");total 是用同样的筛选条件另查一次的总数,供前端分页。字段名里藏一句 SQL,同样会被拒绝:
{
"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",重新编译并启动:
REBUILD=1 tutorials/sql2api.sh再请求 order=title%20ASC,三部美国电影就按标题排好了。整个过程没有改一行 Java 代码:新接口、新参数、新的取值,都只是在改定义。仓库里的测试覆盖了上面每一种情况,可以这样跑:
./mvnw -pl qflink-sql/qflink-sql-api -am test -Dtest=Sql2ApiTest -Dsurefire.failIfNoSpecifiedTests=falseDAG 编排:组合出复杂逻辑
把接口拆成若干节点,按依赖关系组成一个有向无环图(DAG):
节点 | 做什么 | 例子 |
|---|---|---|
前置节点 | 先查出后续步骤需要的配置,作为变量传下去 | 先查出这个页面允许展示的电影范围 |
SQL 节点 | 执行 SQL 模板,可以用模板语法实现循环、函数 | 按请求中的维度和指标动态生成 SELECT |
关联节点 | 把不同系统的查询结果在服务层关联 | OLAP 里的电影指标 + OLTP 里的大会员标记样式 |
UDF 节点 | 用代码实现 SQL 不好表达的逻辑 | 按 ID 调用外部接口获取封面图 |
其中 SQL 节点的变量替换只能实现简单逻辑,可以引入模板语法。比如用 Go template 按请求动态生成维度和指标:
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 的「服务」一站。
参考资料
QFlink:qflink-sql-api,本文的全部代码,以及 教程第 08 章。
全文评论
0正在加载评论…