Lingo: 用于构建 DSL 的 Go 微语言框架

DSL 语言

DSL 即领域专用语言(domain specific language / DSL),其基本思想是求专不求全,是专门针对某一特定问题的计算机语言。与通用语言相比,在一些特定领域,其执行效率更高,更易于使用。

下面来举一个例子: 求解某 CSV 文件中不同银行账户余额总和。第一列为账户所有人姓名,第二列为账户金额。

name, balance
Lisa, 100.30
Bert, 241.41
Maria, 151.13

可以使用通用语言(例如 Ruby)来求解金额总和,具体见下面代码。

#!/usr/bin/env ruby

exit(1) if ARGV.empty? || !File.exist?(ARGV[0])

sum = 0
File.foreach(ARGV[0]).each_with_index do |line, idx|
next if idx == 0
sum += Float(line.split(',')[1])
end
puts sum.round(2)

下面使用 DSL 语言 AWK 来解决上面问题

#!/usr/bin/awk -f

BEGIN{FS=","}{sum+=$2}END{print sum}

可以发现,AWK 程序代码非常短,相较 Ruby 缩短了四倍。此外,由于 DSL 更专注于解决特定领域的问题,AWK 更详尽的处理了 CSV 中的异常情况。通过上述案例,我们可以明显感觉到 DSL 领域在某些特定领域的强大之处。

设计 DSL 语言的挑战

原型设计、DSL 设计以及 DSL 的演化都具备一定的难度。根据过往经验,你需要不断地将想法设计成原型,将其整合到 DSL 语言中,然后做具体的测试,根据测试反馈不断改进 DSL

DSL 的设计,有两个组件非常重要: 语言词法分析器/解析器和语言处理器。词法分析器/解析器使根据语言制定的语义来接受输入的组件。解析/词法分析阶段会生成一个语法树,然后将其传递给语言处理器。语言处理器来对语法树进行评估。上文提到的案例中,运行 RubyAWK 解释器,脚本和 CSV 文件作为输入,两个解释程序对其进行评估,求解出账户余额综合。

解释器生成器等工具通过生成代码的方式来减少词法分析器/解析器开发的工作量,复杂的 DSL 框架还提供在 IDE 中自定义语言支持的功能。

设计 DSL 语言的另一方式是借助标准数据交换格式(例如.toml.yaml或  .json作为一种配置方式)。与解析器生成器类似,这种方法也可以减少解析器的工作量,但如果涉及到实际语言处理器的实现时,这种方法无法实现。此外大多数标准数据交换格式本质上仅限于一些简单的格式(如列表、字典、字符串和数字)来表示数据。这种限制下,配置文件会特别大。例如下面案例: 开发一个使用乘法 *、加法 + 对整数进行运算的计算器。

如果使用类似 YAML 配置来描述语言,即使是简单的数学术语,配置文件也会非常复杂。例如 1 + 2 * 3 + 5 语言。

term:
add: - 1 - times: - 2 - 3 - 5

本篇文章的重点在于微语言的设计,核心思想是提供一个简单的、可扩展的语言核心,无需解析器或者语言处理器,便可以便捷的使用自定义类型和自定义函数进行扩展。DSL 设计者通过接口实现的方式将它们挂接在语言核心中,以实现 DSL 中的核心概念。

Lingo:Go 的微型语言框架

Gitlab 中,我们经常使用 Go 开发一些自用的、小型的、可嵌入的 DSL,来简化用户的配置和交互。

最初的方案是尝试集成现有的、可嵌入的和可扩展的语言,唯一的条件是能嵌入原生 Go 应用程序中。例如 go-lua —— 利用 Go 是实现的 Lua VMgo-yeagi —— 提供一个 Go 解释器;go-zygomys —— 使用 Go 编写的 LISP 解释器。但这些包都是基于通用语言的集成,在其之上构建 DSL 会是模块变得复杂,而我们希望设计、实现、嵌入和发展 DSL 成为一个灵活、小型、简易、适应性好的 Go 应用程序。

可以将上述要求凝练为下面几条:

  • 稳定性: DSL 的更改既不需要实现核心词法分析器/解析器,也不需要对语言处理器实现进行任何更改。
  • 灵活性/可组合性:可以通过简单的插件机制集成新的 DSL 概念(数据类型、函数)。
  • 简单性:语言框架应具备强大的可扩展性,以保证 DSL 的迭代和发展。微语言框架应该基于纯 Go 语言实现,这样可以保证其简单的与 Go 应用程序集成。

FOSS 工具都无法满足上述需求,因此基于 Go 我们开发了新的微语言框架 Lingo,是一门 Go 中基于 LISPDSL 语言。

Lingo 为构建基于符号表达式(S-表达式)的 DSL 提供了基础,即嵌套列表形式表达式 (f...),f 是函数符号的占位符。使用这种格式,可以将上面提到的数学术语写成 S-expression (+ 1 (* 2 3) 5)

S 表达式统一了表达式格式,用途广泛且易于处理,同时还可以使用一致的格式来表示代码和数据。

Lingo 提供了一种简单的插件机制来添加新功能和类型,无需更改核心解析器或语言处理器。从 S 表达式解析器方面来分析,实际的函数符号与 S 表达式本质上是无关的,语言处理器只是评估 S 表达式,然后将其分配给接口进行实现。具体的实现由插件提供。

Lingo 代码库大约由 3kGo 代码组成,包括词法解析器/分析器、代码转换引擎和解释器/评估器。

使用 Lingo 设计数据生成引擎

下面的案例中,利用 Go 基于 Lingo 设计一个数据生成引擎。该数据生成引擎可用于模糊测试或其他应用上下文的结构化输入数据。此案例将详细讲解如何利用 Lingo 创建语言及其对应的语言处理器。

案例文件依旧使用最初的 CSV 文件:


name, balance
Lisa, 100.30
Bert, 241.41
Maria, 151.13

我们的语言包含下列功能:

  • (oneof s0, s1, ..., sN): 随机返回任一字符串参数 sX (0 <= X <= N)
  • (join e0, e1, ..., eN): 连接所有的参数表达式,值为 eX
  • (genfloat min max): 生成最值之间的任一数值
  • (times num exp): 重复 num 次 exp 数据模式

该案例中,利用 Lingo 构建语言和语言处理器来自动生成 CSV 输出,然后将对应输出传入 RubyAWK 程序中,进行相应压力测试。同时我们将该语言命名为随机文本生成器 (RTG).rtg

下面是一个示例脚本 script.rtg,测试是否可以随机生成 CSV 文件,程序中首先利用连接功能生成 CSV 的标题名称,然后随机生成 10 行名称和金额,同时还随机生成了部分空行。


(join
(join "name" "," "balance" "\n")
(times 10
'(join
(oneof
"Jim"
"Max"
"Simone"
"Carl"
"Paul"
"Karl"
"Ines"
"Jane"
"Geralt"
"Dandelion"
"Triss"
"Yennefer"
"Ciri")
","
(`genfloat` 0 10000)
"\n"
(oneof "" "\n"))))

下面是 script.rtg 脚本的生成结果。


name,balance
Carl,25.648205
Ines,11758.551

Ciri,13300.558
...

接下来我们来探究 RTG 剩余的两大核心部分:

  • 浮点数据类型和结果集
  • 实现 times,oneof,genfloat 和 join 函数

引入浮点数据类型和结果集

Lingo 中区分数据类型和结果集,数据类型规定数据的使用方式,结果集则用于在函数间传递结果,每个结果的类型都是唯一的。下面的代码片段中,引入 float 数据类型:


// introduce float type
var TypeFloatId, TypeFloat = types.NewTypeWithProperties("float", types.Primitive)
// introduce token float type for parser
var TokFloat = parser.HookToken(parser.TokLabel(TypeFloat.Name))

// recognize (true) as boolean
type FloatMatcher struct{}

// this function is used by the parser to "recognize" floats as such
func (i FloatMatcher) Match(s string) parser.TokLabel {
if !strings.Contains(s, ".") {
return parser.TokUnknown
}

if \_, err := strconv.ParseFloat(s, 32); err == nil {
return TokFloat.Label
}

return parser.TokUnknown
}
func (i FloatMatcher) Id() string {
return string(TokFloat.Label)
}

func init() {
// hook matcher into the parser
parser.HookMatcher(FloatMatcher{})
}

此外,我们来需要一个传递浮点值的结果集。我们用接口来实现它,函数的名称代表了函数的作用,最重要的是 Type 函数,它返回了自定义浮点类型 float


type FloatResult struct{ Val float32 }
// deep copy
func (r FloatResult) DeepCopy() eval.Result { return NewFloatResult(r.Val) }
// returns the string representation of this result type
func (r FloatResult) String() string {
return strconv.FormatFloat(float64(r.Val), 'f', -1, 32)
}
// returns the data type for this result type
func (r FloatResult) Type() types.Type { return custtypes.TypeFloat }
// call-back that is cleaned up when the environment is cleaned up
func (r FloatResult) Tidy() {}

func (r FloatResult) Value() interface{} { return r.Val }
func (r *FloatResult) SetValue(value interface{}) error {
boolVal, ok := value.(float32)
if !ok {
return fmt.Errorf("invalid type for Bool")
}
r.Val = boolVal
return nil
}
func NewFloatResult(value float32) *FloatResult {
return &FloatResult{
value,
}
}

实现 DSL 函数

DSL 函数的实现与数据类型和结果集类型,都需要接口来实现。下面我们来举个 genfloat 函数的例子。genfloat 函数中有三个很重要的函数,分别是 Symbol()Validate()Evaluate() 函数。Symbol() 函数返回值为处于 genfloat 特殊情况下的函数符号。

其中还有两个重要的概念: 环境和堆栈。环境通常用来存储中间结果,通常在环境中定义/声明变量。堆栈用来存储函数的输入参数,例如 (genfloat 0 10000),堆栈中将存储两个 IntResult 参数(IntResult 是 Lingo 提供的标准结果集),分别是 0 和 10000。

Validate()Evaluate() 函数接收环境 env 和堆栈 stack 作为参数。Validate() 确保参数可以被函数使用,而 Evaluate() 实际上调用函数。在这种情况下,我们在指定范围内生成一个浮点值并返回对应的 FloatResult

type FunctionGenfloat struct{}

// returns a description of this function
func (f *FunctionGenfloat) Desc() (string, string) {
  return fmt.Sprintf("%s%s %s%s",
    string(parser.TokLeftPar),
    f.Symbol(),
	"min max",
	string(parser.TokRightPar)),
	"generate float in rang [min max]"
}

// this is the symbol f of the function (f ...)
func (f *FunctionGenfloat) Symbol() parser.TokLabel {
  return parser.TokLabel("genfloat")
}

// validates the parameters of this function which are passed in
func (f *FunctionGenfloat) Validate(env *eval.Environment, stack *eval.StackFrame) error {
  if stack.Size() != 2 {
    return eval.WrongNumberOfArgs(f.Symbol(), stack.Size(), 2)
  }

  for idx, item := range stack.Items() {
    if item.Type() != types.TypeInt {
	  return eval.WrongTypeOfArg(f.Symbol(), idx+1, item)
	}
  }
  return nil
}

// evaluates the function and returns the result
func (f *FunctionGenfloat) Evaluate(env *eval.Environment, stack *eval.StackFrame) (eval.Result, error) {
  var result float32
  rand.Seed(time.Now().UnixNano())
  for !stack.Empty() {
    max := stack.Pop().(*eval.IntResult)
    min := stack.Pop().(*eval.IntResult)

	minval := float32(min.Val)
	maxval := float32(max.Val)

	result = minval + (rand.Float32() * (maxval - minval))
  }

  return custresults.NewFloatResult(result), nil
}

func NewFunctionGenfloat() (eval.Function, error) {
  fun := &FunctionGenfloat{}
  parser.HookToken(fun.Symbol())
  return fun, nil
}

整合

所有功能实现完成后,我们把他们注册/集成在 (eval.HookFunction(...)),以便 Lingo 的解析。下面的代码段中,将注册实现的 timesoneofjoingenfloat 自定义函数,并使用 main 函数测试 script.rtg 脚本中的代码。


// register function
func register(fn eval.Function, err error) {
if err != nil {
log.Fatalf("failed to create %s function %s:", fn.`Symbol()`, err.Error())
}
err = eval.HookFunction(fn)
if err != nil {
log.Fatalf("failed to hook bool function %s:", err.Error())
}
}

func main() {
// register custom functions
register(functions.NewFunctionTimes())
register(functions.NewFunctionOneof())
register(functions.NewFunctionJoin())
register(functions.NewFunction`genfloat`())
register(functions.NewFunctionFloat())
if len(os.Args) <= 1 {
fmt.Println("No script provided")
os.Exit(1)
}
// evaluate script
result, err := eval.RunScriptPath(os.Args[1])
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
}

// print output
fmt.Printf(strings.ReplaceAll(result.String(), "\n", "\n"))

os.Exit(0)
}

文章到这里,通过大约 300 行 Go 代码,成功设计一门语言并实现其语言处理器。下面我们使用 RTG 来测试 Ruby 脚本和 AWK 脚本的稳健性。


timeout 10 watch -e './rtg script.rtg > out.`CSV` && ./computebalance.`AWK` out.`CSV`'
timeout 10 watch -e './rtg script.rtg > out.`CSV` && ./computebalance.rb out.`CSV`'

上面的案例证明,RTG 可以处理所有生成的 CSV 文件,其输出结果满足 AWK 脚本的要求。而 Ruby 脚本由于无法正确处理 CSV 文件的换行符,执行会抛出异常。

如果想了解更多,请参考: about.gitlab.com/blog/2022/0…