Hotdry.
compilers

Elo 多目标编译:为 No-Code 工具设计的数据表达式语言

深入解析 Elo 语言如何通过统一类型系统和 AST→IR 转换,实现 JavaScript、Ruby、SQL 三端语义一致的表达式编译。

在 No-Code 工具日益普及的今天,非技术用户需要一种安全、简单且可移植的方式来表达数据操作逻辑。Elo 语言应运而生 —— 这是一个纯函数式数据表达式语言,专门为 No-Code 工具设计,能够将同一表达式编译为 JavaScript、Ruby 和 PostgreSQL SQL,确保前端、后端和数据库层使用完全一致的语义。

设计哲学:安全第一的可移植表达式

Elo 的核心设计理念围绕三个关键词展开:简单、安全、可移植。与传统的通用编程语言不同,Elo 专注于数据表达式的特定领域,这使得它能够在保持强大功能的同时,大幅降低学习门槛。

安全性设计体现在多个层面。首先,Elo 采用值语义而非引用语义,所有数据都是不可变的,这从根本上避免了副作用带来的复杂性。其次,语言内置了 Finitio-like 的模式验证系统,开发者可以定义数据模式并在运行时进行验证:

let Person = { name: String, age: Int(c | c > 0) } in data |> Person

这种模式定义不仅提供了类型安全,还能自动进行数据转换和验证。对于非技术用户来说,这意味着他们可以在不担心数据格式错误的情况下编写表达式。

可移植性是 Elo 的另一大亮点。同一个 Elo 表达式可以无缝编译到三个不同的目标环境:

2 ^ 10 > 1000 and TODAY >= SOY
  • JavaScript: Math.pow(2, 10) > 1000 && DateTime.now().startOf('day') >= DateTime.now().startOf('year')
  • Ruby: 2 ** 10 > 1000 && Date.today >= Date.today.beginning_of_year
  • SQL: POWER(2, 10) > 1000 AND CURRENT_DATE >= DATE_TRUNC('year', CURRENT_DATE)

这种跨平台一致性对于 No-Code 工具至关重要,因为这些工具往往需要在不同的技术栈中部署相同的业务逻辑。

多目标编译架构:AST→IR→目标代码

Elo 的编译器采用经典的三阶段架构,但针对多目标编译进行了特殊优化。整个编译流程可以概括为:解析 → 类型推断 → IR 生成 → 目标代码生成。

1. 解析与 AST 构建

Elo 使用手写的递归下降解析器,将源代码转换为抽象语法树(AST)。AST 节点设计简洁,主要包含以下几种类型:

  • 字面量节点:数字、字符串、布尔值、时间值等
  • 变量节点:引用外部数据或局部变量
  • 二元操作节点:算术、比较、逻辑运算
  • 函数调用节点:标准库函数或用户定义的 lambda
  • 管道操作节点:Elixir 风格的 |> 操作符

时间类型的处理是 Elo 的一大特色。语言原生支持 ISO8601 格式的时间字面量:

D2024-01-15           # 日期
D2024-01-15T10:30:00Z # 日期时间
P1D                   # 1天持续时间
PT1H30M               # 1小时30分钟持续时间

2. 类型系统与 IR 转换

Elo 的类型系统基于 Hindley-Milner 类型推断的简化版本。编译器在 AST 到中间表示(IR)的转换过程中进行类型推断,确保表达式的类型一致性。

IR 层是 Elo 实现多目标编译的关键。它提供了一个与具体目标语言无关的中间表示,包含了所有必要的语义信息但去除了语法细节。IR 的设计原则包括:

  • 平台无关性:不包含任何特定于 JavaScript、Ruby 或 SQL 的构造
  • 语义完整性:保留所有必要的类型信息和操作语义
  • 优化友好:支持常量折叠、死代码消除等优化

类型选择器(Type Selectors)是 Elo 类型系统的重要组成部分。它们允许开发者在运行时进行类型转换和验证:

Int("42")      # 将字符串转换为整数,失败时抛出错误
Float(3.14)    # 显式指定浮点数类型
Date("2024-01-01") # 解析日期字符串

3. 目标代码生成

针对每个目标语言,Elo 提供了专门的代码生成器。这些生成器将 IR 转换为目标语言的代码,同时处理语言间的语义差异:

JavaScript 生成器

  • 使用 Math.pow() 处理幂运算
  • 依赖 Luxon 库处理时间类型
  • 生成 ES6 模块格式的代码

Ruby 生成器

  • 使用 ** 操作符处理幂运算
  • 依赖 ActiveSupport 处理时间类型
  • 生成 Ruby 方法或 lambda 表达式

SQL 生成器

  • 使用 POWER() 函数处理幂运算
  • 使用 PostgreSQL 的原生时间类型
  • 生成可嵌入 SQL 查询的表达式

每个生成器都包含一个前导代码(prelude)系统,用于注入必要的运行时依赖。开发者可以选择是否包含前导代码,这在某些集成场景中非常有用。

时间处理:原生支持与跨平台一致性

时间处理是数据表达式中最复杂也最容易出错的部分之一。Elo 通过原生支持时间类型,彻底解决了这个问题。

时间字面量与操作

Elo 的时间字面量语法直观且符合 ISO8601 标准:

let
  signup = D2024-06-15
in
  TODAY > signup + P30D  # 检查今天是否在注册日期30天后

时间操作符的设计考虑了常见业务场景:

  • + / -:时间与持续时间的加减
  • ...:时间范围(包含两端)
  • in:检查时间点是否在范围内

跨平台时间库抽象

为了确保不同平台间的时间处理一致性,Elo 定义了一个抽象的时间库接口。每个目标语言的实现都需要提供对应的适配器:

  • JavaScript:使用 Luxon 库,通过 DateTimeDuration
  • Ruby:使用 ActiveSupport,通过 DateDateTimeActiveSupport::Duration
  • SQL:使用 PostgreSQL 的原生时间类型和函数

这种抽象层确保了即使底层实现不同,Elo 表达式的时间语义在所有平台上都是一致的。

工程实践:测试策略与工具链

Elo 项目采用严格的测试驱动开发(TDD)方法,确保编译器的正确性和跨平台一致性。

测试金字塔结构

  1. 单元测试:覆盖解析器、AST 构建、类型推断等基础组件
  2. 集成测试:验证整个编译流程,检查生成的代码语法正确性
  3. 验收测试:在实际的运行时环境中执行生成的代码,验证语义一致性

测试套件包含了数百个测试用例,每个测试用例都会在三个目标平台上运行,确保 “一次编写,到处运行” 的承诺得以实现。

CLI 工具设计

Elo 提供了两个命令行工具,分别面向不同的使用场景:

编译器(eloc)

# 编译表达式到 JavaScript(默认)
./bin/eloc -e "2 + 3 * 4"

# 编译到 Ruby
./bin/eloc -e "2 + 3 * 4" -t ruby

# 编译到 SQL
./bin/eloc -e "2 + 3 * 4" -t sql

# 包含前导代码
./bin/eloc -e "NOW + PT2H" -t ruby -p

求值器(elo)

# 直接求值表达式
./bin/elo -e "2 + 3 * 4"
# 输出: 14

# 使用输入数据
./bin/elo -e "_.x + _.y" -d '{"x": 1, "y": 2}'
# 输出: 3

程序化 API

对于需要在应用程序中集成 Elo 的开发者,项目提供了 TypeScript/JavaScript API:

import { compile } from '@enspirit/elo';
import { DateTime, Duration } from 'luxon';

// 编译表达式为可调用函数
const addTen = compile<(x: number) => number>(
  '_ + 10',
  { runtime: { DateTime, Duration } }
);

addTen(5); // => 15

API 设计注重类型安全和运行时依赖注入,避免了全局变量污染,保持了代码的纯净性。

实际应用场景与最佳实践

1. No-Code 工具中的业务规则

在 Klaro Cards 等 No-Code 工具中,Elo 用于定义计算字段、过滤条件和汇总函数:

# 计算含税价格
_.price * 1.21

# 过滤高价值订单
filter(_, fn(o ~> o.total > 1000))

# 周汇总
_.amount when _.date in SOW ... EOW

2. 数据验证与转换

Elo 的模式验证功能非常适合数据清洗和验证场景:

let Product = {
  id: String,
  price: Float(p | p > 0),
  category: String(c | c in ['electronics', 'clothing', 'food'])
} in input |> Product

3. 跨平台查询构建

对于需要在应用程序和数据库层使用相同逻辑的场景,Elo 提供了完美的解决方案:

# 业务逻辑:获取最近30天的活跃用户
let
  cutoff = TODAY - P30D
in
  filter(_, fn(u ~> u.last_login >= cutoff))

这个表达式可以同时用于:

  • 前端:过滤 JavaScript 数组
  • 后端:过滤 Ruby 集合
  • 数据库:生成 SQL WHERE 子句

4. 测试断言语言

在 Webspicy 测试框架中,Elo 可以用作更强大的断言语言:

# 替代原来的简单断言
assert: _.status == 200 and _.body.items.size() > 0

技术挑战与解决方案

挑战 1:语义差异处理

不同编程语言在细节上存在诸多差异,例如:

  • JavaScript 使用 &&||!,SQL 使用 ANDORNOT
  • 幂运算:JavaScript 用 Math.pow(),Ruby 用 **,SQL 用 POWER()
  • 空值处理:JavaScript 的 null vs SQL 的 NULL

解决方案:在 IR 层使用统一的逻辑操作节点,在代码生成阶段根据目标语言进行转换。

挑战 2:时间处理一致性

不同平台的时间库 API 差异巨大,且时区处理复杂。

解决方案:定义抽象的时间操作接口,为每个平台提供适配器实现,统一时区处理逻辑。

挑战 3:性能优化

编译到 SQL 时需要生成高效的查询表达式。

解决方案:在 IR 层进行表达式优化,如常量折叠、公共子表达式消除,针对 SQL 生成器进行特殊优化。

未来发展方向

根据项目路线图,Elo 语言有几个重要的发展方向:

  1. 关系代数支持:集成 Bmg 关系代数库,支持更复杂的数据转换和查询操作
  2. 扩展标准库:增加更多数据处理函数,特别是针对数组和对象的操作
  3. 性能优化:改进编译器和生成代码的性能
  4. 更多目标平台:考虑支持 Python、Java 等其他流行语言

总结

Elo 语言代表了领域特定语言(DSL)设计的一个成功案例。它通过专注于数据表达式这一特定领域,在简单性、安全性和可移植性之间找到了良好的平衡点。多目标编译架构不仅解决了 No-Code 工具的实际需求,也为其他需要跨平台一致性的场景提供了参考方案。

对于开发者而言,Elo 的价值在于它提供了一种声明式的、类型安全的数据操作方式。对于最终用户而言,Elo 降低了编程门槛,让他们能够用直观的方式表达业务逻辑。随着 No-Code 运动的深入发展,像 Elo 这样的工具语言将在构建更智能、更易用的软件系统中发挥越来越重要的作用。

资料来源

通过统一类型系统和多阶段编译架构,Elo 证明了即使是看似简单的数据表达式语言,也能通过精心设计实现强大的跨平台能力。这为未来的 DSL 设计提供了宝贵经验:专注于解决特定问题,通过抽象和分层处理复杂性,最终实现简单而强大的用户体验。

查看归档