文章目录
引言
Apache Thrift是Apache软件基金会开源的一个RPC框架,具有体积小,高性能,跨语言支持较为完善的特点。但是官方并不提供与SpringBoot整合的starter,纵观Maven仓库,几乎没有将SpringBoot与Thrift整合的包。在一些简单的跨语言调用场景上(例如单机AI模型调用,走Shell太慢,RPC效率高),直接上现有RPC框架又显得太过臃肿,目前主流RPC框架会和服务治理等微服务相关内容整合,这就会导致包打出来很大,很多东西是在这种单纯RPC场景下不需要的。
因此,针对一些简单的RPC调用场景,打算自己整合一个starter,在配置和使用上,尽量做到开箱即用,最大化降低用户上手成本。一方面可以增加用户的可选择性,另一方面也学习学习相关技术。(有时候,还是要造造轮子的)
目标
使用Java注解来标识Thrift客户端和服务器,用户导入依赖后,即开即用,0配置
代码仓库 & Demo
从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
进行数据传输的,比较常用的实现是TSocket
,TSocket
实际上是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
中,提供了两个方法:postProcessBeforeInitialization
和postProcessAfterInitialization
,它们的区别就在于执行的时机,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实例化过程有更加深入的理解。
其实这个博客应该上个月就写了,但是毕业嘛,拖延症加剧,不过还是把坑补上了,马上就是社会人了,好好享受这最后的时光~
当然,如果有写错,或者不合理的地方,欢迎评论区提出~