Go 标准库 net/rpc 提供的 HTTP 支持并不是一个普通的 POST 接口,它先通过 HTTP CONNECT 请求建立一条长连接,再把连接从 HTTP 协议中劫持出来,后面完全走 gob 二进制流。因此表面上看服务端只是调用了 http.ListenAndServe,实际上 RPC 调用根本没有经过常规的 HTTP 请求体和响应体。理解了这一层,很多看似奇怪的现象就能解释清楚。

RPC over HTTP 的核心机制与方法签名约束
net/rpc 对可导出的服务方法有严格的签名要求。一个合法的方法必须满足以下形式:方法名首字母大写,第一个参数可以是值或指针,第二个参数必须是指针,返回值只能是 error。如果某个方法的第二个参数不是指针、返回值不是 error,或者方法本身不可导出,服务端注册时并不会报错,而是通过反射将这些方法自动过滤掉。也就是说服务端启动看似正常,但客户端调用时会提示找不到方法。
下面是一个最简单的算术服务定义。注意 Add 方法的第二个参数 reply 必须是指针类型,服务端通过它把结果写回客户端。
type MathService struct{}
type AddArgs struct {
A int
B int
}
type AddReply struct {
Sum int
}
// 方法签名必须满足:func(t *T, args *Args, reply *Reply) error
// 第一个参数可以是值或指针,第二个参数必须是指针
func (m *MathService) Add(args *AddArgs, reply *AddReply) error {
reply.Sum = args.A + args.B
return nil
}
注册服务并启动 HTTP 监听通常只需要三行代码。rpc.HandleHTTP 会把默认路径 rpc.DefaultRPCPath 注册到 DefaultServeMux 上,这个默认路径就是 /_goRPC_。当 http.ListenAndServe 的第二个参数为 nil 时,会使用 DefaultServeMux,因此路径可以被访问到。
rpc.RegisterName("MathService", new(MathService))
rpc.HandleHTTP()
http.ListenAndServe(":8080", nil)
这里需要特别留意:rpc.HandleHTTP 只对 DefaultServeMux 生效。如果服务端创建了自己的 http.NewServeMux 并传入 ListenAndServe,默认路径不会自动注册,客户端将无法连接。很多人一开始使用自定义 mux 时都会遇到连接失败,却不知道原因就是路径没有被注册到正确的 mux 上。
客户端如何正确发起调用以及连接背后的细节
客户端使用 rpc.DialHTTP 建立连接,之后通过 client.Call 同步调用远程方法。调用成功后,返回值会写入 reply 结构体的对应字段。
client, err := rpc.DialHTTP("tcp", "127.0.0.1:8080")
if err != nil {
log.Fatal(err)
}
defer client.Close()
args := &AddArgs{A: 3, B: 5}
var reply AddReply
err = client.Call("MathService.Add", args, &reply)
if err != nil {
log.Fatal(err)
}
fmt.Println(reply.Sum) // 输出 8
rpc.DialHTTP 内部发起的并不是普通 POST 请求,而是一个 CONNECT 请求。服务端在 /_goRPC_ 路径上检测到 CONNECT 方法后,会通过 Hijack 把 TCP 连接从 HTTP 处理器中接管出来,之后这条连接上传输的内容就完全是 gob 编码的 RPC 请求和响应。这就是为什么不能使用 curl 或者浏览器直接调用这个接口,也不能通过普通的 HTTP 反向代理转发,因为这些工具和中间件通常只处理常规 HTTP 方法。
rpc.Client 本身是并发安全的,多个 goroutine 可以同时调用 Call 方法。标准库还提供了 client.Go 用于异步调用,它返回一个 Call 对象,通过 Done 通道可以等待调用完成,并配合 select 实现超时控制。
五个高频陷阱与排查方法
陷阱一:方法签名错误被静默忽略
很多开发者以为注册时方法签名不对会直接 panic,实际上 net/rpc 只是将不符合条件的方法从可调用列表中移除。比如第二个参数不是指针、返回值不是 error,都会导致客户端调用时返回找不到方法的错误,而服务端本身没有任何告警。
// 错误签名:第二个参数不是指针,方法不会出现在可调用列表中
func (m *MathService) WrongAdd(args *AddArgs, reply AddReply) error {
reply.Sum = args.A + args.B
return nil
}
排查这类问题时,可以检查 rpc.DefaultServer 上是否注册了预期的方法,或者在客户端调用前使用 client.Call 测试真实方法名。更稳妥的做法是代码评审时严格核对方法签名,避免把值类型写在 reply 参数上。
陷阱二:gob 只编码可导出字段
gob 编码器只处理首字母大写的字段。如果参数结构体里写了小写字段,客户端即使赋值了也不会被编码,服务端收到的值永远是零值。这个问题不会报错,只会导致计算结果异常,非常隐蔽。
type AddArgs struct {
a int // 小写字段不会进入 gob 编码
B int
}
另外,如果参数或返回值中包含接口类型,必须通过 gob.Register 注册具体的实现类型,否则解码时会出现错误。虽然内建类型和普通结构体通常能自动处理,但涉及接口时一定要显式注册。
陷阱三:默认路径与自定义 mux 冲突
rpc.HandleHTTP 只把路径注册到 DefaultServeMux。如果服务端使用自己的 http.NewServeMux,就需要手动注册。否则客户端连接时要么 404,要么连接被直接关闭。
mux := http.NewServeMux()
mux.Handle(rpc.DefaultRPCPath, rpc.DefaultServer)
http.ListenAndServe(":8080", mux)
如果希望使用自定义路径,可以调用 rpc.DefaultServer.HandleHTTP 的变体,或者直接使用 mux.Handle 注册一个自定义路径,并把 rpc.DefaultServer 作为 handler。客户端必须使用 rpc.DialHTTPPath 指定完全相同的路径。
陷阱四:HTTP 代理与 CONNECT 劫持的限制
由于标准库的 HTTP RPC 客户端实际发送的是 CONNECT 请求而不是 POST,普通 HTTP 反向代理默认只转发 GET、POST 等方法,对 CONNECT 要么拒绝,要么需要单独配置。如果把 Go RPC 服务放在 Kubernetes Ingress 或者公司统一网关后面,客户端很可能连握手都完成不了。
对于需要经过七层代理的场景,建议改用 gRPC 或者 JSON-RPC over HTTP。如果仍然希望使用 net/rpc,可以改用 TCP 四层代理直接透传端口,避免 HTTP 解析带来问题。
陷阱五:连接复用与超时导致 EOF
rpc.DialHTTP 底层的 http.Client 默认开启 keep-alive,客户端多次调用会复用同一条 gob 长连接。如果服务端设置了较短的 ReadTimeout 或 WriteTimeout,空闲连接会被服务端切断,而客户端连接池并不知情。下一次调用写入请求时才会发现连接已经损坏,于是返回 EOF 或 connection reset。
要避免这个问题,最直接的做法是不在 Go RPC HTTP 服务上设置过短的读写超时。如果需要限制调用时间,建议在客户端使用 channel 加 select 实现超时,而不是依赖 HTTP server 的全局超时参数。
进阶:自定义路径、TLS 与异步调用
如果需要把多个 RPC 服务部署在同一个 HTTP 端口上,自定义路径是一种常见做法。服务端把不同服务注册到不同路径,客户端通过 rpc.DialHTTPPath 选择对应的路径。
const rpcPath = "/math-rpc"
mux := http.NewServeMux()
mux.Handle(rpcPath, rpc.DefaultServer)
http.ListenAndServe(":8080", mux)
// 客户端
client, err := rpc.DialHTTPPath("tcp", "127.0.0.1:8080", rpcPath)
生产环境中通常还需要加 TLS 保护。服务端可以使用 http.ListenAndServeTLS 加载证书。客户端因为 rpc.DialHTTP 内部使用 http.DefaultClient,如果需要信任自签名证书,可以临时调整 http.DefaultTransport 的 TLS 配置,但要注意这会影响全局。
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
http.DefaultClient.Transport = tr
client, err := rpc.DialHTTP("tcp", "127.0.0.1:8443")
异步调用配合超时控制可以避免单个慢请求阻塞业务。Go 标准库没有直接提供 context 支持,但可以借助 channel 模拟超时。
call := client.Go("MathService.Add", args, &reply, nil)
select {
case done := <-call.Done:
if done.Error != nil {
log.Fatal(done.Error)
}
fmt.Println(reply.Sum)
case <-time.After(2 * time.Second):
log.Fatal("rpc timeout")
}
总体来说,net/rpc over HTTP 适合内部小规模 Go 服务之间的点对点通信。它实现简单、依赖少,但私有性也强,不支持跨语言,也不适合经过常规七层代理。如果这些限制成为瓶颈,直接选择 gRPC 或 JSON-RPC 会更省心。