我们如何在 Vue3 + Spring Boot 的低代码平台上,用 Aviator + DAG 依赖分析 + 函数市场,构建一套可配置、可调试、可扩展的公式体系。
做过 CRM、ERP、合同管理的同学一定见过这类需求:
这些逻辑在传统开发里,写在 Service 层的 Java 代码里,每个业务对象一套。需求变更时改代码、改测试、改上线——哪怕只是把"10000"改成"8000"。
低代码平台的核心承诺是"配置即业务"。但如果你问一个低代码平台用户:"你的金额自动计算怎么配?",大概率会得到一个尴尬的回答:"这个得写代码。"
这就是我们面对的计算缺口:低代码平台能搭页面、能配 CRUD、能拖表单,但一涉及字段间的计算关系,就回到了写代码的老路。
我们的目标是:让业务人员在字段设计器里点几下,就能配置"总额 = 单价 × 数量",发布后自动生效,不需要写一行 Java。
这篇文章记录了我们在 Forge 低代码平台上,从 V1 字段级公式到 V2 对象图公式的完整技术方案。如果你也在给自己的平台造公式引擎,希望这篇能给你一些参考。
我们定义了三种基础公式类型,覆盖 90% 的业务计算场景:
| 类型 | 用途 | 示例 |
|---|---|---|
CALC |
同对象字段间算术计算 | unit_price * quantity |
AGGREGATE |
从表字段聚合到主表 | SUM(detail.amount) |
CONDITIONAL |
条件判断赋值 | IF(amount > 10000, 0.1, 0) |
每种类型对应一个 FormulaType 枚举:
public enum FormulaType {
CALC, // 算术计算
AGGREGATE, // 聚合计算
CONDITIONAL // 条件计算
}
公式不是只有"怎么算",还有"什么时候算":
| 模式 | 触发时机 | 存储 | 适用场景 |
|---|---|---|---|
VIRTUAL |
查询时实时计算 | 不存库 | 计算频繁变化、不需要持久化 |
STORED |
保存时计算并持久化 | 存库 | 需要排序/筛选/报表的字段 |
public enum FormulaMode {
VIRTUAL, // 虚拟字段,读时算
STORED // 存储字段,写时算
}
设计决策:金额类字段默认用 STORED(需要排序和报表),天数类字段用 VIRTUAL(实时变化)。这个决策后来被写进了项目规范:CON-002: 金额类公式计算结果使用 long 类型(单位:分)。
每个字段的公式配置存储为一个 JSON 对象:
{
"type": "CALC",
"mode": "STORED",
"expression": "unit_price * quantity",
"dependsOn": ["unit_price", "quantity"],
"maxDepth": 3
}
| 字段 | 说明 |
|---|---|
type |
公式类型:CALC / AGGREGATE / CONDITIONAL |
mode |
计算模式:VIRTUAL / STORED |
expression |
表达式字符串(Aviator 语法) |
dependsOn |
依赖的字段编码列表 |
maxDepth |
最大嵌套深度(防止循环引用) |
聚合公式额外带一个 aggregateConfig:
{
"type": "AGGREGATE",
"mode": "STORED",
"expression": "",
"aggregateConfig": {
"function": "SUM",
"detailObjectCode": "order_detail",
"detailField": "amount",
"masterField": "id",
"detailForeignKey": "order_id"
}
}
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| Aviator | 轻量、安全沙箱、性能好 | 函数扩展需注册 | ✅ 选它 |
| MVEL | 功能丰富 | 太重、有安全风险 | ❌ |
| Spring SpEL | Spring 原生 | 与 Spring 强绑定、表达式语法不够直观 | ❌ |
| 自研 | 完全可控 | 工作量大、容易出 bug | ❌ |
安全沙箱:Aviator 默认不允许访问 Java 类的任意方法,只暴露白名单内的函数。这对低代码平台至关重要——你不想让用户通过表达式调用 Runtime.exec("rm -rf /")。
性能:Aviator 编译表达式为字节码,重复执行时不需要重新解析。在我们的压测中,单次表达式执行 < 0.1ms。
轻量:jar 包 500KB 左右,对 Spring Boot 应用的启动时间影响可忽略。
我们封装了一个 AviatorAdapter,统一入参出参:
public class AviatorAdapter {
/**
* 执行表达式
* @param expression Aviator 表达式,如 "unit_price * quantity"
* @param context 变量上下文,如 {"unit_price": 100, "quantity": 3}
* @return 计算结果
*/
public Object execute(String expression, Map<String, Object> context) {
Expression compiled = AviatorEvaluator.compile(expression, true);
return compiled.execute(new AviatorMapContext(context));
}
}
ExpressionExecutor 负责组装上下文、调用 Aviator、处理异常:
public class ExpressionExecutor {
public ExecutionResult execute(FormulaConfig config, Map<String, Object> recordData) {
try {
// 1. 组装上下文:只取 dependsOn 中声明的字段
Map<String, Object> context = buildContext(config.getDependsOn(), recordData);
// 2. 调用 Aviator
Object result = aviatorAdapter.execute(config.getExpression(), context);
// 3. 金额类结果转 long(分)
if (result instanceof Number) {
result = ((Number) result).longValue();
}
return ExecutionResult.success(result, context);
} catch (Exception e) {
return ExecutionResult.error(e.getMessage(), context);
}
}
}
假设有三个字段:
discount = IF(amount > 10000, 0.1, 0) → 依赖 amountamount = price * quantity → 依赖 price, quantityfinal_price = amount * (1 - discount) → 依赖 amount, discount这是正常的依赖链。但如果有人配成:
a = b + 1 → 依赖 bb = c + 1 → 依赖 cc = a + 1 → 依赖 a死循环。在运行时无限递归,直到 StackOverflowError。
我们的 FormulaDependencyAnalyzer 做两件事:
1. 构建依赖图
遍历对象所有字段的公式配置,提取 dependsOn,构建有向图:
public class FormulaDependencyAnalyzer {
public DependencyAnalysisResult analyze(List<GenTableColumn> columns) {
Map<String, Set<String>> graph = new HashMap<>();
for (GenTableColumn col : columns) {
FormulaConfig config = parseFormulaConfig(col.getFormulaConfig());
if (config != null && config.getDependsOn() != null) {
graph.put(col.getJavaField(), new HashSet<>(config.getDependsOn()));
}
}
return detectCycles(graph);
}
}
2. 拓扑排序检测循环
用 Kahn 算法做拓扑排序。如果排序后的节点数 < 总节点数,说明存在环:
private DependencyAnalysisResult detectCycles(Map<String, Set<String>> graph) {
// 计算入度
Map<String, Integer> inDegree = new HashMap<>();
graph.keySet().forEach(node -> inDegree.put(node, 0));
graph.values().forEach(deps ->
deps.forEach(dep -> inDegree.merge(dep, 1, Integer::sum))
);
// BFS 拓扑排序
Queue<String> queue = new LinkedList<>();
inDegree.forEach((node, degree) -> {
if (degree == 0) queue.add(node);
});
List<String> sorted = new ArrayList<>();
while (!queue.isEmpty()) {
String node = queue.poll();
sorted.add(node);
if (graph.containsKey(node)) {
graph.get(node).forEach(dep -> {
inDegree.merge(dep, -1, Integer::sum);
if (inDegree.get(dep) == 0) queue.add(dep);
});
}
}
// 如果排序后节点数 < 总节点数,存在环
if (sorted.size() < graph.size()) {
Set<String> cycleNodes = new HashSet<>(graph.keySet());
cycleNodes.removeAll(sorted);
return DependencyAnalysisResult.cycle(cycleNodes, findCyclePath(graph, cycleNodes));
}
return DependencyAnalysisResult.success(sorted);
}
在 BusinessObjectPublishService 中,发布前必须过 DAG 校验:
DependencyAnalysisResult result = formulaDependencyAnalyzer.analyze(columns);
if (result.hasCycle()) {
throw new BusinessException(
"公式存在循环依赖: " + String.join(" → ", result.getCyclePath())
);
}
设计决策:maxDepth = 3。超过 3 层嵌套的公式,即使没有循环,也会给出警告。因为深层嵌套意味着业务逻辑过于复杂,应该拆解。
V1 上线后,用户反馈最多的问题是:"公式算出来的值不对,但我不知道哪一步算错了。"
这很正常。当一个对象有 20 个字段、其中 8 个有公式、4 个是嵌套公式时,出了问题你看到的只是一个最终值。要排查,只能在脑子里"人肉执行"整个依赖链。
V2 的第一优先级不是加新公式类型,而是让公式可解释、可追踪、可排错。
每次公式执行,可选记录一条日志:
public class AiFormulaExecutionLog {
private String objectCode; // 业务对象编码
private String recordId; // 记录 ID
private String fieldCode; // 字段编码
private String formulaType; // 公式类型
private String mode; // 计算模式
private String expression; // 表达式
private String inputSnapshot; // 输入变量快照 JSON
private String outputValue; // 输出值
private Boolean success; // 是否成功
private String errorMessage; // 错误信息
private Long elapsedMs; // 耗时毫秒
private String traceId; // 链路追踪 ID
}
关键设计:inputSnapshot 记录了公式执行时的所有变量值。出了问题,你可以看到"当时传进去了什么",而不是只能猜。
调试器是执行日志的"交互版"。用户输入一组 sampleValues,调试器逐步执行并展示每一步:
public class FormulaDebugger {
public FormulaDebugResponse debug(FormulaDebugRequest request) {
List<FormulaExecutionStep> steps = new ArrayList<>();
Map<String, Object> context = new HashMap<>(request.getSampleValues());
// 按拓扑序执行公式
for (String fieldCode : request.getExecutionPlan()) {
FormulaConfig config = getFormulaConfig(fieldCode);
long start = System.currentTimeMillis();
Object result = expressionExecutor.execute(config, context).getValue();
long elapsed = System.currentTimeMillis() - start;
steps.add(FormulaExecutionStep.builder()
.fieldCode(fieldCode)
.formulaType(config.getType())
.expression(config.getExpression())
.inputVariables(Map.copyOf(context))
.outputValue(result)
.elapsedMs(elapsed)
.build());
// 将结果加入上下文,供后续公式使用
context.put(fieldCode, result);
}
return FormulaDebugResponse.builder()
.executionPlan(request.getExecutionPlan())
.steps(steps)
.finalContext(context)
.build();
}
}
前端拿到 steps 后,展示为一个逐步执行的表格:
┌─────────────┬─────────┬──────────────────────┬─────────────┬─────────┐
│ 字段 │ 类型 │ 表达式 │ 输入 │ 输出 │
├─────────────┼─────────┼──────────────────────┼─────────────┼─────────┤
│ unit_price │ - │ (用户输入) │ 100 │ 100 │
│ quantity │ - │ (用户输入) │ 3 │ 3 │
│ amount │ CALC │ unit_price * quantity │ price=100,qty=3 │ 300 │
│ discount │ COND │ IF(amount>1000,0.1,0) │ amount=300 │ 0 │
│ final_price │ CALC │ amount*(1-discount) │ amt=300,disc=0 │ 300 │
└─────────────┴─────────┴──────────────────────┴─────────────┴─────────┘
依赖图返回 nodes 和 edges,前端用 ECharts 或 D3 渲染:
{
"nodes": [
{ "id": "unit_price", "type": "FIELD", "label": "单价" },
{ "id": "quantity", "type": "FIELD", "label": "数量" },
{ "id": "amount", "type": "FORMULA", "label": "金额(CALC)" }
],
"edges": [
{ "from": "unit_price", "to": "amount", "type": "DEPENDS_ON" },
{ "from": "quantity", "to": "amount", "type": "DEPENDS_ON" }
]
}
检测到循环时,edges 中会包含一条特殊边,cyclePath 字段列出完整循环路径:
{
"hasCycle": true,
"cyclePath": ["a", "b", "c", "a"]
}
前端根据 cyclePath 高亮循环路径的节点和边,让用户一眼看到问题出在哪。
V1 的公式只能在同一个业务对象内部计算。但真实业务场景往往是跨对象的:
level 字段realName 字段这些场景需要公式能跨越对象边界,读取关联对象的数据。
LOOKUP 是最简单的跨对象公式:根据当前对象的字段值,去关联对象"查一下"拿回来。
配置示例:
{
"type": "LOOKUP",
"mode": "VIRTUAL",
"dependsOn": ["customer_id"],
"lookup": {
"relationCode": "order_customer",
"targetObjectCode": "customer",
"sourceField": "customer_id",
"targetField": "id",
"returnField": "level",
"notFoundValue": null
}
}
执行逻辑:
customer_id 值relationCode 找到对象关系配置customer 表查 WHERE id = #{customer_id},取 level 字段notFoundValue关键约束:LOOKUP 必须基于已配置的对象关系,不能自由拼接表名。这是安全红线——防止用户通过公式注入任意 SQL。
比 LOOKUP 更简洁的写法是路径表达式:
{
"type": "CALC",
"mode": "VIRTUAL",
"expression": "customer.level",
"crossObject": {
"paths": [
{ "field": "customer", "targetObject": "customer", "relation": "order_customer" }
]
}
}
customer.level 会被解析为:先通过 order_customer 关系找到客户对象,再取 level 字段。
一跳限制:首期只支持一跳关系(customer.level),不支持多跳(customer.manager.realName)。多跳意味着每多一跳就多一次数据库查询,性能和复杂度都难以控制。
跨对象公式引入了新的循环风险:对象 A 的公式引用对象 B,对象 B 的公式又引用对象 A。
发布时,FormulaDependencyAnalyzer 会扩展到对象图级别:
对象A.field_x → (LOOKUP) → 对象B.field_y → (LOOKUP) → 对象A.field_z → 循环!
检测到跨对象循环时,发布阻断,返回完整路径。
条件公式 IF(amount > 10000, 0.1, 0) 对开发者来说很直观,但对业务配置人员来说:
IF?> 还是 >=?条件规则设计器让配置人员通过下拉选择构建条件树:
┌─────────────────────────────────────────────────────┐
│ 满足以下 [全部] 条件: │
│ ┌───────────────────────────────────────────────────┐│
│ │ [订单金额] [大于] [10000] ││
│ └───────────────────────────────────────────────────┘│
│ ┌───────────────────────────────────────────────────┐│
│ │ 且 [客户等级] [等于] [VIP] ││
│ └───────────────────────────────────────────────────┘│
│ │
│ 则:[0.1] 否则:[0] │
└─────────────────────────────────────────────────────┘
设计器输出一个 JSON AST:
{
"operator": "AND",
"rules": [
{ "field": "amount", "op": "GT", "value": 10000 },
{ "field": "customer_level", "op": "EQ", "value": "VIP" }
],
"then": 0.1,
"else": 0
}
后端将 AST 编译为 Aviator 表达式:
(amount > 10000 && customer_level == 'VIP') ? 0.1 : 0
双向同步:配置人员可以在"规则模式"和"表达式模式"之间切换。高级用户可以直接写表达式,切换回规则模式时自动解析。
V1 的函数列表是硬编码的:SUM、AVG、MAX、MIN、COUNT。用户问:"能不能加一个MEDIAN(中位数)?"答案是:"等我们改代码。"
V2 引入函数市场,把函数变成可注册、可安装、可治理的"插件"。
每个函数有完整的元数据描述:
public class AiFormulaFunction {
private String functionCode; // 唯一编码,如 "MEDIAN"
private String displayName; // 显示名称,如 "中位数"
private String category; // 分类,如 "统计"
private String description; // 描述
private String argumentSchema; // 参数 JSON Schema
private String returnType; // 返回类型
private String example; // 使用示例
private String status; // ENABLED / DISABLED
private String source; // BUILTIN / SYSTEM / TENANT / MARKET
}
注册 → 安装 → 启用 → 禁用 → 卸载
↓
版本管理(回滚到旧版本)
安全约束:
函数调用会自动接入执行日志和调试器。在调试器的 steps 中,函数调用会显示为一个独立步骤:
│ discount_rate │ FUNCTION │ MEDIAN(history_discounts) │ [0.1,0.2,0.15] │ 0.15 │
┌──────────────────────────────────────────────────────────────┐
│ 配置层 │
│ 字段设计器 │ 条件规则设计器 │ 函数市场 │ 公式预览 │
└──────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────┐
│ 校验层 │
│ 语法校验 │ 依赖分析(DAG) │ 循环检测 │ 深度限制 │ 发布拦截 │
└──────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────┐
│ 执行层 │
│ ExpressionExecutor │ AviatorAdapter │ AggregateEngine │
│ LookupExecutor │ CrossObjectResolver │ FunctionExecutor│
└──────────────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────────────┐
│ 可观测层 │
│ 执行日志 │ 公式调试器 │ 依赖可视化 │ 函数调用追踪 │
└──────────────────────────────────────────────────────────────┘
| 决策 | 理由 |
|---|---|
| 不替换 Aviator | 够用、安全、性能好,换引擎成本高 |
| 不开放任意脚本 | 安全红线,函数只能 Java Bean 注册 |
| 一跳跨对象限制 | 多跳性能不可控,先验证一跳场景 |
| DAG 发布拦截 | 宁可阻断发布,不能运行时死循环 |
| 公式配置用 JSON | 与低代码体系一致,便于序列化/反序列化 |
| 能力 | Salesforce 公式 | 明道云公式 | Forge 公式 |
|---|---|---|---|
| 基础计算 | ✅ | ✅ | ✅ |
| 聚合公式 | ✅(Rollup) | ✅ | ✅ |
| 条件公式 | ✅ | ✅ | ✅ |
| 跨对象引用 | ✅(多跳) | ❌ | ✅(一跳) |
| 公式调试器 | ❌ | ❌ | ✅ |
| 执行日志 | ❌ | ❌ | ✅ |
| 函数市场 | ❌ | ❌ | ✅ |
| 依赖可视化 | ❌ | ❌ | ✅ |
造公式引擎这件事,最难的不是写代码,而是想清楚边界:
每一个决策都会影响后续半年的架构演进。如果你也在做类似的事情,希望这篇文章能帮你少走一些弯路。
代码已开源在 Forge Admin,欢迎 star 和交流。