登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go Goja 怎么预编译脚本并在多个运行时执行

来源:17golang原创

时间:2026-10-13 15:23:56 306浏览 收藏

如果一段 JavaScript 规则会被反复用于不同请求,直接对每个请求调用 RunString 会把解析和编译也带进每次执行。Goja 提供的 goja.Compile 可以先生成一个与运行时无关的 *goja.Program,之后把这个 Program 交给多个独立的 Runtime 使用。

官方地址:https://github.com/dop251/goja

正确的复用单位是“共享只读的 Program,隔离每个 Runtime 的状态”。Program 可以被多个 Runtime 使用,但 Runtime 本身不能被多个 goroutine 同时操作;请求数据也应通过各自 Runtime 的输入绑定注入。

先分清 Program 与 Runtime 的职责

Compile(name, src, strict) 负责把脚本文本转换成 Goja 的内部编译表示;Runtime.RunProgram 再在某个 Runtime 的全局上下文中执行它。Program 不绑定某一个 Runtime,因此可以缓存或被多个 Runtime 读取;变量、对象、全局函数和执行结果则属于具体 Runtime。

这一区分很重要:如果把 Runtime 放进全局变量后让多个请求同时调用,问题不是“Program 没有缓存”,而是运行时状态被并发访问。Goja 官方资料明确说明,一个 Runtime 同时只能由一个 goroutine 使用。

Goja Compile 生成可共享 Program,多个独立 Runtime 分别持有执行状态的静态结构说明图
图1:Program 与多个 Runtime 的静态关系;Program 位于共享编译边界,Runtime A 与 Runtime B 各自承载全局对象和执行状态,这是一张结构说明图,不是运行截图。

准备模块并编译一次脚本

先创建一个最小 Go module,并安装 Goja。官方仓库当前 README 还列出了它的 Go 版本要求,实际项目应以仓库当前说明和自身锁定的依赖版本为准。

# 创建实验目录并初始化 Go module。
mkdir goja-program-demo
cd goja-program-demo
go mod init example.com/goja-program-demo

# 引入 Goja;版本由当前 module 的依赖解析结果记录到 go.mod。
go get github.com/dop251/goja

把脚本的职责限制在“读取 input 并返回一个对象”。这样可以清楚看到:编译只依赖源代码,业务数据在 Runtime 中注入。

package main

import (
	"fmt"

	"github.com/dop251/goja"
)

const script = `
function quote(input) {
    // 只读取当前 Runtime 中的 input,不把请求数据写入共享 Program。
    return {
        id: input.id,
        total: input.price * input.quantity
    };
}

quote(input);
`

// compileProgram 只负责编译脚本文本;语法错误应在初始化或加载阶段暴露。
func compileProgram() (*goja.Program, error) {
	return goja.Compile("quote.js", script, false)
}

func main() {
	program, err := compileProgram()
	if err != nil {
		// 编译失败时没有可执行的 Program,调用方应停止发布或加载。
		panic(err)
	}

	fmt.Printf("compiled program: %T\n", program)
}

这里使用 Compile 而不是 MustCompile,是因为脚本可能来自配置、数据库或管理端输入。初始化阶段可以把错误记录清楚;只有源码随程序一起发布且编译失败代表开发错误时,才适合考虑 MustCompile。

在多个 Runtime 中传入数据并执行

编译得到 Program 后,每次执行都新建一个 Runtime,把当前请求的 input 放入该 Runtime,再调用 RunProgram。下面的辅助函数不共享 Runtime,因此既可以顺序调用,也可以由多个 goroutine 分别调用。

// runProgram 为一次执行创建独立 Runtime,避免跨请求共享全局对象。
func runProgram(program *goja.Program, input map[string]any) (any, error) {
	vm := goja.New()
	if err := vm.Set("input", input); err != nil {
		// 输入转换失败时返回错误,不让错误数据继续进入脚本。
		return nil, fmt.Errorf("set input: %w", err)
	}

	value, err := vm.RunProgram(program)
	if err != nil {
		// JavaScript 异常属于运行错误,与 Compile 阶段的语法错误分开处理。
		return nil, fmt.Errorf("run program: %w", err)
	}
	return value.Export(), nil
}

func main() {
	program, err := compileProgram()
	if err != nil {
		panic(err)
	}

	inputs := []map[string]any{
		{"id": "A-100", "price": 12.5, "quantity": 2},
		{"id": "B-200", "price": 8.0, "quantity": 3},
	}
	for _, input := range inputs {
		result, err := runProgram(program, input)
		if err != nil {
			fmt.Println(err)
			continue
		}
		// 结果属于本次 Runtime,导出后交给 Go 业务层继续处理。
		fmt.Printf("%v\n", result)
	}
}

这个例子里只编译一次脚本,两个输入分别进入两个生命周期独立的 Runtime。value.Export() 把脚本返回的对象转换为 Go 值;如果希望得到严格的结构体,可以在导出阶段使用 Runtime.ExportTo,而不是把 Runtime 或 JavaScript 对象跨请求保存。

每个 Goja Runtime 独立接收 input、调用 RunProgram 并导出 result 的静态数据关系图
图2:单次执行的数据边界说明图;输入只进入对应 Runtime,RunProgram 读取共享 Program,结果从该 Runtime 导出到 Go,这是一张静态说明图,不是运行截图。

检查错误、并发边界与状态隔离

把执行路径拆开后,错误可以按来源处理:

错误位置典型表现处理建议
Compile脚本文法或编译选项不合法拒绝缓存该 Program,并记录脚本来源
vm.Set输入无法转换到 Goja 值在进入脚本前返回输入错误
RunProgram脚本抛异常或访问了不存在的数据把它作为本次请求的运行失败处理
Export / ExportTo结果结构与 Go 目标类型不匹配在边界层返回转换错误,不复用脏结果

如果要并发执行,同一个 program 可以被多个调用读取,但每个调用都应拥有自己的 vm := goja.New()。不要把同一个 Runtime 放入 sync.Pool 后直接交给多个 goroutine;即使通过池化降低创建频率,也必须保证一次只由一个 goroutine 使用,并在归还前清理会影响下一次请求的全局变量。

// 并发调用时共享 Program,但每个任务仍通过 runProgram 创建自己的 Runtime。
func runBatch(program *goja.Program, inputs []map[string]any) {
	var wg sync.WaitGroup
	for _, input := range inputs {
		input := input
		wg.Add(1)
		go func() {
			defer wg.Done()
			// 不把 vm 提到循环外,避免多个 goroutine 共享 Runtime。
			result, err := runProgram(program, input)
			if err != nil {
				fmt.Println(err)
				return
			}
			fmt.Println(result)
		}()
	}
	wg.Wait()
}

这段并发片段需要在文件导入 sync,并沿用前面的 runProgram。输出顺序不应作为业务协议,因为 goroutine 完成顺序是不固定的;如果业务需要稳定顺序,应在 Go 层给任务编号并收集结果。

扩展为缓存和受控复用

实际服务可以把脚本源文本映射到 *goja.Program 缓存,但缓存键要包含会改变语义的内容,例如脚本版本、严格模式选项或租户边界。编译成功后再写入缓存,编译失败不能用旧的半成品覆盖有效 Program。

Runtime 是否池化要单独决定。Program 复用通常只要求保存一个只读对象;Runtime 池化则要求完整清理全局状态、回调、定时器和业务注入对象,排查成本更高。若单次脚本很短,优先保持“一个执行一个 Runtime”的清晰模型,再用真实业务基准判断创建成本是否值得优化。

还要留意脚本执行本身可能耗时过长。Goja 官方仓库提供了 Runtime.Interrupt 相关能力,但中断策略需要结合服务的取消、超时和错误回收方案设计;不要只把 goroutine 停止当成脚本已经安全结束。

清理与总结

  • 用 goja.Compile 把稳定脚本文本转换成可缓存的 *goja.Program。
  • 每次请求创建独立 Runtime,通过 Set 注入输入,再调用 RunProgram。
  • Program 可以在多个 Runtime 间共享;Runtime 不能被多个 goroutine 同时使用。
  • 分别处理编译错误、输入转换错误、JavaScript 运行错误和结果导出错误。
  • 只有在能清理状态并有基准数据时,才把 Runtime 放进池化复用方案。

相关问题

Program 可以在多个 Runtime 中同时执行吗?

可以。Program 与 Runtime 解耦,多个 Runtime 可以分别调用 RunProgram;并发时仍需保证每个 goroutine 使用自己的 Runtime。

为什么不直接缓存 Runtime?

Runtime 内含全局对象和执行状态,缓存后必须处理并发互斥、请求数据残留和异常恢复。缓存 Program 只复用编译结果,隔离边界更清晰。

RunProgram 和 RunString 有什么区别?

RunString 接收脚本文本并执行,适合一次性脚本;RunProgram 接收已经由 Compile 生成的 Program,适合同一脚本被反复执行的路径。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>