o-实现一个简单的DSL
DSL 是 Domain Specific Language 的缩写,中文翻译为领域特定语言(下简称 DSL);而与 DSL 相对的就是 GPL,这里的 GPL 并不是我们知道的开源许可证,而是 General Purpose Language 的简称,即通用编程语言,也就是我们非常熟悉的 Objective-C、Java、Python 以及 C 语言等等。
简单说,就是为了解决某一类任务而专门设计的计算机语言。
没有计算和执行的概念;
实现DSL总共需要完成两部分工作:
设计语法和语义,定义 DSL 中的元素是什么样的,元素代表什么意思 实现 parser,对 DSL 解析,最终通过解释器来执行 那么我们可以得到DSL的设计原则:
大部分编译器的工作可以被分解为三个主要阶段:解析(Parsing),转化(Transformation)以及 代码生成(Code Generation)
那么想要实现一个脚本解释器的话,就需要实现上面的三个步骤,而且我们发现,承上启下的是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是什么结构呢,他大致可以分为如下结构
All declaration nodes implement the Decl interface.
var a int //GenDecl
func main() //FuncDecl
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
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 ")
}
}
代码的整体结构有了,那么对于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{}{}最大容忍度
通过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)
}
}()
如果我们深入研究上面的代码,我们会注意到:
从上一节的例子来看,存在一些小小的不便:
运行 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 添加测试,而无需弄清楚等待策略、暴露端口等。如果出现不兼容的情况,可以更新内部库以集中修复问题。
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语言进阶之路(八):正则表达式
*请认真填写需求信息,我们会在24小时内与您取得联系。