探索Goja:Golang中的JavaScript运行时

原文信息: 查看原文查看原文

Exploring Goja: A Golang JavaScript Runtime

- Jtarchie

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

背景:Goja的需求

在我的项目中,我在查询和操作大型数据集时遇到了挑战。最初,一切都是用Go编写的,这在效率上是有利的,但在处理复杂的JSON响应时变得笨拙。虽然Go的极简主义方法通常是个优势,但特定任务所需的冗长代码减慢了我的速度。

使用嵌入式脚本语言可以简化这个过程,这促使我探索了各种选择。Lua是我的第一个选择,因为它以轻量级和可嵌入而闻名。然而,我很快发现,Go的Lua库在实现、版本(5.1、5.2等)和积极支持方面参差不齐。

随后,我调查了Go生态系统中的其他流行的脚本语言。我考虑了ExprV8Starlark等选项,但最终,Goja成为最有希望的候选者。

这是我在这些库上进行性能和与Go集成的便利性测试的GitHub仓库

为什么选择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()
    // 传递一个1到100的整数数组
    values := []int{}
    for i := 1; i <= 100; i++ {
        values = append(values, i)
    }
    // 定义JavaScript代码以过滤偶数值
    script := `
        values.filter((x) => {
            return x % 2 === 0;
        })  `
    // 在JavaScript运行时中设置数组
    vm.Set("values", values)
    // 运行脚本
    result, err := vm.RunString(script)
    if err != nil {
        panic(err)
    }
    // 将结果转换回Go的空接口切片
    filteredValues := result.Export().([]interface{})
    fmt.Println(filteredValues)
    // 输出:[2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34 36 38 40 42 44 46 48 50 52 54 56 58 60 62 64 66 68 70 72 74 76 78 80 82 84 86 88 90 92 94 96 98 100]
    first := filteredValues[0].(int64)
    fmt.Println(first)
}

在这个例子中,你可以看到,在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) GetAge() int {
    return p.age
}
func main() {
    vm := goja.New()
    // 创建一个新的Person实例    person := &Person{        Name: "John Doe",        age:  30,    }
    // 在JavaScript运行时中设置Person结构体   vm.Set("person", person)
    // JavaScript代码以访问结构体的字段和方法    script := `        const name = person.Name;    // 访问导出字段        const age = person.GetAge(); // 通过getter访问未导出字段        name + " is " + age + " years old.";    `
    result, err := vm.RunString(script)    if err != nil {        panic(err)    }
    fmt.Println(result.String()) // 输出:John Doe is 30 years old.
}

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

有一个模式是通过FieldNameMapper将JavaScript的命名约定camel case转换为Golang的命名约定。这允许在javascript调用中将Go方法GetAge称为getAge

异常处理

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

package main
import (
    "errors"
    "fmt"
    "github.com/dop251/goja"
)
// 触发除以零错误的JavaScript代码
const script = `
    // 在JavaScript中使用BigInt表示法
    const a = 1n / 0n;
`
func main() {
    vm := goja.New()
    // 执行JavaScript代码
    _, err := vm.RunString(script)
    // 处理发生的任何错误
    var exception *goja.Exception
    if errors.As(err, &exception) {
        fmt.Printf("JavaScript error: %s\n", exception.Error())
        // 输出:JavaScript error: RangeError: Division by zero at <eval>:1:1(3)
    } else if err != nil {
        // 处理其他类型的错误(如果有)
        fmt.Printf("Error: %s\n", err.Error())
    }
}

返回的错误值是类型*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"
    "sync"
    "github.com/dop251/goja"
)
var vmPool = sync.Pool{
    New: func() interface{} {
        vm := goja.New()
        // 在每个VM中定义全局函数
        vm.Set("add", func(a, b int) int {
            return a + b
        })
        // ... 设置其他全局值 ...
        return vm
    },
}
func main() {
    vm := vmPool.Get().(*goja.Runtime)
    // 将VM重新放入池中以重用
    defer vmPool.Put(vm)
    script := `
        const result = add(5, 10);
        result;
    `
    value, err := vm.RunString(script)
    if err != nil {
        panic(err)
    }
    fmt.Println("Result:", value.Export())
    // 结果:15
}

由于sync.Pool已经很好地记录在案,让我们专注于JavaScript运行时。在这个例子中,用户声明了一个变量result,它的值被返回。然而,我们遇到了一个限制:VM不能按原样重用。

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

到目前为止,我给出的例子都是预定义代码的演示。然而,我的应用程序允许用户在Goja运行时内提供自己的代码。这需要一些实验,探索,以及采纳模式以避免“已经声明”的错误。

value, err := vm.RunString("(function() {" + userCode + "})()")
if err != nil {
    panic(err)
}

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

结论

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

分享于 2024-09-08

访问量 158

预览图片