什么是RPC,为什么需要RPC?
RPC(Remote Procedure Call)远程过程调用。它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。
在单体应用的情况下,方法调用只需要知道函数指针就可以进行方法的调用,这种情况是不需要rpc的。而如果是分布式微服务的结构,模块A的某个对象要调用模块B的某个方法,由于模块A与模块B不在同一个机器(服务器)上,所以A是不可能知道B的函数指针的,如果想进行方法调用,必然需要先进行 通信 ,而通信的手段就是 网络编程(Socket 编程),然后A找到B的服务地址,并发送信息,B收到信息后调用本地服务,并将结果返回给A。整个过程如果都需要编程人员来写,将会是一个很复杂的工程。而RPC的作用就是,封装这一系列操作,让客户端调用远程服务时就像调用本地服务一样简便。
RPC需要实现的一些功能和设计思路
1.公共接口:服务端与客户端都能访问到的方法接口,但只有服务端有接口的实现。
2.服务注册与发现:服务端启动时把服务名称及地址注册到注册中心,客户端通过服务名称从注册中心中找到服务地址,之后才能进行网络请求。
3.传输协议:服务端与客户端之间必须确定好通信的消息结构。
4.序列化协议:远程通信必须要将对象转化为二进制传输,也就是序列化。
5.负载均衡:服务请求量过大时,不能把请求落在单个服务器上。
公共接口:
创建一个公共接口,但是只在服务端实现接口。
public interface HelloService {
String sayHello(HelloObject helloObject);
}
@Service
public class HelloServerImp implements HelloService {
@Override
public String sayHello(HelloObject helloObject) {
return "这是Impl1方法"; }
}
那么客户端如何调用这个服务呢? 因为客户端没有接口的具体实现类,也就没办法生成实例对象。所以我们需要在客户端中把参数,接口,方法等信息序列化,通过网络传输给服务端,服务端解析后执行方法得到结果返回客户端,如何简化这一过程:动态代理
动态代理:
为什么要动态代理?假设客户端不用动态代理,而使用硬编码,那对于服务端只有一个服务时还行,只需要写一套封装的代码,如果服务端有多个服务,我们不可能对每个服务写一套硬编码,所以就可以考虑到动态代理。
然后就是动态代理的选型思考。常见的动态代理有两种:JDK反射提供的动态代理,CGLIB动态代理。前者代理的类需要实现接口,后者不需要。一般来说,实现接口的类都直接用JDK动态代理,CGLIB是一个基于ASM的字节码生成库,通过类继承的方式实现动态代理。此处选用JDK动态代理。
动态代理需要知道哪些信息?host,port。需要传递这两个值来指明服务端的位置;同时我们需要发送一个request请求对象,也就是说服务端通过接收到这个对象,来确定调用哪个接口的哪个方法。首先需要interfaceName(接口名称),methodName(方法名),考虑到重载,我们还需要知道,methodParam(方法参数),methodParamType(参数类型)。 最后考虑到健康检测,还需要一个flag表示是否为heartBeat(心跳包)。 同样的,客户端收到消息后需要返回一个response响应对象,需要包含如下信息:statusCode(状态码),message(响应补充信息),data(响应数据)。 request 与 response 通过 requestId来确保对应关系。
客户端发送请求:
对象创建完成之后,我们就需要通过网络传输的方式,将请求对象发送到服务器端。这时候就需要用到网络通信(网络编程)。我这里使用Netty来进行网络通信。首先创建NettyClient
private static final EventLoopGroup group;
private static final Bootstrap bootstrap;
static {
group = new NioEventLoopGroup();
bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioSocketChannel.class);
}
同时考虑到对象传输过程中的序列化,client端从注册中心中查找服务这两方面问题。还需要构造两个字段。具体实现放在后边说。
private final CommonSerializer serializer;
private final ServiceDiscovery serviceDiscovery;
private final UnprocessedRequests unprocessedRequests;
public NettyClient(Integer serializer, LoadBalance loadBalancer) {
this.serviceDiscovery = new NacosServiceDiscovery(loadBalancer);
this.serializer = CommonSerializer.getByCode(serializer);
this.unprocessedRequests = SingletonFactory.getInstance(UnprocessedRequests.class);
}
之后声明sendRequest方法,为了提高并发量,我们使用CompletableFuture来封装该方法。首先,想要发送信息,必须知道接收方(服务端)的地址。这里就牵扯到了服务的注册与发现。
服务的注册与发现:
显然,这个功能的实现需要借助注册中心,常见的注册中心(我见过的)有:zookeeper,nacos。这里使用nacos,为啥不用zookeeper?当然是因为我没学过啊。参考官网的nacos Java sdk,创建一个NacosUtil类,由于只需要nacos实现服务中心功能,所以配置中心相关的方法不做实现。
第一步:创建nacos实例(首先要启动nacos客户端这个没啥好说的)
private static final NamingService namingService = NamingFactory.createNamingService("127.0.0.1:8848");
第二步:编写注册服务进nacos的方法
public static void registerServer(String serviceName, InetSocketAddress address){
// 第一个参数是nacos实例名称,第一步已经得到了,第二个参数是服务端在注册服务时的address
namingService.registerInstance(serviceName,address.getHostName(),address.getPort());
NacosUtil.address = address;
serviceNames.add(serviceName);
}
第三步:编写获取所有服务实例的方法
public static List<Instance> getAllInstance(String serviceName) throws NacosException {
return namingService.getAllInstances(serviceName);
}
第四步:编写注销服务的方法
public static void clearRegistry(){
if (!serviceNames.isEmpty() && address!=null){
String host = address.getHostName();
int port = address.getPort();
Iterator<String> iterator = serviceNames.iterator();
while (iterator.hasNext()){
String next = iterator.next();
namingService.deregisterInstance(next,host,port);
}
}
}
至此,NacosUtil编写完毕,本项目所需注册中心功能已全部实现。
服务端注册服务:
客户端想要找到这个服务,首先这个服务必须已经被注册进服务中心,我们必须在服务端启动时执行这个操作。与客户端一样,服务端的实现也使用netty。由于是个人实现,所以需要在启动时先对nacos做一个初始化清理,防止之前的服务没有注销。
服务端在初始化过程中,会扫描所有服务,那么服务的定义是什么呢? 我们这里使用自定义注解@Service和@ServiceScan,凡是被这个注解修饰的Class,将被看作是一个服务,我们通过反射机制,获取到所有服务Class,然后将这些服务注册进nacos中,同时我们在HashMap中存储一份,方便我们快速获得服务实例。初始化结束后,执行start方法,对NioEventLoopGroup进行各种参数设置,配置等。为了实现心跳检测机制,我们在ChildHandler中加入IdleStateHandler。然后绑定端口。至此,服务端启动成功,服务被注册到nacos成功。
客户端sendRequest的实现:
该方法接受一个request参数,前文已经说到,该对象包含interfaceName字段,因此我们可以通过该字段从nacos中查询是否有该服务,并返回该服务的地址的ip,port等信息,封装成一个InetSocketAddress。接着,我们对这个地址创建通道。参考之前服务实例的存储方式,我们对于通道也使用一个HashMap进行存储,方便快速获取。创建完成后,调用writeAndFlush方法,开始进行数据发送。
服务端接受消息:
channelRead0方法,在收到消息时,先通过heartBeat字段判断是否为心跳包,心跳包则不做处理。如果是有效信息,首先对信息进行处理:从存服务实例的HashMap中找到这个服务,然后通过反射调用目标方法。具体代码如下:
public Object handler(RpcRequest request){
Object o = RequestHandler.serviceProvider.getServiceProvider(request.getInterfaceName());
return invokeTargetMethod(request,o);
}
public Object invokeTargetMethod(RpcRequest request,Object service){
Object res = null;
try {
Method method = service.getClass().getMethod(request.getMethodName(), request.getParamType());
res = method.invoke(service,request.getParameters());
logger.info("服务:{} 成功调用方法:{}", request.getInterfaceName(), request.getMethodName());
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
return RpcResponse.fail(ResponseCode.METHOD_NOT_FOUND, request.getRequestId());
}
return res;
}
将调用成功后的数据通过writeAndFlush发送回客户端。
客户端接受调用成功的数据并检查:
客户端成功接收到数据response,将response与request进行检查,判断是否调用成功。检查逻辑如下:response == null ? requestId == responseId? response.statusCode == 200?,如果检查无误,返回response响应体中的data字段。