Simple RPC - 04 从零开始设计一个客户端(上)

文章目录

Pre

Simple RPC - 01 框架原理及总体架构初探

Simple RPC - 02 通用高性能序列化和反序列化设计与实现

Simple RPC - 03 借助Netty实现异步网络通信


设计

  1. 回顾基础组件的实现
    • 序列化与网络传输的实现。
    • 这些组件在RPC框架中的重要性和作用。

  1. 理解客户端"桩"的实现原理
    • "桩"在RPC框架中的角色及其工作原理。
    • "代理模式"在RPC框架中应用。

  1. 设计并动态生成桩
    • 如何动态生成"桩"。
    • 如何使用动态代理机制在运行时生成和编译桩类。
    • 使用StubFactory接口以及如何实现它。

  1. 动态代理模式的应用
    • 动态代理模式在RPC框架及其他应用场景中的广泛用途。
    • 如何在实际开发中利用代理模式实现非侵入式逻辑注入。

  1. 依赖倒置原则的应用
    • 依赖倒置原则及其在解耦设计中的重要性。
    • Java中的SPI机制以及如何在RPC框架中应用它。

Code

实现RPC框架的客户端部分,尤其是其中最关键的"桩"部分。

1. 理解Stub"桩"的实现原理

"桩"是RPC框架在客户端的服务代理,它实现了与远程服务相同的方法签名,使得客户端在调用服务时,实际上是在调用"桩"提供的方法。这些方法负责将调用封装成网络请求,并将请求发送到服务端以获得结果。

在实现"桩"时,我们采用代理模式。这种模式为某个对象提供一个代理对象,由代理对象控制对原对象的引用。在RPC框架中,代理对象即为"桩",而委托对象则是服务端实现具体业务逻辑的实例。

举个例子: 最常用 Spring 框架,它的核心 IOC(依赖注入)和 AOP(面向切面)机制,就是这种代理模式的一个实现。在日常开发的过程中,可以利用这种代理模式,在调用流程中动态地注入一些非侵入式业务逻辑。
这里的"非侵入"指的是,在现有的调用链中,增加一些业务逻辑,而不用去修改调用链上下游的代码。比如说,我们要监控一个方法 A 的请求耗时,普通的方式就是在方法的开始和返回这两个地方各加一条记录时间的语句,这种方法就需要修改这个方法的代码,这是一种"侵入式"的方式。
我们还可以给这个方法所在的类创建一个代理类,在这个代理类的 A 方法中,先记录开始时间,然后调用委托类的 A 方法,再记录结束时间。把这个代理类加入到调用链中,就可以实现"非侵入式"记录耗时了。同样的方式,还可以用在权限验证、风险控制、调用链跟踪等等这些场景中。


2. 动态生成桩的接口 StubFactory

为了动态生成桩,我们首先定义了一个StubFactory接口, 用于生成客户端的"桩"(即服务的代理类)。这个"桩"将负责与服务端通信,使客户端能够像调用本地方法一样调用远程服务。

java 复制代码
 
/**
 * StubFactory接口定义了创建服务存根的工厂.
 * 它的主要作用是通过传输层和特定的服务类创建并返回一个服务存根.
 * @author artisan
 */
public interface StubFactory {
    /**
     * 创建并返回指定服务类的存根.
     * 
     * @param transport 传输层对象,用于网络通信.
     * @param serviceClass 服务类的Class对象,指定了要创建的存根类型.
     * @return 返回创建的服务存根对象,类型由serviceClass参数决定.
     */
    <T> T createStub(Transport transport, Class<T> serviceClass);
}
  • Transport:用于客户端与服务端之间的数据传输,负责发送请求和接收响应。
  • Class<T> serviceClass:表示服务接口的类型,createStub 方法需要根据该类型生成相应的"桩"。
  • 返回值 TcreateStub 返回生成的"桩"实例,该实例实现了指定的服务接口。

3. 如何来实现工厂方法创建桩

我们需要动态生成一个"桩"类,这个类会实现指定的接口,并将方法调用转化为网络请求发送给服务端,然后返回服务端的响应结果。

为了简化实现和便于理解,我们在这里做了一些假设:服务接口只能有一个方法,这个方法只能有一个参数,并且参数和返回值的类型都是 String 类型。

通常情况下,类是通过编写源代码,然后由编译器编译成字节码文件(.class 文件),最后由 JVM 加载并执行的。但是在动态生成"桩"类的场景中,我们需要在运行时根据指定的接口生成对应的类。这可以通过以下两种方式来实现:

  1. 静态生成:像 gRPC 这样的框架,会在编译 IDL 文件时生成目标语言(如 Java)的源代码文件,这些文件在编译后成为"桩"类。这种方式相对简单,但缺乏灵活性,因为"桩"类必须在编译时生成,运行时无法修改。

  2. 动态生成:像 Dubbo 这样的框架,会在运行时根据接口动态生成"桩"类。这种方式更加灵活,可以根据需要在运行时生成和修改"桩"类。


动态生成"桩"类的过程

我们将采用一种更通用的方法来动态生成"桩"类:首先生成"桩"类的源代码,然后动态编译该源代码,并将其加载到 JVM 中。

步骤概述
  1. 生成源代码:根据给定的接口定义,生成"桩"类的 Java 源代码。这个类将实现指定接口,并包含将方法调用转化为 RPC 请求的逻辑。

  2. 动态编译:将生成的源代码编译成字节码(.class 文件),这个过程可以在运行时完成。

  3. 加载类:将编译生成的字节码加载到 JVM 中,使得生成的类可以在当前应用中使用。


代码实现

编写一个 DynamicStubFactory 类来实现 StubFactory 接口。这个工厂类将动态生成"桩"类的源代码、编译并加载它,然后创建并返回对应的实例。

为了减少重复代码并简化动态生成"桩"的过程,我们可以引入一个 AbstractStub 抽象类,来封装那些通用的逻辑。通过让动态生成的"桩"类继承这个抽象类,可以避免在每个生成的类中重复实现相同的逻辑

java 复制代码
/**
 * 动态存根工厂类,用于实现存根的动态创建
 * @author artisan
 */
public class DynamicStubFactory implements StubFactory{
    /**
     * 存根源代码模板,用于生成存根类的源代码
     */
    private final static String STUB_SOURCE_TEMPLATE =
            "package com.github.liyue2008.rpc.client.stubs;\n" +
            "import com.github.liyue2008.rpc.serialize.SerializeSupport;\n" +
            "\n" +
            "public class %s extends AbstractStub implements %s {\n" +
            "    @Override\n" +
            "    public String %s(String arg) {\n" +
            "        return SerializeSupport.parse(\n" +
            "                invokeRemote(\n" +
            "                        new RpcRequest(\n" +
            "                                \"%s\",\n" +
            "                                \"%s\",\n" +
            "                                SerializeSupport.serialize(arg)\n" +
            "                        )\n" +
            "                )\n" +
            "        );\n" +
            "    }\n" +
            "}";

    /**
     * 根据传输层和接口类创建存根对象
     * @param transport 传输层实现,用于远程调用
     * @param serviceClass 服务接口类
     * @param <T> 服务接口类型
     * @return T 类型的存根对象
     */
    @Override
    @SuppressWarnings("unchecked")
    public <T> T createStub(Transport transport, Class<T> serviceClass) {
        try {
            // 填充模板,生成存根类的源代码
            String stubSimpleName = serviceClass.getSimpleName() + "Stub";
            // 服务接口的全限定名
            String classFullName = serviceClass.getName();
            // 生成的"桩"类的全限定名-->桩的类名就定义为:"接口名 + Stub",为了避免类名冲突,我们把这些桩都统一放到固定的包 com.github.liyue2008.rpc.client.stubs 下面
            String stubFullName = "com.github.liyue2008.rpc.client.stubs." + stubSimpleName;
            // 服务接口中的方法名
            String methodName = serviceClass.getMethods()[0].getName();

            // 填充模板后的源代码字符串
            String source = String.format(STUB_SOURCE_TEMPLATE, stubSimpleName, classFullName, methodName, classFullName, methodName);
            
            // 编译源代码
            JavaStringCompiler compiler = new JavaStringCompiler();
            Map<String, byte[]> results = compiler.compile(stubSimpleName + ".java", source);

            // 加载编译好的类
            Class<?> clazz = compiler.loadClass(stubFullName, results);

            // 创建存根实例,并设置传输层实现
            ServiceStub stubInstance = (ServiceStub) clazz.newInstance();
            // 把用于网络传输的对象 transport 赋值给桩,这样桩才能与服务端进行通信。
            stubInstance.setTransport(transport);
            // 返回存根实例
            return (T) stubInstance;
        } catch (Throwable t) {
            // 将任何异常转换为运行时异常
            throw new RuntimeException(t);
        }
    }
}
  • 生成源代码:首先根据给定的服务接口,利用模板生成对应的"桩"类的源代码。这个类实现了指定的接口,并将方法调用转化为 RPC 请求。

  • 动态编译 :生成的源代码被传递给 JavaStringCompiler 进行编译,编译结果是字节码(.class 文件)。

  • 加载类 :编译后的字节码通过 JavaStringCompiler 加载到 JVM 中,使得这个类在当前应用中可用。

  • 创建实例 :通过反射创建"桩"类的实例,并将 Transport 对象注入到该实例中。最终,这个实例被返回给调用方。


关键点总结

  • 动态生成代码:使用模板和反射机制,动态生成实现特定接口的"桩"类的源代码。

  • 动态编译:使用自定义编译器(JavaStringCompiler),在运行时编译生成的源代码,得到字节码。

  • 动态加载:将编译后的字节码加载到 JVM 中,使得生成的类可以被正常使用。

  • 反射机制:利用 Java 反射机制实例化动态生成的类,并通过接口类型进行返回,确保生成的"桩"可以在客户端代码中透明使用。

  • 简化与复用:通过继承 AbstractStub,将通用的 RPC 调用逻辑封装起来,减少重复代码,并提升可维护性。

通过这种方法,我们可以在运行时动态生成并加载"桩"类,实现对远程调用的透明化封装。这个过程虽然复杂,但它提供了极大的灵活性,允许 RPC 框架根据不同的接口动态生成和管理"桩"类。通过了解这一过程,我们可以轻松扩展和优化这个实现,以支持更多的接口方法和参数类型。

java 复制代码
/**
 * 抽象服务存根类,实现了ServiceStub接口
 * @author artisan
 */
public abstract class AbstractStub implements ServiceStub {
    /**
     * 用于通信的传输层对象
     */
    protected Transport transport;

    /**
     * 调用远程服务方法
     *
     * @param request 远程过程调用请求对象
     * @return 远程服务返回的数据
     * @throws RuntimeException 如果执行过程中出现异常
     * @throws Exception 如果远程服务返回错误
     */
    protected byte[] invokeRemote(RpcRequest request) {
        // 创建请求头,指定服务类型为RPC请求,版本为1,请求ID通过RequestIdSupport生成
        Header header = new Header(ServiceTypes.TYPE_RPC_REQUEST, 1, RequestIdSupport.next());
        // 将请求对象序列化为字节数组
        byte[] payload = SerializeSupport.serialize(request);
        // 使用请求头和负载创建一个请求命令对象
        Command requestCommand = new Command(header, payload);

        try {
            // 通过传输层发送请求命令,等待返回命令
            Command responseCommand = transport.send(requestCommand).get();
            // 解析返回命令的头信息
            ResponseHeader responseHeader = (ResponseHeader) responseCommand.getHeader();

            // 检查响应状态码,如果成功则返回响应数据
            if (responseHeader.getCode() == Code.SUCCESS.getCode()) {
                return responseCommand.getPayload();
            } else {
                // 如果响应状态码表示错误,则抛出异常
                throw new Exception(responseHeader.getError());
            }

        } catch (ExecutionException e) {
            // 如果异步执行过程中出现异常,包装后抛出RuntimeException
            throw new RuntimeException(e.getCause());
        } catch (Throwable e) {
            // 捕获所有异常和错误,包装后抛出RuntimeException
            throw new RuntimeException(e);
        }
    }

    /**
     * 设置用于通信的传输层对象
     *
     * @param transport 传输层对象
     */
    @Override
    public void setTransport(Transport transport) {
        this.transport = transport;
    }
}

技术点

动态代理模式的应用

动态代理模式 是一种设计模式,它允许我们在运行时动态地创建实现某些接口的代理对象,并定义这些代理对象的行为。相比于静态代理,它更加灵活,因为代理类并不需要在编译时定义,而是可以根据运行时的需求动态生成。

在RPC框架的设计中,动态代理模式可以在不修改源码的情况下,在调用链中注入额外的逻辑。我们利用这一模式,在桩中动态封装调用请求,并发送到服务端。这种非侵入式的设计方法使得代码更灵活、可扩展性更强。

在上述代码中,使用了 动态代理模式 来实现客户端调用远程服务的"桩"(Stub),即在运行时根据服务接口生成代理类,使得客户端可以像调用本地方法一样调用远程服务。

动态代理的应用分析

在上述代码中,DynamicStubFactory 通过动态代理模式实现了 RPC 客户端的"桩"生成。其具体应用如下:

  1. 接口与代理类

    • StubFactory 接口定义了一个通用的桩工厂方法 createStub,用来创建某个服务接口的代理对象(即"桩")。
    • 动态生成的代理类("桩")会实现客户端指定的服务接口,并封装远程调用逻辑。
  2. 模板生成代理类

    • 通过字符串模板 STUB_SOURCE_TEMPLATE,生成了服务接口的代理类的 Java 源代码。这些代理类继承自 AbstractStub,并实现了指定的服务接口。
    • 在模板中,代理类的每个方法都按照统一的模式,将方法名、参数等信息封装成一个 RpcRequest 对象,通过网络传输给远程服务器,并处理返回值。
  3. 动态编译与加载

    • 使用 JavaStringCompiler 在运行时编译生成的 Java 源代码,得到字节码。
    • 将编译后的字节码动态加载到 JVM 中,生成代理类的 Class 对象。
    • 通过反射机制实例化这个代理类,并返回给客户端。
  4. 动态代理的好处

    • 灵活性:通过动态代理,RPC 框架可以根据任意服务接口动态生成对应的代理类,而无需手动编写和维护这些代理类。
    • 封装性:客户端只需调用服务接口中的方法,具体的远程调用逻辑完全被代理类封装起来,客户端无需关心底层实现。
    • 扩展性:模板和编译机制使得动态生成的代理类可以适应不同的接口和方法,无需在编译期确定具体的代理逻辑。

依赖倒置和SPI

为了使客户端与具体的桩实现解耦,我们采用了依赖倒置原则 。调用方依赖于StubFactory接口,而不直接依赖具体实现。我们还利用了Java的SPI机制,通过配置文件的方式动态加载实现类,实现了完全的解耦。

如何实现的,我们下一篇博文分解。

相关推荐
叫我龙翔7 分钟前
【计网】实现reactor反应堆模型 --- 框架搭建
linux·运维·网络
不爱学习的YY酱1 小时前
【计网不挂科】计算机网络期末考试——【选择题&填空题&判断题&简述题】试卷(4)
网络·计算机网络
装睡的小5郎1 小时前
家庭宽带如何开启公网ipv4和ipv6
网络
yfs10241 小时前
压缩Minio桶中的文件为ZIP,并通过 HTTP 响应输出
网络·网络协议·http
有谁看见我的剑了?1 小时前
Ubuntu 22.04.5 配置vlan子接口和网桥
服务器·网络·ubuntu
hgdlip1 小时前
有什么办法换网络ip动态
网络·tcp/ip·智能路由器
超栈1 小时前
HCIP(11)-期中综合实验(BGP、Peer、OSPF、VLAN、IP、Route-Policy)
运维·网络·网络协议·计算机网络·web安全·网络安全·信息与通信
დ旧言~2 小时前
【网络】应用层——HTTP协议
开发语言·网络·网络协议·http·php
不爱学习的YY酱2 小时前
【计网不挂科】计算机网络期末考试——【选择题&填空题&判断题&简述题】试卷(1)
网络·计算机网络
汪小敏同学2 小时前
【Django进阶】django-rest-framework中文文档——快速入门
网络·windows·oracle