learn-tech/专栏/消息队列高手课/34动手实现一个简单的RPC框架(四):服务端.md
2024-10-16 06:37:41 +08:00

170 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
34 动手实现一个简单的RPC框架服务端
你好,我是李玥。
上节课我们一起学习了如何来构建这个 RPC 框架中最关键的部分,也就是:在客户端,如何根据用户注册的服务接口来动态生成桩的方法。在这里,除了和语言特性相关的一些动态编译小技巧之外,你更应该掌握的是其中动态代理这种设计思想,它的使用场景以及实现方法。
这节课我们一起来实现这个框架的最后一部分:服务端。对于我们这个 RPC 框架来说,服务端可以分为两个部分:注册中心和 RPC 服务。其中,注册中心的作用是帮助客户端来寻址,找到对应 RPC 服务的物理地址RPC 服务用于接收客户端桩的请求,调用业务服务的方法,并返回结果。
注册中心是如何实现的?
我们先来看看注册中心是如何实现的。一般来说,一个完整的注册中心也是分为客户端和服务端两部分的,客户端给调用方提供 API并实现与服务端的通信服务端提供真正的业务功能记录每个 RPC 服务发来的注册信息,并保存到它的元数据中。当有客户端来查询服务地址的时候,它会从元数据中获取服务地址,返回给客户端。
由于注册中心并不是这个 RPC 框架的重点内容,所以在这里,我们只实现了一个单机版的注册中心,它只有客户端没有服务端,所有的客户端依靠读写同一个元数据文件来实现元数据共享。所以,我们这个注册中心只能支持单机运行,并不支持跨服务器调用。
但是,我们在这里,同样采用的是“面向接口编程”的设计模式,这样,你可以在不改动一行代码的情况下,就可以通过增加一个 SPI 插件的方式,提供一个可以跨服务器调用的真正的注册中心实现,比如说,一个基于 HTTP 协议实现的注册中心。我们再来复习一下,这种面向接口编程的设计是如何在注册中心中来应用的。
首先,我们在 RPC 服务的接入点,接口 RpcAccessPoint 中增加一个获取注册中心实例的方法:
public interface RpcAccessPoint extends Closeable{
/**
* 获取注册中心的引用
* @param nameServiceUri 注册中心 URI
* @return 注册中心引用
*/
NameService getNameService(URI nameServiceUri);
// ...
}
这个方法的参数就是注册中心的 URI也就是它的地址返回值就是访问这个注册中心的实例。然后我们再给 NameService 接口增加两个方法:
public interface NameService {
/**
* 所有支持的协议
*/
Collection<String> supportedSchemes();
/**
* 连接注册中心
* @param nameServiceUri 注册中心地址
*/
void connect(URI nameServiceUri);
// ...
}
其中 supportedSchemes 方法返回可以支持的所有协议比如我们在这个例子中的实现它的协议是“file”。connect 方法就是给定注册中心服务端的 URI去建立与注册中心服务端的连接。
下面我们来看获取注册中心的方法 getNameService 的实现,它的实现也很简单,就是通过 SPI 机制加载所有的 NameService 的实现类,然后根据给定的 URI 中的协议,去匹配支持这个协议的实现类,然后返回这个实现的引用就可以了。由于这部分实现是通用并且不会改变的,我们直接把实现代码放在 RpcAccessPoint 这个接口中。
这样我们就实现了一个可扩展的注册中心接口,系统可以根据 URI 中的协议,动态地来选择不同的注册中心实现。增加一种注册中心的实现,也不需要修改任何代码,只要按照 SPI 的规范,把协议的实现加入到运行时 CLASSPATH 中就可以了。(这里设置 CLASSPATH 的目的,在于告诉 Java 执行环境,在哪些目录下可以找到你所要执行的 Java 程序所需要的类或者包。)
我们这个例子中注册中心的实现类是 LocalFileNameService它的实现比较简单就是去读写一个本地文件实现注册服务 registerService 方法时,把服务提供者保存到本地文件中;实现查找服务 lookupService 时,就是去本地文件中读出所有的服务提供者,找到对应的服务提供者,然后返回。
这里面有一点需要注意的是,由于这个本地文件它是一个共享资源,它会被 RPC 框架所有的客户端和服务端并发读写。所以,这时你要怎么做呢?对,必须要加锁!
由于我们这个文件可能被多个进程读写,所以这里不能使用我们之前讲过的,编程语言提供的那些锁,原因是这些锁只能在进程内起作用,它锁不住其他进程。我们这里面必须使用由操作系统提供的文件锁。这个锁的使用和其他的锁并没有什么区别,同样是在访问共享文件之前先获取锁,访问共享资源结束后必须释放锁。具体的代码你可以去查看 LocalFileNameService 这个实现类。
RPC 服务是怎么实现的?
接下来,我们再来看看 RPC 服务是怎么实现的。RPC 服务也就是 RPC 框架的服务端。我们在之前讲解这个 RPC 框架的实现原理时讲到过RPC 框架的服务端主要需要实现下面这两个功能:
服务端的业务代码把服务的实现类注册到 RPC 框架中 ;
接收客户端桩发出的请求,调用服务的实现类并返回结果。
把服务的实现类注册到 RPC 框架中,这个逻辑的实现很简单,我们只要使用一个合适的数据结构,记录下所有注册的实例就可以了,后面在处理客户端请求的时候,会用到这个数据结构来查找服务实例。
然后我们来看RPC 框架的服务端如何来处理客户端发送的 RPC 请求。首先来看服务端中,使用 Netty 接收所有请求数据的处理类 RequestInvocation 的 channelRead0 方法。
@Override
protected void channelRead0(ChannelHandlerContext channelHandlerContext, Command request) throws Exception {
RequestHandler handler = requestHandlerRegistry.get(request.getHeader().getType());
if(null != handler) {
Command response = handler.handle(request);
if(null != response) {
channelHandlerContext.writeAndFlush(response).addListener((ChannelFutureListener) channelFuture -> {
if (!channelFuture.isSuccess()) {
logger.warn("Write response failed!", channelFuture.cause());
channelHandlerContext.channel().close();
}
});
} else {
logger.warn("Response is null!");
}
} else {
throw new Exception(String.format("No handler for request with type: %d!", request.getHeader().getType()));
}
}
这段代码的处理逻辑就是,根据请求命令的 Hdader 中的请求类型 type去 requestHandlerRegistry 中查找对应的请求处理器 RequestHandler然后调用请求处理器去处理请求最后把结果发送给客户端。
这种通过“请求中的类型”,把请求分发到对应的处理类或者处理方法的设计,我们在 RocketMQ 和 Kafka 的源代码中都见到过,在服务端处理请求的场景中,这是一个很常用的方法。我们这里使用的也是同样的设计,不同的是,我们使用了一个命令注册机制,让这个路由分发的过程省略了大量的 if-else 或者是 switch 代码。这样做的好处是,可以很方便地扩展命令处理器,而不用修改路由分发的方法,并且代码看起来更加优雅。这个命令注册机制的实现类是 RequestHandlerRegistry你可以自行去查看。
因为我们这个 RPC 框架中只需要处理一种类型的请求RPC 请求所以我们只实现了一个命令处理器RpcRequestHandler。这部分代码是这个 RPC 框架服务端最核心的部分,你需要重点掌握。另外,为了便于你理解,在这里我只保留了核心业务逻辑,你在充分理解这部分核心业务逻辑之后,可以再去查看项目中完整的源代码,补全错误处理部分。
我们先来看它处理客户端请求,也就是这个 handle 方法的实现。
@Override
public Command handle(Command requestCommand) {
Header header = requestCommand.getHeader();
// 从 payload 中反序列化 RpcRequest
RpcRequest rpcRequest = SerializeSupport.parse(requestCommand.getPayload());
// 查找所有已注册的服务提供方,寻找 rpcRequest 中需要的服务
Object serviceProvider = serviceProviders.get(rpcRequest.getInterfaceName());
// 找到服务提供者,利用 Java 反射机制调用服务的对应方法
String arg = SerializeSupport.parse(rpcRequest.getSerializedArguments());
Method method = serviceProvider.getClass().getMethod(rpcRequest.getMethodName(), String.class);
String result = (String ) method.invoke(serviceProvider, arg);
// 把结果封装成响应命令并返回
return new Command(new ResponseHeader(type(), header.getVersion(), header.getRequestId()), SerializeSupport.serialize(result));
// ...
}
把 requestCommand 的 payload 属性反序列化成为 RpcRequest
根据 rpcRequest 中的服务名,去成员变量 serviceProviders 中查找已注册服务实现类的实例;
找到服务提供者之后,利用 Java 反射机制调用服务的对应方法;
把结果封装成响应命令并返回,在 RequestInvocation 中,它会把这个响应命令发送给客户端。
再来看成员变量 serviceProviders它的定义是Map serviceProviders。它实际上就是一个 MapKey 就是服务名Value 就是服务提供方,也就是服务实现类的实例。这个 Map 的数据从哪儿来的呢?我们来看一下 RpcRequestHandler 这个类的定义:
@Singleton
public class RpcRequestHandler implements RequestHandler, ServiceProviderRegistry {
@Override
public synchronized <T> void addServiceProvider(Class<? extends T> serviceClass, T serviceProvider) {
serviceProviders.put(serviceClass.getCanonicalName(), serviceProvider);
logger.info("Add service: {}, provider: {}.",
serviceClass.getCanonicalName(),
serviceProvider.getClass().getCanonicalName());
}
// ...
}
可以看到,这个类不仅实现了处理客户端请求的 RequestHandler 接口,同时还实现了注册 RPC 服务 ServiceProviderRegistry 接口也就是说RPC 框架服务端需要实现的两个功能——注册 RPC 服务和处理客户端 RPC 请求,都是在这一个类 RpcRequestHandler 中实现的,所以说,这个类是这个 RPC 框架服务端最核心的部分。成员变量 serviceProviders 这个 Map 中的数据,也就是在 addServiceProvider 这个方法的实现中添加进去的。
还有一点需要注意的是,我们 RpcRequestHandler 上增加了一个注解 @Singleton,限定这个类它是一个单例模式,这样确保在进程中任何一个地方,无论通过 ServiceSupport 获取 RequestHandler 或者 ServiceProviderRegistry 这两个接口的实现类,拿到的都是 RpcRequestHandler 这个类的唯一的一个实例。这个 @Singleton 的注解和获取单例的实现在 ServiceSupport 中,你可以自行查看代码。顺便说一句,在 Spring 中,也提供了单例 Bean 的支持,它的实现原理也是类似的。
小结
以上就是实现这个 RPC 框架服务端的全部核心内容,照例我们来做一个总结。
首先我们一起来实现了一个注册中心,注册中心的接口设计采用了依赖倒置的设计原则(也就是“面向接口编程”的设计),并且还提供了一个“根据 URI 协议,自动加载对应实现类”的机制,使得我们可以通过扩展不同的协议,增加不同的注册中心实现。
这种“通过请求参数中的类型,来动态加载对应实现”的设计,在我们这个 RPC 框架中不止这一处用到,在“处理客户端命令并路由到对应的处理类”这部分代码中,使用的也是这样一种设计。
在 RPC 框架的服务端处理客户端请求的业务逻辑中,我们分两层做了两次请求分发:
在 RequestInvocation 类中,根据请求命令中的请求类型 (command.getHeader().getType()),分发到对应的请求处理器 RequestHandler 中;
RpcRequestHandler 类中,根据 RPC 请求中的服务名,把 RPC 请求分发到对应的服务实现类的实例中去。
这两次分发采用的设计是差不多的,但你需要注意的是,这并不是一种过度设计。原因是,我们这两次分发分别是在不同的业务抽象分层中,第一次分发是在服务端的网络传输层抽象中,它是网络传输的一部分,而第二次分发是 RPC 框架服务端的业务层,是 RPC 框架服务端的一部分。良好的分层设计,目的也是让系统各部分更加的“松耦合,高内聚”。