整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

Golang-实现一个简单的DSL解释器

Golang:实现一个简单的DSL解释器

o-实现一个简单的DSL

什么是DSL

DSL 是 Domain Specific Language 的缩写,中文翻译为领域特定语言(下简称 DSL);而与 DSL 相对的就是 GPL,这里的 GPL 并不是我们知道的开源许可证,而是 General Purpose Language 的简称,即通用编程语言,也就是我们非常熟悉的 Objective-C、Java、Python 以及 C 语言等等。

简单说,就是为了解决某一类任务而专门设计的计算机语言。

  • Regex
  • SQL
  • HTML&CSS

共同特点

没有计算和执行的概念;

  • 其本身并不需要直接表示计算;
  • 使用时只需要声明规则、事实以及某些元素之间的层级和关系;
  • 总结起来一句话:表达能力有限,通过在表达能力上做的妥协换取在某一领域内的高效 那么DSL解释器的主要功能是解释执行DSL

设计原则

实现DSL总共需要完成两部分工作:

设计语法和语义,定义 DSL 中的元素是什么样的,元素代表什么意思 实现 parser,对 DSL 解析,最终通过解释器来执行 那么我们可以得到DSL的设计原则:

简单

  • 学习成本低,DSL语法最好和部门主要技术栈语言保持一致(go,php)
  • 语法简单,删减了golang大部分的语法,只支持最基本的数据格式,二元运算符,控制语句少量的语法糖嵌入式DSL
  • DSL需要嵌入到现有的编程语言中,发挥其实时解释执行且部署灵活的特点
  • 使用json类型的context与外部系统进行通信,且提供与context操作相关的语法糖

解释器工作流程

大部分编译器的工作可以被分解为三个主要阶段:解析(Parsing),转化(Transformation)以及 代码生成(Code Generation)

  • 解析 将源代码转换为一个更抽象的形式。
  • 转换 接受解析产生的抽象形式并且操纵这些抽象形式做任何编译器想让它们做的事。
  • 代码生成 基于转换后的代码表现形式(code representation)生成目标代码。

解析

  • 词法分析 —— tokenizer 通过一个叫做tokenizer(词素生成器,也叫lexer)的工具将源代码分解成一个个词素。(词素是描述编程语言语法的对象。它可以描述数字,标识符,标点符号,运算符等等。)
  • 语法分析 —— parser 接收词素并将它们组合成一个描述了源代码各部分之间关系的中间表达形式:抽象语法树。(抽象语法树是一个深度嵌套的对象,这个对象以一种既能够简单地操作又提供很多关于源代码信息的形式,来展现代码。)

转换

  • 这个过程接收解析生成的抽象语法树并对它做出改动
  • 转换阶段可以改变抽象语法树使代码保持在同一个语言,或者编译成另外一门语言。

代码生成

  • 生成新的代码,一般是二进制或者汇编

aki-DSL解释器设计原理

解析源代码生成AST

那么想要实现一个脚本解释器的话,就需要实现上面的三个步骤,而且我们发现,承上启下的是AST(抽象语法树),它在解释器中十分重要

好在万能的golang将parse api暴露给用户了,可以让我们省去一大部分工作去做语法解析得到AST,示例代码如下:

package main

import (
	"fmt"
	"go/ast"
	"go/parser"
	"go/token"
)

func main() {
	expr :=`a==1 && b==2`
	fset :=token.NewFileSet()
	exprAst, err :=parser.ParseExpr(expr)
	if err !=nil {
		fmt.Println(err)
		return
	}

	ast.Print(fset, exprAst)
}

得到的结果:

0  *ast.BinaryExpr {
     1  .  X: *ast.BinaryExpr {
     2  .  .  X: *ast.Ident {
     3  .  .  .  NamePos: -
     4  .  .  .  Name: "a"
     5  .  .  .  Obj: *ast.Object {
     6  .  .  .  .  Kind: bad
     7  .  .  .  .  Name: ""
     8  .  .  .  }
     9  .  .  }
    10  .  .  OpPos: -
    11  .  .  Op:==12  .  .  Y: *ast.BasicLit {
    13  .  .  .  ValuePos: -
    14  .  .  .  Kind: INT
    15  .  .  .  Value: "1"
    16  .  .  }
    17  .  }
    18  .  OpPos: -
    19  .  Op: &&
    20  .  Y: *ast.BinaryExpr {
    21  .  .  X: *ast.Ident {
    22  .  .  .  NamePos: -
    23  .  .  .  Name: "b"
    24  .  .  .  Obj: *(obj @ 5)
    25  .  .  }
    26  .  .  OpPos: -
    27  .  .  Op:==28  .  .  Y: *ast.BasicLit {
    29  .  .  .  ValuePos: -
    30  .  .  .  Kind: INT
    31  .  .  .  Value: "2"
    32  .  .  }
    33  .  }
    34  }

并且,作为一个嵌入式的DSL,我们的设计是依托在golang代码之上运行的,我们不需要代码生成这一个步骤,直接使用golang来解析AST来执行相应的操作

那么,我们的现在的工作就是如何解析AST并做相应的操作即可.

解析AST

AST的结构分析

那么AST是什么结构呢,他大致可以分为如下结构

1.ast.Decl

All declaration nodes implement the Decl interface.

var a int //GenDecl
func main()  //FuncDecl

2.ast.Stmt

All statement nodes implement the Stmt interface.

a :=1 //AssignStmt
b :=map[string]string{"name":"nber1994", "age":"eghiteen"}

if a > 2 { //IfStmt
	b["age"]="18" //BlockStmt 
} else {
}

for i:=0;i<10;i++ { //ForStmt
}


for k, v :=range b { //RangeStmt
}
return a //ReturnStmt

3.ast.Expr

All expression nodes implement the Expr interface.

a :=1 //BasicLit
b :="string"
a=a + 1 //BinaryExpr
b :=map[string]string{} //CompositLitExpr
c :=Get("test.test") //CallExpr
d :=b["name"] //IndexExpr

主要思路

通过分析AST结构我们知道,一个ast.Decl是由多个ast.Stmt,并且一个ast.Stmt是由多个ast.Expr组成的,简单来说就是一个树形结构,那么这么一来就好办了,代码大框架一定是递归。

我们自底向上,分别实现对各种类型的ast.Expr,ast.Stmt, ast.Decl的解释执行方法,并把解释结果向上传递。然后通过一个根节点切入,递归方式从上向下解释执行即可

主要代码:

//编译Expr
func (this *Expr) CompileExpr(dct *dslCxt.DslCxt, rct *Stmt, r ast.Expr) interface{} {
    var ret interface{}
    if nil==r {
        return ret
    }
    switch r :=r.(type) {
    case *ast.BasicLit: //基本类型
        ret=this.CompileBasicLitExpr(dct, rct, r)
    case *ast.BinaryExpr: //二元表达式
        ret=this.CompileBinaryExpr(dct, rct, r)
    case *ast.CompositeLit: //集合类型
        switch  r.Type.(type) {
        case *ast.ArrayType: //数组
            ret=this.CompileArrayExpr(dct, rct, r)
        case *ast.MapType: //map
            ret=this.CompileMapExpr(dct, rct, r)
        default:
            panic("syntax error: nonsupport expr type")
        }
    case *ast.CallExpr:
        ret=this.CompileCallExpr(dct, rct, r)
    case *ast.Ident:
        ret=this.CompileIdentExpr(dct, rct, r)
    case *ast.IndexExpr:
        ret=this.CompileIndexExpr(dct, rct, r)
    default:
        panic("syntax error: nonsupport expr type")
    }
    return ret
}

//编译stmt
func (this *Stmt) CompileStmt(cpt *CompileCxt, stmt ast.Stmt) {
    if nil==stmt {
        return
    }
    cStmt :=this.NewChild()
    switch stmt :=stmt.(type) {
    case *ast.AssignStmt:
        //赋值在本节点的内存中
        this.CompileAssignStmt(cpt, stmt)
    case *ast.IncDecStmt:
        this.CompileIncDecStmt(cpt, stmt)
    case *ast.IfStmt:
        cStmt.CompileIfStmt(cpt, stmt)
    case *ast.ForStmt:
        cStmt.CompileForStmt(cpt, stmt)
    case *ast.RangeStmt:
        cStmt.CompileRangeStmt(cpt, stmt)
    case *ast.ReturnStmt:
        cStmt.CompileReturnStmt(cpt, stmt)
    case *ast.BlockStmt:
        cStmt.CompileBlockStmt(cpt, stmt)
    case *ast.ExprStmt:
        cStmt.CompileExprStmt(cpt, stmt)
    default:
        panic("syntax error: nonsupport stmt ")
    }
}

实现runtime context

代码的整体结构有了,那么对于DSL中声明的变量存储,以及局部变量的作用域怎么解决呢

首先,从虚拟内存的结构我们得到启发,可以使用hash表的结构来模拟最基本的内存空间以及存取操作,得益于golang的interface{},我们可以把不同数据类型的数据存入一个map[string]interface{}中得到一个范类型的数组,这样我们就构建出了一个简单的runtime memory的雏形。

type RunCxt struct {
    Vars map[string]interface{}
    Name string
}

func NewRunCxt() *RunCxt{
    return &RunCxt{
        Vars: make(map[string]interface{}),
    }
}

//获取值
func (this *RunCxt) GetValue(varName string) interface{}{
    if _, exist :=this.Vars[varName]; !exist {
        panic("syntax error: not exist var")
    }
    return this.Vars[varName]
}

func (this *RunCxt) ValueExist(varName string) bool {
    _, exist :=this.Vars[varName]
    return exist
}

//设置值
func (this *RunCxt) SetValue(varName string, value interface{}) bool {
    this.Vars[varName]=value
    return true
}

func (this *RunCxt) ToString() string {
    jsonStu, _ :=json.Marshal(this.Vars)
    return string(jsonStu)
}

那么,如何实现局部变量的作用域呢?

package main

func main() {
    a :=2

    for i:=0;i<10;i++ {
        a++
        b :=2
    }
    a=3
    b=3 //error b的声明是在for语句中,外部是无法访问的
}

那么,这个runtime context的位置就很重要,我们做如下处理:

每个Stmt节点都有一个runtime context 写入数据时,AssignStmt类型在本Stmt节点中赋值,其他类型新建一个Stmt子节点执行 读取数据时,从本节点开始向上遍历父节点,在runtime context中寻找变量,找到即止 通过这一机制,我们可以得到的效果是:

同一个BlockStmt下的多个Stmt(IfStmt,ForStmt等)处理节点之间的runtime context是互相隔离的 每个子节点,都能访问到父节点中定义的变量

代码实现:

type Stmt struct{
    Rct *runCxt.RunCxt //变量作用空间
    Type int
    Father *Stmt //子节点可以访问到父节点的内存空间
}

func NewStmt() *Stmt {
    rct :=runCxt.NewRunCxt()
    return &Stmt{
        Rct: rct,
    }
}

func (this *Stmt) NewChild() *Stmt {
    stmt :=NewStmt()
    stmt.Father=this
    return stmt
}

//编译stmt
func (this *Stmt) CompileStmt(cpt *CompileCxt, stmt ast.Stmt) {
    if nil==stmt {
        return
    }
    cStmt :=this.NewChild()
    switch stmt :=stmt.(type) {
    case *ast.AssignStmt:
        //赋值在本节点的内存中
        this.CompileAssignStmt(cpt, stmt)
    case *ast.IncDecStmt:
        this.CompileIncDecStmt(cpt, stmt)
    case *ast.IfStmt:
        cStmt.CompileIfStmt(cpt, stmt)
    case *ast.ForStmt:
        cStmt.CompileForStmt(cpt, stmt)
    case *ast.RangeStmt:
        cStmt.CompileRangeStmt(cpt, stmt)
    case *ast.ReturnStmt:
        cStmt.CompileReturnStmt(cpt, stmt)
    case *ast.BlockStmt:
        cStmt.CompileBlockStmt(cpt, stmt)
    case *ast.ExprStmt:
        cStmt.CompileExprStmt(cpt, stmt)
    default:
        panic("syntax error: nonsupport stmt ")
    }
}

变量类型与内部变量类型

首先,嵌入式的是golang系统,为了和外部系统保持一个很好地数据类型交互以及数据的准确性,DSL最好也是强类型语言。但是为了简单,我们会删减一些数据类型,保留最基本且最稳定的数据类型

func (this *Expr) CompileBasicLitExpr(cpt *CompileCxt, rct *Stmt, r *ast.BasicLit) interface{} {
    var ret interface{}
    switch r.Kind {
    case token.INT:
        ret=cast.ToInt64(r.Value)
    case token.FLOAT:
        ret=cast.ToFloat64(r.Value)
    case token.STRING:
        retStr :=cast.ToString(r.Value)
        var err error
        ret, err=strconv.Unquote(retStr)
        if nil !=err {
            panic(fmt.Sprintf("syntax error: Bad String %v", cpt.Fset.Position(r.Pos())))
        }
    default:
        panic(fmt.Sprintf("syntax error: Bad BasicList Type %v", cpt.Fset.Position(r.Pos())))
    }
    return ret
}

func (this *Expr) CompileMapExpr(cpt *CompileCxt, rct *Stmt, r *ast.CompositeLit) interface{} {
    ret :=make(map[interface{}]interface{})
    var key interface{}
    var value interface{}
    for _, e :=range r.Elts {
        key=this.CompileExpr(cpt, rct, e.(*ast.KeyValueExpr).Key)
        value=this.CompileExpr(cpt, rct, e.(*ast.KeyValueExpr).Value)
        ret[key]=value
    }
    return ret
}

func (this *Expr) CompileArrayExpr(cpt *CompileCxt, rct *Stmt, r *ast.CompositeLit) interface{} {
    var ret []interface{}
    for _, e :=range r.Elts {
        switch e :=e.(type) {
        case *ast.BasicLit:
            ret=append(ret, this.CompileExpr(cpt, rct, e))
        case *ast.CompositeLit:
            //拼接结构体
            compLit :=*.CompositeLit{
                Type: r.Type.(*ast.ArrayType).Elt,
                Elts: e.Elts,
            }
            ret=append(ret, this.CompileExpr(cpt, rct, compLit))
        default:
            panic(fmt.Sprintf("syntax error: Bad Array Item Type %v", cpt.Fset.Position(r.Pos())))
        }
    }
    return ret
}

我们可以看到,DSL数据与go数据类型对应关系为:

DSL数据类型go数据类型备注intint64最大范围floatfloat64最大范围stringstringmapmap[interface{}]interface{}最大容忍度array slice[]interface{}{}最大容忍度

DSL与外部系统交互

通过JsonMap与外部系统进行交互,且提供Get(path) Set(path)方法,去动态的访问与修改Json context中的节点

但是外部交互Json又是多种结构类型的,借助于nodejson可以解析动态json结构,通过XX.X格式的路径,来动态的访问和修改json中的字段

解析CallExpr,通过reflect来调用内部函数

func (this *Expr) CompileCallExpr(dct *dslCxt.DslCxt, rct *Stmt, r *ast.CallExpr) interface{} {
    var ret interface{}
    //校验内置函数
    var funcArgs []reflect.Value
    funcName :=r.Fun.(*ast.Ident).Name
    //初始化入参
    for _, arg :=range r.Args {
        funcArgs=append(funcArgs, reflect.ValueOf(this.CompileExpr(dct, rct, arg)))
    }
    var res []reflect.Value
    if RealFuncName, exist:=SupFuncList[funcName]; exist {
        flib :=NewFuncLib()
        res=reflect.ValueOf(flib).MethodByName(RealFuncName).Call(funcArgs)
    } else {
        res=reflect.ValueOf(dct).MethodByName(funcName).Call(funcArgs)
    }
    if nil==res {
        return ret
    }
    return res[0].Interface()
}

成果

https://github.com/nber1994/akiDsl

Testcontainers for Go使开发人员能够轻松地针对容器化依赖项运行测试。在我们之前的文章中,您可以找到使用 Testcontainers 进行集成测试的介绍,并探索如何使用 Testcontainers(用 Java)编写功能测试。

这篇博文将深入探讨如何使用模块以及 Golang 测试容器的常见问题。

我们用它做什么?

服务经常使用外部依赖项,如数据存储或队列。可以模拟这些依赖项,但如果您想要运行集成测试,最好根据实际依赖项(或足够接近)进行验证。

使用依赖项的映像启动容器是验证应用程序是否按预期运行的便捷方法。使用 Testcontainers,启动容器是通过编程方式完成的,因此您可以将其定义为测试的一部分。运行测试的机器(开发人员、CI/CD)需要具有容器运行时接口(例如 Docker、Podman...)

基本实现

Testcontainers for Go 非常易于使用,快速启动示例如下:

ctx :=context.TODO()
req :=testcontainers.ContainerRequest{
    Image:        "redis:latest",
    ExposedPorts: []string{"6379/tcp"},
    WaitingFor:   wait.ForLog("Ready to accept connections"),
}
redisC, err :=testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
    ContainerRequest: req,
    Started:          true,
})
if err !=nil {
    panic(err)
}
defer func() {
    if err :=redisC.Terminate(ctx); err !=nil {
        panic(err)
    }
}()

如果我们深入研究上面的代码,我们会注意到:

  1. testcontainers.ContainerRequest使用容器镜像、暴露端口和等待策略参数初始化结构体
  2. testcontainers.GenericContainer启动容器并返回容器和错误结构
  3. redisC.Terminatedefer测试完成后终止容器

实现我们自己的内部库

从上一节的例子来看,存在一些小小的不便:

  1. wait.ForLog("Ready to accept connections")使用日志等待容器启动,这很容易中断
  2. ExposedPorts: []string{"6379/tcp"}需要了解 Redis 的暴露端口

运行 Redis 容器可能还需要一些额外的环境变量和其他参数,这需要更深入的知识。因此,我们决定创建一个内部库,该库将使用简化测试实施所需的默认参数初始化容器。为了保持灵活性,我们使用了功能选项模式,以便消费者仍然可以根据需要进行自定义。

Redis 的实现示例:

func defaultPreset() []container.Option {
    return []container.Option{
        container.WithPort("6379/tcp"),
        container.WithGetURL(func(port nat.Port) string {
            return "localhost:" + port.Port()
        }),
        container.WithImage("redis"),
        container.WithWaitingStrategy(func(c *container.Container) wait.Strategy {
            return wait.ForAll(
                wait.NewHostPortStrategy(c.Port),
                wait.ForLog("Ready to accept connections"))
        }),
    }
}

// New - create a new container able to run redis
func New(options ...container.Option) (*container.Container, error) {
    c :=container.Container{}
    options=append(defaultPreset(), options...)
    for _, o :=range options {
        o(&c)
    }

    return &c, nil
}

// Start - start a Redis container and return a container.CreatedContainer
func Start(ctx context.Context, options ...container.Option) (container.CreatedContainer, error) {
    p, err :=New(options...)
    if err !=nil {
        return container.CreatedContainer{}, err
    }
    return p.Start(ctx)
}

Redis 库的使用:

ctx :=context.TODO()
cc, err :=redis.Start(ctx, container.WithVersion("latest"))
if err !=nil {
    panic(err)
}
defer func() {
    if err :=cc.Stop(ctx, nil); err !=nil {
        panic(err)
    }
}()

有了这个内部库,开发人员可以轻松地为 Redis 添加测试,而无需弄清楚等待策略、暴露端口等。如果出现不兼容的情况,可以更新内部库以集中修复问题。

常见问题 - 垃圾收集器(Ryuk / Reaper)

Testcontainers 还额外确保了测试完成后容器会被移除,它使用垃圾收集器defer,这是一个作为“sidecar”启动的附加容器。即使测试崩溃(这将阻止运行),此容器也会负责停止正在测试的容器。

当使用Docker时,它可以正常工作,但使用其他容器运行时接口(如Podman)时经常会遇到这种错误:Error response from daemon: container create: statfs /var/run/docker.sock: permission denied: creating reaper failed: failed to create container

“修复此问题”的一种方法是使用环境变量将其停用TESTCONTAINERS_RYUK_DISABLED=true

另一种方法是设置 Podman 机器 rootful 并添加:

export TESTCONTAINERS_RYUK_CONTAINER_PRIVILEGED=true; # needed to run Reaper (alternative disable it TESTCONTAINERS_RYUK_DISABLED=true)
export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock; # needed to apply the bind with statfs

在我们的内部库中,我们采取默认禁用它的方法,因为开发人员在本地运行它时遇到了问题。

转向模块

一旦我们的内部库足够稳定,我们就决定是时候通过为 Testcontainers 做贡献来回馈社区了。但令人惊讶的是…… Testcontainers 刚刚引入了模块。模块的功能与我们的内部库完全一样,因此我们将所有服务迁移到模块并停止使用内部库。从迁移中,我们了解到,既然已经引入了模块,就可以使用开箱即用的标准库,从而降低了我们服务的维护成本。主要的挑战是使用 Makefile 微调开发人员环境变量以在开发人员机器上运行(使垃圾收集器工作)。

改编自testcontainers 文档的示例:

ctx :=context.TODO()
redisContainer, err :=redis.RunContainer(ctx,
    testcontainers.WithImage("docker.io/redis:latest"),
)
if err !=nil {
    panic(err)
}
defer func() {
    if err :=redisContainer.Terminate(ctx); err !=nil {
        panic(err)
    }
}()

结论

Testcontainers for Golang 是一个很棒的支持测试的库,现在引入了模块,它变得更好了。垃圾收集器存在一些小障碍,但可以按照本文所述轻松修复。

我希望通过这个博客,如果您还没有采用 Testcontainers,我们强烈推荐它来提高您的应用程序的可测试性。


作者:Fabien Pozzobon

出处:https://engineering.zalando.com/posts/2023/12/using-modules-for-testcontainers-with-golang.html

次聊到了《Go语言进阶之路(八):正则表达式