探索 Goja: 一個 Golang JavaScript 運行時

本文探討了 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
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Wev3QWx72MQ2jdX-XJas2w