一、RPC 核心概念
1. 什么是 RPC
RPC(Remote Procedure Call,远程过程调用) 是一个节点请求另一个节点提供的服务。
- 本地过程调用:最常见的形式是函数调用,如
Add(1, 2),程序在同一进程内执行 - 远程过程调用:将函数执行放在远程机器上,通过网络返回结果,调用方感知不到“远程”的存在
RPC 的目标是让开发者像调用本地函数一样调用远程服务,框架负责屏蔽网络通信、序列化、函数定位等底层细节。
RPC 不是某个具体的第三方服务,而是一种设计思想或编程模式——它定义了一套规范,让程序可以调用另一个程序中的函数。这个“另一个程序”通常运行在独立的进程中(即微服务架构中的一个服务),两者通过网络通信。实现这套规范的库(如 Go 的
net/rpc、gRPC 等)统称为 RPC 框架。
2. RPC 要解决的三大核心问题
1) Call ID 映射
远程调用无法像本地调用那样使用函数指针(不同进程的地址空间不同),需要为每个函数分配唯一的 Call ID,客户端和服务端各自维护函数与 Call ID 的映射表。
调用流程:
- 客户端查表获取目标函数的 Call ID
- 将 Call ID 通过网络发送到服务端
- 服务端查表确定要调用的函数
2) 序列化与反序列化
参数和返回值需要在网络传输前转换为字节流,接收方再还原为内存对象。
| 操作 | 方向 | 说明 |
|---|---|---|
| 序列化 | 对象 → 字节流 | 客户端发送参数、服务端返回结果 |
| 反序列化 | 字节流 → 对象 | 服务端接收参数、客户端接收结果 |
-
常见序列化协议:
JSON、XML、Protocol Buffers(Protobuf)、MessagePack、Gob(Go 内置)
-
序列化协议的选择逻辑:
序列化协议的核心差异在于数据格式和解析效率。
- 文本协议(JSON、XML):可读性好,便于调试,但体积大、解析慢。适合对外 API、调试场景
- 二进制协议(Protobuf、MessagePack、Gob):体积小、解析快,但不可读。适合内部服务间通信
这也是 gRPC 选择 Protobuf 的原因——微服务内部通信对性能和带宽有更高要求,二进制协议的优势在此场景下更为明显。
3) 网络传输
需要可靠的网络传输机制,传输 Call ID 和序列化后的数据。
| 协议 | 特点 | 适用场景 |
|---|---|---|
| TCP | 可靠、面向连接 | 大多数 RPC 框架的基础 |
| HTTP/1.x | 短连接、文本协议 | 简单的 Web API |
| HTTP/2 | 多路复用、长连接 | gRPC 的基础 |
3. RPC 架构的四大组件
| 组件 | 角色 | 核心职责 |
|---|---|---|
| Client(客户端) | 服务调用方 | 发起 RPC 请求 |
| Client Stub(客户端存根) | 本地代理 | 对调用方隐藏“远程”的存在,让远程调用像本地调用一样自然 |
| Server Stub(服务端存根) | 本地代理 | 接收请求,反序列化,调用实际方法,返回结果 |
| Server(服务端) | 服务提供方 | 执行实际的业务逻辑 |
Stub 的本质价值是让调用方只关心“调什么”,不关心“怎么调”。没有 Stub,调用方需要手动处理连接、序列化、网络发送、接收、反序列化等一系列步骤——相当于笔记中“通过 HTTP 理解 RPC”示例的客户端 Add 函数里的全部代码。Stub 将这些底层细节完全封装,开发者只需要像调用本地函数一样调用代理方法即可。
二、通过 HTTP 理解 RPC
在深入 Go 内置 RPC 之前,先用原生 HTTP 实现一个简易 RPC 示例,理解 RPC 要解决的核心问题。
1. 服务端实现
// server/main.go
package main
import (
"encoding/json"
"net/http"
"strconv"
)
// 加法服务
func addHandler(w http.ResponseWriter, r *http.Request) {
// 1. 解析 URL 参数
r.ParseForm()
a, _ := strconv.Atoi(r.Form.Get("a"))
b, _ := strconv.Atoi(r.Form.Get("b"))
// 2. 执行业务逻辑
result := a + b
// 3. 返回 JSON 响应
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]int{"data": result})
}
func main() {
// 注册路由:将 URL 路径 /add 映射到 addHandler 函数
http.HandleFunc("/add", addHandler)
// 启动 HTTP 服务,监听 8000 端口(阻塞调用)
http.ListenAndServe(":8000", nil)
}
2. 客户端实现
// client/main.go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
type Response struct {
Data int `json:"data"`
}
func Add(a, b int) int {
// 1. 构造请求 URL(参数以 key=value 格式拼接到 URL 中,这是一种最简单的序列化方式)
url := fmt.Sprintf("http://127.0.0.1:8000/add?a=%d&b=%d", a, b)
// 2. 发送 HTTP GET 请求(阻塞调用,等待服务端响应)
resp, err := http.Get(url)
if err != nil {
fmt.Println("请求失败:", err)
return 0
}
defer resp.Body.Close()
// 3. 读取响应体
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("读取响应失败:", err)
return 0
}
// 4. 反序列化 JSON 响应
var result Response
json.Unmarshal(body, &result)
return result.Data
}
func main() {
fmt.Println(Add(1, 2)) // 3
}
代码中
http.Get是阻塞调用,它会等待服务器返回响应(或超时、出错)后才会继续执行后续代码。
3. 这种实现的局限
通过 HTTP 手动实现远程调用,虽然能工作,但存在明显的不足:
| 问题 | 说明 |
|---|---|
| 手动处理 HTTP 细节 | 每个接口都需要重复写 URL 拼接、Header 设置、状态码判断 |
| 手动序列化/反序列化 | 每次调用都要手动 json.Marshal/Unmarshal |
| 函数映射硬编码 | 函数通过 URL 路径(如 /add)硬编码,无法动态扩展 |
-
当前客户端
Add函数虽然已经封装了 HTTP 调用细节,从调用方视角看“像本地函数”,但这个封装是手动为每个函数逐一编写的。如果有几十上百个函数,手动封装的工作量会急剧膨胀。RPC 框架的价值在于:自动完成这个封装过程——你只需要定义服务接口,框架自动生成客户端代理,序列化、网络传输、函数映射等细节都由框架处理。 -
这个 HTTP 示例很好地说明了 RPC 框架要解决的核心问题:将网络通信、序列化、函数映射等通用逻辑抽象出来,让开发者无需手动为每个函数编写封装代码,就能像调用本地函数一样调用远程服务。
三、Go 内置 RPC 开发
Go 标准库 net/rpc 提供了完整的 RPC 实现,自动处理序列化、网络传输和函数映射。
1. 服务端实现
// server/main.go
package main
import (
"net"
"net/rpc"
)
// 1. 定义服务结构体(首字母大写,导出)
type HelloService struct{}
// 2. 定义服务方法
// 方法签名要求:func (t *T) MethodName(args *Args, reply *Reply) error
func (h *HelloService) Hello(request string, reply *string) error {
*reply = "hello " + request
return nil
}
func main() {
// 3. 注册服务
rpc.RegisterName("HelloService", new(HelloService))
// 4. 启动 TCP 监听
listener, _ := net.Listen("tcp", ":1234")
// 5. 接受并处理连接(并发)
for {
conn, _ := listener.Accept()
go rpc.ServeConn(conn)
}
}
2. 客户端实现
// client/main.go
package main
import (
"fmt"
"net/rpc"
)
func main() {
// 1. 建立连接
client, err := rpc.Dial("tcp", "localhost:1234")
if err != nil {
panic("连接失败")
}
// 2. 发起 RPC 调用
var reply string
err = client.Call("HelloService.Hello", "bobby", &reply)
if err != nil {
panic("调用失败")
}
fmt.Println(reply) // hello bobby
}
3. 方法签名规则
Go 内置 RPC 对方法签名有严格要求,否则无法注册:
func (t *T) MethodName(args *Args, reply *Reply) error
必须满足以下条件:
- 方法必须导出(首字母大写)
- 接收者类型必须导出(首字母大写)或为内置类型
- 方法必须有两个参数:请求参数(任意类型)和响应参数(必须是指针)
- 返回值必须是
error
4. 常见错误
错误1:响应参数未分配内存
// 错误写法:传递 nil 指针
var reply *string
client.Call("HelloService.Hello", "bobby", reply)
// 报错:DecodeValue of unassignable value
// 正确写法:使用 new 或已声明的变量
reply := new(string)
client.Call("HelloService.Hello", "bobby", reply)
// 或
var reply string
client.Call("HelloService.Hello", "bobby", &reply)
错误2:方法签名不正确
// 错误写法:第二个参数不是指针
func (h *HelloService) Hello(request string, reply string) error
// 错误写法:返回值不是 error
func (h *HelloService) Hello(request string, reply *string) string
// 错误写法:方法未导出
func (h *HelloService) hello(request string, reply *string) error
// 正确写法
func (h *HelloService) Hello(request string, reply *string) error
四、替换序列化协议为 JSON
Go 内置 RPC 默认使用 gob 协议(Go 原生二进制格式),性能好但仅限 Go 语言使用。替换为 JSON 协议后,任何语言只要支持 JSON-RPC 即可调用。
1. 服务端改造
// server/main.go
package main
import (
"net"
"net/rpc"
"net/rpc/jsonrpc"
)
type HelloService struct{}
func (h *HelloService) Hello(request string, reply *string) error {
*reply = "hello " + request
return nil
}
func main() {
rpc.RegisterName("HelloService", new(HelloService))
listener, _ := net.Listen("tcp", ":1234")
for {
conn, _ := listener.Accept()
go func() {
// 关键改动:使用 jsonrpc 替代默认的 gob
rpc.ServeCodec(jsonrpc.NewServerCodec(conn))
}()
}
}
2. 客户端改造
// client/main.go
package main
import (
"fmt"
"net"
"net/rpc"
"net/rpc/jsonrpc"
)
func main() {
// 关键改动:使用 net.Dial + jsonrpc
conn, _ := net.Dial("tcp", "localhost:1234")
client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))
var reply string
client.Call("HelloService.Hello", "bobby", &reply)
fmt.Println(reply)
}
3. 跨语言验证:Python 客户端
import json
import socket
# 构造 JSON-RPC 请求
request = {
"id": 0,
"params": ["bobby"],
"method": "HelloService.Hello"
}
# 发送 TCP 请求
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(("localhost", 1234))
sock.sendall(json.dumps(request).encode())
# 接收并解析响应
response = json.loads(sock.recv(1024).decode())
print(response["result"]) # hello bobby
通过 Python 客户端成功调用 Go 服务,证明 JSON-RPC 可以实现跨语言通信。
五、封装客户端代理
原生 client.Call 调用方式不够直观,需要通过代理模式封装,实现类似本地函数的调用体验。
1. 原生调用的不足
// 原生调用需要手动拼接服务名和方法名
var reply string
client.Call("HelloService.Hello", "bobby", &reply)
问题:
- 服务名和方法名以字符串形式硬编码,容易拼写错误
- 每次调用都要重复写相同的参数结构
- 调用体验与本地函数差异大
2. 代理模式实现
// client/proxy.go
package main
import (
"net/rpc"
)
// 客户端代理结构体
type HelloServiceClient struct {
client *rpc.Client
}
// 工厂函数:创建代理实例
func NewHelloServiceClient(addr string) *HelloServiceClient {
client, _ := rpc.Dial("tcp", addr)
return &HelloServiceClient{client: client}
}
// 代理方法:封装 RPC 调用
func (c *HelloServiceClient) Hello(request string, reply *string) error {
return c.client.Call("HelloService.Hello", request, reply)
}
3. 使用代理
// client/main.go
package main
import (
"fmt"
)
func main() {
// 代理封装了连接和调用细节
client := NewHelloServiceClient("localhost:1234")
var reply string
client.Hello("bobby", &reply)
fmt.Println(reply) // hello bobby
}
现在调用方式与本地函数完全一致,RPC 细节完全隐藏在代理内部。这为后续迁移到 gRPC 等框架打下了基础。
在实际项目中,客户端代理通常由代码生成工具自动生成(如 gRPC 的
protoc插件),无需手写。这里的示例是为了理解代理模式的工作原理。如果项目有多个服务,可以按服务拆分代理文件(如hello_proxy.go、user_proxy.go),但包名需要根据目录结构决定——如果放在client/下并与main.go同包,则仅限该包内使用;如果设计为可被其他包导入的库,则需要将代理代码放在独立的包中(如client/proxy/下声明package proxy)。
六、业务逻辑解耦
将业务逻辑从网络层分离,提高代码的可维护性和可测试性。
1. 独立业务逻辑包
// handler/hello.go
package handler
type HelloService struct{}
func (h *HelloService) Hello(request string, reply *string) error {
*reply = "hello " + request
return nil
}
2. 服务端注册
// server/main.go
package main
import (
"go-playground/handler"
"net"
"net/rpc"
)
func main() {
// 注册业务逻辑(不依赖网络细节)
rpc.RegisterName("HelloService", new(handler.HelloService))
listener, _ := net.Listen("tcp", ":1234")
for {
conn, _ := listener.Accept()
go rpc.ServeConn(conn)
}
}
3. 解耦的核心价值
| 价值 | 说明 |
|---|---|
| 可测试性 | 业务逻辑层与网络层解耦后,测试时可以直接实例化 HelloService,像测试普通函数一样测试 Hello 方法,无需启动完整的 RPC 服务进程 |
| 可维护性 | 业务代码变更不影响网络层代码 |
| 框架迁移 | 切换 RPC 框架(如从内置 RPC 迁移到 gRPC)时,业务逻辑层无需改动 |
| 代码清晰 | 职责分离,每层关注自己的核心问题 |
七、Go 内置 RPC 的适用场景
Go 内置 RPC(net/rpc)适合学习和理解 RPC 原理,但在实际生产项目中较少使用。主要原因:
- 传输协议单一:仅支持 TCP,不支持 HTTP/2,无法利用多路复用、流控等特性
- 跨语言能力弱:Gob 是 Go 特有的,JSON-RPC 虽然跨语言但性能不如 Protobuf
- 缺乏治理能力:没有服务发现、负载均衡、超时控制、链路追踪等分布式系统所需的功能
- 生态已迁移:社区和企业几乎都转向了 gRPC,后者已成为 Go 微服务通信的事实标准
这也正是后续学习 gRPC 的价值所在。
小结
| 知识点 | 核心内容 |
|---|---|
| RPC 三大核心问题 | Call ID 映射、序列化/反序列化、网络传输 |
| RPC 四大架构组件 | Client、Client Stub、Server Stub、Server |
| Go 内置 RPC | net/rpc + net/rpc/jsonrpc;方法签名 func(*args, *reply) error |
| 序列化协议 | Gob(Go 默认)、JSON(跨语言)、Protobuf(高性能) |
| 客户端代理封装 | 通过代理结构体隐藏网络细节,实现本地化调用 |
| 业务解耦 | 业务逻辑独立于网络层,便于测试和框架迁移 |
| 跨语言通信 | JSON-RPC 使得 Python 等语言可调用 Go 服务 |

2214

被折叠的 条评论
为什么被折叠?



