这是一个非常硬核且经典的问题。要真正理解 RPC(Remote Procedure Call,远程过程调用)和 HTTP 的区别,以及如何手写一个 RPC 框架,我们需要深入操作系统的网络层、IO 模型以及序列化协议。
第一部分:RPC 与 传统 HTTP 的区别
1. 概念上的误区
首先要纠正一个常见的误区:RPC 和 HTTP 不是对立的。
- RPC 是一种设计思想(我要像调用本地函数一样调用远程函数)。
- HTTP 是一个传输协议。
事实上,很多 RPC 框架(如 gRPC)底层用的就是 HTTP/2。我们通常所说的 "RPC vs HTTP",实际上是在比较 "RPC 风格(如 gRPC, Dubbo)" 与 "RESTful 风格(基于 HTTP/1.1 + JSON)"。
2. 核心区别对比
|----------|-----------------------------------------------------------|----------------------------------------------------|
| 特性 | RPC (以 gRPC/Dubbo 为例) | RESTful HTTP (传统 Web API) |
| 设计初衷 | 动作导向 。隐藏网络细节,让远程调用像本地调用一样(service.getUser(id))。 | 资源导向 。操作资源的状态(GET /users/1)。 |
| 传输协议 | 通常基于 TCP 或 HTTP/2。 | 通常基于 HTTP/1.1。 |
| 报文体积 | 极小。通常使用二进制序列化(Protobuf, Thrift, Hessian),没有冗余 Header。 | 较大。使用文本(JSON/XML),且 HTTP 1.1 Header 通常包含大量元数据。 |
| 性能 | 高。基于二进制、TCP 长连接、多路复用。 | 中等。JSON 解析慢,HTTP/1.1 存在队头阻塞问题。 |
| 开发体验 | 强类型 。通常需要 IDL 文件(如 .proto)生成代码,客户端服务端强契约。 | 弱类型。接口灵活,通常看文档对接,容易出现字段拼写错误。 |
| 适用场景 | 微服务内部通信(追求低延迟、高吞吐)。 | 对外接口(如移动端、Web 前端),因为 HTTP 通用性最好。 |
总结:
- HTTP (REST) 像寄信:格式通用,谁都能读,但信封(Header)很厚,且一来一回慢。
- RPC (TCP/Binary) 像专线电话:语言精简(二进制),双方约定暗号(IDL),连接建立后说话极快。
第二部分:涉及的计算机网络硬核知识
要手搓 RPC,你必须解决网络通信中的三大核心问题。这也是计算机网络的精髓。
1. 寻址与传输(Layer 4 - Transport Layer)
- Socket 编程 :RPC 的本质是网络通信。你需要使用 Socket API(在 Java 中是
Socket/ServerSocket,在 Go 中是net.Dial/net.Listen)。 - TCP 连接复用 :建立 TCP 连接(三次握手)很慢。成熟的 RPC 框架都会使用连接池 或长连接,避免每次调用都握手。
2. 序列化与反序列化(Layer 6 - Presentation Layer)
网络只能传输 0 和 1(字节流),不能传输内存中的对象(如 Java 的 Object 或 Go 的 Struct)。
- 序列化(Marshaling):把内存对象变成二进制串。
- 反序列化(Unmarshaling):把二进制串变回内存对象。
- 手搓选择:为了简单,我们可以用 JSON。为了性能,通常用 Protobuf 或 Hessian。
3. 拆包与粘包(TCP 字节流特性)
这是很多初学者最容易忽略的点。
TCP 是面向字节流的协议,它没有"消息"的概念。
- 粘包:你发送了两个请求 "ABC" 和 "DEF",TCP 为了效率可能会把它们合并成 "ABCDEF" 发送过去。
- 拆包:你发送了一个很大的包,TCP 可能会把它拆成两段发送。
解决方案(自定义应用层协议) :
我们需要定义一个协议格式 。最常用的方式是 Length-Prefix(长度前缀法) 。
格式:[消息长度 (4字节)] + [消息体 (N字节)]
读取时,先读4个字节拿到长度 N,再读取 N 个字节,这样才能保证读到的是一个完整的 RPC 请求。
第三部分:如何手搓一个简单的 RPC
我们以 Java 为例(因为 Java 的动态代理最适合演示 RPC 原理),逻辑通用于所有语言。
架构图
[Client App] --调用--> [Client Stub (Proxy)] --序列化--> [Network]
|
[Server Impl] <--反射调用-- [Server Skeleton] <--反序列化--|
步骤 1: 定义公共接口
客户端和服务端都需要这个接口。
public interface UserService {
User getUser(Integer id);
}
步骤 2: 客户端代理 (The Magic)
客户端并没有 UserService 的实现类,怎么调用?用动态代理 。
代理的作用是:拦截方法调用,把方法名、参数打包发给服务端。
// 这是一个极简的动态代理逻辑
public class RpcClientProxy implements InvocationHandler {
private String host;
private int port;
public RpcClientProxy(String host, int port) {
this.host = host;
this.port = port;
}
// 当你调用 userService.getUser(1) 时,会被这个 invoke 方法拦截
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 1. 封装请求对象 (包含接口名、方法名、参数类型、参数值)
RpcRequest request = new RpcRequest();
request.setClassName(method.getDeclaringClass().getName());
request.setMethodName(method.getName());
request.setParamTypes(method.getParameterTypes());
request.setArgs(args);
// 2. 建立网络连接 (Socket)
Socket socket = new Socket(host, port);
// 3. 发送请求 (序列化) - 这里偷懒用了 Java 自带的序列化,实际应用应换成 JSON/Protobuf
ObjectOutputStream output = new ObjectOutputStream(socket.getOutputStream());
output.writeObject(request); // 发送数据
// 4. 接收响应 (阻塞等待)
ObjectInputStream input = new ObjectInputStream(socket.getInputStream());
Object result = input.readObject(); // 获取结果
// 5. 清理资源
socket.close();
// 6. 返回结果给调用者
return result;
}
// 获取代理对象的工具方法
public <T> T getProxy(Class<T> interfaceClass) {
return (T) Proxy.newProxyInstance(
interfaceClass.getClassLoader(),
new Class<?>[]{interfaceClass},
this
);
}
}
步骤 3: 服务端监听 (The Server)
服务端需要在一个端口一直监听,接收请求,找到对应的实现类,执行代码。
public class RpcServer {
// 存储接口和服务实现类的映射关系
private Map<String, Object> serviceRegistry = new HashMap<>();
public void register(Class interfaceClass, Object impl) {
serviceRegistry.put(interfaceClass.getName(), impl);
}
public void start(int port) throws Exception {
ServerSocket serverSocket = new ServerSocket(port);
System.out.println("RPC Server started on port " + port);
while (true) {
// 1. 监听连接 (BIO 模型,阻塞)
Socket socket = serverSocket.accept();
// 2. 开启线程处理 (为了支持并发,实际应该用线程池)
new Thread(() -> {
try {
ObjectInputStream input = new ObjectInputStream(socket.getInputStream());
// 3. 读取请求 (反序列化)
RpcRequest request = (RpcRequest) input.readObject();
// 4. 从注册表中找到具体的实现类
Object serviceImpl = serviceRegistry.get(request.getClassName());
// 5. 通过反射调用真实方法
Method method = serviceImpl.getClass().getMethod(
request.getMethodName(),
request.getParamTypes()
);
Object result = method.invoke(serviceImpl, request.getArgs());
// 6. 写回结果
ObjectOutputStream output = new ObjectOutputStream(socket.getOutputStream());
output.writeObject(result);
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
步骤 4: 跑起来
服务端代码:
UserService service = new UserServiceImpl(); // 真实的业务逻辑
RpcServer server = new RpcServer();
server.register(UserService.class, service);
server.start(8080);
客户端代码:
RpcClientProxy proxy = new RpcClientProxy("localhost", 8080);
UserService userService = proxy.getProxy(UserService.class);
// 这一步看起来像本地调用,实际上走了网络!
User user = userService.getUser(1001);
System.out.println(user.getName());
第四部分:进阶思考(从玩具到生产级框架)
上面的代码只是一个玩具(Toy RPC),要变成一个生产级框架(如 Dubbo/gRPC),还需要解决以下问题,这也体现了计算机网络的高级应用:
- IO 模型(BIO vs NIO):
-
- 上面的代码使用了
ServerSocket.accept()和Stream.read(),这是同步阻塞 IO (BIO)。如果不开启多线程,一个客户端卡住,整个服务端就挂了。 - 优化 :使用 Java NIO (Netty) 或 Go 的 Goroutine。使用 IO 多路复用 (epoll) 技术,让一个线程管理成千上万个连接。
- 上面的代码使用了
- 序列化性能:
-
- Java 原生序列化(
ObjectOutputStream)非常慢且生成的数据包很大。 - 优化 :使用 Protobuf, Kryo, Hessian。它们基于二进制位操作,体积可能只有 Java 原生的 1/10。
- Java 原生序列化(
- 服务发现(Service Discovery):
-
- 上面代码写死了
localhost:8080。如果是集群呢? - 优化 :引入 Zookeeper/Nacos/Etcd 。服务端启动时把 IP 注册上去,客户端调用时先去注册中心拉取 IP 列表,然后进行负载均衡。
- 上面代码写死了
- 自定义协议(解决粘包):
-
- 上面的代码依赖 Java 对象的边界。如果跨语言(Java 调 Go),必须定义字节级协议。
- 优化 :定义
Header(包含 Magic Number, Version, Length) +Body。
总结
RPC 的本质就是 "透明化网络通信"。
- 网络层:利用 TCP/Socket 传输数据。
- 表示层:利用序列化解决对象传输问题。
- 应用层:利用动态代理(Stub)让调用者无感知。