在我们日常开发里,调一个服务的方法,代码看起来是这样的:
// 声明一个接口
MathService mathService = ...;
// 调用方法,拿到结果
boolean result = mathService.isPrime(17);
不管用的是Dubbo还是Spring Cloud,写出来的代码都和本地调用没什么区别。因为框架把网络通信、序列化、服务发现这些复杂性全部封装在接口背后。调用方只看到一个接口,调用它就行了。
经常这么用,会忘记底下做了什么。这篇文章尝试用最少的代码,从0到1写一个能跑的mini版RPC,把框架帮你做的事情,展现出来。
RPC到底在干什么
先从一个具体的场景说起。假设有一个判断素数的方法,运行在一台算力很强的服务器上。其他程序想复用这个方法,但它不在本地,在另一台机器上。
本地调用时,方法入参在内存里直接传给函数,返回值也在内存里。远程调用时,入参需要先变成字节流,通过网络发到另一台机器,那台机器再把字节流还原成参数,调用真实的方法,结果原路返回。
RPC做的事情,就是在这条链路上加一层封装,让调用方感知不到网络的存在。调用方看到的只是一个接口,这个接口在RPC里有个专门的名字:stub(桩)。
调用链路是这样的:调用方代码调接口 -> 客户端stub拦截调用,把方法名和参数打包,通过Socket发出去 -> 服务端stub收到数据,拆开包,找到对应的方法执行 -> 结果按原路返回
客户端stub和服务端stub,一个是发送方的代理人,一个是接收方的代理人。它们把网络通信的细节全部屏蔽掉了。
通信协议
RPC的协议就是约定好请求和响应长什么样。客户端按这个格式打包,服务端按这个格式拆包。
这里设计一个最简单的二进制协议。
请求由三部分组成:方法名、参数个数、每个参数的序列化数据。具体格式是这样的:先写一个int表示方法名的字节长度,再写方法名的UTF-8字节。然后写一个int表示参数个数,每个参数也是先写int长度再写序列化数据。响应更简单:一个字节的状态码(0表示成功,1表示异常),后面跟一个int长度和序列化后的数据。
这个协议足够简单,也足够通用。方法名用字符串,可以支持一个服务暴露多个方法。参数个数不固定,可以支持任意数量的入参。
序列化
有了协议格式,还需要一个把Java对象变成字节数组、把字节数组还原成Java对象的手段。这就是序列化。
生产环境一般用Protobuf、Hessian这类高性能的序列化方案。在这个最简实现里,直接用JDK自带的序列化就可以了。
// 对象 -> 字节数组
public static byte[] serialize(Object obj) throws IOException {
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos)) {
oos.writeObject(obj);
oos.flush();
return bos.toByteArray();
}
}
反序列化就是反过来,用ObjectInputStream从字节数组里把对象读出来。需要注意的是,被序列化的对象要实现Serializable接口,这是JDK序列化的硬性要求。
有了序列化手段,协议的读写实现就能实现了。写请求时,先用writeInt写方法名长度,再写方法名的UTF-8字节,然后写参数个数,每个参数先序列化再写长度再写数据。读请求时按同样的顺序,先读长度,再读对应长度的字节,最后反序列化。
服务端
服务端需要做三件事:监听端口等连接、读取并解析请求、调用方法返回结果。
核心的处理逻辑在一个循环里:
// 启动服务端,阻塞式循环处理请求
try (ServerSocket serverSocket = new ServerSocket(port)) {
while (true) {
Socket socket = serverSocket.accept();
handleRequest(socket);
}
}
accept()会阻塞,有客户端连进来才往下走。handleRequest方法里,用DataInputStream从Socket里读请求数据,按协议格式拆出方法名和参数,然后通过反射找到服务实例上对应的方法,调用它,把结果序列化后写回Socket。
方法查找这部分,最简单的做法是按方法名加参数个数匹配:
// 按方法名和参数个数查找,不支持方法重载
for (Method m : service.getClass().getMethods()) {
if (m.getName().equals(methodName)
&& m.getParameterCount() == args.length) {
return m;
}
}
生产级的RPC框架会用更精细的路由机制,比如带上参数类型信息来区分重载方法。这里为了保持简单,暂时不考虑重载的场景。
反射调用方法时有一个细节:反序列化出来的参数是包装类型(比如Integer),而方法签名可能声明的是基本类型(比如int)。JDK的反射机制会自动做拆箱,所以Method.invoke(service, args)可以正常工作,不需要手动处理类型转换。
客户端和动态代理
服务端写好了,接下来是客户端。客户端最核心的部分不是Socket通信,而是动态代理。
动态代理的作用:给一个接口,生成一个实现了这个接口的代理对象。代理对象拦截所有方法调用,把调用转成一次网络请求。调用方拿到的是接口,调的是方法,完全不知道底下走的是网络。
// 创建代理对象
MathService mathService = client.createProxy(MathService.class);
// 这一行看起来就是普通的方法调用,实际走了网络
boolean result = mathService.isPrime(17);
createProxy方法内部用的是JDK的Proxy.newProxyInstance:
public <T> T createProxy(Class<T> serviceInterface) {
return (T) Proxy.newProxyInstance(
serviceInterface.getClassLoader(),
new Class<?>[]{serviceInterface},
new RpcInvocationHandler()
);
}
关键在RpcInvocationHandler的invoke方法。每次调用代理对象的方法,都会进入这里:
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// 建立Socket连接
try (Socket socket = new Socket(host, port);
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
DataInputStream in = new DataInputStream(socket.getInputStream())) {
// 按协议打包请求并发送
RpcProtocol.writeRequest(out, method.getName(), args);
// 读取响应
Object[] response = RpcProtocol.readResponse(in);
byte status = (Byte) response[0];
Object data = response[1];
if (status == RpcProtocol.STATUS_ERROR) {
throw new RuntimeException("远程调用失败: " + data);
}
return data;
}
}
这个invoke方法做的事情对应了客户端stub的全部职责:建连接、序列化请求、发送、接收响应、反序列化、返回结果。调用方一行代码都不用改。
这里用的是短连接,每次调用都新建Socket再关闭。生产环境会用长连接加连接池来减少连接建立的开销。
跑起来
把服务端和客户端放在一起,一起启动。服务端在后台线程启动,监听5005端口。主线程创建MathService的代理对象,然后像调本地方法一样调用isPrime(17)、isPrime(20)和add(3, 5)。运行结果:
[RPC Server] 服务端启动,监听端口 5005
[RPC Server] 收到请求: isPrime, 参数个数: 1
[RPC Server] 返回结果: true
isPrime(17) = true
[RPC Server] 收到请求: isPrime, 参数个数: 1
[RPC Server] 返回结果: false
isPrime(20) = false
[RPC Server] 收到请求: add, 参数个数: 2
[RPC Server] 返回结果: 8
add(3, 5) = 8
服务端日志清楚地显示了请求的到达和处理过程。而从客户端代码的角度看,和调用本地方法没有任何区别。整个RPC的核心机制就跑通了。
生产级RPC还差什么
上面这个实现是能跑,但它是一个最小原型,是个demo。从原型到Dubbo、gRPC这样的生产级框架,中间还隔着很多工程问题。
服务发现。 这里的服务端地址写死成了localhost加5005端口。真实环境里,服务实例的地址会动态变化,需要一个注册中心(比如Nacos、ZooKeeper)来管理服务的地址列表,客户端启动时去注册中心查地址。
长连接和连接池。 每次调用都新建Socket再关闭,连接建立的开销在高频调用下会非常明显。生产环境一般维护一批长连接,调用时从池子里取一个用,用完放回去。
序列化性能。 JDK自带的序列化方便但效率不高,序列化后的字节数也偏大。生产环境一般用Protobuf(gRPC默认)、Hessian(Dubbo默认)这类方案,序列化速度快,体积小,还支持跨语言。
超时和重试。 网络不可靠,服务端可能挂掉。生产级框架会设置调用超时时间,超时后可以选择重试或者快速失败。Dubbo提供了丰富的超时、重试、降级配置。
方法路由。 这里的方法查找只按名字和参数个数匹配,不支持方法重载。生产框架会把参数类型信息也写进协议,精确匹配到具体的重载方法。
并发处理。 这里的服务端是单线程串行处理请求,一个请求慢了后面全部排队。生产框架会用线程池来并发处理请求,gRPC底层用的是Netty的异步IO模型。
可观测性。 调用链路追踪、Metrics监控、日志记录,这些在分布式环境下排查问题不可或缺。
把上面这些能力补齐,就是一个生产级RPC框架的雏形。Dubbo和gRPC做的事情,就是在这些维度上分别给出了成熟的方案。
小结
RPC的发明有一个很重要的意义:它让分布式调用的编程模型和本地调用保持了一致。调用方不需要关心数据怎么打包、怎么传输、怎么找到对方,这些复杂性被stub这一层完全隐藏掉了。这也是为什么Dubbo、gRPC这类框架的API都设计得极简,因为它们的核心价值就在于让你忘记底下还有一个网络。
从实现角度看,RPC的技术栈并没有那么的复杂。Socket负责传输,序列化负责打包拆包,反射负责方法调度,动态代理负责屏蔽细节。把这四样东西组合起来,RPC的骨架就搭好了。理解了这个骨架,再去看Dubbo或gRPC的源码,会发现它们解决的是同一类问题,只是在每个环节都做了更精细的工程化处理。
自己动手写一遍的好处,不在于造一个能用的轮子,而在于建立对底层机制的了解。知道框架在帮你做什么,遇到问题时排查的思路会清晰很多。
感谢阅读,希望这篇文章能帮到你。