探索 Goja: 一个 Golang JavaScript 运行时

文摘   2024-09-12 18:30   上海  

本文探讨了 Golang 生态系统中的 JavaScript 运行时库  Goja[1] 。Goja 作为一个在 Go 应用程序中嵌入 JavaScript 的强大工具脱颖而出,在操作数据和提供无需 go build 步骤的 SDK 方面具有独特优势。

背景:为什么需要 Goja


在我的项目中,在查询和操作大型数据集时遇到了挑战。最初,所有内容都是用 Go 编写的,这很高效,但在处理复杂的 JSON 响应时变得很麻烦。虽然 Go 的极简主义方法通常是有利的,但特定任务所需的冗长性降低了我的速度。

使用嵌入式脚本语言可以简化这个过程,这促使我探索各种选择。Lua 是我的首选,因为它以轻量级和可嵌入而闻名。但我很快发现,Go 中可用的 Lua 库在实现、版本(5.1、5.2 等)和活跃支持方面都各不相同。

然后我调查了 Go 生态系统中其他流行的脚本语言。我考虑了  Expr[2] 、 V8[3]  和  Starlark[4]  等选项,但最终 Goja 成为了最有前途的候选者。

这里是  GitHub 仓库[5] ,我在其中对这些库进行了一些基准测试,测试它们的性能和与 Go 的集成便利性。

为什么选择 Goja?


Goja 之所以赢得我的青睐,是因为它与 Go 结构体的无缝集成。当你将 Go 结构体分配给 JavaScript 运行时中的值时,Goja 会自动推断字段和方法,使它们在 JavaScript 中可访问,而无需单独的桥接层。它利用 Go 的反射能力来调用这些字段的 getter 和 setter,提供了 Go 和 JavaScript 之间强大而透明的交互。

让我们深入一些例子来看看 Goja 的实际应用。这些例子突出了我发现有用的功能,但希望在文档中有更多的示例。

分配和返回值


首先,让我们看一个简单的例子,将一个整数数组从 Go 传递到 JavaScript 运行时,并过滤出偶数值。

package main

import (
    "fmt"
    "github.com/dop251/goja"
)

func main() {
    vm := goja.New()

    // 将 Go 切片分配给 JavaScript 变量
    err := vm.Set("numbers", []int{1, 2, 3, 4, 5})
    if err != nil {
        panic(err)
    }

    // 在 JavaScript 中执行过滤操作
    v, err := vm.RunString(`
        numbers.filter(n => n % 2 === 0)
    `)
    if err != nil {
        panic(err)
    }

    // 将结果转换回 Go 切片
    result := v.Export().([]interface{})
    fmt.Println(result) // 输出: [2 4]
}

在这个例子中,你可以看到在 Goja 中遍历数组不需要显式的类型注释。Goja 能够根据其内容推断数组的类型,这要归功于 Go 的反射机制。在过滤值并返回结果时,Goja 将结果转换回空接口数组([]interface{})。这是因为 Goja 需要在 Go 的静态类型系统中处理 JavaScript 的动态类型。

如果你需要在 Go 中处理结果值,你将不得不执行类型断言来提取整数。在内部,Goja 将所有整数表示为 int64

结构体和方法调用


接下来,让我们探讨 Goja 如何处理 Go 结构体,特别关注方法和导出字段。

package main

import (
    "fmt"
    "github.com/dop251/goja"
)

type Person struct {
    Name string
    age  int
}

func (p *Person) GetName() string {
    return p.Name
}

func main() {
    vm := goja.New()

    person := &Person{Name: "Alice", age: 30}
    err := vm.Set("person", person)
    if err != nil {
        panic(err)
    }

    v, err := vm.RunString(`
        person.Name + " is " + person.GetName()
    `)
    if err != nil {
        panic(err)
    }

    fmt.Println(v.Export()) // 输出: Alice is Alice
}

在这个例子中,我定义了一个 Person 结构体,有一个导出的 Name 字段和一个未导出的 age 字段。GetName 方法是导出的。从 JavaScript 访问这些字段和方法时,Goja 遵循结构体上的命名约定。方法 GetAge 被访问为 GetName

有一种模式可以通过  FieldNameMapper[6]  将 JavaScript 的驼峰命名约定转换为 Golang 的命名约定。这允许 Go 方法 GetAge 在 JavaScript 调用中被称为 getAge

异常处理


当 JavaScript 中发生异常时,Goja 使用标准的 Go 错误处理来管理它。让我们探讨一个运行时异常的例子——除以零。

package main

import (
    "fmt"
    "github.com/dop251/goja"
)

func main() {
    vm := goja.New()

    _, err := vm.RunString(`
        1 / 0
    `)
    if err != nil {
        fmt.Printf("Error: %v\n", err)
        // 输出: Error: RangeError: Division by zero at <eval>:2:9(1)
    }
}

返回的错误值类型为 *goja.Exception,它提供了有关引发的 JavaScript 异常及其失败位置的信息。虽然我没有发现除了将这些错误记录到 New Relic 或 DataDog 等服务之外有强烈需要检查这些错误,但 Goja 确实提供了这样做的工具(如果需要的话)。

此外,Goja 可以引发其他类型的异常,如 *goja.StackOverflowError*goja.InterruptedError*goja.CompilerSyntaxError,它们对应于与解释器相关的特定问题。这些异常在处理执行 JavaScript 代码的客户端时可能很有用。

使用 VM 池沙箱化用户代码


在开发我的应用程序时,我注意到初始化 VM 需要相当长的时间。每个 VM 都需要在运行时对用户可用的全局模块。Go 提供了 sync.Pool 来帮助_重用_对象,这非常适合我的用例,可以避免繁重初始化的开销。

以下是 Goja VM 池的示例:

package main

import (
    "fmt"
    "github.com/dop251/goja"
    "sync"
)

var vmPool = sync.Pool{
    New: func() interface{} {
        vm := goja.New()
        // 在这里初始化 VM,添加全局模块等
        return vm
    },
}

func main() {
    vm := vmPool.Get().(*goja.Runtime)
    defer vmPool.Put(vm)

    v, err := vm.RunString(`
        var result = 42;
        result;
    `)
    if err != nil {
        panic(err)
    }

    fmt.Println(v.Export()) // 输出: 42
}

由于  sync.Pool[7]  有很好的文档,让我们专注于 JavaScript 运行时。在这个例子中,用户声明了一个变量 result,并返回其值。然而,我们遇到了一个限制:VM 不能按原样重用。

全局命名空间已被变量 result 污染。如果我用同一个池重新运行相同的代码,我会收到以下错误:SyntaxError: Identifier 'result' has already been declared at <eval>:1:1(0)。有一个  GitHub issue[8]  建议每次都清除 result 的值。然而,我发现这种模式在处理用户提供的代码时不切实际,因为增加了复杂性。

到目前为止,我给出的例子都是预定义代码的演示。然而,我的应用程序允许用户提供自己的代码在 Goja 运行时中运行。这需要一些实验、 探索[9] 和采用模式来避免"已声明"错误。

package main

import (
    "fmt"
    "github.com/dop251/goja"
    "sync"
)

var vmPool = sync.Pool{
    New: func() interface{} {
        return goja.New()
    },
}

func runUserCode(userCode string) (interface{}, error) {
    vm := vmPool.Get().(*goja.Runtime)
    defer vmPool.Put(vm)

    v, err := vm.RunString(fmt.Sprintf(`
        (function() {
            %s
        })();
    `, userCode))

    if err != nil {
        return nil, err
    }

    return v.Export(), nil
}

func main() {
    userCode := `
        var x = 10;
        var y = 20;
        return x + y;
    `

    result, err := runUserCode(userCode)
    if err != nil {
        panic(err)
    }

    fmt.Println(result) // 输出: 30
}

沙箱化用户代码的最终解决方案涉及在其自己的作用域内的匿名函数中执行 userCode。由于该函数没有命名,它不会被全局分配,因此不需要清理。经过一些基准测试,我确认垃圾收集也能有效地清理它。

结论


我已经解锁了一种灵活高效的方式来处理复杂的脚本任务,而不牺牲性能。这种方法显著减少了在繁琐任务上花费的时间,让你有更多时间专注于其他重要方面,并通过提供无缝响应的脚本环境来增强整体用户体验。

我对 Goja 细微差别的经验可以帮助你快速入门!

参考链接

1. Goja: https://github.com/dop251/goja
2. Expr: https://github.com/expr-lang/expr/
3. V8: https://github.com/tommie/v8go
4. Starlark: https://github.com/google/starlark-go
5. GitHub 仓库: https://github.com/jtarchie/benchmark-tests/blob/22789057b4fcf95443ea8cb61f261dea31935cda/eval_benchmark_test.go
6. FieldNameMapper: https://pkg.go.dev/github.com/dop251/goja#FieldNameMapper
7. sync.Pool: https://pkg.go.dev/sync#Pool
8. GitHub issue: https://github.com/dop251/goja/issues/205
9. 探索: https://github.com/pocketbase/pocketbase/blob/5547c0deded8f9cc329cd6f0670aef19e2a3001a/plugins/jsvm/binds.go#L218

幻想发生器
图解技术本质
 最新文章