RPC的原理及Go RPC
RPC的原理及Go RPC
一. RPC 相关概念
RPC(Remote Procedure Call),即远程过程调用。它允许像调用本地函数一样去调用远程服务器上的函数。
比如我们在写代码获取用户ID时调用了 GetUserById 这个函数,看起来是个普通函数调用,但实际上这个函数是运行在另一台机器上的。
user,err:=mysql.GetUserById(1)
RPC 解决了:
- 网络通信(TCP/HTTP)
- 数据序列化与反序列化
- 请求分发与结果返回
举个形象的例子,我们调用函数就像打一通“电话”,RPC 就是负责:
拨号(找到远程服务)——> 传话(序列化请求参数,发过去)——> 等回话(反序列化返回结果)
1.1 本地调用
为了更好理解RPC,首先我们来尝试一个简单的本地调用例子
package mainimport "fmt"func Add(a, b int) int {return a + b
}func main() {x := 3y := 5res := Add(x, y)fmt.Println(res)
}
- 编译器把
Add(x, y)
替换成一次函数调用指令(CALL),它会把参数放入寄存器或堆栈中。 x=3
、y=5
作为参数被压栈或传寄存器。- 执行到
CALL Add
指令时,跳转到Add()
的代码地址,在Add()
内部执行a + b
,结果存放在返回寄存器 - 返回寄存器的值被带回调用点,赋值给变量
res
整个过程完全发生在同一个进程内存空间中,没有网络,没有序列化,定义Add
函数的代码和调用Add
函数的代码共享同一个内存空间,所以调用能够正常执行。
1.2 RPC调用
但是我们无法直接在另一个程序中调用Add
函数,因为它们是两个程序——内存空间是相互隔离的。
意思是每个程序运行在自己的进程空间中,我们每运行一个Go程序,系统会创建一个进程,这个进程有自己独立的内存空间,堆,栈等,再运行另一个程序 app2
,系统又创建了另一个进程,它的内存空间和 app1
完全分开。
RPC就是为了解决类似远程、跨内存空间、的函数/方法调用的。
实现RPC面临的问题
一. 如何确定要执行的函数?
我们知道在本地调用中,函数主体通过函数指针函数指定,然后调用 add 函数,编译器通过函数指针函数自动确定 add 函数在内存中的位置。
但在 RPC 中,情况完全不同:
- 客户端和服务端是两个进程,甚至两台机器。
Add()
在客户端并不存在,它的机器码在远端。
所以我们不能用本地函数指针,也不能直接 CALL(函数调用指令)。
于是就需要一种“间接定位”的方式。
这里的解决思路是建立一个函数映射表,也可以理解为设立一个关于函数的注册中心,例如:
function name | ID | 实际函数指针 |
---|---|---|
“Add” | 1 | Add(a, b) |
“Login” | 2 | Login(user, pass) |
客户端调用时:
{"func_id": 1,"params": [3, 5]
}
服务端解析到 ID=1,就能找到对应的 Add()
函数,反射调用它。
因此,RPC 不靠内存地址找函数,而是靠函数名或 ID 查表调用。
二. 如何表达参数?(序列化问题)
本地调用时
在同一进程中,函数调用的参数是直接通过 栈内存 或 寄存器 传递的,比如:
参数 | 值 | 存储位置 |
---|---|---|
a | 3 | 栈上 |
b | 5 | 栈上 |
CPU 直接取栈上的值就能算。
RPC 调用时
客户端和服务端的内存不共享。
你不能直接把栈内存传过去——因为对方进程根本看不到你内存的地址。
所以必须把参数**“打包”成可以跨网络传输的格式**。
这就叫序列化。
序列化后就变成字节流,例如:
{"a":3,"b":5}
→ 转成二进制后发送出去。
服务端收到数据后,再进行反序列化,恢复成结构体。
RPC 不能传内存地址,只能把参数序列化成字节流传过去。
三. 如何进行网络传输?
本地调用时
调用在同一个进程空间内,不需要任何通信协议。
RPC 调用时
客户端和服务端一般在不同进程或不同机器上。
因此必须通过网络通信。
这就涉及两部分:
- 建立连接(TCP / HTTP)
- 发送请求字节流,等待响应字节流
此时我们会用到一些传输协议,比如TCP,比如 gRPC 使用 HTTP/2 协议
RPC 通过网络协议传输序列化后的函数调用请求和返回结果。
关系图
Client (app2) Server (app1)
+--------------------------+ +------------------------------+
| func_id=1 ("Add") | | registry: {"Add"->Add()} |
| params={3,5} | | |
| serialize -> bytes |──TCP/HTTP──>| deserialize -> call Add(3,5) |
| wait for response bytes |<────────────| serialize result=8 |
+--------------------------+ +------------------------------+
RPC 的原理
(这里借用七米老师博客的一张图)
① RPC Call
-
Client(调用方)像本地函数一样调用一个方法,例如:
result := Add(3, 5)
-
实际上,这个函数并不是真正的函数实现,而是一个代理(Client Stub)。
② Client Stub 打包参数(bundle args)
-
Client Stub 把调用的函数名、参数等打包(序列化)成字节流。
-
比如将:
{"method": "Add", "params": [3, 5]}
转换成可以通过网络传输的格式(如 JSON、protobuf、msgpack)。
③ 发送请求(send)
- Client Stub 把序列化后的数据发送给本机的网络层(Network Service)。
- 网络层通过 TCP/HTTP 等协议发送到 Server 所在的主机。
④ 网络传输(Network)
- 数据包在网络上传输,从 Computer 1 发送到 Computer 2。
⑤ Server Stub 接收并解包参数(unbundle args)
-
Server 端的网络服务接收到请求数据,交给 Server Stub。
-
Server Stub 将收到的字节流反序列化(unmarshal)为实际参数。
method = "Add" params = [3, 5]
⑥ 本地调用(local call)
-
Server Stub 根据解析结果调用真正的本地函数:
result := Add(3, 5)
-
这部分就和普通函数调用没区别。
⑦ 函数返回(local return)
-
函数执行完返回结果:
result = 8
⑧ Server Stub 打包返回值(bundle ret vals)
-
Server Stub 将返回值序列化成字节流。
{"result": 8}
⑨ 发送返回数据(send)
- Server Stub 通过网络服务把数据发回给客户端。
⑩ 客户端网络服务接收(receive)
- 客户端网络层收到服务器返回的数据包。
⑪ Client Stub 解包返回值(unbundle ret vals)
-
Client Stub 将字节流反序列化为真正的结果值:
result = 8
⑫ 返回给调用者(RPC return)
-
最终,Client 得到返回值,就像本地函数执行完一样:
fmt.Println(result) // 输出 8
二. 相关方法
在写代码前我们要明确编写架构,及我们需要客户端发起请求,需要服务端接收请求以及需要注册的方法(结构体等相关工具)
rpc_http_demo/
├── client.go # 客户端:发起 RPC 调用
├── server.go # 服务端:提供 RPC 服务
└── service.go # 公共结构体与工具(请求/响应定义)
2.1 基于HTTP的RPC
service.go
// service.go
package main// 封装 RPC 调用的参数
type Params struct {A, B int
}// 定义一个结构体作为服务对象,承载RPC方法,在 RPC 框架里,方法必须属于某个类型(结构体)才能被注册
type ServiceA struct{}// 定义Add方法
func (s *ServiceA) Add(p *Params, result *int) (err error) {*result = p.A + p.Breturn
}
server.go
package mainimport ("log""net""net/http""net/rpc"
)// 注册服务
func main() {//创建实例service := new(ServiceA)//注册服务rpc.Register(service)//注册HTTP处理器rpc.HandleHTTP()//监听连接l, err := net.Listen("tcp", ":8080")if err != nil {log.Fatal("listen error", err)}//启动服务,第二个参数为 nil,意味着使用默认的 HTTP Handlerhttp.Serve(l, nil)
}
client.go
package mainimport ("fmt""log""net/rpc"
)func main() {//建立到服务端的HTTP连接client, err := rpc.DialHTTP("tcp", "127.0.0.1:8080")if err != nil {log.Fatal("dial err:", err)}//传参param := &Params{1, 2}var result interr = client.Call("ServiceA.Add", param, &result)if err != nil {log.Fatal("ServiceA.Add err:", err)}fmt.Println("Add : %d + %d = %d\n", param.A, param.B, result)
}
验证方法
我们可以在本地开两个终端,先启动server.go, 再到另一个终端启动client.go,最后可以看到相加结果
2.2 基于TCP的RPC
我们需要在server和client端做一些修改
server.go
func main() {service := new(ServiceA)rpc.Register(service)//tcp协议l, err := net.Listen("tcp", ":8080")if err != nil {log.Fatal("listen error:", err)}// tcp 核心逻辑for {conn, _ := l.Accept() //阻塞等待客户端连接(返回一个net.Conn对象)rpc.ServeConn(conn) // 为这个连接启动一个 RPC 会话,专门在该连接上处理一次 RPC 请求/响应。}
}
client.go
func main() {// 建立TCP连接client, err := rpc.Dial("tcp", "127.0.0.1:8080")if err != nil {log.Fatal("dialing:", err)}param := &Params{3, 5}var result interr = client.Call("ServiceA.Add", param, &result)if err != nil {log.Fatal("ServiceA.Add error:", err)}fmt.Printf("ServiceA.Add: %d+%d=%d\n", param.A, param.B, result)}
验证方法都是一样的
2.3 基于JSON协议的RPC
server.go
func main() {service := new(ServiceA)rpc.Register(service)//tcp协议l, err := net.Listen("tcp", ":8080")if err != nil {log.Fatal("listen error:", err)}// tcp 核心逻辑for {conn, _ := l.Accept() //阻塞等待客户端连接(返回一个net.Conn对象)//使用JSON协议 rpc.ServeCodec(jsonrpc.NewServerCodec(conn))}
}
client.go
func main() {// 建立TCP连接conn, err := rpc.Dial("tcp", "127.0.0.1:8080")if err != nil {log.Fatal("dialing:", err)}// 使用JSON协议client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))param := &Params{3, 5}var result interr = client.Call("ServiceA.Add", param, &result)if err != nil {log.Fatal("ServiceA.Add error:", err)}fmt.Printf("ServiceA.Add: %d+%d=%d\n", param.A, param.B, result)}