项目3:从0开始的RPC框架(扩展版)-1

一. 全局配置加载

1. 需求分析

通常情况下,在RPC框架运行的会涉及到多种配置信息,比如注册中心的地址、序列化方式、网络服务端接口号等。

在简易版框架中,硬编码了这些配置,也就是都写死了,在真实的应用环境中是不利于维护和后期扩展的。同时RPC框架需要被其它项目引入,作为服务提供者和消费者沟通的桥梁,所以应当允许引入框架的项目通过编写配置文件来自定义配置。一般情况下,服务提供者与消费者需要编写相同的RPC配置。

综上,我们需要一套全局配置加载功能。能够让RPC框架轻松地从配置文件中读取配置,并且维护一个全局配置对象,便于框架快速获取到一致的配置。

2. 设计方案

(1)配置项

从最简单出发,先提供几个基础配置项:

  • name 名称
  • version 版本号
  • serverHost 服务器主机名
  • serverPort 服务器端口号

之后随着框架功能的扩展再不断增加新配置即可,比如注册中心地址、服务接口、序列化方式等。

可参考:Dubbo RPC框架的配置项。包括应用配置、协议配置、注册中心等。

(2)读取配置文件

配置文件的读取,使用Java的Properties类自行编写。通常情况下,读取的配置文件名称为application.properties,还可以通过指定文件名称后缀的方式来区分多环境,比如application-prod.properties表示生产环境,application-test.properties表示测试环境。

3. 具体实现

(1)项目初始化

创建khr-rpc-core模块,扩展版RPC项目都基于此模块进行。直接复制粘贴easy模块包并改名。

引入日志库(ch.qos.logback)和单元测试(junit)依赖,并将consumer和provider模块引入的RPC依赖都替换成khr-rpc-core。

(2)配置加载

创建配置类RpcConfig:

用于保存配置信息。

可以给属性指定一些默认值,

package com.khr.krpc.config;

import lombok.Data;

/**
 * RPC框架配置
 */
@Data
public class RpcConfig {

    /**
     * 名称
     */
    private String name = "k-rpc";

    /**
     * 版本号
     */
    private String version = "1.0";

    /**
     * 服务器主机名
     */
    private String serverHost = "localhost";

    /**
     * 服务器端口号
     */
    private String serverPort = "8080";
}

创建工具类ConfigUtils: 

用于读取配置文件并返回配置对象,可以简化调用。配置类应当尽量通用,不和业务强绑定。

之后调用ConfigUtils的静态方法loadConfig就能读取配置了。

package com.khr.krpc.utils;

import cn.hutool.core.util.StrUtil;
import cn.hutool.setting.dialect.Props;

/**
 * 配置工具类
 */
public class ConfigUtils {

    /**
     * 加载配置对象
     *
     * @param tClass
     * @param perfix
     * @param <T>
     * @return
     */
    public static <T> T loadConfig(Class<T> tClass,String perfix){
        return loadConfig(tClass,perfix,"");
    }

    /**
     * 加载配置对象,支持区分环境
     *
     * @param tClass
     * @param perfix
     * @param environment
     * @param <T>
     * @return
     */
    public static <T> T loadConfig(Class<T> tClass,String perfix,String environment){
        StringBuilder configFileBuilder = new StringBuilder("application");
        if (StrUtil.isNotBlank(environment)){
            configFileBuilder.append("-").append(environment);
        }
        configFileBuilder.append(".properties");
        Props props = new Props(configFileBuilder.toString());
        return props.toBean(tClass,perfix);
    }
}

创建RpcConstant接口:

用于存储RPC框架相关的常量。比如默认配置文件的加载前缀为rpc。

package com.khr.krpc.constant;

/**
 * RPC相关常量
 */
public interface RpcConstant {

    /**
     * 默认配置文件加载前缀
     */
    String DEFAULT_CONFIG_PREFIX = "rpc";
}

可以读取到类似下面的配置:

rpc.name=krpc
rpc.version=2.0
rpc.serverPort=8081

(3)维护全局配置对象

RPC框架中需要维护一个全局的配置对象。在引入RPC框架后并启动项目时,从配置文件中读取配置并创建对象实例,之后就可以集中地从这个对象中获取配置信息,而不需要每次加载配置时再重新读取并创建对象,减少了性能开销。

使用了设计模式中的单例模式。通常情况下会使用holder来维护全局配置对象实例,在本项目中使用RpcApplication类作为RPC项目的启动入口,并维护项目全局用到的变量。

package com.khr.krpc;

import com.khr.krpc.config.RpcConfig;
import com.khr.krpc.constant.RpcConstant;
import com.khr.krpc.utils.ConfigUtils;
import lombok.extern.slf4j.Slf4j;

/**
 * RPC框架应用
 * 相当于holder,存放了项目全局用到的变量。双检锁单例模式实现。
 */
@Slf4j
public class RpcApplication {

    private static volatile RpcConfig rpcConfig;

    /**
     * 框架初始化,支持传入自定义配置
     *
     * @param newRpcConfig
     */
    public static void init(RpcConfig newRpcConfig){
        rpcConfig = newRpcConfig;
        log.info("rpc init, config = {}",newRpcConfig.toString());
    }

    /**
     * 初始化
     */
    public static void init(){
        RpcConfig newRpcConfig;
        try {
            newRpcConfig = ConfigUtils.loadConfig(RpcConfig.class,RpcConstant.DEFAULT_CONFIG_PREFIX);
        }catch (Exception e){
            //配置加载失败,使用默认值
            newRpcConfig =new RpcConfig();
        }
        init(newRpcConfig);
    }

    /**
     * 获取配置
     *
     * @return
     */
    public static RpcConfig getRpcConfig(){
        if (rpcConfig == null){
            synchronized (RpcApplication.class){
                if (rpcConfig == null){
                    init();
                }
            }
        }
        return rpcConfig;
    }
}

 双检锁单例模式的经典实现,支持在获取配置时才调用init方法实现懒加载。

为了便于扩展,还支持自己传入配置对象,如果不传入的话,默认调用前面写好的ConfigUtils来加载配置。

之后一行代码即可正确加载配置:

RpcConfig rpc = RpcApplication.getRpcConfig();

4. 测试

(1)测试配置文件读取

在example-consumer模块的resources目录下编写配置文件application.properties,

rpc.name=krpc
rpc.version=2.0
rpc.serverPort=8081

创建ConsumerExample作为扩展版RPC项目的示例消费者类,测试配置文件读取,

package com.khr.example.consumer;

import com.khr.example.common.model.User;
import com.khr.example.common.service.UserService;
import com.khr.krpc.config.RpcConfig;
import com.khr.krpc.proxy.ServiceProxyFactory;
import com.khr.krpc.utils.ConfigUtils;

/**
 * 服务消费者示例
 */
public class ConsumerExample {

    public static void main(String[] args){

        RpcConfig rpc = ConfigUtils.loadConfig(RpcConfig.class,"rpc");
        System.out.println(rpc);
    }
}

读取结果为,

已成功读到配置文件中的内容。 

(2)测试全局配置对象加载

在example-provider模块中创建ProviderExample服务提供者示例类,能够根据配置动态地在不同端口启动Web服务

package com.khr.example.provider;

import com.khr.example.common.service.UserService;
import com.khr.krpc.RpcApplication;
import com.khr.krpc.registry.LocalRegistry;
import com.khr.krpc.server.HttpServer;
import com.khr.krpc.server.VertxHttpServer;

/**
 * 服务提供者示例
 */
public class ProviderExample {

    public static void main(String[] args){
        //RPC框架初始化
        RpcApplication.init();

        //注册服务
        LocalRegistry.registry(UserService.class.getName(),UserServiceImpl.class);

        //启动web服务
        HttpServer httpServer = new VertxHttpServer();
        httpServer.doStart(Integer.parseInt(RpcApplication.getRpcConfig().getServerPort()));
    }
}

启动结果为,

 

已成功在8080端口启动。 

至此,扩展功能,全局配置加载完成,后续可能会根据新增的功能逐步修改全局配置信息。

二. Mock服务

1. 需求分析

(1)什么是Mock?

Mock是指模拟对象或模拟数据,通常用于代码测试,方便开发者跑通业务流程。

(2)为什么要支持Mock?

RPC框架的核心功能是调用其它远程服务,但在实际的开发和测试环境中,有时可能会因为各种不可控因素导致无法访问远程服务,比如网络延迟、远程服务宕机等。因此需要使用Mock服务进行模拟调用,以进行接口测试。

比如用户想调用订单服务,但是该服务还未上线,流通就跑不通,所以不如先设置一个模拟对象orderService,调用它的order方法时,任意返回一个默认值,以进行接口测试。

此外,Mock服务开发成本不高,并且RPC框架支持Mock后,开发者不必依赖真实的远程服务,轻松调用服务接口跑通流程,提高使用体验。并且支持用最简单的方式,一个配置就让开发者使用Mock服务。

2. 设计方案

之前在简易版RPC框架中曾利用动态代理的方式创建对象,同理,通过动态代理创建一个调用方法时返回固定值的对象,即模拟对象。

3. 具体实现

(1)全局配置类新增Mock字段

支持开发者通过修改配置文件的方式开启Mock,默认值为false,

package com.khr.krpc.config;

import lombok.Data;

/**
 * RPC框架配置
 */
@Data
public class RpcConfig {

    ...

    /**
     * 模拟调用
     */
    private boolean mock = false;
}

(2)创建MockServiceProxy类

用于生成Mock代理服务。提供一个根据服务接口类型返回固定值的方法。

通过getDefaultObject方法,根据代理接口的class返回不同的默认值,比如boolean类型返回false,对象类型返回null,long类型返回0L等。

package com.khr.krpc.proxy;

import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;

/**
 * Mock 服务代理(JDK动态代理)
 */
@Slf4j
public class MockServiceProxy implements InvocationHandler{

    /**
     * 调用代理
     *
     * @return
     * @throws Throwable
     */
    @Override
    public Object invoke(Object proxy, Method method, Object[] args)throws Throwable{
        //根据方法的返回值类型,生成特定的默认值对象
        Class<?> methodReturnType = method.getReturnType();
        log.info("moke invoke {}",method.getName());
        return getDefaultObject(methodReturnType);
    }

    /**
     * 生成指定类型的默认值对象
     *
     * @param type
     * @return
     */
    private Object getDefaultObject(Class<?> type){
        //基本类型
        if (type.isPrimitive()){
            if (type == boolean.class){
                return false;
            } else if (type == short.class){
                return (short) 0;
            } else if (type == int.class){
                return 0;
            } else if(type == long.class){
                return 0L;
            }
        }
        //对象类型
        return null;
    }
}

(3)在ServiceProxyFactory服务代理工厂类中新增Mock代理对象的方法getMockProxy

通过读取已定义的全局配置Mock来区分创建哪种代理对象,

package com.khr.krpc.proxy;

import java.lang.reflect.Proxy;
import com.khr.krpc.RpcApplication;

/**
 * 服务代理工厂(用于创建代理对象)
 */

public class ServiceProxyFactory {

    /**
     * 根据服务类获取代理对象
     *
     * @param serviceClass
     * @param <T>
     * @return
     */
    public static <T> T getProxy(Class<T> serviceClass){
        if (RpcApplication.getRpcConfig().isMock()){
            return getMockProxy(serviceClass);
        }

        return (T) Proxy.newProxyInstance(
                serviceClass.getClassLoader(),
                new Class[]{serviceClass},
                new ServiceProxy());
    }

    /**
     * 根据服务类获取Mock代理对象
     *
     * @param serviceClass
     * @param <T>
     * @return
     */
    public static <T> T getMockProxy(Class<T> serviceClass){
        return (T) Proxy.newProxyInstance(
                serviceClass.getClassLoader(),
                new Class[]{serviceClass},
                new MockServiceProxy());
    }
}

4. 测试

在common模块的UserService中新增一个具有默认实现的新方法,通过调用该方法来测试Mock代理服务是否生效,即查看调用的是模拟服务还是真实服务,

package com.khr.example.common.service;

import com.khr.example.common.model.User;

/**
 * 用户服务
 */
public interface UserService {
    /**
     * 获取用户
     *
     * @param user
     * @return
     */
    User getUser(User user);

    /**
     * 测试方法 - 获取数字
     */
    default short getNumber(){
        return 1;
    }
}

在application.properties配置文件中,将mock设置为true,即开启模拟服务,

rpc.name=krpc
rpc.version=2.0
rpc.mock = true

在ConsumerExample类中新增调用userService.getNumber的测试代码,

package com.khr.example.consumer;

import com.khr.example.common.model.User;
import com.khr.example.common.service.UserService;
import com.khr.krpc.config.RpcConfig;
import com.khr.krpc.proxy.ServiceProxyFactory;
import com.khr.krpc.utils.ConfigUtils;

/**
 * 服务消费者示例
 */
public class ConsumerExample {

    public static void main(String[] args){

        //获取代理
        UserService userService = ServiceProxyFactory.getProxy(UserService.class);
        User user = new User();
        user.setName("KHR");
        //调用
        User newUser = userService.getUser(user);
        if (newUser != null){
           System.out.println(newUser.getName());
        } else {
            System.out.println("user == null");
        }
        long number = userService.getNumber();
        System.out.println(number);
    }
}

因为开启了Mock服务,所以会去调用MockServiceProxy模拟服务代理,因为getNumber方法返回值是1,即int类型,所以Mock服务的返回值为0,也就是输出结果为0。

如果没有开启Mock服务,设置为mock = false,那么返回值为,

 即调用了真实的服务方法getUser和getNumber。

至此,扩展功能,Mock服务开发完成,后续可能会根据新增的功能通过Mock服务进行测试。

三. 序列化器与SPI机制

1. 需求分析

无论是请求或响应,都会涉及参数的传输。而Java对象存活在JVM虚拟机中,因此无法直接传输。序列化器就是用来将Java对象转换成字节码等形式,以便于在网络中传输。包括序列化和反序列化两个步骤。

在简易版RPC框架中,实现了基于Java原生序列化的Jdk序列化器,但一个相对完善的RPC框架需要同时具备:

  • 多种性能高效的序列化器
  • 让使用框架的开发者指定序列化器
  • 让使用框架的开发者定制序列化器

2. 设计方案

(1)常见的序列化器实现方式

高性能的序列化器,能够更快速地完成RPC的请求和响应。除了Java原生序列化形式外,主流的序列化方式还有Json、Hessian、Kryo等。

各类序列化方式对比:

Json

优点:

  • 可读性强,便于理解和调试。
  • 跨语言支持,几乎所有编程语言都有Json的解析和生成库。

缺点:

  • 序列化后的数据量相对较大。
  • 不能很好地处理复杂的数据结构和循环引用,可能导致性能下降或失败。

Hessian

优点:

  • 二进制序列化,序列化后的数据量小,网络传输效率高。
  • 跨语言支持,适用于分布式系统的服务调用。

缺点:

  • 相比于Json,性能较低。因为需要将对象转换为二进制格式。
  • 对象必须实现Serializable接口,限制了可序列化的对象范围。

Kryo

优点:

  • 高性能,序列化和反序列化速度快。
  • 支持循环引用和自定义序列化器,适用于复杂的对象结构。
  • 无需实现Serializable接口,可以序列化任意对象。

缺点:

  • 不支持跨语言,只适用于Java。
  • 对象的序列化格式不友好,不易读和调试。

(2)动态使用序列化器

之前在代码中硬编码了序列化器,

Serializer serializer = new JdkSerializer();

如果开发者想要替换成别的序列化器,需要修改所有上述代码,效率很低。

因此,希望通过配置文件来指定使用的序列化器。

可以通过定义一个 序列化器名称 => 序列化器实现类对象 的Map,然后根据名称从Map中获取对象接口。

(3)自定义序列化器

问题:开发者想要自定义一个新的序列化器实现,但不能修改框架代码。

思路:RPC框架支持读取用户自定义的类路径,读取后加载这个类,作为Serializer序列化器接口的实现。

实现:SPI机制。

(4)SPI机制

SPI(Service Provider Interface),服务提供接口。主要用于模块化开发和插件化扩展,通常被服务提供者或扩展框架功能的开发者使用。比如,某一企业设计了接口A,其它不同厂商可以针对接口A做出不同的实现,提交有自家特色的实现方式。

SPI机制允许服务提供者通过特定的配置文件将自己的实现注册到系统中,然后系统通过反射机制动态加载这些实现,而无需修改原始框架的代码,从而实现系统的解耦

当服务提供者提供了一种接口的实现之后,需要在classpath下的META-INF/services/目录下创建一个以服务接口命名的文件,这个文件的内容就是接口具体的实现类。

SPI的实现分为系统实现和自定义实现。

系统实现(Demo)

  • 首先在resources资源目录下创建META-INF/services目录,并创建一个名称为要实现的接口的空文件,

  • 编写一个测试接口,两个测试类,

//Animal接口
public interface Animal {
    void eat(String food);
}

//Dog实现类
public class Dog implements Animal{
    @Override
    public void eat(String food){
        System.out.println("Dog eat " + food);
    }
}

//Cat实现类
public class Cat implements Animal{
    @Override
    public void eat(String food){
        System.out.println("Cat eat " + food);
    }
}
  • 使用系统内置的ServiceLoader动态加载指定接口的实现类,
import com.khr.krpc.proxy.ServiceProxyFactory;

import java.util.ServiceLoader;

public class SpiLoaderTest {

    public static void main(String[] args) throws Exception{
        ServiceLoader<Animal> animalServiceLoader = ServiceLoader.load(Animal.class);
        for(Animal animal : animalServiceLoader){
            animal.eat("meat");
        }
    }
}
  • 在配置文件中编写定制的接口实现类的完整类路径,
com.khr.rpc.spi.Cat
com.khr.rpc.spi.Dog
  • 测试,

当只添加com.khr.rpc.spi.Cat一个类路径时,输出结果为,

当两个都添加时,输出结果为,

自定义实现

系统实现相对容易,但如果想定制多个不同的接口实现类,没办法在框架中指定使用哪一个,无法满足快速指定序列化器的需求。

因此需要自定义SPI实现,通过读取配置文件,能够得到一个 序列化器名称 => 序列化器实现类对象的映射,之后就可以根据用户配置的序列化器名称动态加载指定实现类对象。

jdk=com.khr.krpc.serializer.JdkSerializer
hessian=com.khr.krpc.serializer.HessianSerializer
json=com.khr.krpc.serializer.JsonSerializer
kryo=com.khr.krpc.serializer.KryoSerializer

3. 具体实现

(1)Json序列化器

import com.fasterxml.jackson.databind.ObjectMapper;
import com.khr.krpc.model.RpcRequest;
import com.khr.krpc.model.RpcResponse;

import java.io.IOException;

/**
 * Json序列化器
 */
public class JsonSerializer implements Serializer{

    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    @Override
    public  <T> byte[] serialize(T obj) throws IOException{
        return OBJECT_MAPPER.writeValueAsBytes(obj);
    }

    @Override
    public <T> T deserialize(byte[] bytes,Class<T> classType) throws IOException{
        T obj = OBJECT_MAPPER.readValue(bytes,classType);
        if(obj instanceof RpcRequest){
            return handleRequest((RpcRequest) obj, classType);
        }
        if (obj instanceof RpcResponse){
            return handleResponse((RpcResponse) obj, classType);
        }
        return obj;
    }

    /**
     * 由于Object的原始对象会被擦除,导致反序列化时会被作为LinkedHashMap无法转换成原始对象,因此这里做了特殊处理。
     *
     * @param rpcRequest rpc 请求
     * @param type 类型
     * @return {@link T}
     * @throws IOException IO异常
     */
    private <T> T handleRequest(RpcRequest rpcRequest,Class<T> type) throws IOException{
        Class<?>[] parameterTypes = rpcRequest.getParameterTypes();
        Object[] args = rpcRequest.getArgs();

        //循环处理每个参数的类型
        for(int i = 0; i < parameterTypes.length; i++){
            Class<?> clazz = parameterTypes[i];
            //如果类型不同,则重新处理一下类型
            if (!clazz.isAssignableFrom(args[i].getClass())){
                byte[] argBytes =  OBJECT_MAPPER.writeValueAsBytes(args[i]);
                args[i] = OBJECT_MAPPER.readValue(argBytes,clazz);
            }
        }
        return type.cast(rpcRequest);
    }

    /**
     * 由于Object的原始对象会被擦除,导致反序列化时会被作为LinkedHashMap无法转换成原始对象,因此这里做了特殊处理。
     *
     * @param rpcResponse rpc 请求
     * @param type 类型
     * @return {@link T}
     * @throws IOException IO异常
     */
    private <T> T handleResponse(RpcResponse rpcResponse,Class<T> type) throws IOException{
        //处理响应数据
        byte[] dataBytes = OBJECT_MAPPER.writeValueAsBytes(rpcResponse.getData());
        rpcResponse.setData(OBJECT_MAPPER.readValue(dataBytes,rpcResponse.getDataType()));
        return type.cast(rpcResponse);
    }
}

(2)Kryo序列化器

Kryo本身线程不安全,使用ThreadLocal保证每个线程有一个单独的Kryo对象实例。

首先导入依赖,

    <dependency>
      <groupId>com.esotericsoftware</groupId>
      <artifactId>kryo</artifactId>
      <version>5.6.0</version>
    </dependency>
import com.esotericsoftware.kryo.Kryo;
import com.esotericsoftware.kryo.io.Input;
import com.esotericsoftware.kryo.io.Output;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;

/**
 * Kryo 序列化器
 */
public class KryoSerializer implements Serializer {
    /**
     * kryo 线程不安全,使用 ThreadLocal 保证每个线程只有一个 Kryo
     */
    private static final ThreadLocal<Kryo> KRYO_THREAD_LOCAL = ThreadLocal.withInitial(() -> {
        Kryo kryo = new Kryo();
        // 设置动态动态序列化和反序列化类,不提前注册所有类(可能有安全问题)
        kryo.setRegistrationRequired(false);
        return kryo;
    });

    @Override
    public <T> byte[] serialize(T obj) {
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        Output output = new Output(byteArrayOutputStream);
        KRYO_THREAD_LOCAL.get().writeObject(output, obj);
        output.close();
        return byteArrayOutputStream.toByteArray();
    }

    @Override
    public <T> T deserialize(byte[] bytes, Class<T> classType) {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
        Input input = new Input(byteArrayInputStream);
        T result = KRYO_THREAD_LOCAL.get().readObject(input, classType);
        input.close();
        return result;
    }
}

(3)Hessian序列化器

导入依赖,

    <dependency>
      <groupId>com.caucho</groupId>
      <artifactId>hessian</artifactId>
      <version>4.0.66</version>
    </dependency>
import com.caucho.hessian.io.HessianInput;
import com.caucho.hessian.io.HessianOutput;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;

/**
 * Hessian序列化器
 */

public class HessianSerializer implements Serializer{
    @Override
    public <T> byte[] serialize(T object) throws IOException{
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        HessianOutput ho = new HessianOutput(bos);
        ho.writeObject(object);
        return bos.toByteArray();
    }

    @Override
    public <T> T deserialize(byte[] bytes, Class<T> tClass) throws IOException{
        ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
        HessianInput hi = new HessianInput(bis);
        return (T) hi.readObject(tClass);
    }
}

三种序列化器实现后均放在serializer目录下,并且序列化器的实现有多种,代码可能会略有差异,现用现查即可。

(4)动态使用序列化器

定义序列化器名称的常量,使用接口实现,

/**
 * 序列化器键名
 */
public interface SerializerKeys {
    String JDK = "jdk";
    String JSON = "json";
    String KRYO = "kryo";
    String HESSIAN = "hessian";
}

定义序列化器工厂。序列化器对象是可以复用的,使用设计模式中的工厂模式+单例模式来简化创建和获取对象的操作,不需要每次执行序列化时都创建新对象,

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
 * 序列化器工厂(用于获取序列化器对象)
 */
public class SerializerFactory {

     private static final Map<String, Serializer>KEY_SERIALIZER_MAP = new HashMap<String, Serializer>(){{
        put(SerializerKeys.JDK, new JdkSerializer());
        put(SerializerKeys.JDK, new JsonSerializer());
        put(SerializerKeys.JDK, new KryoSerializer());
        put(SerializerKeys.JDK, new HessianSerializer());
}}

    /**
     * 默认序列化器
     */
    private static final Serializer DEFAULT_SERIALIZER = KEY_SERIALIZER_MAP.get("jdk");

    /**
     * 获取实例
     *
     * @param key
     * @return
     */
    public static Serializer getInstance(String key){
        return KEY_SERIALIZER_MAP.getOrDefault(key, DEFAULT_SERIALIZER);
    }

}

在全局配置类RpcConfig中增加序列化器的配置,

import com.khr.krpc.serializer.SerializerKeys;
import lombok.Data;

/**
 * RPC框架配置
 */
@Data
public class RpcConfig {

    ...

    /**
     * 序列化器
     */
    private String Serializer = SerializerKeys.JDK;
}

动态获取序列化器,将之前代码中所有用到序列化器的位置更改为 使用工厂+读取配置 来获取实现类,需要修改ServiceProxy类和HttpServerHandler类,

统一修改为,

//指定序列化器
final Serializer serializer = SerializerFactory.getInstance(RpcApplication.getRpcConfig().getSerializer());

(4)自定义序列化器

允许用户自定义序列化器并指定键名。

系统内置的SPI机制会加载resources资源目录下的META-INF/services目录,同理,让自定义的序列化器也去加载,但改为读取META-INF/rpc目录。

进一步,可以将SPI配置分为系统内置SPI和用户自定义SPI,

  • 用户自定义SPI:META-INF/rpc/custom。用户可以在该目录下新建配置,加载专属实现类。
  • 系统内置SPI:META-INF/rpc/system。RPC框架自带的实现类,比如之前开发的JdkSerializer。

这样所有接口的实现类都可以通过SPI动态加载,不用在代码中硬编码Map来维护实现类了。

分别创建用户自定义SPI与系统内置SPI的配置文件,

jdk=com.khr.krpc.serializer.JdkSerializer
hessian=com.khr.krpc.serializer.HessianSerializer
json=com.khr.krpc.serializer.JsonSerializer
kryo=com.khr.krpc.serializer.KryoSerializer

创建SpiLoader加载器,

类似于工具类,用于读取配置并加载实现类的方法。

import cn.hutool.core.io.resource.ResourceUtil;
import com.khr.krpc.serializer.Serializer;
import lombok.extern.slf4j.Slf4j;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
import java.util.HashMap;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * SPI加载器
 * 自定义实现,支持键值对映射
 */
@Slf4j
public class SpiLoader {

    /**
     * 存储已加载的类:接口名 =>(key => 实现类)
     */
    private static final Map<String, Map<String,Class<?>>> loaderMap = new ConcurrentHashMap<>();

    /**
     * 对象实例缓存(避免重复 new),类路径 => 对象实例,单例模式
     */
    private static final Map<String, Object> instanceCache = new ConcurrentHashMap<>();

    /**
     * 系统SPI目录
     */
    private static final String RPC_SYSTEM_SPI_DIR = "META-INF/rpc/system/";

    /**
     * 用户自定义SPI目录
     */
    private static final String RPC_CUSTOM_SPI_DIR = "META-INF/rpc/custom/";

    /**
     * 扫描路径
     */
    private static final String[] SCAN_DIRS = new String[]{RPC_SYSTEM_SPI_DIR,RPC_CUSTOM_SPI_DIR};

    /**
     * 动态加载的类列表
     */
    private static final List<Class<?>> LOAD_CLASS_LIST = Arrays.asList(Serializer.class);

    /**
     * 加载所有类型
     */
    public static void loadALl(){
        log.info("加载所有 SPI");
        for (Class<?> aClass : LOAD_CLASS_LIST){
            load(aClass);
        }
    }

    /**
     * 加载某个类型
     *
     * @param loadClass
     * @throws IOException
     */
    public static Map<String, Class<?>> load(Class<?> loadClass){
        log.info("加载类型为 {} 的SPI",loadClass.getName());
        //扫描路径,用户自定义的SPI优先级高于系统SPI
        Map<String, Class<?>> keyClassMap = new HashMap<>();
        for (String scanDir : SCAN_DIRS){
            log.info("扫描路径为 {}",scanDir + loadClass.getName());
            List<URL> resources = ResourceUtil.getResources(scanDir + loadClass.getName());
            //读取每个资源文件
            for (URL resource : resources){
                try {
                    InputStreamReader inputStreamReader = new InputStreamReader(resource.openStream());
                    BufferedReader bufferedReader = new BufferedReader(inputStreamReader);
                    String line;
                    while ((line = bufferedReader.readLine()) != null){
                        String[] strArray = line.split("=");
                        if (strArray.length > 1){
                            String key = strArray[0];
                            String className = strArray[1];
                            keyClassMap.put(key, Class.forName(className));
                        }
                    }
                } catch (Exception e){
                    log.error("spi resource load error", e);
                }
            }
        }
        loaderMap.put(loadClass.getName(),keyClassMap);
        return keyClassMap;
    }
    
    /**
     * 获取某个接口的实例
     *
     * @param tClass
     * @param key
     * @param <T>
     * @return
     */
    public static <T> T getInstance(Class<?> tClass,String key){
        String tClassName = tClass.getName();
        Map<String,Class<?>> keyClassMap = loaderMap.get(tClassName);
        if (keyClassMap == null){
            throw new RuntimeException(String.format("SpiLoader 未加载 %s 类型",tClassName));
        }
        if (!keyClassMap.containsKey(key)){
            throw new RuntimeException(String.format("SpiLoader 的 %s 不存在 key=%s 的类型", tClassName, key));
        }
        //获取到要加载的实现类型
        Class<?> implClass = keyClassMap.get(key);
        //从实例缓存中加载指定类型的实例
        String implClassName = implClass.getName();
        if (!instanceCache.containsKey(implClassName)){
            try {
                instanceCache.put(implClassName, implClass.newInstance());
            } catch (InstantiationException | IllegalAccessException e){
                String errorMsg = String.format("%s 类实例化失败",implClassName);
                throw new RuntimeException(errorMsg, e);
            }
        }
        return (T) instanceCache.get(implClassName);
    }
}

 部分代码解析:

 /**
     * 存储已加载的类:接口名 =>(key => 实现类)
     */
    private static final Map<String, Map<String,Class<?>>> loaderMap = new ConcurrentHashMap<>();

    /**
     * 对象实例缓存(避免重复 new),类路径 => 对象实例,单例模式
     */
    private static final Map<String, Object> instanceCache = new ConcurrentHashMap<>();

loaderMap中存放接口 => 实现类的映射,instanceCache中存放key对应的类的实例。

    public static void loadALl(){
        log.info("加载所有 SPI");
        for (Class<?> aClass : LOAD_CLASS_LIST){
            load(aClass);
        }

虽然提供了loadAll方法,扫描所有路径下的文件进行加载,但更推荐使用load方法按需加载类。

log.info("扫描路径为 {}",scanDir + loadClass.getName());
List<URL> resources = ResourceUtil.getResources(scanDir + loadClass.getName());

配置文件的获取是通过ResourceUtil.getResources方法,而不是通过文件路径,因为如果框架作为依赖被引入,是无法得到正确文件路径的。

String line;
while ((line = bufferedReader.readLine()) != null){
    String[] strArray = line.split("=");
    if (strArray.length > 1){
        String key = strArray[0];
        String className = strArray[1];
        eyClassMap.put(key, Class.forName(className));
}

依次读取到每行内容之后,用"="进行分割,前面是key,后面是类名,

例如读到 json=com.khr.krpc.serializer.JsonSerializer,key就是json,类名就是com.khr.krpc.serializer.JsonSerializer。

也就是说,ClassName中存放了对应类名,通过Class.forName(ClassName)进行动态加载类。而Class.forName(ClassName)方法只返回一个表示指定类的Class对象,并不会实例化该对象。真正的实例化操作是在getInstance方法中完成的,通过newInstance方法或反射的方式创建SPI实现类的实例并缓存。

实现了分离加载和实例化,让SPI加载器更加灵活高效。同时避免了重复创建类的实例对象,减少了资源消耗。

     //获取到要加载的实现类型
     Class<?> implClass = keyClassMap.get(key);
     //从实例缓存中加载指定类型的实例
     String implClassName = implClass.getName();
        if (!instanceCache.containsKey(implClassName)){
            try {
                instanceCache.put(implClassName, implClass.newInstance());
            } catch (InstantiationException | IllegalAccessException e){
                String errorMsg = String.format("%s 类实例化失败",implClassName);
                throw new RuntimeException(errorMsg, e);
            }
        }
         return (T) instanceCache.get(implClassName);

重构SerializerFactory序列化器工厂。

之前是硬编码了HashMap来存储序列化器和实现类,现在可以通过SPI进行动态加载。使用静态代码块,在工厂首次加载时调用SpiLoader.load方法加载序列化器接口的所有实现类,之后就可以通过调用getInstance方法获取指定的实现类对象,

import com.khr.krpc.spi.SpiLoader;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
 * 序列化器工厂(用于获取序列化器对象)
 */
public class SerializerFactory {

    static {
        SpiLoader.load(Serializer.class);
    }

    /**
     * 默认序列化器
     */
    private static final Serializer DEFAULT_SERIALIZER = new JdkSerializer();

    /**
     * 获取实例
     *
     * @param key
     * @return
     */
    public static Serializer getInstance(String key){
        return SpiLoader.getInstance(Serializer.class, key);
    }

}

4. 测试

(1)SPI加载测试

在custom和system目录下的配置文件中任意指定键名和实现类路径,检验SPI加载能否完成。

 例如加载json序列化器,

输出结果为,

 此外,经测试发现,当custom与system配置中的key相同时,custom下的配置会覆盖system,即自定义配置优先级更高。如果key不存在,则报错。

 if (!keyClassMap.containsKey(key)){
      throw new RuntimeException(String.format("SpiLoader 的 %s 不存在 key=%s 的类型", tClassName, key));
}

(2)完整流程测试

更新consumer和provider模块中的配置文件,指定不同的序列化器,

rpc.name=krpc
rpc.version=2.0
rpc.mock=false
rpc.serializer=kryo

wAAACH5BAEKAAAALAAAAAABAAEAAAICRAEAOw==

启动消费者和服务提供者后,RPC请求与响应正常完成,

 (3)自定义序列化器

之后如果开发者想要实现自定义的序列化器,只需写一个类实现Serializer接口,即一个新的序列化器,然后在custom目录下编写SPI配置文件即可,加载自己的实现类。

至此,扩展功能,自定义序列化器开发完成。

四. 注册中心(基本实现)

1. 需求分析

注册中心的作用是帮助消费者获取到服务提供者的调用地址,而不是将调用地址硬编码到项目中。

2. 设计方案

(1)注册中心的核心能力

  • 数据分布式存储:集中的注册信息数据存储、读取和共享。
  • 服务注册:服务提供者上传服务信息到注册中心。
  • 服务发现:消费者从注册中心拉取服务信息。
  • 心跳检测:定期检查服务提供者的存活状态。
  • 服务注销:手动剔除节点或者自动剔除失效节点。
  • ……

(2)常用的注册中心

应当根据需求选择合适的注册中心。

在本项目中,需要一个能够集中存储和读取数据的中间件,并且具有数据过期、数据监听的功能。此外,还需考虑其稳定性、可用性、可靠性、数据一致性等。

主流的注册中心实现中间件有Zookeeper、Redis、Nacos等。在本项目中,将采用一种更为新颖、更适合存储元信息(注册信息)的云原生中间件Etcd,来实现注册中心。

(3)Etcd

Etcd是一个基于Go语言实现的开源分布式键值存储系统,主要用于分布式系统中的服务发现、配置管理和分布式锁等场景。它通常被作为云原生应用的基础设施,存储元信息,性能较高。

此外,Etcd采用Raft一致性算法来保证数据的一致性和可靠性,具有高可用性、强一致性、分布式等特性。它提供了简单的API、数据过期机制、数据监听和通知机制等,非常适合做注册中心。

Etcd采用层次化的键值对存储数据,支持类似于文件系统路径的层次结构,和Zookeeper相似,能够灵活地单key查询、按前缀查询、按范围查询。

Etcd的核心数据结构:

  • Key(键):Etcd中的基本数据单元,类似于文件系统中的文件名。每个键都唯一标识一个值,并且可以包含子健,形成类似于路径的层次结构。
  • Value(值):与键关联的数据,可以是任意类型的数据,通常是字符串形式。 

因为只有键和值,所以更易理解,并且可以将数据序列化后写入value。

Etcd的常用特性:

  • Lease(租约):用于对键值对进行TTL超时设置。即键值对的过期时间。过期后键值对将被自动删除。
  • Watch(监听):监听特定键的变化并触发相应的通知机制。

Etcd保证数据的一致性:

  • 支持事务操作,能够保证数据一致性。
  • 使用Raft一致性算法保证。

Raft是一种分布式一致性算法,确保了分布式系统中的所有节点在任何时间点都能达成一致的数据视图。

Etcd安装:Releases · etcd-io/etcd (github.com)

Etcd可视化工具(etcdkeeper)安装:GitHub - evildecay/etcdkeeper: web ui client for etcd

Etcd Java客户端:导入依赖jetcd,

    <dependency>
      <groupId>io.etcd</groupId>
      <artifactId>jetcd-core</artifactId>
      <version>0.7.7</version>
    </dependency>

注册信息在注册中心内的存储:

由于一个服务可能有多个服务提供者(负载均衡),可以有两种结构设计,

  • 层级结构

服务就是文件夹,服务下的节点就是文件夹内的文件。可以通过服务名称,用前缀查询的方式查到某个服务的所有节点。比如,键名的规则可以是 /业务前缀/服务名/服务节点地址

  • 列表结构

将所有的服务节点以列表的形式整体作为value。

选择哪种存储结构与技术选型有关。Etcd和Zookeeper支持层级查询,选择层级结构较好;Redis本身支持列表数据结构,选择列表结构更合适。 

同时要给key设置过期时间,默认30s,这样即使服务提供者宕机,超时后也会自动移除。

3. 具体实现

(1)注册信息定义

在model包下新建ServiceMetaInfo类,对服务的注册信息进行封装,包括服务名称、服务版本号、服务地址、服务分组等。同时增加一些工具方法,用于获取服务注册键名、获取服务注册节点键名等。

import cn.hutool.core.util.StrUtil;
import com.khr.krpc.constant.RpcConstant;
import lombok.Data;

/**
 * 服务元信息(注册信息)
 */
@Data
public class ServiceMetaInfo {

    /**
     * 服务名称
     */
    private String serviceName;

    /**
     * 服务版本号
     */
    private String serviceVersion = "1.0";

    /**
     * 服务域名
     */
    private String serviceHost;

    /**
     * 服务端口号
     */
    private Integer servicePort;

    /**
     * 服务分组
     */
    private String serviceGroup = "default";

    /**
     * 获取服务键名
     *
     * @return
     */
    public String getServiceKey(){
        //后续可扩展服务分组
        return String.format("%s:%s",serviceName,serviceVersion);
    }

    /**
     * 获取服务注册节点键名
     *
     * @return
     */
    public String getServiceNodeKey(){
        return String.format("%s/%s:%s",getServiceKey(),serviceHost,servicePort);
    }

}

同时给RpcConstant常量类补充默认服务版本常量,

/**
 * RPC相关常量
 */
public interface RpcConstant {

    /**
     * 默认配置文件加载前缀
     */
    String DEFAULT_CONFIG_PREFIX = "rpc";

    /**
     * 默认服务版本
     */
    String DEFAULT_SERVICE_VERSION = "1.0";
}

在RpcRequest请求类中使用该常量,

    /**
     * 服务版本
     */
    private String serviceVersion = RpcConstant.DEFAULT_SERVICE_VERSION;

(2)注册中心配置

在config包下创建注册中心配置类RegistryConfig,提供用户配置连接注册中心所需的信息,

import lombok.Data;

/**
 * RPC框架注册中心配置
 */
@Data
public class RegistryConfig {

    /**
     * 注册中心类别
     */
    private String registry = "etcd";

    /**
     * 注册中心地址
     */
    private String address = "http://localhost:2380";

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码
     */
    private String password;

    /**
     * 超时时间(毫秒)
     */
    private Long timeout = 10000L;
}

同时给RpcConfig全局配置补充注册中心配置,

    /**
     * 注册中心配置
     */
    private RegistryConfig registryConfig = new RegistryConfig();

(3)注册中心接口

和序列化器一样,我们希望后续可以实现多种不同的注册中心,让其具有可扩展性,并且使用SPI机制动态加载。注册中心接口主要提供初始化、注册服务、注销服务、服务发现、服务销毁等方法。

import com.khr.krpc.config.RegistryConfig;
import com.khr.krpc.model.ServiceMetaInfo;

import java.util.List;

/**
 * 注册中心
 */
public interface Registry {

    /**
     * 初始化
     *
     * @param registryConfig
     */
    void init(RegistryConfig registryConfig);

    /**
     * 注册服务(服务端)
     *
     * @param serviceMetaInfo
     */
    void register(ServiceMetaInfo serviceMetaInfo) throws Exception;

    /**
     * 注销服务(服务端)
     *
     * @param serviceMetaInfo
     */
    void unRegister(ServiceMetaInfo serviceMetaInfo);

    /**
     * 服务发现(获取某服务的所有节点,消费端)
     *
     * @param serviceKey 服务键名
     * @return
     */
    List<ServiceMetaInfo> serviceDiscovery(String serviceKey);

    /**
     * 服务销毁
     */
    void destory();
}

(4)Etcd注册中心实现

在registry包下新建EtcdRegistry类,实现注册中心接口。

初始化方法

public class EtcdRegistry implements Registry {

    private Client client;

    private KV kvClient;

    /**
     * 根节点
     */
    private static final String ETCD_ROOT_PATH = "/rpc/";

    @Override
    public void init(RegistryConfig registryConfig){
        client = Client.builder()
                .endpoints(registryConfig.getAddress())
                .connectTimeout(Duration.ofMillis(registryConfig.getTimeout()))
                .build();
        kvClient = client.getKVClient();
    }

服务注册方法

    @Override
    public void register(ServiceMetaInfo serviceMetaInfo) throws Exception{
        //创建 Lease 和 KV 客户端
        Lease leaseClint = client.getLeaseClient();

        //创建一个 30秒 的租约
        long leaseId = leaseClint.grant(30).get().getID();

        //设置要存储的键值对
        String registerKey = ETCD_ROOT_PATH + serviceMetaInfo.getServiceNodeKey();
        ByteSequence key = ByteSequence.from(registerKey, StandardCharsets.UTF_8);
        ByteSequence value = ByteSequence.from(JSONUtil.toJsonStr(serviceMetaInfo),StandardCharsets.UTF_8);

        //将键值对与租约关联起来,并设置过期时间
        PutOption putOption = PutOption.builder().withLeaseId(leaseId).build();
        kvClient.put(key,value,putOption).get();
    }

服务注销方法

    @Override
    public void unRegister(ServiceMetaInfo serviceMetaInfo){
        kvClient.delete(ByteSequence.from(ETCD_ROOT_PATH + serviceMetaInfo.getServiceNodeKey(),StandardCharsets.UTF_8));
    }

服务发现方法

    @Override
    public List<ServiceMetaInfo> serviceDiscovery(String serviceKey) {
        //前缀搜索,结尾一定要加 ‘/’
        String searchPrefix = ETCD_ROOT_PATH + serviceKey + "/";

        try {
            //前缀查询
            GetOption getOption = GetOption.builder().isPrefix(true).build();
            List<KeyValue> keyValues = kvClient.get(
                    ByteSequence.from(searchPrefix,StandardCharsets.UTF_8),
                    getOption)
                    .get()
                    .getKvs();
            //解析服务信息
            return keyValues.stream()
                    .map(keyValue -> {
                        String value = keyValue.getValue().toString(StandardCharsets.UTF_8);
                        return JSONUtil.toBean(value,ServiceMetaInfo.class);
                    })
                    .collect(Collectors.toList());
        }catch (Exception e){
            throw new RuntimeException("获取服务列表失败", e);
        }
    }

服务销毁方法

    @Override
    public void destory(){
        System.out.println("当前节点下线");
        //关闭客户端
        if (kvClient != null){
            kvClient.close();
        }
        if (client != null){
            client.close();
        }
    }

(5)支持配置和扩展注册中心

和序列化器一样,开发者也能够通过写配置来指定使用的注册中心,并且支持自定义注册中心,让框架更加通用。同理,使用工厂创建对象、使用SPI动态加载自定义的注册中心。

在registry包下新建RegistryKeys类,列举出所有支持的注册中心键名,

/**
 * 注册中心键名常量
 */
public interface RegistryKeys {

    String ETCD = "etcd";

    String ZOOKEEPER = "zookeeper";
}

使用工厂模式,支持根据key从SPI获取注册中心对象实例,创建RegistryFactory类,

import com.khr.krpc.spi.SpiLoader;

/**
 * 注册中心工厂(用于获取注册中心对象)
 */
public class RegistryFactory {

    static {
        SpiLoader.load(Registry.class);
    }

    /**
     * 默认注册中心
     */
    private static final Registry DEFAULT_REGISTRY = new EtcdRegistry();

    /**
     * 获取实例
     *
     * @param key
     * @return
     */

    public static Registry getInstance(String key){
        return SpiLoader.getInstance(Registry.class,key);
    }
}

在META-INF/rpc/system目录下编写注册中心接口的SPI配置文件,

最后需要初始化注册中心。由于服务提供者和消费者都需要和注册中心建立连接,是RPC框架启动后的必经流程,因此可以将注册中心的初始化放在RpcApplication类中,

    /**
     * 框架初始化,支持传入自定义配置
     *
     * @param newRpcConfig
     */
    public static void init(RpcConfig newRpcConfig){
        rpcConfig = newRpcConfig;
        log.info("rpc init, config = {}",newRpcConfig.toString());
        //注册中心初始化
        RegistryConfig registryConfig = rpcConfig.getRegistryConfig();
        Registry registry = RegistryFactory.getInstance(registryConfig.getRegistry());
        registry.init(registryConfig);
        log.info("registry init, config = {}",registryConfig);
    }

(6)修改消费者调用逻辑

消费者首先从注册中心获取节点信息,再得到调用地址执行,给ServiceMetaInfo增加一个获取完整服务地址的方法,

    /**
     * 获取完整服务地址
     *
     * @return
     */
    public String getServiceAddress(){
        if (!StrUtil.contains(serviceHost,"http")){
            return String.format("http://%s:%s",serviceHost,servicePort);
        }
        return String.format("%s:%s",servicePort,servicePort);
    }

修改服务代理ServiceProxy类,更改调用逻辑,

之前将调用地址写死在本地注册服务中,现在消费者要从注册中心获取,

            //序列化
            byte[] boduBytes = serializer.serialize(rpcRequest);

            //从注册中心获取服务提供者请求地址
            RpcConfig rpcConfig = RpcApplication.getRpcConfig();
            Registry registry = RegistryFactory.getInstance(rpcConfig.getRegistryConfig().getRegistry());
            ServiceMetaInfo serviceMetaInfo = new ServiceMetaInfo();
            serviceMetaInfo.setServiceName(serviceName);
            serviceMetaInfo.setServiceVersion(RpcConstant.DEFAULT_SERVICE_VERSION);
            List<ServiceMetaInfo> serviceMetaInfoList = registry.serviceDiscovery(serviceMetaInfo.getServiceKey());
            if (CollUtil.isEmpty(serviceMetaInfoList)){
                throw new RuntimeException("暂无服务地址");
            }

            //暂时先取第一个
            ServiceMetaInfo selectedServiceMetaInfo = serviceMetaInfoList.get(0);

4. 测试

(1)注册中心测试

创建一个单元测试类RegistryTest,验证注册中心各项功能是否正常,

import com.khr.krpc.config.RegistryConfig;
import com.khr.krpc.model.ServiceMetaInfo;
import com.khr.krpc.registry.EtcdRegistry;
import com.khr.krpc.registry.Registry;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;

import java.util.List;

/**
 * 注册中心测试
 */

public class RegistryTest {

    final Registry registry = new EtcdRegistry();

    @Before
    public void init(){
        RegistryConfig registryConfig = new RegistryConfig();
        registryConfig.setAddress("http://localhost:2379");
        registry.init(registryConfig);
    }

    @Test
    public void register() throws Exception{
        ServiceMetaInfo serviceMetaInfo = new ServiceMetaInfo();
        serviceMetaInfo.setServiceName("KHRservice");
        serviceMetaInfo.setServiceVersion("1.0");
        serviceMetaInfo.setServiceHost("localhost");
        serviceMetaInfo.setServicePort(1234);
        registry.register(serviceMetaInfo);
        serviceMetaInfo = new ServiceMetaInfo();
        serviceMetaInfo.setServiceName("KHRservice");
        serviceMetaInfo.setServiceVersion("1.0");
        serviceMetaInfo.setServiceHost("localhost");
        serviceMetaInfo.setServicePort(1235);
        registry.register(serviceMetaInfo);
        serviceMetaInfo = new ServiceMetaInfo();
        serviceMetaInfo.setServiceName("KHRservice2");
        serviceMetaInfo.setServiceVersion("2.0");
        serviceMetaInfo.setServiceHost("localhost");
        serviceMetaInfo.setServicePort(1234);
        registry.register(serviceMetaInfo);
    }

    @Test
    public void unRegister(){
        ServiceMetaInfo serviceMetaInfo = new ServiceMetaInfo();
        serviceMetaInfo.setServiceName("KHRservice");
        serviceMetaInfo.setServiceVersion("1.0");
        serviceMetaInfo.setServiceHost("localhost");
        serviceMetaInfo.setServicePort(1234);
        registry.unRegister(serviceMetaInfo);
    }

    @Test
    public void serviceDiscovery(){
        ServiceMetaInfo serviceMetaInfo = new ServiceMetaInfo();
        serviceMetaInfo.setServiceName("KHRservice");
        serviceMetaInfo.setServiceVersion("1.0");
        String serviceKey = serviceMetaInfo.getServiceKey();
        List<ServiceMetaInfo> serviceMetaInfoList = registry.serviceDiscovery(serviceKey);
        Assert.assertNotNull(serviceMetaInfoList);
    }
}

执行后在etcdkeeper中能够看到注册成功的服务节点信息,通过树形展示后能够明显发现etcd的层级结构,

测试时发现,给key设置30秒的过期时间略短, 30秒后这些节点信息会自动删除,为方便查看节点信息内容,可以适当延长过期时间。

 (2)完整流程测试

修改provider模块中的服务提供者示例类代码,将服务注册到注册中心,

import com.khr.example.common.service.UserService;
import com.khr.krpc.RpcApplication;
import com.khr.krpc.config.RegistryConfig;
import com.khr.krpc.config.RpcConfig;
import com.khr.krpc.model.ServiceMetaInfo;
import com.khr.krpc.registry.LocalRegistry;
import com.khr.krpc.registry.Registry;
import com.khr.krpc.registry.RegistryFactory;
import com.khr.krpc.server.HttpServer;
import com.khr.krpc.server.VertxHttpServer;

/**
 * 服务提供者示例
 */
public class ProviderExample {

    public static void main(String[] args){
        //RPC框架初始化
        RpcApplication.init();

        //注册服务
        String serviceName = UserService.class.getName();
        LocalRegistry.registry(serviceName,UserServiceImpl.class);

        //注册服务到注册中心
        RpcConfig rpcConfig = RpcApplication.getRpcConfig();
        RegistryConfig registryConfig = rpcConfig.getRegistryConfig();
        Registry registry = RegistryFactory.getInstance(registryConfig.getRegistry());
        ServiceMetaInfo serviceMetaInfo = new ServiceMetaInfo();
        serviceMetaInfo.setServiceName(serviceName);
        serviceMetaInfo.setServiceHost(rpcConfig.getServerHost());
        serviceMetaInfo.setServicePort(Integer.valueOf(rpcConfig.getServerPort()));
        try {
            registry.register(serviceMetaInfo);
        }catch (Exception e){
            throw new RuntimeException(e);
        }

        //启动web服务
        HttpServer httpServer = new VertxHttpServer();
        httpServer.doStart(Integer.parseInt(RpcApplication.getRpcConfig().getServerPort()));
    }
}

先不改消费者代码,启动服务提供者与消费者后,整体流程正常跑通,

 至此,注册中心的基本实现完成,侧重于流程跑通,后续会对注册中心进行优化。

五. 注册中心(优化扩展)

1. 需求分析

之前基于Etcd完成了基础的注册中心,能够注册和获取服务节点信息。

进一步优化:

  • 数据一致性:服务提供者下线,注册中心需要即使更新,剔除下线节点。防止消费者调用失效节点。
  • 使用缓存:通过缓存来提高性能,消费者不需要每次都从注册中心获取服务。
  • 高可用性:保证注册中心本身不会宕机。
  • 可扩展性:实现其他种类的注册中心。

2. 具体实现

(1)心跳检测和续期机制

心跳检测(HeartBeat)是一种用于监测系统是否正常工作的机制。它通过定期发送心跳信号(请求)来检测目标系统的状态。如果接收方在一定时间内没有收到心跳信号或者未能正常响应请求,就会认为目标系统故障或不可用,从而触发通知或告警机制。

心跳监测被广泛应用于分布式、微服务系统中,做服务健康监测或集群管理等。

通过Etcd实现心跳检测

Etcd自带key过期机制,可以给节点注册信息一个TTL,让节点定期续费重置自己的倒计时。如果节点宕机,一直不续期,Etcd就会对key进行过期删除。

  • 服务提供者向Etcd注册自己的服务信息,并在注册时设置TTL。
  • Etcd在接收到服务提供者的注册信息后,会自动维护服务信息的TTL,过期时自动删除。
  • 服务提供者定期请求Etcd续费服务信息,重置TTL。

由于可能有多个服务提供者,因此需要让当前服务提供者找到自己的节点。在服务提供者本地维护一个已注册节点集合,注册时添加节点key到集合中,之后续费集合内的key即可。

在Registry接口中补充心跳检测方法,

    /**
     * 心跳检测(服务端)
     */
    void heartBeat();

在EtcdRegistry中定义一个本机注册的节点key集合,用于维护续期,

    /**
     * 本机注册的节点 key 集合(用于维护续期)
     */
    private final Set<String> localRegisterNodeKeySet = new HashSet<>();

服务注册时,将节点添加到集合中,

    @Override
    public void register(ServiceMetaInfo serviceMetaInfo) throws Exception{

        ……

        //添加节点信息到本地缓存
        localRegisterNodeKeySet.add(registerKey);

同理,服务注销时,从集合中移除对应节点,

    @Override
    public void unRegister(ServiceMetaInfo serviceMetaInfo){
        String registerKey = ETCD_ROOT_PATH + serviceMetaInfo.getServiceNodeKey();
        kvClient.delete(ByteSequence.from(registerKey,StandardCharsets.UTF_8));
        //从本地缓存中移除
        localRegisterNodeKeySet.remove(registerKey);
    }

在EtcdRegistry中实现heartBeat方法。使用了Hutool工具类的CronUtil实现定时任务,对所有集合中的节点执行重新注册操作,相当于续期。这样即使Etcd注册中心的数据丢失,通过心跳检测机制也能重新注册节点信息,

    @Override
    public void heartBeat(){
        //10秒续签一次
        CronUtil.schedule("*/10 * * * * *", new Task() {
            @Override
            public void execute() {
                //遍历本节点所有的key
                for (String key : localRegisterNodeKeySet){
                    try {
                        List<KeyValue> keyValues = kvClient.get(ByteSequence.from(key,StandardCharsets.UTF_8))
                                .get()
                                .getKvs();
                        //该节点已过期(需要重启节点才能重新注册)
                        if (CollUtil.isEmpty(keyValues)){
                            continue;
                        }
                        //节点未过期,重新注册(相当于续签)
                        KeyValue keyValue = keyValues.get(0);
                        String value = keyValue.getValue().toString(StandardCharsets.UTF_8);
                        ServiceMetaInfo serviceMetaInfo = JSONUtil.toBean(value,ServiceMetaInfo.class);
                        register(serviceMetaInfo);
                    } catch (Exception e){
                        throw new RuntimeException(key + "续签失败", e);
                    }
                }
            }
        });

        //支持秒级别定时任务
        CronUtil.setMatchSecond(true);
        CronUtil.start();
    }

在注册中心初始化init方法中调用heartBeat,

    @Override
    public void init(RegistryConfig registryConfig){
        client = Client.builder()
                .endpoints(registryConfig.getAddress())
                .connectTimeout(Duration.ofMillis(registryConfig.getTimeout()))
                .build();
        kvClient = client.getKVClient();
        heartBeat();
    }

测试

在RegistryTest单元测试中补充心跳检测测试方法,

    @Test
    public void heartBeat() throws Exception {
        //init方法中已经执行心跳检测了
        register();
        //阻塞1分钟
        Thread.sleep(60 * 1000L);
    }

执行后在etcdkeeper中可以观察到,当节点信息底部的TTL到20左右的时候,又会重置为30。因此设置了默认存活时间为30秒,10秒续期一次,

(2)服务节点下线机制 

当服务提供者节点宕机时,应该从注册中心移除掉已注册的节点,防止消费者调用失效节点。

服务节点下线分为主动下线和被动下线

被动下线也就是服务提供者出现异常后,Etcd利用key过期机制进行移除,已经自动实现。

主动下线也就是可以主动从注册中心中移除服务信息。即Java项目正常退出时,执行destory。

利用JVM的ShutdownHook,允许开发者在JVM即将关闭前执行清理工作或其它必要操作,如关闭数据库连接、释放资源等。是一种优雅停机能力。

在服务销毁方法中补充下线节点逻辑,

    @Override
    public void destory(){
        System.out.println("当前节点下线");
        //下线节点
        //遍历本节点所有的key
        for (String key : localRegisterNodeKeySet){
            try {
                kvClient.delete(ByteSequence.from(key,StandardCharsets.UTF_8)).get();
            } catch (Exception e){
                throw new RuntimeException(key + "节点下线失败");
            }
        }
        ……
    }

在RpcApplication的init方法中注册ShutdownHook,当程序正常退出时会执行注册中心的destory方法,

    public static void init(RpcConfig newRpcConfig){
         ……

        //创建并注册Shutdown Hook,JVM退出时执行操作
        Runtime.getRuntime().addShutdownHook(new Thread(registry::destory));
    }

测试

  • 启动服务提供者,服务信息被成功注册。
  • 正常停止服务提供者,服务信息立刻被删除。

(3)消费端服务缓存

在实际情况中,服务节点信息列表的更新频次不高,因此消费者可以将这些信息缓存在本地,这样就不用每次都去注册中心请求,直接从缓存中读取即可,提高性能。

本地缓存的实现直接用列表来存储服务信息即可,在registry包下创建缓存类RegistryServiceCache,包括写缓存、读缓存、清空缓存。

import com.khr.krpc.model.ServiceMetaInfo;

import java.util.List;

/**
 * 注册中心服务本地缓存
 */
public class RegistryServiceCache {

    /**
     * 服务缓存
     */
    List<ServiceMetaInfo> serviceCache;

    /**
     * 写缓存
     *
     * @param newServiceCache
     * @return
     */

    void writeCache(List<ServiceMetaInfo> newServiceCache){
        this.serviceCache = newServiceCache;
    }

    /**
     * 读缓存
     *
     * @return
     */
    List<ServiceMetaInfo> readCache(){
        return this.serviceCache;
    }

    /**
     * 清空缓存
     */
    void clearCache(){
        this.serviceCache = null;
    }
}

在EtcdRegistry中使用本地缓存对象,

    /**
     * 注册中心服务缓存
     */
    private final RegistryServiceCache registryServiceCache = new RegistryServiceCache();

修改服务发现方法逻辑,消费者优先从缓存中获取服务,如果缓存中没有,再从注册中心获取并设置到缓存中,

    @Override
    public List<ServiceMetaInfo> serviceDiscovery(String serviceKey) {
        //优先从缓存中获取服务
        List<ServiceMetaInfo> cachedServiceMetaInfoList = registryServiceCache.readCache();
        if (cachedServiceMetaInfoList != null){
            return cachedServiceMetaInfoList;
        }

        //前缀搜索,结尾一定要加 ‘/’
        String searchPrefix = ETCD_ROOT_PATH + serviceKey + "/";

        try {
            //前缀查询
            GetOption getOption = GetOption.builder().isPrefix(true).build();
            List<KeyValue> keyValues = kvClient.get(
                    ByteSequence.from(searchPrefix,StandardCharsets.UTF_8),
                    getOption)
                    .get()
                    .getKvs();
            //解析服务信息
            List<ServiceMetaInfo> serviceMetaInfoList = keyValues.stream()
                    .map(keyValue -> {
                        String key = keyValue.getKey().toString(StandardCharsets.UTF_8);
                        //监听 key 的变化
                        watch(key);
                        String value = keyValue.getValue().toString(StandardCharsets.UTF_8);
                        return JSONUtil.toBean(value, ServiceMetaInfo.class);
                    })
                    .collect(Collectors.toList());
            //写入服务缓存
            registryServiceCache.writeCache(serviceMetaInfoList);
            return serviceMetaInfoList;
        }catch (Exception e){
            throw new RuntimeException("获取服务列表失败", e);
        }
    }

缓存服务更新—监听机制

当服务注册信息发生变化时,缓存中的信息也需要即使更新。

因此需要去监听服务注册信息何时会变化。使用Etcd的watch监听机制,当监听的某个key发生修改或删除时,就会触发事件通知。

首先watch监听是消费者执行的,因为目标是更新缓存,而缓存是在消费端维护使用。

其次服务发现方法是比较合适的位置创建watch监听器,可以对本次获取到的所有服务节点key监听。并且要防止重复监听同一个key,可以定义一个已监听key的集合。

在Registry注册中心接口补充监听key的方法,

    /**
     * 监听(消费端)
     *
     * @param serviceNodeKey
     */
    void watch(String serviceNodeKey);

在EtcdRegistry类中添加监听key的集合,使用ConcurrentHashset防止并发冲突,

    /**
     * 正在监听的 key 集合
     */
    private final Set<String> watchingKeySet = new ConcurrentHashSet<>();

在EtcdRegistry类中实现监听key的方法,通过调用Etcd的WatchCilent实现监听,出现DELETE key删除事件,则清理服务注册缓存。即使key在注册中心被删除后再重新设置,之前的监听依然生效,所以只监听首次加入到集合的key,防止重复,

    @Override
    public void watch(String serviceNodeKey){
        Watch watchClient = client.getWatchClient();
        //之前未被监听,开启监听
        boolean newWatch = watchingKeySet.add(serviceNodeKey);
        if (newWatch){
            watchClient.watch(ByteSequence.from(serviceNodeKey,StandardCharsets.UTF_8),response ->{
                for (WatchEvent event : response.getEvents()){
                    switch (event.getEventType()){
                        //key删除时触发
                        case DELETE:
                            //清理注册服务缓存
                            registryServiceCache.clearCache();
                            break;
                        case PUT:
                        default:
                            break;
                    }
                }
            });
        }
    }

在消费端获取服务时调用watch方法,对获取到的节点key进行监听,

    @Override
    public List<ServiceMetaInfo> serviceDiscovery(String serviceKey) {
        ……

        try {
            //前缀查询
            GetOption getOption = GetOption.builder().isPrefix(true).build();
            List<KeyValue> keyValues = kvClient.get(
                    ByteSequence.from(searchPrefix,StandardCharsets.UTF_8),
                    getOption)
                    .get()
                    .getKvs();
            //解析服务信息
            List<ServiceMetaInfo> serviceMetaInfoList = keyValues.stream()
                    .map(keyValue -> {
                        String key = keyValue.getKey().toString(StandardCharsets.UTF_8);
                        //监听 key 的变化
                        watch(key);
                        String value = keyValue.getValue().toString(StandardCharsets.UTF_8);
                        return JSONUtil.toBean(value, ServiceMetaInfo.class);
                    })
                    .collect(Collectors.toList());
            //写入服务缓存
            registryServiceCache.writeCache(serviceMetaInfoList);
            return serviceMetaInfoList;
        }catch (Exception e){
            throw new RuntimeException("获取服务列表失败", e);
        }
    }

测试

  • debug测试,首先启动服务提供者。
  • 修改consumer代码,连续调用服务3次,debug可以发现,第一次查注册中心,第二次查询缓存。
  • 在第三次调用服务时,下线服务提供者,可以在注册中心看到节点的注册key已经删除。
  • 继续执行,发现第三次调用时,又重新从注册中心查询,说明缓存已更新。

(4)Zookeeper注册中心

与Etcd注册中心实现相似,这里不再阐述详细流程,

  • 安装Zookeeper
  • 引入客户端依赖
  • 实现接口
  • SPI补充Zookeeper注册中心

Zookeeper注册中心实现代码,

import cn.hutool.core.collection.ConcurrentHashSet;
import com.yupi.yurpc.config.RegistryConfig;
import com.yupi.yurpc.model.ServiceMetaInfo;
import lombok.extern.slf4j.Slf4j;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.cache.CuratorCache;
import org.apache.curator.framework.recipes.cache.CuratorCacheListener;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.curator.x.discovery.ServiceDiscovery;
import org.apache.curator.x.discovery.ServiceDiscoveryBuilder;
import org.apache.curator.x.discovery.ServiceInstance;
import org.apache.curator.x.discovery.details.JsonInstanceSerializer;

import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * zookeeper 注册中心
 * 操作文档:<a href="https://curator.apache.org/docs/getting-started">Apache Curator</a>
 * 代码示例:<a href="https://github.com/apache/curator/blob/master/curator-examples/src/main/java/discovery/DiscoveryExample.java">DiscoveryExample.java</a>
 * 监听 key 示例:<a href="https://github.com/apache/curator/blob/master/curator-examples/src/main/java/cache/CuratorCacheExample.java">CuratorCacheExample.java</a>
*/

@Slf4j
public class ZooKeeperRegistry implements Registry {

    private CuratorFramework client;

    private ServiceDiscovery<ServiceMetaInfo> serviceDiscovery;

    /**
     * 本机注册的节点 key 集合(用于维护续期)
     */
    private final Set<String> localRegisterNodeKeySet = new HashSet<>();

    /**
     * 注册中心服务缓存
     */
    private final RegistryServiceCache registryServiceCache = new RegistryServiceCache();

    /**
     * 正在监听的 key 集合
     */
    private final Set<String> watchingKeySet = new ConcurrentHashSet<>();

    /**
     * 根节点
     */
    private static final String ZK_ROOT_PATH = "/rpc/zk";


    @Override
    public void init(RegistryConfig registryConfig) {
        // 构建 client 实例
        client = CuratorFrameworkFactory
                .builder()
                .connectString(registryConfig.getAddress())
                .retryPolicy(new ExponentialBackoffRetry(Math.toIntExact(registryConfig.getTimeout()), 3))
                .build();

        // 构建 serviceDiscovery 实例
        serviceDiscovery = ServiceDiscoveryBuilder.builder(ServiceMetaInfo.class)
                .client(client)
                .basePath(ZK_ROOT_PATH)
                .serializer(new JsonInstanceSerializer<>(ServiceMetaInfo.class))
                .build();

        try {
            // 启动 client 和 serviceDiscovery
            client.start();
            serviceDiscovery.start();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void register(ServiceMetaInfo serviceMetaInfo) throws Exception {
        // 注册到 zk 里
        serviceDiscovery.registerService(buildServiceInstance(serviceMetaInfo));

        // 添加节点信息到本地缓存
        String registerKey = ZK_ROOT_PATH + "/" + serviceMetaInfo.getServiceNodeKey();
        localRegisterNodeKeySet.add(registerKey);
    }

    @Override
    public void unRegister(ServiceMetaInfo serviceMetaInfo) {
        try {
            serviceDiscovery.unregisterService(buildServiceInstance(serviceMetaInfo));
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        // 从本地缓存移除
        String registerKey = ZK_ROOT_PATH + "/" + serviceMetaInfo.getServiceNodeKey();
        localRegisterNodeKeySet.remove(registerKey);
    }

    @Override
    public List<ServiceMetaInfo> serviceDiscovery(String serviceKey) {
        // 优先从缓存获取服务
        List<ServiceMetaInfo> cachedServiceMetaInfoList = registryServiceCache.readCache();
        if (cachedServiceMetaInfoList != null) {
            return cachedServiceMetaInfoList;
        }

        try {
            // 查询服务信息
            Collection<ServiceInstance<ServiceMetaInfo>> serviceInstanceList = serviceDiscovery.queryForInstances(serviceKey);

            // 解析服务信息
            List<ServiceMetaInfo> serviceMetaInfoList = serviceInstanceList.stream()
                    .map(ServiceInstance::getPayload)
                    .collect(Collectors.toList());

            // 写入服务缓存
            registryServiceCache.writeCache(serviceMetaInfoList);
            return serviceMetaInfoList;
        } catch (Exception e) {
            throw new RuntimeException("获取服务列表失败", e);
        }
    }

    @Override
    public void heartBeat() {
        // 不需要心跳机制,建立了临时节点,如果服务器故障,则临时节点直接丢失
    }

    /**
     * 监听(消费端)
     *
     * @param serviceNodeKey 服务节点 key
     */
    @Override
    public void watch(String serviceNodeKey) {
        String watchKey = ZK_ROOT_PATH + "/" + serviceNodeKey;
        boolean newWatch = watchingKeySet.add(watchKey);
        if (newWatch) {
            CuratorCache curatorCache = CuratorCache.build(client, watchKey);
            curatorCache.start();
            curatorCache.listenable().addListener(
                    CuratorCacheListener
                            .builder()
                            .forDeletes(childData -> registryServiceCache.clearCache())
                            .forChanges(((oldNode, node) -> registryServiceCache.clearCache()))
                            .build()
            );
        }
    }

    @Override
    public void destroy() {
        log.info("当前节点下线");
        // 下线节点(这一步可以不做,因为都是临时节点,服务下线,自然就被删掉了)
        for (String key : localRegisterNodeKeySet) {
            try {
                client.delete().guaranteed().forPath(key);
            } catch (Exception e) {
                throw new RuntimeException(key + "节点下线失败");
            }
        }

        // 释放资源
        if (client != null) {
            client.close();
        }
    }

    private ServiceInstance<ServiceMetaInfo> buildServiceInstance(ServiceMetaInfo serviceMetaInfo) {
        String serviceAddress = serviceMetaInfo.getServiceHost() + ":" + serviceMetaInfo.getServicePort();
        try {
            return ServiceInstance
                    .<ServiceMetaInfo>builder()
                    .id(serviceAddress)
                    .name(serviceMetaInfo.getServiceKey())
                    .address(serviceAddress)
                    .payload(serviceMetaInfo)
                    .build();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

至此,注册中心的优化扩展完成。 

  • 37
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Phoenixxxxxxxxxxxxx

感谢支持!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值