# gobridge 在 Go 与 Python 之间建立双向通信桥接,将 Go 的 `channel` 与 Python 的 `yield` 原生对接,支持普通调用、单向流、双向流。 底层通过 **Unix Domain Socket (UDS)** 通信,Go 侧维护 **Worker 进程池**,Python 侧以多线程方式并发处理请求。 ## 特性 - **零配置序列化**:Go struct/slice ↔ Python dict/list 通过 JSON 自动互转 - **原生流语义**:Go `chan T` 对应 Python `Iterator[T]`,无需额外 API - **进程池**:Go 自动启动并管理多个 Python 子进程,崩溃后自动重启 - **ctx 取消**:Go `context` 取消时自动中断 Python 计算,无需函数内检查 - **四种调用模式**:普通、流式输出、流式输入、双向流,同一个 `Invoke` 函数自动推断 ## 安装 ```bash go get git.fsdpf.net/go/gobridge ``` Python 端直接复制 `python/gobridge/` 目录到项目中,无需安装依赖(仅用标准库)。 ## 快速开始 **Python 端(worker.py):** ```python from gobridge import gobridge, run from typing import Iterator @expose def add(a: int, b: int) -> int: return a + b run() ``` **Go 端:** ```go pool, _ := gobridge.NewPool("worker.py") defer pool.Close() ctx := context.Background() sum, _ := gobridge.Invoke[int](ctx, pool, "add", 3, 4) fmt.Println(sum) // 7 ``` ## 四种调用模式 ### 1. 普通调用 ```go // Go sum, err := gobridge.Invoke[int](ctx, pool, "add", 3, 4) user, err := gobridge.Invoke[User](ctx, pool, "get_user", 42) result, err := gobridge.Invoke[[]User](ctx, pool, "enrich_users", users) ``` ```python # Python @expose def add(a: int, b: int) -> int: return a + b @expose def get_user(uid: int) -> dict: return {"id": uid, "name": f"user_{uid}", "score": uid * 1.5} @expose def enrich_users(users: list) -> list: for u in users: u["level"] = "gold" if u["score"] >= 10 else "silver" return users ``` ### 2. 流式输出(Python yield → Go channel) 返回类型为 `chan T` 时自动进入流式输出模式,Python 函数使用 `yield`,Go 侧通过 `range` 消费。 ```go // Go ch, err := gobridge.Invoke[chan int](ctx, pool, "range_gen", 1, 6) for v := range ch { fmt.Println(v) // 1 2 3 4 5 } userCh, err := gobridge.Invoke[chan User](ctx, pool, "gen_users", 3) for u := range userCh { fmt.Println(u) } ``` ```python # Python @expose def range_gen(start: int, stop: int) -> Iterator[int]: for i in range(start, stop): yield i @expose def gen_users(count: int) -> Iterator[dict]: for i in range(1, count + 1): yield {"id": i, "name": f"user_{i}", "score": float(i * 3)} ``` ### 3. 流式输入(Go channel → Python Iterator) 参数中含 `chan T` 且返回非 `chan` 时自动进入流式输入模式。 ```go // Go inputCh := make(chan int, 10) go func() { for i := 1; i <= 5; i++ { inputCh <- i } close(inputCh) }() total, err := gobridge.Invoke[int](ctx, pool, "sum_stream", inputCh) fmt.Println(total) // 15 ``` ```python # Python @expose def sum_stream(numbers: Iterator[int]) -> int: return sum(numbers) ``` ### 4. 双向流(Go channel 输入 + Go channel 输出) 参数含 `chan T` 且返回类型也为 `chan R` 时自动进入双向流模式。 ```go // Go inCh := make(chan User, 5) go func() { for _, u := range users { inCh <- u } close(inCh) }() outCh, err := gobridge.Invoke[chan User](ctx, pool, "process_users", inCh) for u := range outCh { fmt.Println(u) } ``` ```python # Python @expose def process_users(users: Iterator[dict]) -> Iterator[dict]: for u in users: yield {"id": u["id"], "name": u["name"].upper(), "score": u["score"] * 2} ``` ## ctx 取消 Go 的 `context` 取消会自动中断 Python 侧的执行,无需在 Python 函数中做任何检查: ```go ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() // 超时或手动 cancel() 后,Python 计算立即中断,返回 context.DeadlineExceeded result, err := gobridge.Invoke[int](ctx, pool, "slow_compute", 1000000) ``` ```python @expose def slow_compute(n: int) -> int: total = 0 for i in range(n): total += i # ctx 取消时此处自动抛出 InterruptedError,无需手动检查 return total ``` **实现机制:** 1. Go ctx 取消 → 发送 `cancel` 消息给 Python 2. Python 单 reader 线程收到 `cancel` → 向执行线程注入 `InterruptedError`(`PyThreadState_SetAsyncExc`) 3. Python 函数在下一条字节码指令处中断 4. Go 同时关闭连接,解除阻塞的读写操作 > 限制:长时间不释放 GIL 的 C 扩展(如大规模 numpy 矩阵运算)无法被中断,需等其释放 GIL 后才触发。 ## Session 亲和路由 默认情况下,每次 `Invoke` 通过轮询分配 worker 进程。当多次调用需要共享同一 Python 进程的状态时,可以使用 Session 或 StickyCtx 将调用固定到同一进程。 ### NewSession `NewSession` 返回一个固定到某个 worker 的 `Pool` 视图,通过它发起的所有 `Invoke` 始终路由到同一 Python 进程: ```go pool, _ := gobridge.NewPool("worker.py", gobridge.WithWorkers(2)) sessA := gobridge.NewSession(pool) // 固定到 worker 1 sessB := gobridge.NewSession(pool) // 固定到 worker 0(轮询下一个) sessC := gobridge.NewSession(pool) // 固定到 worker 1(与 sessA 同进程) gobridge.Invoke(ctx, sessA, "init", "A", 100) gobridge.Invoke(ctx, sessA, "step", "A", 10) // 始终走 worker 1 gobridge.Invoke(ctx, sessC, "step", "C", 99) // 也走 worker 1,但 session_id 不同 ``` **Python 侧**用模块级 dict 以 `session_id` 为 key 隔离状态: ```python _sessions = {} @expose def init(session_id: str, value: int): _sessions[session_id] = {"value": value} @expose def step(session_id: str, delta: int) -> int: _sessions[session_id]["value"] += delta return _sessions[session_id]["value"] ``` > `NewSession` 不拥有底层 pool 的生命周期,调用 `session.Close()` 是空操作,只需关闭原始 pool。 ### StickyCtx `StickyCtx` 将亲和键写入 ctx,相同 key 通过哈希稳定路由到同一 worker,无需持有 session 对象: ```go // 每次调用前附加 key,相同 key 始终走同一 worker ctx = gobridge.StickyCtx(ctx, "user-42") gobridge.Invoke(ctx, pool, "method_a", ...) gobridge.Invoke(ctx, pool, "method_b", ...) ``` 适合按用户 ID、租户 ID 等自然键做路由,不需要显式创建 session 对象。 ### 进程级全局变量 同一 worker 进程内的所有 session 共享进程级变量(如模块级 `dict`、计数器等),不同 worker 进程之间完全隔离: ```python _counter = 0 # 进程级全局变量 @expose def increment(delta: int) -> int: global _counter _counter += delta return _counter ``` ```go sessA := gobridge.NewSession(pool) // worker 1 sessC := gobridge.NewSession(pool) // worker 1(与 A 同进程) sessB := gobridge.NewSession(pool) // worker 0(独立进程) gobridge.Invoke(ctx, sessA, "increment", 10) // worker 1: counter = 10 gobridge.Invoke(ctx, sessC, "increment", 5) // worker 1: counter = 15(共享) gobridge.Invoke(ctx, sessB, "increment", 99) // worker 0: counter = 99(独立) ``` ### 两种方式对比 | | `NewSession` | `StickyCtx` | |---|---|---| | 路由方式 | 创建时轮询确定 worker | 按 key 哈希确定 worker | | 适用场景 | 显式会话管理 | 按自然键(用户 ID 等)路由 | | 携带方式 | 替换 `pool` 参数 | 写入 `ctx` | | 超时支持 | `context.WithTimeout(ctx, d)` 正常使用 | 同左 | ## []byte ↔ bytes 支持 Go 的 `[]byte` 通过 **base64** 编码在 JSON 帧中传输,框架在 Python 侧自动完成编解码,用户侧完全透明。 ### Python 侧 函数参数注解为 `bytes` 时,框架自动将 Go 传入的 base64 字符串解码为 `bytes`; 返回值为 `bytes` 时,框架自动将其编码为 base64 字符串再发送给 Go。 ```python from gobridge import expose from typing import Iterator @expose def bytes_reverse(data: bytes) -> bytes: return data[::-1] @expose def bytes_concat(a: bytes, b: bytes) -> bytes: return a + b # 流式输出 bytes:对应 Go Invoke[chan []byte] @expose def bytes_chunks(data: bytes, size: int) -> Iterator[bytes]: for i in range(0, len(data), size): yield data[i:i + size] ``` ### Go 侧 Go 直接使用 `[]byte`,`encoding/json` 自动处理 base64 编解码: ```go // 普通 []byte 参数与返回值 rev, err := gobridge.Invoke[[]byte](ctx, pool, "bytes_reverse", []byte("hello")) fmt.Printf("%s\n", rev) // olleh cat, err := gobridge.Invoke[[]byte](ctx, pool, "bytes_concat", []byte("foo"), []byte("bar")) fmt.Printf("%s\n", cat) // foobar // 流式输出 []byte ch, err := gobridge.Invoke[chan []byte](ctx, pool, "bytes_chunks", []byte("abcdefgh"), 3) for chunk := range ch { fmt.Printf("%s ", chunk) // abc def gh } ``` > **效率说明**:base64 编码约使数据体积增大 33%,并有少量 CPU 开销。 > 对于小块二进制数据(< 1 MB)的 RPC 调用,这通常可以忽略不计; > 若需传输大量原始二进制流,建议改用独立的 socket/文件通道。 ## 注意事项 ### 在 handler 中使用 `threading.Thread` handler 函数内部可以启动后台线程,但行为取决于是否等待: ```python # ✅ fire-and-forget:立即返回,连接立即释放,后台线程独立运行 @expose def do_something(): threading.Thread(target=long_task, daemon=True).start() return "ok" # ⚠️ 等待线程:连接被占用直到线程结束,等同于直接在 handler 里执行 @expose def do_something(): t = threading.Thread(target=long_task) t.start() t.join() # 连接在此阻塞 return "ok" ``` **`call_go()` 只能在 handler 的原始线程中调用。** `call_go()` 依赖线程局部变量 `_local.mux` 获取当前连接,后台线程中该变量不存在,调用会抛出 `RuntimeError`: ```python @expose def do_something(): def bg(): call_go("Method") # ❌ RuntimeError: call_go() must be called within a gobridge handler threading.Thread(target=bg, daemon=True).start() return "ok" ``` 如果后台线程的结果需要回调 Go,应在 handler 线程中通过 `queue.Queue` 等待后台线程结果后再调用: ```python @expose def do_something(): result_q = queue.Queue() threading.Thread(target=lambda: result_q.put(compute()), daemon=True).start() result = result_q.get() # 等待后台线程 return call_go[str]("Process", result) # ✅ 在 handler 线程中调用 ``` ## 进程自动重启 Python worker 进程崩溃时自动重启,调用方无感知: ``` Python 进程崩溃 → monitor goroutine 检测到退出 → 排空失效连接 → 指数退避重启(100ms → 200ms → ... → 30s) → 新进程就绪后恢复连接池 ``` ## 配置 `NewPool` 使用函数选项模式,第一个参数为脚本路径: ```go // 最简调用 pool, err := gobridge.NewPool("worker.py") // 完整配置 pool, err := gobridge.NewPool("worker.py", gobridge.WithWorkers(4), // Python 进程数量,默认 2 gobridge.WithMaxConns(8), // 每进程最大连接数,默认 4 gobridge.WithPythonExe("python3"), // 可执行文件,默认 "python3" gobridge.WithWorkDir("/path/to/workdir"), // 工作目录,默认继承当前进程 gobridge.WithEnv("PYTHONUNBUFFERED=1", "K=V"), // 附加环境变量,与当前进程环境合并 gobridge.WithSocketDir("/var/run/myapp"), // socket 文件目录,默认 /tmp gobridge.WithStdout(os.Stdout), // 子进程 stdout,默认 os.Stdout gobridge.WithStderr(os.Stderr), // 子进程 stderr,默认 os.Stderr ) // 静默模式:丢弃子进程输出 pool, err := gobridge.NewPool("worker.py", gobridge.WithStdout(io.Discard), gobridge.WithStderr(io.Discard), ) ``` | Option | 说明 | 默认值 | |--------|------|--------| | `WithWorkers(n)` | Python 进程数量 | `2` | | `WithMaxConns(n)` | 每进程最大连接数 | `4` | | `WithPythonExe(exe)` | 可执行文件 | `"python3"` | | `WithScriptArgs(args...)` | 脚本路径之后的附加参数 | 无 | | `WithWorkDir(dir)` | 子进程工作目录 | 继承当前进程 | | `WithEnv(kv...)` | 附加环境变量 `"K=V"` | 无 | | `WithSocketDir(dir)` | UDS socket 文件目录 | `"/tmp"` | | `WithStdout(w)` | 子进程标准输出 | `os.Stdout` | | `WithStderr(w)` | 子进程标准错误 | `os.Stderr` | ## 使用 uv 管理 Python 环境 推荐使用 [uv](https://github.com/astral-sh/uv) 管理 Python 版本和虚拟环境。 **方式一:`uv run`(推荐,无需手动激活环境)** ```go // 等价于执行:uv run worker.py pool, err := gobridge.NewPool("run", gobridge.WithPythonExe("uv"), gobridge.WithScriptArgs("worker.py"), gobridge.WithWorkDir("./worker"), // uv 项目目录(含 pyproject.toml) ) ``` **方式二:直接使用虚拟环境的 python** ```bash cd worker && uv sync ``` ```go venvPython, _ := exec.LookPath("worker/.venv/bin/python") pool, err := gobridge.NewPool("worker/worker.py", gobridge.WithPythonExe(venvPython), ) ``` **方式三:shell 脚本封装(适合 CI/部署)** ```bash #!/bin/sh # run_worker.sh cd "$(dirname "$0")" exec uv run python worker.py ``` ```go pool, err := gobridge.NewPool("./run_worker.sh", gobridge.WithPythonExe("/bin/sh"), ) ``` **典型项目结构:** ``` myproject/ ├── main.go ├── go.mod └── worker/ ├── pyproject.toml ├── uv.lock ├── .venv/ └── worker.py ``` `pyproject.toml`: ```toml [project] name = "worker" version = "0.1.0" requires-python = ">=3.11" dependencies = [] [tool.uv.sources] # 从 git 仓库安装(推荐) gobridge = { git = "https://git.fsdpf.net/go/gobridge.git", subdirectory = "python" } # 本地开发时改用本地路径 # gobridge = { path = "../../python", editable = true } ``` ## 通信协议 ### 整体架构 ``` Go 进程 ┌─────────────────────────────────────────────────────────┐ │ Invoke[R](ctx, pool, method, args...) │ │ │ │ │ ┌────▼─────────────────────────────────────────────┐ │ │ │ Pool │ │ │ │ workers[0] workers[1] ... workers[N-1] │ │ │ │ (轮询选择) │ │ │ └────┬─────────────────────────────────────────────┘ │ │ │ 每 worker 维护 M 个可复用连接 │ └───────┼─────────────────────────────────────────────────┘ │ Unix Domain Socket(每 worker 独立 .sock 文件) ┌───────▼──────────────┐ ┌──────────────────────────┐ │ Python 进程 0 │ │ Python 进程 1 │ │ worker.py │ │ worker.py │ └──────────────────────┘ └──────────────────────────┘ ``` ### Python Worker 内部结构 ``` Python 进程 ┌──────────────────────────────────────────────────────────────┐ │ run() ── UDS server.accept() 循环 │ │ │ │ │ 每个连接 → 独立线程 _handle_conn() │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ _handle_conn(连接线程) │ │ │ │ │ │ │ │ ┌──────────────────────────────────────────────┐ │ │ │ │ │ _ConnMux(单 reader 线程) │ │ │ │ │ │ │ │ │ │ │ │ socket ──► 读消息 │ │ │ │ │ │ │ │ │ │ │ │ │ ┌────────┼──────────┐ │ │ │ │ │ │ ▼ ▼ ▼ │ │ │ │ │ │ call_q chunk_q cancel │ │ │ │ │ └─────────┬────────┬──────────┼───────────────┘ │ │ │ │ │ │ │ │ │ │ │ ▼ │ ▼ │ │ │ │ 主循环读取 │ PyThreadState_SetAsyncExc │ │ │ │ _dispatch() │ → 执行线程抛 InterruptedError│ │ │ │ │ │ │ │ │ │ ┌──────▼──────┐ │ │ │ │ │ │ @expose fn│ │ │ │ │ │ │ │ │ │ │ │ │ │ 普通函数 │ │ │ │ │ │ │ return val ──────────────► result/error │ │ │ │ │ │ │ │ │ │ │ │ 生成器函数 │ │ │ │ │ │ │ yield val ───────────────► chunk × N │ │ │ │ │ │ │ + end │ │ │ │ │ 流式输入 │ │ │ │ │ │ │ Iterator ◄─┘ │ ← chunk_q │ │ │ │ └─────────────┘ │ │ │ └─────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────┘ ``` **消息帧:** `[4字节大端长度][JSON载荷]` **消息类型:** | type | 方向 | 含义 | |----------|--------------|------------------------------| | `call` | Go → Python | 调用请求 | | `result` | Python → Go | 普通返回值 | | `chunk` | 双向 | 流数据块 | | `end` | 双向 | 流结束标记 | | `error` | 双向 | 错误响应 | | `cancel` | Go → Python | 取消请求,触发 InterruptedError | ## 项目结构 ``` gobridge/ ├── protocol.go # Message 结构与类型常量 ├── framing.go # 帧读写(4字节长度前缀 + JSON) ├── worker.go # Python 子进程管理 + UDS 连接池 + 自动重启 ├── pool.go # 多进程池(轮询负载均衡)+ Option 函数 ├── client.go # Invoke[R] 泛型函数(四种模式自动推断) ├── example/ │ ├── main.go # 完整调用示例 │ └── worker.py # Python 函数示例 └── python/ └── gobridge/ └── __init__.py # Python 库(expose、run、_ConnMux) ``` ## 类型对应关系 | Go 类型 | Python 类型 | |-----------|---------------| | `int` | `int` | | `float64` | `float` | | `string` | `str` | | `bool` | `bool` | | `[]byte` | `bytes` | | `struct` | `dict` | | `[]T` | `list` | | `chan T` | `Iterator[T]` | > `[]byte` 经 base64 编码在 JSON 帧中传输,框架自动完成编解码,用户侧透明。`chan T` 中的 `T` 同样支持 `[]byte`,即 `chan []byte` ↔ `Iterator[bytes]`。 ## 参考 本项目的进程池、UDS 通信、帧协议设计参考自 [pyproc](https://github.com/YuminosukeSato/pyproc),在此基础上增加了 Go channel 与 Python yield 的流式对接及 ctx 取消支持。