文章目录
我最早接触分布式系统是在2017年,那时候使用了Dubbo服务治理框架,那时Dubbo还没拥抱Spring Cloud,使用ZooKeeper实现服务注册中心。也就从那时候起开始认识RPC框架。
简介
RPC是Remote Procedure Call(远程过程调用),即一台服务器上的服务可以像调用本进程内的方法一样去调用远程服务器上的方法,简单点理解就是让不同网络节点的服务相互调用,是一种典型的分布式节点间同步通信的实现方式。
RPC常见的应用领域:
- 消息队列
- 分布式缓存
- 分布式数据库
RPC屏蔽了网络细节,使远程服务的调用像本地一样,所以给程序员提供了很大的方便,只需关注业务即可。
RPC常用框架:
- Facebook的Apache Thrift
- Hessian
- gRPC
- Dubbo
RPC框架的关键点:
- 动态代理
使客户端可以像调用本地方法一样调用服务端接口 - 序列化
将传输的信息打包成字节码,适应网络传输 - 协议编码
对序列化信息进行标注,使其能顺利的到达目的地,且能反序列化 - 网络传输
使用socket套接字
调用过程
- 首先服务提供方制定接口和数据结构,然后将接口和数据结构同步给服务调用方
- 服务调用方使用“动态代理”向提供方发起调用
- 服务调用方将代理信息进行序列化操作,编码成二进制信息
- 服务调用方将编码的二进制信息进行协议编码,对二进制信息进行说明,比如数据包大小、请求类型等
- 服务提供方接收到服务调用方发过来的信息后,先进行协议解码,解读协议信息
- 服务提供方将协议解码后的数据包进行反序列化
- 服务提供方使用反射机制,获取动态代理封装好的请求
- 服务提供方处理请求,发送响应信息给调用方
- 响应数据传输就是和请求处理一样的过程
动态代理
服务调用者调用的服务实际是远程服务的本地代理,这就涉及到了JDK动态代理。通过动态代理拦截机制,将本地调用封装成远程服务调用。
接口定义
package com.demo.dynamic.provide;
public interface HiService {
public String hi(String username);
}
接口实现
package com.demo.dynamic.provide.impl;
import com.demo.dynamic.provide.HiService;
public class HiServiceImpl implements HiService {
@Override
public String hi(String username) {
System.out.println("username: " + username);
return "hi " + username;
}
}
代理类
package com.demo.dynamic.client;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class DynamicHiSubject implements InvocationHandler {
// 需要代理的真实对象
private Object target = null;
public DynamicHiSubject(Object object) {
// 绑定真实对象
this.target = object;
}
// 调用真实对象的方法并执行
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("start now ......");
Object retObj = method.invoke(target, args);
System.out.println("end over");
return retObj;
}
}
DynamicHiSubject实现InvocationHandler接口后,通过反射的方式调用传入真实对象的方法,正是因为这种对真实对象的不限定性,所以称作动态代理。
package com.demo.dynamic.test;
import com.demo.dynamic.client.DynamicHiSubject;
import com.demo.dynamic.provide.HiService;
import com.demo.dynamic.provide.impl.HiServiceImpl;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
public class ProxyTest {
public static void main(String[] args){
// 声明一个真实的服务端对象
HiService hiService = new HiServiceImpl();
// 声明一个代理类对象,并在构造器中进行代理对象和真实对象的绑定
InvocationHandler handler = new DynamicHiSubject(hiService);
// 生成代理对象实例
// 第一个参数加载代理对象,第二个参数指定服务端接口,第三个参数将代理对象关联到InvocationHandler
HiService hiServiceProxy = (HiService) Proxy.newProxyInstance(handler.getClass().getClassLoader(),
hiService.getClass().getInterfaces(),
handler);
String retStr = hiServiceProxy.hi("lilei");
System.out.println(retStr);
}
}
运行结果
start now ......
username: lilei
end over
hi lilei
序列化
远程调用需要将请求对象装换成二进制码流进行网络传输,不同的序列化框架支持的数据类型、数据包大小、性能各不相同。反过来将二进制码流转换成对象的过程,称为反序列化。
常见的序列化方式:
名称 | 优点 | 缺点 |
---|---|---|
JSON | 简单;key-value方式; | 空间开销大;遇到类型转换会损耗性能; |
Hessian | 跨语言;性能比JSON高;兼容性好;稳定性好; | |
Protobuf | 轻便;高效;适用结构化数据;多语言支持; | |
Thrift | 高性能;轻量级; | 兼容性差 |
JDK Serializable | 性能相对差点,序列化数据较大 |
Serializable示例
我用JDK自带的序列化研究一下,看看序列化后的数据是什么样的。
Person类
package com.demo.serial.domain;
import java.io.Serializable;
public class Person implements Serializable {
String name = "zhangsan";
}
序列化代码
package com.demo.serial.test;
import com.demo.serial.domain.Person;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
public class PersonTest {
public static void main(String args[]) throws IOException {
FileOutputStream fos = new FileOutputStream("temp_person.out");
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(new Person());
oos.close();
}
}
temp_person.out文件
使用16进制格式显示,文件内容如下:
表格显示为(表中括号数字部分便于说明):
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | a | b | c | d | e | f |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
ac(1) | ed | 00 | 05 | 73 | 72 | 00 | 1d | 63 | 6f | 6d | 2e | 64 | 65 | 6d | 6f |
2e | 73 | 65 | 72 | 69 | 61 | 6c | 2e | 64 | 6f | 6d | 61 | 69 | 6e | 2e | 50 |
65 | 72 | 73 | 6f | 6e | 8c(2) | b8 | 96 | 4e | 4a | 1f | f8 | 5f | 02(3) | 00 | 01 |
4c | 00 | 04 | 6e | 61 | 6d | 65 | 74 | 00 | 12 | 4c | 6a | 61 | 76 | 61 | 2f |
6c | 61 | 6e | 67 | 2f | 53 | 74 | 72 | 69 | 6e | 67 | 3b | 78(4) | 70 | 74 | 00 |
08 | 7a | 68 | 61 | 6e | 67 | 73 | 61 | 6e |
(1) 开头
ac ed: Magic Number,幻数,固定值
00 05: Version number,版本号
73: new Object,表示一个新对象
72: new Class Descriptor,声明一个新类
00 1d: 类的长度,包含全路径名,29个字节,到第三行的6e字节
(2) 序列化ID
代码中未指定,则随机生成一个8字节的id
(3) 字段说明
02: Bit mask for ObjectStreamClass flag,Indicates class is Serializable。声明该类支持序列化
00 01: 该类包含的字段数量
4c: ascii值是L,第一个域的类型
00 04: 表示字段长度,4个字节
6e 61 6d 65: 表示字段名称name
74: new String,新的字符串对象
00 12: 类的全名,包含路径共18字节
4c…3b: 标准对象签名,java.lang.String;
(4)字段值的说明
78: 对象块的结束标志
70: Null object reference,没有其他父类的标志
74: 新的对象
00 08: 新的对象长度,8字节
7a…6e: zhangsan
由上看出,Serializable序列化后的数据主要包括:序列化协议、类声明信息、域信息等。本例为了解读序列化后的数据示例比较简单,更详细的内容可以参考java.io.ObjectStreamConstants,这个接口里定义了流数据中的一些标记值。除了这些标记值以外,其它的十六进制都可以转换成ascii码,就可以知道是什么数据。
协议编码
序列化将请求对象转换成可以在网络上传输的数据,这个时候还需要协议的支撑,因为二进制流在网络上传输,需要包装一些额外的信息,比如数据包发送到哪里,发送大小等信息。这就需要制定规则化的rpc协议。
协议是 RPC 的核心,它规范了数据在网络中的传输内容和格式。除必须的请求、响应数据外,通常还会包含额外控制数据,如单次请求的序列化方式、超时时间、压缩方式和鉴权信息等。
以Dubbo2协议为例(官方图示太小,我手绘一个):
- Magic - Magic High & Magic Low (16 bits)
使用值0xdabb标识dubbo协议 - Req/Res (1 bit)
请求、响应标识位,1-请求,0-响应 - 2 Way (1 bit)
仅当请求时有效,是否返回值,1需要返回值 - Event (1 bit)
标识事件消息,1-是 - Serialization ID (5 bit)
标识序列化类型,fastjson是6 - Status (8 bits)
仅用于响应,状态位主要有:
20-OK、
30-CLIENT_TIMEOUT、
31-SERVER_TIMEOUT、
40-BAD_REQUEST、
50-BAD_RESPONSE、
60-SERVICE_NOT_FOUND、
70-SERVICE_ERROR、
80-SERVER_ERROR、
90-CLIENT_ERROR、
100-SERVER_THREADPOOL_EXHAUSTED_ERROR - Request ID (64 bits)
long类型,请求唯一标识 - Data Length (32)
Variable Part部分序列化后的字节长度,integer类型 - Variable Part
Req/Res=1,包含Dubbo version、Service name、Service version、Method name、Method parameter types、Method arguments、Attachments
Req/Res=0,包含Return value type、Return value
RPC 协议的设计需要考虑以下内容:
- 通用性: 统一的二进制格式,跨语言、跨平台、多传输层协议支持
- 扩展性: 协议增加字段、升级、支持用户扩展和附加业务元数据
- 性能:As fast as it can be
- 穿透性:能够被各种终端设备识别和转发:网关、代理服务器等 通用性和高性能通常无法同时达到,需要协议设计者进行一定的取舍。
网络传输
网络程序所做的很大一部分工作都是简单的输入输出:将数据字节从一个系统移动到另一个系统。在很大程度上讲,读取服务器发送给你的数据与读取文件并没有什么不同。向客户端发送文本与写文件也没有什么不同。
制定协议之后,服务调用方和提供方有了统一的读写和转换标准,那么接下来就要进行网络传输了。RPC网络传输本质上是一次调用方和提供方之间网络信息交换过程。如果屏蔽底层通信的细节,那么我们就可以理解为,服务调用方知道服务提供方的ip、socket端口就可以了。如果要深究细节,那么这一章节就涉及到了系统调用了。
操作系统的抽象层中有对外提供的接口,叫做系统调用,例如read、open、close等。跟进程的内部函数调用不同的是,这种系统调用会让进程从用户态切换到内核态,也就是到操作系统的内核代码中去执行。
Linux的内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的系统命令,返回一个file descriptor(fd,文件描述符)。而对一个socket的读写也会有相应的描述符,称为socketfd(socket 描述符),描述符就是一个数字,它指向内核中的一个结构体(文件路径,数据区等一些属性)。
由此得知,用户程序不能直接访问网卡,必须通过系统调用去访问网卡,我们从数据流转的过程总结如下:
由于在网络上传输二进制流,同一个请求可能分成好几块,并且到达目的地的时间有先后,那么程序计算必须在所有数据包获取后才能进行。为了避免程序阻塞问题,IO模型一直在演变,常见的IO模型主要有:
- 同步阻塞(blocking IO)
- 同步非阻塞(non-blocking IO)
- 多路复用(multiplexing IO)
同步阻塞(blocking IO)
用户程序发起请求后,需要一直等待响应,不能进行其它操作。
同步非阻塞(non-blocking IO)
用户程序发起请求后,无需一直等待。会不断的轮询,直到数据准备好。
多路复用(multiplexing IO)
在同步非阻塞基础上,添加一个独立的进程专门负责监听多个请求的数据状态,发现数据准备好主动通知所属请求进程去处理。
RPC框架示例
参考《分布式服务框架原理与实践》李林锋/著
通过Java原生序列化、Socket通信、动态代理和反射机制,实现最简单的RPC框架。
提供方:负责提供服务接口和服务实现类
发布方:运行在服务端,负责发布服务供消费者调用
本地服务代理:运行在客户端,通过代理调用远程服务提供者,并将响应结果返回给本地消费者
提供方
接口定义
package com.demo.rpc.service;
public interface HiService {
public String sayHi(String username);
}
接口实现
package com.demo.rpc.service.impl;
import com.demo.rpc.service.HiService;
public class HiServiceImpl implements HiService {
@Override
public String sayHi(String username) {
return username != null ? "hi, " + username : "hi";
}
}
发布方
- 监听TCP连接,将客户端请求封装成Task使用线程池运行
- 将客户端发送的码流反序列化,反射调用服务实现者,获取执行结果
- 将返回结果反序列化,发送给客户端
- 调用结束后释放资源
package com.demo.rpc;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Method;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
public class RpcRegister {
static Executor executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
public static void register(String host, int port) throws Exception {
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(host, port));
try{
while (true) {
executor.execute(new RegisterTask(serverSocket.accept()));
}
} finally {
serverSocket.close();
}
}
private static class RegisterTask implements Runnable {
Socket socket = null;
public RegisterTask(Socket client) {
this.socket = client;
}
@Override
public void run() {
ObjectInputStream inputStream = null;
ObjectOutputStream outputStream = null;
try {
inputStream = new ObjectInputStream(socket.getInputStream());
// 第一个字符串是接口名
String interfaceName = inputStream.readUTF();
// 类加载
Class<?> service = Class.forName(interfaceName);
// 第二个字符串是方法名
String methodName = inputStream.readUTF();
// 第三个对象是参数类型
Class<?>[] parameterTypes = (Class<?>[])inputStream.readObject();
// 第四个对象是参数值
Object[] arguments = (Object[])inputStream.readObject();
// 使用反射机制,调用业务逻辑
Method method = service.getMethod(methodName, parameterTypes);
Object result = method.invoke(service.newInstance(), arguments);
// 返回数据
outputStream = new ObjectOutputStream(socket.getOutputStream());
outputStream.writeObject(result);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (outputStream != null) {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (socket != null) {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
}
本地服务代理
- 将本地的接口调用转换成JDK的动态代理,在动态代理中实现接口的远程调用
- 创建Socket客户端,根据指定地址连接远程服务提供者
- 将远程服务调用所需的接口类、方法名、参数类型、参数值等序列化后发送到服务端
- 同步阻塞等待响应信息,获取应答信息后返回
package com.demo.rpc;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.net.InetSocketAddress;
import java.net.Socket;
public class RpcClient<T> {
public T call(final Class<?> serviceClass, final InetSocketAddress addr) {
return (T) Proxy.newProxyInstance(serviceClass.getClassLoader(),
new Class<?>[]{serviceClass.getInterfaces()[0]},
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Socket socket = null;
ObjectOutputStream outputStream = null;
ObjectInputStream inputStream = null;
try {
socket = new Socket();
socket.connect(addr);
outputStream = new ObjectOutputStream(socket.getOutputStream());
// 设置请求代理的类名称
outputStream.writeUTF(serviceClass.getName());
// 设置请求方法名
outputStream.writeUTF(method.getName());
// 设置请求方法参数类型
outputStream.writeObject(method.getParameterTypes());
// 设置请求方法参数
outputStream.writeObject(args);
inputStream = new ObjectInputStream(socket.getInputStream());
return inputStream.readObject();
} finally {
if (socket != null) {
socket.close();
}
if (outputStream != null) {
outputStream.close();
}
if (inputStream != null) {
inputStream.close();
}
}
}
});
}
}
服务端模拟
package com.demo.rpc;
public class ServerStart {
public static void main(String[] args){
new Thread(new Runnable() {
@Override
public void run() {
try {
RpcRegister.register("127.0.0.1", 8088);
} catch (Exception e) {
e.printStackTrace();
}
}
}).start();
}
}
客户端模拟
package com.demo.rpc;
import com.demo.rpc.service.HiService;
import com.demo.rpc.service.impl.HiServiceImpl;
import java.net.InetSocketAddress;
public class ClientStart {
public static void main(String[] args){
RpcClient<HiService> rpcClient = new RpcClient<>();
HiService hiService = rpcClient.call(HiServiceImpl.class, new InetSocketAddress("127.0.0.1", 8088));
System.out.println(hiService.sayHi("China Qingdao"));
}
}
客户端输出日志
hi, China Qingdao
此示例展示了一个完整的rpc调用流程,之前使用dubbo的时候,生产者只需暴露接口及相关序列化,且注册到zookeeper即可,屏蔽了底层细节。
总结
通过梳理RPC相关知识点,加深了对动态代理、序列化和协议编码的理解,熟悉了rpc的调用流程,为后边深入研究一下Dubbo打下了良好的基础。