Files
gobridge/README.md
what 57775a33ec feat: 添加 []byte ↔ bytes 支持(base64 透明编解码)
- Python 侧:_decode_bytes_args 根据函数注解自动解码入参,_bytes_encode 自动编码 bytes 返回值
- _cast 支持 bytes 类型(call_go[bytes] 返回值解码)
- 流式输出同步支持 chan []byte(每个 chunk 独立编解码)
- example/worker.py 新增 bytes_reverse / bytes_concat / bytes_chunks 示例
- example/main.go 新增对应演示用例
- README 补充类型表 []byte 行及完整使用章节
2026-05-19 20:07:31 +08:00

604 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 取消支持。