You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

14 KiB

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框架的服务端主要需要实现下面这两个功能

  1. 服务端的业务代码把服务的实现类注册到RPC框架中;
  2. 接收客户端桩发出的请求,调用服务的实现类并返回结果。

把服务的实现类注册到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()));
    }
}

这段代码的处理逻辑就是根据请求命令的Header中的请求类型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));
    // ...
}

  1. 把requestCommand的payload属性反序列化成为RpcRequest
  2. 根据rpcRequest中的服务名去成员变量serviceProviders中查找已注册服务实现类的实例
  3. 找到服务提供者之后利用Java反射机制调用服务的对应方法
  4. 把结果封装成响应命令并返回在RequestInvocation中它会把这个响应命令发送给客户端。

再来看成员变量serviceProviders它的定义是Map<String/service name/, Object/service provider/> 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框架的服务端处理客户端请求的业务逻辑中我们分两层做了两次请求分发

  1. 在RequestInvocation类中根据请求命令中的请求类型(command.getHeader().getType())分发到对应的请求处理器RequestHandler中
  2. RpcRequestHandler类中根据RPC请求中的服务名把RPC请求分发到对应的服务实现类的实例中去。

这两次分发采用的设计是差不多的但你需要注意的是这并不是一种过度设计。原因是我们这两次分发分别是在不同的业务抽象分层中第一次分发是在服务端的网络传输层抽象中它是网络传输的一部分而第二次分发是RPC框架服务端的业务层是RPC框架服务端的一部分。良好的分层设计目的也是让系统各部分更加的“松耦合高内聚”。

思考题

这节课的课后作业我们来继续写代码。需要你实现一个JDBC协议的注册中心并加入到我们的RPC框架中。加入后我们的注册中心就可以使用一个支持JDBC协议的数据库比如MySQL作为注册中心的服务端实现跨服务器的服务注册和查询。要求

  1. 调用RpcAccessPoint.getNameService()方法获取注册中心实例时传入的参数就是JDBC的URL比如“jdbc:mysql://127.0.0.1/mydb”;
  2. 不能修改RPC框架的源代码;
  3. 实现必须具有通用性可以支持任意一种JDBC数据库。

欢迎你在评论区留言,分享你的代码。

感谢阅读,如果你觉得这篇文章对你有一些启发,也欢迎把它分享给你的朋友。