antvis/G2

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}"
  }
}

原则:

  1. 仅仅支持简单的函数回调,不用完全覆盖所有的函数逻辑
  2. 安全性

参考说明

可以参考 echarts、highcharts 等如何处理。


认领

posted by BQXBQX 5 months ago

实现方案

目标

将 G2 的 Spec 配置中的回调函数(如 (d) => d.type)转换为可序列化的字符串(如 "{d.type}"),并在服务端渲染(SSR)时安全地还原为函数。


方案设计

1. 字符串语法规范
  • 格式:使用 {} 包裹表达式,例如 "{d.type}"
  • 语义:字符串内的内容为 JavaScript 表达式,作用域中仅包含变量 d(数据项)。
2. 序列化与反序列化
  • 序列化:将回调函数替换为字符串(如 {d.type})。
  • 反序列化:解析字符串,生成函数 (d) => <表达式>,并确保表达式安全性。
3. 安全校验
  • 限制表达式内容
    • 支持简单运算符(如 +-*/、三元运算符)。
    • 禁止函数调用、newthis、全局变量等危险操作。
    • 仅支持特定白名单内部的变量的属性,比如访问变量 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 选项来控制是否启用这个功能,因为不是所有场景都需要序列化

posted by BQXBQX 3 months ago

具体是实现是怎么做?

posted by hustcc 3 months ago

安全检验函数

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};`);
}
posted by BQXBQX 3 months ago

检验函数引入 acorn 会带来不小的包大小吧,而且安全风险感觉不一定能完全规避。

posted by hustcc 3 months ago

如果能确保 ssr 运行 render 函数是在一个完全隔离且安全的沙箱环境,可以不用做检验,检验的目的是为了生成安全不会影响上下文的 function。也或者可以不用 acorn,自己实现一个基础的简单检验方法来排除侵入代码,从而避免包体积增大的问题。🤔

posted by BQXBQX 3 months ago

我感觉要避免使用 new Function,而是自行实现表达式的识别,也可以省去检验。另外,我们不仅仅考虑 ssr,即便非 ssr 也可以使用这套 api 语法。

posted by hustcc 3 months ago

👍 明白了,就是基于 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)}

解释库内部自行解释带有语法糖的模版语法,并且返回模版语法的结果。

posted by BQXBQX 3 months ago

我觉得可以新建一个库来提供这个模版语法解释功能,这样可以方便后期对语法糖扩展以提供更强的功能,而且同时也可以为其他的 graph lib 提供 ssr 方案

posted by BQXBQX 3 months ago

我觉得可以新建一个库来提供这个模版语法解释功能,这样可以方便后期对语法糖扩展以提供更强的功能,而且同时也可以为其他的 graph lib 提供 ssr 方案

是的,是一个单独的库,未来 G6 也会使用。

posted by hustcc 2 months ago

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";
posted by BQXBQX 2 months ago

几个小建议:

  1. 这个库的 api 看起来有些怪,是有什么参考吗?可以好好定位下这个 repo,设计下简洁的 api,然后取个好名字,应该还是有用户的
  2. 增加完备单测,通过单测、readme 文档,展示能力范围
  3. 性能做一些社区的对比,比如对比 new Function,eval,或者其他的模板库
  4. 构建之后包大小
posted by hustcc 2 months ago

几个点:

  1. 这个库的 api 看起来有些怪,是有什么参考吗?可以好好定位下这个 repo,设计下简洁的 api,然后取个好名字,应该还是有用户的
  2. 增加完备单侧
  3. 性能做一些社区的对比,比如对比 new Function,eval,或者其他的模板库
  4. 构建之后包大小

嗯,好的,我再打磨打磨,感谢意见🙏🏻

posted by BQXBQX 2 months ago

@BQXBQX 这个有后续了吗?

posted by hustcc about 1 month ago

@BQXBQX 这个有后续了吗?

现在正在完善一些单侧和基准测试以及文档,争取这周 ready

posted by BQXBQX about 1 month ago

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" />

posted by BQXBQX about 1 month ago

已经有了 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. 函数模版
  }
}

有两个问题:

  1. 对于 2、3、4 如何高性能的区分开?不然大数据下,这类回调是每个数据都需要,可能大量时间都消耗在这里。
  2. 函数模版字符串的参数如何设计?让开发者更容易理解和使用。
posted by hustcc about 1 month ago

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;
}

我的想法是直接复用,参数名就设置为 datumidataoptions 。(对于特殊参数情况,下面在具体实现时我会给出解决方案)。

具体实现(全链路介绍)

实现要求:

  1. 减少核心代码逻辑的修改,集成时优先考虑稳定性和健壮性。

实现链路 Demo

  1. 写一个 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,
         };
     });
  2. 通过 options 传入

    拿到 options 后,进行预编译处理,返回一个可执行函数,用户可以通过前四个参数分别拿到 datumidataoptions ,通过第五个参数拿到上下文的全部参数(解决了特殊情况的问题),可以通过这个处理所有的情况。

     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 },
             });
         };
     }
  3. plot 调用回调函数

    由于返回的是一个执行函数,只是执行解释逻辑走了 expr ,调用和结果输出的形式都没有发生改变,所以 plot 核心代码可以不做任何修改,保证了代码的最小改动,保证健壮性。

posted by BQXBQX about 1 month ago

挺完整的,使用 { expr } 包裹的方式来区分, 我感觉是不错的。

两个疑问:

  1. 前置直接对 options 进行全量处理,其实相当于所有的配置都支持,但是实际上,我们仅仅需要对 style、encode 等支持回调的配置即可。

虽然代码简单了,但不符合最小原则。

性能上,不分极端情况,也不是很好,毕竟需要遍历整个 spec/options。

  1. 参数名就设置为 datum,i,data,options,感觉语义上会崩。

比如 echarts 使用的就是 a、b、c、d,无语义的。

posted by hustcc about 1 month ago

如果只是仅仅对 style、encode 等支持回调,可不可以在前置 options 使用白名单的机制,只对白名单数组里面的 spec keys 进行处理,这样既可以减小 spec/options 的遍历,也方便后期的扩展,减少代码的重复书写和核心代码的反复改动 🤔

posted by BQXBQX about 1 month ago

加白名单,也是要全部遍历 options 吧,然后加一个判断。

修改是指在 valueOf 这里修改吗?

posted by hustcc about 1 month ago

如果不前置的话我觉得可以在 valueOf 这里修改,在 valueOf 加一个 if 判断,但是 valueOf 中可以判断当前是 style、encode 的吗?如果前置的话,可以拿到直接进行处理,直接遍历 options 的白名单有的 keys 就可以,可以不用遍历整个 options。

whileList.map(item => {
  deepCompile(options[item])
}
posted by BQXBQX about 1 month ago

我今天简单实现了一下,流程是对 options 和 options.children 进行遍历 parse expr,整体可以跑通了,包括 getOptions() 值没有发生变化也解决了..., 我觉得还需要加一个字段:isExpr,来避免因为未使用 expr 但进行一些处理逻辑进行带来的额外开销。

白名单里的需要的字段除了["attr", "data", "encode", "transform", "scale", "interaction", "label", "animate", "coordinate", "axis", "legend", "slider", "scrollbar", "state", "tooltip"] 还有什么需要的吗?我今后天把 pr 提上来 🥺

posted by BQXBQX about 1 month ago

isExpr 是什么?在哪里标记?

posted by hustcc about 1 month ago

isExpr 是什么?在哪里标记?

就是是否要进行 epxr 解析,默认是 false,当是 false 时,跳过 expr 解析的所有逻辑,这个参数可以通过 options 传入。我觉得这样可以避免一些额外的没必要的开销。相当于是一个开关,开发者可以自己打开这个开关来使用 expr

可能叫 isSupportExpr 语义性更强一些?🤔

posted by BQXBQX about 1 month ago

我不太想加入一个配置,只要不判断 data 中数据,性能应该能接受,因为只有 data 是可能带来大量数据的。

posted by hustcc about 1 month ago

我不太想加入一个配置,只要不判断 data 中数据,性能应该能接受,因为只有 data 是可能带来大量数据的。

https://github.com/antvis/G2/pull/6709#discussion_r2016167266 好像存量用户是有 enable 开关的需求的,我们需要增加配置吗?

posted by BQXBQX 29 days ago

Fund this Issue

$0.00
Funded

Pull requests