package jscriptx_test import ( "context" "fmt" "slices" "sync" "testing" "time" "git.fsdpf.net/go/jscriptx" "github.com/dop251/goja" ) // 这一组**不测本库,测 goja**——它是「为什么这个库要这么设计」的实验记录。 // // 三件事: // // 1. Program 无状态,可以给任意多个 Runtime 共用——本库共用编译结果的前提 // 2. **模块顶层的变量跟着 Runtime 走,不是共享状态** // 3. Go 的值进到脚本里长什么样(切片是不是真数组、nil 是 null 还是 undefined) // // 第 2 条是最容易误解的地方。它导致:一个脚本串行开发时表现正常(一直命中同一个 // Runtime),并发起来行为却随调度漂移——有几个 Runtime 就有几份独立的顶层状态, // 一次调用落到哪个 Runtime 取决于当时的 goroutine 调度。 // // 所以要在并发之间共享状态,只能把它放到 Go 侧(写一个扩展,见 Extension 接口): // Go 对象可以被每个 Runtime 各自包一层,并发安全由 Go 自己保证。 // // 第 3 条脚本作者是照着写代码的,一旦变了,业务脚本会**静默**走错分支(比如查出来的 // 记录突然 Array.isArray 为 false,整段列表被当成空的)。 // // 这些行为变了,这一组会先红——那时候要改的是本库的设计,不是这里的断言。 // 其中有一条是哨兵:goja 哪天允许对象跨 Runtime 了,它会失败,提醒我们可以简化设计。 const gojaCounter = ` var counter = 0; function bump() { return ++counter } bump` // compileCounter 编一份 Program。它是无状态的,几个用例共用同一份。 func compileCounter(t *testing.T) *goja.Program { t.Helper() prog, err := goja.Compile("counter.js", gojaCounter, false) if err != nil { t.Fatal(err) } return prog } // bumpOf 在一个新 Runtime 里跑一遍 Program,拿到那个闭包函数。 func bumpOf(t *testing.T, prog *goja.Program) goja.Callable { t.Helper() rt := goja.New() v, err := rt.RunProgram(prog) if err != nil { t.Fatal(err) } fn, ok := goja.AssertFunction(v) if !ok { t.Fatalf("Program 的完成值不是函数: %v", v) } return fn } func callBump(t *testing.T, fn goja.Callable) int64 { t.Helper() v, err := fn(goja.Undefined()) if err != nil { t.Fatal(err) } return v.ToInteger() } // 同一个 Runtime 反复调用,顶层变量一路累加——闭包还在,Program 不会重跑。 func TestGoja_同一个Runtime里顶层变量会累加(t *testing.T) { fn := bumpOf(t, compileCounter(t)) for i := int64(1); i <= 5; i++ { if got := callBump(t, fn); got != i { t.Fatalf("第 %d 次调用该拿到 %d,拿到 %d", i, i, got) } } } // 换一个 Runtime 就是另一份顶层变量,哪怕用的是同一个 Program。 // 这正是「N 个 VM = N 份独立副本」的根源。 func TestGoja_不同Runtime各有一份顶层变量(t *testing.T) { prog := compileCounter(t) a := bumpOf(t, prog) b := bumpOf(t, prog) for range 3 { // a 先数到 3 callBump(t, a) } if got := callBump(t, b); got != 1 { // b 是全新的 t.Errorf("另一个 Runtime 该从 1 开始,拿到 %d", got) } if got := callBump(t, a); got != 4 { // a 不受影响 t.Errorf("原来那个 Runtime 该接着数到 4,拿到 %d", got) } } // Program 可以并发给任意多个 Runtime 用。32 个各调一次,全都拿到 1 // ——32 份独立的顶层状态,互不影响。 func TestGoja_Program可以并发共用(t *testing.T) { prog := compileCounter(t) var mu sync.Mutex var got []int64 var wg sync.WaitGroup for range 32 { wg.Add(1) go func() { defer wg.Done() rt := goja.New() v, err := rt.RunProgram(prog) // 同一份 Program,不同 Runtime if err != nil { t.Error(err) return } fn, ok := goja.AssertFunction(v) if !ok { t.Error("完成值不是函数") return } r, err := fn(goja.Undefined()) if err != nil { t.Error(err) return } mu.Lock() got = append(got, r.ToInteger()) mu.Unlock() }() } wg.Wait() fmt.Println(got) if len(got) != 32 { t.Fatalf("该有 32 个结果,拿到 %d 个", len(got)) } for _, v := range got { if v != 1 { t.Fatalf("每个 Runtime 都是新的,该全是 1,出现了 %d;全部结果 %v", v, got) } } } // 对象不能跨 Runtime 传。这是 goja 的硬限制,也是「JS 内部变量没法并发共享」的根据。 // // 维护者在 dop251/goja#232 里说得很直白:非原始类型的 goja.Value 连并发只读都不安全, // 用 Proxy 包一层也不行,因为 handler 最终还是要访问原来那个值。 // // 这条测试是个哨兵:哪天 goja 放开了这个限制,它会失败,提醒我们重新评估 // 「共享状态必须放 Go 侧」这个结论。 func TestGoja_对象不能跨Runtime传(t *testing.T) { rt1 := goja.New() obj, err := rt1.RunString(`var o = { n: 1 }; o`) if err != nil { t.Fatal(err) } rt2 := goja.New() setErr := rt2.Set("borrowed", obj) // 把 rt1 的对象塞进 rt2 _, runErr := rt2.RunString(`borrowed.n`) if setErr == nil && runErr == nil { t.Fatal("跨 Runtime 传对象居然成功了——goja 的限制变了," + "本库关于「共享状态只能放 Go 侧」的结论需要重新评估") } t.Logf("Set 的结果: %v", setErr) t.Logf("读取的结果: %v", runErr) } // 原始值不受这条限制——字符串、数字、布尔不是对象,可以跨 Runtime 传。 func TestGoja_原始值可以跨Runtime传(t *testing.T) { rt1 := goja.New() v, err := rt1.RunString(`"hello"`) if err != nil { t.Fatal(err) } rt2 := goja.New() if err := rt2.Set("s", v); err != nil { t.Fatalf("原始值该能传: %v", err) } got, err := rt2.RunString(`s + " world"`) if err != nil { t.Fatal(err) } if got.String() != "hello world" { t.Errorf("拿到 %q", got.String()) } } // ============================================================================ // 并发之间要真正共享一个计数器(1,2,3… 不重不漏)该怎么做 // ============================================================================ // sharedCounter 是放在 Go 侧的计数器,并发安全由它自己保证。 // 每个 Runtime 各自把它包一层——这是 goja 维护者认可的唯一共享方式。 type sharedCounter struct { mu sync.Mutex n int64 } func (c *sharedCounter) Bump() int64 { c.mu.Lock() defer c.mu.Unlock() c.n++ return c.n } // 方案一:状态放 Go 侧,Runtime 各自独立。**推荐这条。** // // 32 个 goroutine 各有各的 Runtime(互不阻塞),但 Set 进去的是同一个 Go 对象, // 所以 Bump() 拿到的是同一个计数器:结果正好 1..32,不重不漏。 // // 只有 Bump 里那把 Go 锁是串行的,脚本执行本身全程并行。 // 把它包成扩展之后,脚本侧写 store.Incr("counter", 1) 就行——本仓库的 // internal/testext 是个最小示例,框架层那份在 framework 的 internal/jscript/ext/store。 func TestGoja_共享状态放Go侧(t *testing.T) { const n = 32 prog, err := goja.Compile("shared.js", ` function bump() { return shared.Bump() } bump`, false) if err != nil { t.Fatal(err) } counter := &sharedCounter{} // 进程级,只有一个 got := runConcurrent(t, n, func() (int64, error) { rt := goja.New() if err := rt.Set("shared", counter); err != nil { // 同一个指针,各包一层 return 0, err } v, err := rt.RunProgram(prog) if err != nil { return 0, err } fn, ok := goja.AssertFunction(v) if !ok { return 0, fmt.Errorf("完成值不是函数") } r, err := fn(goja.Undefined()) if err != nil { return 0, err } return r.ToInteger(), nil }) fmt.Println("方案一 共享 Go 对象:", got) assertSequence(t, got, n) } // 方案二:共用一个 Runtime,靠锁串行。 // // 这样 JS 里的 counter 是真共享的——因为只有一个 Runtime。代价是**所有脚本执行 // 完全串行**:脚本跑 1ms,32 个并发就是 32ms 串起来。 // // 除非脚本极快且并发很低,否则别走这条。状态放 Go 侧更安全,也不用牺牲并发。 func TestGoja_共用一个Runtime靠锁串行(t *testing.T) { const n = 32 prog, err := goja.Compile("locked.js", ` var counter = 0; function bump() { return ++counter } bump`, false) if err != nil { t.Fatal(err) } rt := goja.New() v, err := rt.RunProgram(prog) if err != nil { t.Fatal(err) } fn, ok := goja.AssertFunction(v) if !ok { t.Fatal("完成值不是函数") } var rtMu sync.Mutex // Runtime 不是并发安全的,这把锁不能省 got := runConcurrent(t, n, func() (int64, error) { rtMu.Lock() defer rtMu.Unlock() r, err := fn(goja.Undefined()) if err != nil { return 0, err } return r.ToInteger(), nil }) fmt.Println("方案二 共用 Runtime:", got) assertSequence(t, got, n) } // runConcurrent 并发跑 n 次 do,收集结果并排序。 func runConcurrent(t *testing.T, n int, do func() (int64, error)) []int64 { t.Helper() var mu sync.Mutex out := make([]int64, 0, n) var wg sync.WaitGroup for range n { wg.Add(1) go func() { defer wg.Done() v, err := do() if err != nil { t.Error(err) return } mu.Lock() out = append(out, v) mu.Unlock() }() } wg.Wait() slices.Sort(out) return out } // assertSequence 断言结果正好是 1..n,不重不漏。 func assertSequence(t *testing.T, got []int64, n int) { t.Helper() if len(got) != n { t.Fatalf("该有 %d 个结果,拿到 %d 个: %v", n, len(got), got) } for i, v := range got { if v != int64(i+1) { t.Fatalf("该是 1..%d 的连续序列,第 %d 个是 %d;全部结果 %v", n, i+1, v, got) } } } // ============================================================================ // 脚本里 +1 之后阻塞会怎样:两个方案在这里差别最大 // ============================================================================ const blockOnce = 100 * time.Millisecond // sleeper 暴露给脚本的阻塞函数,模拟脚本里做 IO(查库、调接口)。 func sleeper(d time.Duration) func() { return func() { time.Sleep(d) } } // 方案一:计数器在 Go 侧,Runtime 各自独立。 // // 脚本先 +1 拿到自己的号,再阻塞。16 个各睡 20ms,总耗时接近 20ms 而不是 320ms // ——一个脚本卡在 IO 上,不挡别人。而且 +1 在阻塞之前就已经对其他人可见了。 func TestGoja_阻塞时独立Runtime并行(t *testing.T) { const n = 16 prog, err := goja.Compile("blocking.js", ` function bump() { var mine = shared.Bump(); // 先 +1,拿到自己的号 sleep(); // 再阻塞 return mine } bump`, false) if err != nil { t.Fatal(err) } counter := &sharedCounter{} sleep := sleeper(blockOnce) start := time.Now() got := runConcurrent(t, n, func() (int64, error) { rt := goja.New() if err := rt.Set("shared", counter); err != nil { return 0, err } if err := rt.Set("sleep", sleep); err != nil { return 0, err } v, err := rt.RunProgram(prog) if err != nil { return 0, err } fn, ok := goja.AssertFunction(v) if !ok { return 0, fmt.Errorf("完成值不是函数") } r, err := fn(goja.Undefined()) if err != nil { return 0, err } return r.ToInteger(), nil }) elapsed := time.Since(start) fmt.Printf("方案一 独立 Runtime: %d 个各阻塞 %v,总耗时 %v\n", n, blockOnce, elapsed.Round(time.Millisecond)) fmt.Println(got) assertSequence(t, got, n) if limit := blockOnce * n / 2; elapsed > limit { t.Errorf("独立 Runtime 该并行,总耗时 %v 超过了 %v——像是串行了", elapsed, limit) } } // 方案二:计数器就在 JS 里,共用一个 Runtime。 // // 脚本先 counter++(JS 侧的变量,真共享),再阻塞。同样 16 个各睡 20ms, // 总耗时接近 320ms:Runtime 不是并发安全的,锁必须一直持到脚本返回, // **脚本在 IO 上卡多久,锁就被占多久**,包括 +1 之后那段。 // // 所以"共用 Runtime 换 JS 侧共享状态"的代价不是慢一点,是并发归零。 func TestGoja_阻塞时共用Runtime串行(t *testing.T) { const n = 16 // 导出两个方法:最后一个表达式是什么,RunProgram 就返回什么。 // 想导出多个就返回一个对象——注意外面要包一层括号, // 不然 { 会被当成语句块而不是对象字面量。 prog, err := goja.Compile("blocking.js", ` var counter = 0; function bump() { counter++; // 先 +1,这是 JS 里的变量,共用 Runtime 才共享得了 sleep(); // 再阻塞 return counter } function read() { return counter } // 不 +1,只读当前值 ({ bump: bump, read: read })`, false) if err != nil { t.Fatal(err) } rt := goja.New() if err := rt.Set("sleep", sleeper(blockOnce)); err != nil { t.Fatal(err) } v, err := rt.RunProgram(prog) if err != nil { t.Fatal(err) } exports := v.ToObject(rt) fn, ok := goja.AssertFunction(exports.Get("bump")) if !ok { t.Fatal("bump 不是函数") } read, ok := goja.AssertFunction(exports.Get("read")) if !ok { t.Fatal("read 不是函数") } var rtMu sync.Mutex start := time.Now() got := runConcurrent(t, n, func() (int64, error) { rtMu.Lock() defer rtMu.Unlock() // 锁持到脚本返回,包括 +1 之后阻塞的那段 r, err := fn(goja.Undefined()) if err != nil { return 0, err } return r.ToInteger(), nil }) fmt.Println(got) elapsed := time.Since(start) fmt.Printf("方案二 共用 Runtime: %d 个各阻塞 %v,总耗时 %v\n", n, blockOnce, elapsed.Round(time.Millisecond)) assertSequence(t, got, n) if floor := blockOnce * n * 4 / 5; elapsed < floor { t.Errorf("共用 Runtime 必然串行,总耗时才 %v,不到 %v——是不是没锁住", elapsed, floor) } // 全部并发结束后读最终值。读也要加锁——它跟 bump 用的是同一个 Runtime。 rtMu.Lock() final, err := read(goja.Undefined()) rtMu.Unlock() if err != nil { t.Fatal(err) } fmt.Printf(" 并发结束后 read(): %d\n", final.ToInteger()) if got := final.ToInteger(); got != int64(n) { t.Errorf("%d 个并发各 +1 一次,最终该是 %d,读到 %d", n, n, got) } } // 对照:独立 Runtime + JS 侧的 counter++。 // // 跟"共用 Runtime"那条用的是**完全相同的脚本**,只有 Runtime 的用法不同。 // 结果:并行是并行了(~20ms 而不是 320ms),但 16 个全拿到 1 // ——每个 Runtime 一份自己的 counter,压根没共享。 // // 这条和前两条合起来是个 2×2: // // JS 侧 counter Go 侧 counter // 独立 Runtime 全是 1,并行 ✗ 1..16,并行 ✓ // 共用 Runtime 1..16,串行 ✗ (没必要,白白串行) // // 只有右上角那格两头都占。 func TestGoja_独立Runtime里的JS计数器不共享(t *testing.T) { const n = 16 // 跟 TestGoja_阻塞时共用Runtime串行 用的是同一段脚本 prog, err := goja.Compile("blocking.js", ` var counter = 0; function bump() { counter++; sleep(); return counter } function read() { return counter } ({ bump: bump, read: read })`, false) if err != nil { t.Fatal(err) } sleep := sleeper(blockOnce) // 把每个 Runtime 的 read 留下来,跑完再各读各的 var readMu sync.Mutex var readers []goja.Callable start := time.Now() got := runConcurrent(t, n, func() (int64, error) { rt := goja.New() // 每个 goroutine 一个 Runtime if err := rt.Set("sleep", sleep); err != nil { return 0, err } v, err := rt.RunProgram(prog) if err != nil { return 0, err } exports := v.ToObject(rt) fn, ok := goja.AssertFunction(exports.Get("bump")) if !ok { return 0, fmt.Errorf("bump 不是函数") } read, ok := goja.AssertFunction(exports.Get("read")) if !ok { return 0, fmt.Errorf("read 不是函数") } r, err := fn(goja.Undefined()) if err != nil { return 0, err } readMu.Lock() readers = append(readers, read) readMu.Unlock() return r.ToInteger(), nil }) elapsed := time.Since(start) fmt.Printf("对照 独立 Runtime + JS 计数器: %v 总耗时 %v\n", got, elapsed.Round(time.Millisecond)) // 并行了 if limit := blockOnce * n / 2; elapsed > limit { t.Errorf("独立 Runtime 该并行,总耗时 %v 超过了 %v", elapsed, limit) } // 但没共享:每个 Runtime 自己的 counter 都是第一次 +1 for _, v := range got { if v != 1 { t.Fatalf("每个 Runtime 一份 counter,该全是 1,出现了 %d;全部结果 %v", v, got) } } // 全部结束后各读各的:16 个 Runtime,16 份 counter,每份都是 1。 // 对比共用 Runtime 那条——那边最终读到 16。 // // 每个 Runtime 只有创建它的那条路径碰过,此刻也没人在跑,所以读不用加锁。 finals := make([]int64, 0, len(readers)) for _, read := range readers { v, err := read(goja.Undefined()) if err != nil { t.Fatal(err) } finals = append(finals, v.ToInteger()) } fmt.Printf(" 并发结束后各自 read(): %v\n", finals) for i, v := range finals { if v != 1 { t.Errorf("第 %d 个 Runtime 的 counter 该是 1,读到 %d", i, v) } } } // Go 的切片和 map 进到脚本里长什么样。 // // 这不是 jscriptx 的行为,是 goja 的——但脚本作者是照着这个写代码的,一旦变了, // 业务脚本会**静默**走错分支(比如查出来的记录突然 Array.isArray 为 false, // 整段列表被当成空的),所以在这里钉住。 // // 之所以走 jscriptx 而不是直接用 goja:注入前还经过一层 freeze,得连那层一起验。 func TestGo值到脚本里的形状(t *testing.T) { e, err := jscriptx.New(jscriptx.WithLogger(nil)) if err != nil { t.Fatal(err) } defer e.Close() // 照着资源查询的返回长的:[]map[string]any,里面混着切片、字符串和 nil records := func() []map[string]any { return []map[string]any{{ "role": "user", "content": "你好", "attempts": []any{map[string]any{"name": "a.pdf"}, map[string]any{"url": "http://x"}}, "tool_calls": `[{"id":"1"}]`, // 列里存的是 JSON 字符串 "reasoning": nil, }} } s, err := e.Compile("shape.ts", ` export default class C { Run() { const rows = getRecords() const r = rows[0] return { // 切片是真数组:length / for-of / map 都能用 rowsIsArray: Array.isArray(rows), rowsLen: rows.length, attemptsIsArray: Array.isArray(r.attempts), attemptsLen: r.attempts.length, mapped: r.attempts.map(a => a.name || a.url).join(","), // map[string]any 的值按 Go 的类型各自转换,不会统一成字符串 contentType: typeof r.content, toolCallsType: typeof r.tool_calls, // Go 的 nil 是 null,不是 undefined——判空要用 == null 或 === null reasoningIsNull: r.reasoning === null, reasoningIsUndef: r.reasoning === undefined, // 没有的列是 undefined,不会报错 missingIsUndef: r.nope === undefined, } } }`) if err != nil { t.Fatal(err) } ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeGlobals(map[string]any{"getRecords": records})) obj, err := s.New(ctx) if err != nil { t.Fatal(err) } defer obj.Close() var got map[string]any if err := obj.CallInto(ctx, "Run", &got); err != nil { t.Fatal(err) } want := map[string]any{ "rowsIsArray": true, "rowsLen": int64(1), "attemptsIsArray": true, "attemptsLen": int64(2), "mapped": "a.pdf,http://x", "contentType": "string", "toolCallsType": "string", "reasoningIsNull": true, "reasoningIsUndef": false, "missingIsUndef": true, } for k, w := range want { if got[k] != w { t.Errorf("%s = %#v,该是 %#v", k, got[k], w) } } }