SpringBoot整合Apache Thrift

引言

Apache Thrift是Apache软件基金会开源的一个RPC框架,具有体积小,高性能,跨语言支持较为完善的特点。但是官方并不提供与SpringBoot整合的starter,纵观Maven仓库,几乎没有将SpringBoot与Thrift整合的包。在一些简单的跨语言调用场景上(例如单机AI模型调用,走Shell太慢,RPC效率高),直接上现有RPC框架又显得太过臃肿,目前主流RPC框架会和服务治理等微服务相关内容整合,这就会导致包打出来很大,很多东西是在这种单纯RPC场景下不需要的。
因此,针对一些简单的RPC调用场景,打算自己整合一个starter,在配置和使用上,尽量做到开箱即用,最大化降低用户上手成本。一方面可以增加用户的可选择性,另一方面也学习学习相关技术。(有时候,还是要造造轮子的)

目标

使用Java注解来标识Thrift客户端和服务器,用户导入依赖后,即开即用,0配置

代码仓库 & Demo

spring-boot-starter-thrift

从Spring Starter项目结构说起

对于Spring Starter来说,项目结构与一般项目没有太大区别,但是因为其本身不是一个完整的项目,它是其他项目的一部分,因此,相比于传统的SpringBoot应用程序,它应该是没有main函数可以直接启动的。
也正因为starter是其他Spring Boot项目的一部分,它需要让Spring能识别到它。因为starter往往有一些自己的配置类,需要在项目启动时由SpringBoot进行加载,这时,我们就要想办法让SpringBoot的自动配置机制能够扫描到我们自写的starter。
解决办法就是在项目resources文件夹下建立一个META-INF的子文件夹,然后创建一个叫spring.factories的文件,文件内容为:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=starter配置类包路径

spring.factories在SpringBoot 2.7中已经被标记为不推荐使用,因此在未来的版本中可能会被废弃,如果真废弃了,再来改吧~

项目结构

结合上节内容,本项目的结构如下:
在这里插入图片描述

  • annotation: 放置Thrift客户端和服务器注解定义
  • config: 存放各种配置类,这里主要是连接池配置
  • factory: 连接池的连接工厂
  • pool: 连接池实体定义
  • processor: 存放Thrift客户端和服务器的Bean后置处理器
  • proxy: 存放Thrift客户端和服务器的代理类
  • runner: 项目启动时的一些相关动作
  • ThriftAutoConfiguration.java: 主自动配置类
  • spring.factories: 用于标识SpringBoot自动配置类

连接池

Thrift最后实际上是通过TTransport进行数据传输的,比较常用的实现是TSocketTSocket实际上是Java原生Socket的封装。我们知道,Socket连接和断开都是需要消耗一定资源的,理论上会带来一定的性能损耗,因此考虑使用连接池来缓解这个问题。

在本项目中,使用Apache commons pool来实现Socket连接池。

连接池整体配置

首先要对TSocket连接池进行全局配置,这里参考网上的一些配置参数:

public class TSocketPoolConfig extends GenericObjectPoolConfig<TSocket> {
    public TSocketPoolConfig(int timeout) {
        setMaxTotal(20); // 最大连接数
        setTestOnCreate(true); // 是否在创建连接时进行测试
        setTestOnBorrow(true); // 是否在借出连接时进行测试
        setMinEvictableIdleTimeMillis(timeout); // 连接空闲的最小时间,达到此值后空闲连接将可能会被移除。负值(-1)表示不移除
        setTimeBetweenEvictionRunsMillis(1000L); //  “空闲链接”检测线程,检测的周期,毫秒数。如果为负值,表示不运行“检测线程”。
    }
}

连接池工厂

接下来需要创建一个TSocket连接池工厂TSocketPoolFactory,用于创建TSocket对象

public class TSocketPoolFactory extends BasePooledObjectFactory<TSocket> {

    private static final Logger logger = LoggerFactory.getLogger(TSocketPoolFactory.class);

    private String host;

    private int port;

    private int timeout;

    public TSocketPoolFactory(String host, int port, int timeout) {
        this.host = host;
        this.port = port;
        this.timeout = timeout;
    }

    @Override
    public TSocket create() throws Exception {
        TSocket socket = new TSocket(host, port, timeout);
        socket.open();
        logger.debug("TSocket对象{}已创建", socket);
        return socket;
    }

    @Override
    public void destroyObject(PooledObject<TSocket> p) throws Exception {
        TSocket socket = p.getObject();
        if (socket.isOpen()) {
            socket.close();
        }
        p.markAbandoned();
        logger.debug("TSocket对象{}已销毁", socket);
    }

    @Override
    public PooledObject<TSocket> wrap(TSocket tSocket) {
        return new DefaultPooledObject<>(tSocket);
    }


    @Override
    public boolean validateObject(PooledObject<TSocket> p) {
        try {
            Socket socket = new Socket(host, port);
            socket.close();
            return true;
        } catch (Exception e) {
            logger.error("{}:{}连接测试失败", host, port);
            return false;
        }
    }
}

注意一下validateObject这个方法,从名字上可以看出这是一个校验对象的方法,虽然网上说一般在对象空闲时校验,但是在全局配置中,使用了在借出和归还前进行校验。主要原因是:TSocket和Socket并没有提供Socket掉线检测功能,虽然Socket类中有一个isConnected方法,但是这只要有连接成功一次,就为true,并不能检测掉线问题。也正因为这个问题,在前面设置连接对象最小空闲时间时,传入了一个timeout参数,这个参数实际上是用户通过注解传入的。也就是说,一般情况下,服务用完就销毁。这时可能有人会问,既然用完就销毁,何必用连接池搞那么复杂,emm…一般场景下确实不需要,但是对于一些请求密集型场景,理论上还是有一些帮助的。

创建连接池

最后就是创建连接池对象本身了,从连接池工厂获得连接池对象本体,再根据业务需求进一步封装。

public class TSocketPool implements ObjectPool<TSocket> {

    private final GenericObjectPool<TSocket> pool;

    public TSocketPool(String host, int port, int timeout) {
        TSocketPoolFactory factory = new TSocketPoolFactory(host, port, timeout);
        pool = new GenericObjectPool<TSocket>(factory, new TSocketPoolConfig(timeout));
    }

    @Override
    public void addObject() throws Exception, IllegalStateException, UnsupportedOperationException {
        pool.addObject();
    }

    @Override
    public TSocket borrowObject() throws Exception, NoSuchElementException, IllegalStateException {
        return pool.borrowObject();
    }

    @Override
    public void clear() throws Exception, UnsupportedOperationException {
        pool.clear();
    }

    @Override
    public void close() {
        pool.close();
    }

    @Override
    public int getNumActive() {
        return pool.getNumActive();
    }

    @Override
    public int getNumIdle() {
        return pool.getNumIdle();
    }

    @Override
    public void invalidateObject(TSocket tSocket) throws Exception {
        pool.invalidateObject(tSocket);
    }

    @Override
    public void returnObject(TSocket tSocket) throws Exception {
        pool.returnObject(tSocket);
    }
}

Client整合

注解定义

首先给出Client注解定义

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ThriftClient {
    // Thrift服务端主机ip
    String host();
    // Thrift服务端口
    int port();
    // 超时时间
    int timeout() default 300;
    // 要调用的Thrift服务名
    String serviceName() default "";
}

从定义中可以看出,这个注解是作用在字段上的。一般情况,是在Service实现层调用远程服务,那只需要在服务对象字段上加上这个注解,就完成了Thrift客户端的创建,像这样:

 @ThriftClient(host = "127.0.0.1", port = 8989, timeout = 300, serviceName = "personService")
 private PersonService.Client client;

注解定义中的最后一个字段serviceName指的是要调用的服务名,在本项目中,是以单端口多服务模式来启动Thrift服务器的。

工作原理

下图简要说明了SpringBoot整合Thrift客户端的工作原理
在这里插入图片描述

整体上分为两个阶段:代码调用阶段 和 项目启动阶段

技术点嘛,主要有两个:

  • Spring的Bean后置处理器
  • 设计模式中的代理模式
项目启动阶段

整体思路就是当项目初始化的时候,扫描注解,对于每一个用@ThriftClient修饰的客户端对象,生成一个Thrift客户端代理并注入到Spring容器,其中的TSocket是一个Fake Socket,用于占位,在这一步骤同时会创建好连接池;当代码真的要调用远程RPC服务的时候,再从连接池中取真实连接并替换Fake Socket。

首先要谈的,就是扫描注解问题。

Spring为我们提供了面向切面编程(AOP),让自定义注解的实现变得简单,但是那个多针对的是方法。像这个项目中,对于类、字段,AOP就有一点力不从心了。因此,就需要另辟蹊径。幸运的是,Spring提供了Bean的后置处理器BeanPostProcessor来帮助我们解决这个问题。

BeanPostProcessor中,提供了两个方法:postProcessBeforeInitializationpostProcessAfterInitialization,它们的区别就在于执行的时机,postProcessAfterInitialization是在对象初始化完毕后执行的,比如你对对象有一些动态代理操作,那么这个方法的执行时机就是在这之后,你看到的就不是原始对象了,而是经过增强后的对象;相对的postProcessBeforeInitialization就是对象初始化之前触发,供我们操作的,还是原始对象本身。因此,在本项目中,使用postProcessBeforeInitialization来进行注解扫描的工作。

流程代码如下:

 @Override
 public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
 	// 扫描当前类是否有客户端注解
     List<Field> fieldList = scanTargetClass(bean.getClass());
     for (Field field : fieldList) {
     	 // applicationContext通过ApplicationContextAware接口设置
         Object target = applicationContext.getBean(beanName);
         // 设置字段可访问性
         ReflectionUtils.makeAccessible(field);
         try {
         	 // 创建Thrift客户端代理
             TServiceClient client = createClient(field.getType(), field.getAnnotation(ThriftClient.class));
             field.set(target, client);
         } catch (Exception e) {
             System.err.println(e);
             e.printStackTrace();
         }
     }
     return bean;
 }

这里主要关注createClient方法

private TServiceClient createClient(Class clazz, ThriftClient anno) throws Exception{
    String host = anno.host();
    int port = anno.port();
    int timeout = anno.timeout();
    // connPoolMap为连接池键值映射Map(全局),主要考虑不同主机、端口、超时场景下的连接池
    String key = host + "-" + port + "-" + timeout;
    TSocketPool pool = connPoolMap.get(key);
    if (pool == null) {
        pool = new TSocketPool(host, port, timeout);
        logger.info("关于{}的连接池已创建", key);
        connPoolMap.put(key, pool);
    }
    // 创建客户端代理
    ThriftClientProxy clientProxy = new ThriftClientProxy();
    TTransport fakeSocket = new TSocket("127.0.0.1", 0 , 0);
    TProtocol protocol = new TBinaryProtocol(fakeSocket);
    return (TServiceClient) clientProxy.bind(clazz, protocol, anno.serviceName(), pool);
}

现在就来到了重点:客户端代理对象创建

在本项目中,使用CGLib库来实现动态代理。为什么要使用CGLib,而不是JDK动态代理呢?这是因为JDK动态代理要求本体和代理对象都要实现同一个接口,那在本项目的场景下,接口实现是动态的,Thrift根据用户定义的IDL生成代码,类似这样

public class PersonService {

  public interface Iface {
	...
  }
  public static class Client extends org.apache.thrift.TServiceClient implements Iface {
  	...
  }
}

如果要使用JDK动态代理,那就要实现PersonService.Iface接口,先不说怎么实现了,就Iface不停变换的前缀就已经够让人头疼了,因此JDK动态代理不适合这个场景。

那么CGLib呢?它与JDK动态代理不同,它是通过继承来实现的。我们在外部调用的是Iface中的方法,肯定是public的,加上原本的Client类实现了这个Iface,我们不需要做二次调整,直接继承这个Client,然后对每一个暴露出去的Iface方法做增强就好了。因此CGLib更适合这个场景。

主要代码如下:

public class ThriftClientProxy implements MethodInterceptor {

    private static final Logger logger = LoggerFactory.getLogger(ThriftClientProxy.class);

    private TServiceClient realClient;

    private TProtocol protocol;

    private TSocketPool pool;

    private String serviceName;

    public Object bind(Class clazz, TProtocol protocol, String serviceName, TSocketPool pool) throws Exception{
        // 先绑定一个假的客户端,把框架先搭建起来
        TMultiplexedProtocol multiplexedProtocol = new TMultiplexedProtocol(protocol, serviceName);
        Constructor<TServiceClient> constructor = clazz.getDeclaredConstructor(TProtocol.class);
        this.realClient = constructor.newInstance(multiplexedProtocol);
        this.protocol = protocol;
        this.pool = pool;
        this.serviceName = serviceName;
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(this.realClient.getClass());
        enhancer.setCallback(this);
        return enhancer.create(new Class[]{TProtocol.class}, new Object[]{this.protocol});
    }

    @Override
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
          ...
    }
}

为了能使以上内容起作用,需要在自动配置类中用@Bean注解向Spring进行注册

代码调用阶段

代码调用阶段即运行时阶段就比较简单了,由于使用了CGLib动态代理,重点就在ThriftClientProxy.intercept方法上

public class ThriftClientProxy implements MethodInterceptor {

    private static final Logger logger = LoggerFactory.getLogger(ThriftClientProxy.class);

    private TServiceClient realClient;

    private TProtocol protocol;

    private TSocketPool pool;

    private String serviceName;

    public Object bind(Class clazz, TProtocol protocol, String serviceName, TSocketPool pool) throws Exception{
        ...
    }

    @Override
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        // 真正绑定客户端
        // 获取可用连接
        TSocket socket = pool.borrowObject();
        // 替换Fake客户端
        TProtocol protocol = new TBinaryProtocol(socket);
        TMultiplexedProtocol multiplexedProtocol = new TMultiplexedProtocol(protocol, serviceName);
        Constructor<TServiceClient> constructor = (Constructor<TServiceClient>) this.realClient.getClass().getDeclaredConstructor(TProtocol.class);
        this.realClient = constructor.newInstance(multiplexedProtocol);
        try {
            // 调用方法
            Object res = methodProxy.invoke(this.realClient, objects);
            return res;
        } catch (Exception e) {
            logger.error("Thrift RPC调用发生异常:{}", e);
            return null;
        } finally {
            pool.returnObject(socket);
        }
    }
}

主要流程就与最开始的流程图一样,拟调用方法被拦截,接着从连接池中获取可用连接,重新构建客户端并替换Fake客户端,然后调用方法,最后将连接归还。

Server整合

注解定义

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ThriftServer {
    // Thrift服务端口号
    int port();
    // 服务名称
    String serviceName();
}

ThriftServer的注解是作用在Iface实现类上的,相对比较简单,只有服务端口号还有服务名称,注意:本项目是单端口多服务的,像这样:

@Service
@ThriftServer(port = 8989, serviceName = "personService")
public class PersonServiceThriftImpl implements PersonService.Iface{
   ...
}

工作原理

下图简要说明了SpringBoot整合Thrift服务端的工作原理
在这里插入图片描述
其实对于服务端来说,并没有客户端那么复杂了,最主要的流程就是扫描注解,然后构建服务端对象,在项目启动时,利用多线程启动服务端线程(一个服务一个线程)。

扫描注解的原理与客户端是一样的,都由Bean的后置处理器实现,主要思想是将所有被@ThriftServer修饰的类的Bean放到一个Map里,便于后续处理。

// 单端口,多服务,嵌套Map的key表示服务名,value表示实体名称
private Map<Integer, Map<String, Object>> serviceMap;
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
    ThriftServer anno = bean.getClass().getAnnotation(ThriftServer.class);
    if (anno != null) {
        int port = anno.port();
        String serviceName = anno.serviceName();
        Map<String, Object> serviceContentMap = this.serviceMap.get(port);
        if (serviceContentMap == null) {
            serviceContentMap = new HashMap<>();
        }
        serviceContentMap.put(serviceName, bean);
        this.serviceMap.put(port, serviceContentMap);
    }
    return bean;
}

利用SpringBoot的Runner机制,可以在SpringBoot应用启动时进行一些操作,这里是多线程启动Thrift服务

public class ThriftRunner implements ApplicationRunner {

    private static final Logger logger = LoggerFactory.getLogger(ThriftRunner.class);

    @Resource(name = "serviceMap")
    private Map<Integer, Map<String, Object>> serviceMap;

    @Resource(name = "connPoolMap")
    private Map<String, TSocketPool> connPoolMap;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        // 多线程启动服务
        for (Integer key : serviceMap.keySet()) {
            ServeThread thread = new ServeThread(key, serviceMap.get(key));
            thread.setName("thrift-server-" + key);
            thread.start();
        }
    }

    public void destroy() {
       ...
    }
}

重点关注一下Thrift服务线程代码:

class ServeThread extends Thread {

    private int port;
    private Map<String, Object> serviceMap;

    private static final Logger logger = LoggerFactory.getLogger(ServeThread.class);

    public ServeThread(int port, Map<String, Object> serviceMap) {
        this.port = port;
        this.serviceMap = serviceMap;
    }

    @Override
    public void run() {
        try{
            ServerSocket socket = new ServerSocket(port);
            TServerSocket tServerSocket = new TServerSocket(socket);
            TBinaryProtocol.Factory factory = new TBinaryProtocol.Factory();
            TMultiplexedProcessor multiplexedProcessor = new TMultiplexedProcessor();
            for (String serviceName : serviceMap.keySet()) {
                Class<?> serviceImplClass = serviceMap.get(serviceName).getClass();
                Class<?>[] interfaces = serviceImplClass.getInterfaces();
                for (Class<?> facade : interfaces) {
                    String facadeName = facade.getName();
                    if (facadeName.contains("$Iface")) {
                        String processorName = facadeName.substring(0, facadeName.indexOf("$")) + "$Processor";
                        Class<?> processorClass = Class.forName(processorName);
                        Constructor tProcessorConstructor = processorClass.getConstructors()[0];
                        TProcessor tProcessorObj = (TProcessor) tProcessorConstructor.newInstance(serviceMap.get(serviceName));
                        multiplexedProcessor.registerProcessor(serviceName, tProcessorObj);
                        // 假设对于每一个Iface,只有一个实现类
                        break;
                    }
                }
            }
            TThreadPoolServer.Args tArgs = new TThreadPoolServer.Args(tServerSocket);
            tArgs.processor(multiplexedProcessor);
            tArgs.protocolFactory(factory);
            TThreadPoolServer tServer = new TThreadPoolServer(tArgs);
            logger.info("{}端口服务已启动...", port);
            tServer.serve();

        } catch (Exception e) {
            logger.error("Thrift服务端启动失败,端口{}:{},", port, e);
        }

    }
}

ServeThread run是核心,这里稍微做一下解释,前面操作实际上挺常规,就是Thrift单端口多服务模式的服务端创建,比较复杂的是这个代码段:

for (String serviceName : serviceMap.keySet()) {
   Class<?> serviceImplClass = serviceMap.get(serviceName).getClass();
   Class<?>[] interfaces = serviceImplClass.getInterfaces();
   for (Class<?> facade : interfaces) {
        String facadeName = facade.getName();
        if (facadeName.contains("$Iface")) {
            String processorName = facadeName.substring(0, facadeName.indexOf("$")) + "$Processor";
            Class<?> processorClass = Class.forName(processorName);
            Constructor tProcessorConstructor = processorClass.getConstructors()[0];
            TProcessor tProcessorObj = (TProcessor) tProcessorConstructor.newInstance(serviceMap.get(serviceName));
            multiplexedProcessor.registerProcessor(serviceName, tProcessorObj);
            // 假设对于每一个Iface,只有一个实现类
            break;
        }
    }
}

这一段到底在干嘛呢?其实它是在做这样一件事:对于每一个Thrift服务,找到一个它的实现类

其实就是剥洋葱的过程,先剥同一个端口下的服务列表,接着是服务实现的Iface接口列表,这里假设一个实现类只实现了一个Iface,如果实现了多个,那么只有第一个会起作用。

代码段中的"$xxx"都是debug观察出来的,造轮子耐心还是很重要的。

小结

这种SpringBoot整合Thrift的方式还有很多,使用Bean后置处理器只是一种方式,比如还可以直接生成Thrift客户端Bean Definition进行注入,这就要求要对Spring的Bean实例化过程有更加深入的理解。

其实这个博客应该上个月就写了,但是毕业嘛,拖延症加剧,不过还是把坑补上了,马上就是社会人了,好好享受这最后的时光~

当然,如果有写错,或者不合理的地方,欢迎评论区提出~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值