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小時內與您取得聯系。