G2 Spec 的序列化,便于 SSR 的时候纯 JSON #6542
hustcc posted onGitHub
AntV Open Source Contribution Plan(可选)
- 我同意将这个 Issue 参与 OSCP 计划
Issue 类型
中级任务
任务介绍
我们在做 G2 SSR 的时候,会只用一个 json 配置描述图表,然后这个 json 会由用户输入并存储到数据库中。
但是 G2 Spec 有一些配置时使用回调函数实现,没有办法序列,所以需要有一个方案:用一个规范的字符串去描述简单函数。
{
encode: {
x: (d) => d.type
}
}
改成使用(仅做示意,不干扰方案设计):
{
"encode": {
"x": "{d.type}"
}
}
原则:
- 仅仅支持简单的函数回调,不用完全覆盖所有的函数逻辑
- 安全性
参考说明
可以参考 echarts、highcharts 等如何处理。
认领
实现方案
目标
将 G2 的 Spec 配置中的回调函数(如 (d) => d.type
)转换为可序列化的字符串(如 "{d.type}"
),并在服务端渲染(SSR)时安全地还原为函数。
方案设计
1. 字符串语法规范
- 格式:使用
{}
包裹表达式,例如"{d.type}"
。 - 语义:字符串内的内容为 JavaScript 表达式,作用域中仅包含变量
d
(数据项)。
2. 序列化与反序列化
- 序列化:将回调函数替换为字符串(如
{d.type}
)。 - 反序列化:解析字符串,生成函数
(d) => <表达式>
,并确保表达式安全性。
3. 安全校验
- 限制表达式内容:
- 支持简单运算符(如
+
、-
、*
、/
、三元运算符)。 - 禁止函数调用、
new
、this
、全局变量等危险操作。 - 仅支持特定白名单内部的变量的属性,比如访问变量 d 的属性(如 d.type)。
- 支持简单运算符(如
- 实现方式:通过 AST 静态分析 校验表达式合法性(使用
acorn
解析并遍历 AST)。同时,检查是否存在可能导致原型链污染、递归与无限循环的结构。
安全性与限制
- 白名单校验:通过 AST 分析,仅允许安全表达式。
- 作用域隔离:生成的函数仅能访问特定参数,比如
d
,无法调用外部方法或访问全局变量。 - 错误处理:非法表达式直接抛出错误,避免执行危险代码。
测试用例
// 合法表达式
assertValid("{d.age}");
assertValid("{d.value + 10}");
assertValid("{d.status ? 'OK' : 'FAIL'}");
// 非法表达式
assertInvalid("{alert(1)}"); // 函数调用
assertInvalid("{window.location}"); // 全局变量
assertInvalid("{new Date()}"); // new 操作
assertInvalid("{d.__proto__.evilProp = 'injected'}"); // 原型链污染
assertInvalid("{d.a? d.a : d.a}"); // 可能的无限循环
options 拦截解析位置
<img width="642" alt="Image" src="https://github.com/user-attachments/assets/d26f8072-71a8-48c2-b157-1c904e3a8533" />
添加配置 serializable
选项来控制是否启用这个功能,因为不是所有场景都需要序列化
具体是实现是怎么做?
安全检验函数
function isSafeExpression(expr) {
try {
const ast = acorn.parseExpressionAt(expr, 0, { ecmaVersion: 'latest' });
let isSafe = true;
acorn.walk.simple(ast, {
CallExpression() { isSafe = false; }, // 禁止函数调用
NewExpression() { isSafe = false; }, // 禁止 new
ThisExpression() { isSafe = false; }, // 禁止 this
Identifier(node) {
if (node.name !== 'd') isSafe = false; // 仅允许变量 d
},
MemberExpression(node) {
// 确保对象链仅以比如说 d 开头(如 d.a.b)
let obj = node.object;
while (obj.type === 'MemberExpression') obj = obj.object;
if (obj.type !== 'Identifier' || obj.name !== 'd') {
isSafe = false;
}
},
});
return isSafe;
} catch {
return false; // 解析失败视为非法
}
}
递归处理 Spec 配置
function parseSpec(spec) {
const processed = Array.isArray(spec) ? [] : {};
for (const key in spec) {
const value = spec[key];
if (typeof value === 'string' && /^{.*}$/.test(value)) {
const expr = value.slice(1, -1).trim();
processed[key] = createSafeFunction(expr);
} else if (typeof value === 'object' && value !== null) {
processed[key] = parseSpec(value); // 递归处理嵌套对象
} else {
processed[key] = value;
}
}
return processed;
}
反序列化函数生成
function createSafeFunction(expr) {
if (!isSafeExpression(expr)) {
throw new Error(`Unsafe expression: ${expr}`);
}
// 返回一个类似于下面的生成后的函数
return new Function('d', `return ${expr};`);
}
检验函数引入 acorn 会带来不小的包大小吧,而且安全风险感觉不一定能完全规避。
如果能确保 ssr
运行 render
函数是在一个完全隔离且安全的沙箱环境,可以不用做检验,检验的目的是为了生成安全不会影响上下文的 function
。也或者可以不用 acorn
,自己实现一个基础的简单检验方法来排除侵入代码,从而避免包体积增大的问题。🤔
我感觉要避免使用 new Function,而是自行实现表达式的识别,也可以省去检验。另外,我们不仅仅考虑 ssr,即便非 ssr 也可以使用这套 api 语法。
👍 明白了,就是基于 js 独立的实现一个模版语法的简单语法解释器,这个解释器包含一些方法语法塘,包括以下几个方面
- 基础类型:支持字符串("value")、数字、布尔值、null。
- 属性访问:d.prop、d["prop"]。
- 运算:+、-、*、/、%、&&、||、!、===、!==。
- 三元表达式:condition ? a : b。
- 预定义函数:
@funcName(args)
格式调用(如@sum(d.values)
,@max(d.values)
)。(可以作为扩展后期提供)
{
encode: {
x: "{d.type}",
y: "{d.value * @sum(d.values)}",
color: "{d.status ? 'green' : 'red'}"
}
}
// 下面是一个简单的使用 demo
import { parseExpression } from '@antv/expression-engine';
function processSpec(userSpec, data) {
const processed = {};
for (const key in userSpec) {
const value = userSpec[key];
if (typeof value === 'string' && value.startsWith('{')) {
processed[key] = parseExpression(value.slice(1, -1), data);
} else {
processed[key] = value;
}
}
return processed;
}
// 使用示例
const parsedSpec = processSpec(userSpec, {
type: 'category',
value: 10,
values: [1, 2, 3],
status: true
});
console.log(parsedSpec.encode.y); // 输出: 60 {10 * (1+2+3)}
解释库内部自行解释带有语法糖的模版语法,并且返回模版语法的结果。
我觉得可以新建一个库来提供这个模版语法解释功能,这样可以方便后期对语法糖扩展以提供更强的功能,而且同时也可以为其他的 graph lib 提供 ssr 方案
我觉得可以新建一个库来提供这个模版语法解释功能,这样可以方便后期对语法糖扩展以提供更强的功能,而且同时也可以为其他的 graph lib 提供 ssr 方案
是的,是一个单独的库,未来 G6 也会使用。
https://github.com/BQXBQX/graph-secure-eval
基础功能已在此仓库中实现,可以基于此再次封装后使用
// simple encapsulation
import { Tokenizer, Parser, Interpreter } from "@antv/graph-secure-eval";
const tokenizer = new Tokenizer();
const parser = new Parser();
function evaluate(input: string, context = {}, functions = {}) {
const tokens = tokenizer.tokenize(input);
const ast = parser.parse(tokens);
const interpreter = new Interpreter(context, functions);
return interpreter.evaluate(ast);
}
// simple demo
const context = {
data: {
values: [1, 2, 3],
status: "active",
},
};
const functions = {
sum: (arr: number[]) => arr.reduce((a, b) => a + b, 0),
};
const input = '@sum(data.values) > 5 ? data["status"] : "inactive"';
console.log(evaluate(input, context, functions)); // "active";
几个小建议:
- 这个库的 api 看起来有些怪,是有什么参考吗?可以好好定位下这个 repo,设计下简洁的 api,然后取个好名字,应该还是有用户的
- 增加完备单测,通过单测、readme 文档,展示能力范围
- 性能做一些社区的对比,比如对比 new Function,eval,或者其他的模板库
- 构建之后包大小
几个点:
- 这个库的 api 看起来有些怪,是有什么参考吗?可以好好定位下这个 repo,设计下简洁的 api,然后取个好名字,应该还是有用户的
- 增加完备单侧
- 性能做一些社区的对比,比如对比 new Function,eval,或者其他的模板库
- 构建之后包大小
嗯,好的,我再打磨打磨,感谢意见🙏🏻
@BQXBQX 这个有后续了吗?
@BQXBQX 这个有后续了吗?
现在正在完善一些单侧和基准测试以及文档,争取这周 ready
https://github.com/BQXBQX/expr
expr 已经基本 ready,这周尝试集成到 G2 中。
<img width="777" alt="Image" src="https://github.com/user-attachments/assets/19cd914e-4473-4cb1-b985-83715218df54" />
已经有了 expr 能力,在 G2 如何集成?
原始用法:
const options = {
type: 'interval',
style: {
fill: (d, idx, data) => d.value > 100 ? 'red' : 'green', // 1. 回调函数
fill: 'red', // 2. 颜色值常量,也可以传入颜色值 '#00f'
fill: 'value', // 3. 按照字段来映射,部分情况支持
fill: 'args[0].value > 100 ? "red" : "green"', // 4. 函数模版
}
}
有两个问题:
- 对于 2、3、4 如何高性能的区分开?不然大数据下,这类回调是每个数据都需要,可能大量时间都消耗在这里。
- 函数模版字符串的参数如何设计?让开发者更容易理解和使用。
G2 集成 expr
方案
项目背景
G2 面对 SSR(服务端渲染) 时,只支持简单的 json
的形式,而传统的 json
又无法提供动态函数的执行的能力,现在可以借助 @antv/expr
(下面简称 expr
),解析字符串形式的模版语法来实现 json
的动态执行的能力(模版语法的动态能力边界可参考 expr
文档: https://github.com/antvis/expr#readme ),现在需要集成 expr
到 G2 中的方案,下面是我的技术方案:
语法设计
{
"options": {
"type": "interval",
"style": {
"fill": "red"
"fill": "{args[0].value > 100 ? 'red' : 'green'}"
}
}
}
使用 {}
开启 expr
在文档中提醒开发者如果想使用 expr 的能力,需要使用 {}
将模版表达式包起来,告诉 G2 字符串不是简单的字符串,而是需要被 expr
动态解析的模版语法。(需要考虑 {}
是否与现有 G2 语法是否冲突,我觉得应该没有冲突 🤔)。
props
参数名设计
背景:因为 expr
对安全的要求较高,无法实现 js
的参数重命名机制,所以我们需要设计一套参数名使在模版语法中调用,要求对于开发者更加的易懂易用。
我简单调研了一下源码,简单的统计了一下简略的多种回调函数的参数传入,主流的回调函数都是通过 valueOf
函数处理的。
function valueOf(
value: Primitive | ((d: any, i: number, array: any, channel: any) => any),
datum: Record<string, any>,
i: number,
data: Record<string, any>,
options: { channel: Record<string, any>; element?: G2Element },
) {
if (typeof value === 'function') return value(datum, i, data, options);
if (typeof value !== 'string') return value;
if (isStrictObject(datum) && datum[value] !== undefined) return datum[value];
return value;
}
我的想法是直接复用,参数名就设置为 datum
,i
,data
, options
。(对于特殊参数情况,下面在具体实现时我会给出解决方案)。
具体实现(全链路介绍)
实现要求:
- 减少核心代码逻辑的修改,集成时优先考虑稳定性和健壮性。
实现链路 Demo
写一个 spec:
{ "type": "interval", "autoFit": true, "data": [ { "type": "分类一", "value": 27 } ], "encode": { "y": "value", "color": "type" }, "transform": { "type": "stackY" }, "label": { "position": "outside", "text": "{'$' + datum.value}" }, "tooltip": "@resolveTooltip(datum)", }
register("resolveTooltip", (datum) => { return { name: datum.type, value: datum.value, }; });
通过
options
传入拿到 options 后,进行预编译处理,返回一个可执行函数,用户可以通过前四个参数分别拿到
datum
,i
,data
,options
,通过第五个参数拿到上下文的全部参数(解决了特殊情况的问题),可以通过这个处理所有的情况。function f(expression: string) { const evaluator = compile(expression); return (...args) => { return evaluator({ datum: args[0], i: args[1], data: args[2], options: args[3], global: { ...args }, }); }; }
plot 调用回调函数
由于返回的是一个执行函数,只是执行解释逻辑走了
expr
,调用和结果输出的形式都没有发生改变,所以plot
核心代码可以不做任何修改,保证了代码的最小改动,保证健壮性。
挺完整的,使用 { expr }
包裹的方式来区分, 我感觉是不错的。
两个疑问:
- 前置直接对 options 进行全量处理,其实相当于所有的配置都支持,但是实际上,我们仅仅需要对 style、encode 等支持回调的配置即可。
虽然代码简单了,但不符合最小原则。
性能上,不分极端情况,也不是很好,毕竟需要遍历整个 spec/options。
- 参数名就设置为 datum,i,data,options,感觉语义上会崩。
比如 echarts 使用的就是 a、b、c、d,无语义的。
如果只是仅仅对 style、encode 等支持回调,可不可以在前置 options 使用白名单的机制,只对白名单数组里面的 spec keys 进行处理,这样既可以减小 spec/options 的遍历,也方便后期的扩展,减少代码的重复书写和核心代码的反复改动 🤔
加白名单,也是要全部遍历 options 吧,然后加一个判断。
修改是指在 valueOf 这里修改吗?
如果不前置的话我觉得可以在 valueOf 这里修改,在 valueOf 加一个 if 判断,但是 valueOf 中可以判断当前是 style、encode 的吗?如果前置的话,可以拿到直接进行处理,直接遍历 options 的白名单有的 keys 就可以,可以不用遍历整个 options。
whileList.map(item => {
deepCompile(options[item])
}
我今天简单实现了一下,流程是对 options 和 options.children 进行遍历 parse expr,整体可以跑通了,包括 getOptions() 值没有发生变化也解决了..., 我觉得还需要加一个字段:isExpr,来避免因为未使用 expr 但进行一些处理逻辑进行带来的额外开销。
白名单里的需要的字段除了["attr", "data", "encode", "transform", "scale", "interaction", "label", "animate", "coordinate", "axis", "legend", "slider", "scrollbar", "state", "tooltip"] 还有什么需要的吗?我今后天把 pr 提上来 🥺
isExpr 是什么?在哪里标记?
isExpr 是什么?在哪里标记?
就是是否要进行 epxr 解析,默认是 false,当是 false 时,跳过 expr 解析的所有逻辑,这个参数可以通过 options 传入。我觉得这样可以避免一些额外的没必要的开销。相当于是一个开关,开发者可以自己打开这个开关来使用 expr
可能叫 isSupportExpr 语义性更强一些?🤔
我不太想加入一个配置,只要不判断 data 中数据,性能应该能接受,因为只有 data 是可能带来大量数据的。
我不太想加入一个配置,只要不判断 data 中数据,性能应该能接受,因为只有 data 是可能带来大量数据的。
https://github.com/antvis/G2/pull/6709#discussion_r2016167266 好像存量用户是有 enable 开关的需求的,我们需要增加配置吗?