手写实现一个简单版的Dubbo,深刻理解RPC框架的底层实现原理
- RPC框架简介
- 了解Dubbo的实现原理
- 服务暴露
- 服务引入
- 服务调用
- 手写实现一个简单版的Dubbo
- 服务暴露
- ServiceBean
- ProxyFactory#getInvoker
- Protocol#export
- RegistryProtocol#export
- 服务引入
- RegistryProto#refer
- DubboProtocol#refer
- 服务调用
- 服务消费者
- 服务提供者
- 与Spring对接
- 代码
我越来越觉得学习一个开源框架或者中间件的底层原理,最好的方法不是看源码,而是自己尝试去写一个。之前看过许多开源框架和中间件的源码,但是随着时间的推移,多多少少都有点遗忘了,遗忘之后又花时间去把它捡一捡,于是就在这捡起又遗忘,遗忘又捡起这样来来回回的折腾中浪费了许多时间。
Dubbo的源码我很早以前就看过了,而且看了不止一遍,现在对于Dubbo的底层原理只记得个大概,至于源码中的细节,已经忘得差不多了。最近想起之前学习过Dubbo源码,为了加深印象,于是乎自己动手写了一个。这自己动手写跟只用眼睛看,效果还真不一样,因此写成文章分享出来。
RPC框架简介
在以前都是单体应用的年代,是不需要RPC框架的,那时候还不知道RPC是什么,所有的数据都是来自于本地的数据库,然后全都是本地的方法调用。
但是随着业务的发展,访问量的不断增加,原先的单体应用已经不能满足需求,于是要做应用拆分,原先的单体应用被拆成了多个服务,然后就出现了服务间调用的问题。
这样复杂度就一下子上来了,如何解决服务间调用的问题呢?我们可以自己写代码,通过http的方式去访问对方,或者通过Socket连上对方的系统进行通信,发送调用的方法名和调用参数等信息。但是这种方式有点笨,因为这种网络通信的代码一般都是重复的,而且也容易出错。
那么有没有什么办法可以让远程调用变成像之前单体应用那样就调一个本地方法,就能获取到返回结果呢?那就是引入一个RPC框架,RPC框架封装了远程调用的底层逻辑,屏蔽掉了网络通信的过程,让API调用工程师们简单调一下方法,就可以获取到远程系统返回的方法调用结果,于是这些API调用工程师们就觉得自己很牛B了。
我们当然不能当这种API调用工程师,要不然35岁之后就要强势加入骑士军团了。
其实RPC框架的原理并没有多复杂。
比如现在有一个 UserService接口,在以前还是单体应用的时候,Spring给我们注入的是一个实现类,我们调用这个接口的方法时就可以调用UserServiceImpl实现类。
而现在这个实现类在别的系统上,我们拿不到,于是RPC框架给我们注入的是一个代理对象,这个代理对象也实现了这个UserService接口,通过这个代理对象可以访问到对方系统的UserServiceImpl。然后RPC框架通过一个stub存根封装了网络通信的逻辑,当我们调用UserService接口的某个方法时,实际上是通过代理对象调用到了底层的存根类,stub存根再把我们调用的方法和参数封装为网络协议包,发送到对端系统。对端系统也有一个stub存根,接收到发送过来的协议包后,解析出要调用的方法和参数,交给UserServiceImpl去处理,然后再把处理结果通过stub存根返回给我们本地的存根,我们本地的存根再把结果返回给我们。
但是A系统怎么知道B系统的ip地址和端口好呢?如果是在A系统上配死那就太low了,一旦B系统改了ip地址或端口,A系统就要改配置重启。因此,还需要一个注册中心存储B系统发布的ip地址和端口,A系统从注册中心拉取B系统的ip地址和端口。此时B系统就是服务提供者,把ip地址和端口注册到注册中心这个动作就是服务暴露,而A系统就是服务消费者,从注册中心拉取服务提供者的ip地址端口等信息这个动作就是服务引入,A系统调用B系统这个动作就叫服务调用。
这样,一个RPC调用框架就形成了。
了解Dubbo的实现原理
但是,理解到这一层,还远远不够,毕竟别人可以面试前把这些背一背,面试的时候忽悠面试官。于是,我们要阅读源码,对开源框架有更深一层的理解,这样才能让面试官从一众的渣渣中把我们区分出来。
Dubbo是众多的RPC框架中用的比较多的一个,我们来参考参考Dubbo的原理,也是分为服务暴露、服务引入、服务调用三块。
服务暴露
首先我们写的类是这样子的:
UserServiceImpl上声明了@Service注解(这个是Dubbo自己定义的@Service注解,不是Spring的那个),就代表我们要把这个UserServiceImpl暴露出来供服务消费者调用。
Dubbo要扫描它,然后把它封装成ServiceBean,ServiceBean是Dubbo封装的一个用来做服务暴露的实现类,它实现了Spring的ApplicationListener<ContextRefreshedEvent>接口,会监听ContextRefreshedEvent容器刷新完成事件触发服务暴露,而ServiceBean的export方法就是服务暴露的入口。
Dubbo在服务暴露中,一共做了三件事:
- 把我们的UserServiceImpl对象包装成Invoker,再把Invoker包装成Exporter,再把Exporter放入一个map中,key是根据实现类计算出来的一个唯一的serviceKey。这一步相当于是本地注册,方便后面接收到远程调用请求时,根据请求信息计算出serviceKey,找到目标实现类。
- 开启Netty服务端,用于接收远程调用请求。
- ip地址、端口、协议等服务信息注册到注册中心,这些信息注册到注册中心后,消费方就可以从注册中心中获取到服务提供者的信息了。
服务引入
至于服务引入,我们写的消费者类一般是这样:
属性上声明了这个@Reference注解,表示这个属性需要通过Dubbo的服务引入来进行属性注入。
那么,Dubbo就是扫描所有被@Reference注解修饰的属性,进行属性注入。Dubbo这里处理属性注入的逻辑与Spring处理@Autowired的逻辑大体是相同的,这里先不用管(后面会讲解)。扫描到的属性,通过ReferenceBean的get()方法触发服务引入,返回一个代理对象,注入到该属性中。
而服务引入里面做了些啥呢?其实就是从注册中心中获取服务提供者的ip端口等信息,开启NettyClient连接上服务提供者,然后封装到一个Invoker中,这个Invoker就等同于上面说的存根类。然后通过动态代理返回一个代理对象,这个代理对象会调用Invoker进行远程方法调用。
服务调用
服务调用的逻辑自然是被隐藏在了Invoker里面。服务间通过Netty进行网络通信,服务消费者把调用的方法名,接口的类全限定名,方法参数等封装成一个Invocation对象,然后序列化成二进制,通过Netty发送给服务提供者,服务提供者的Netty接收到服务消费者发来的数据时,会反序列化成Invocation对象,根据服务消费者提供的信息,找到具体的实现类进行处理,处理结果再由Netty返回给服务消费者。
最终整体流程就是这样:
没有看过源码的,也可以记一下这张图,然后面试的时候喷给面试官,也能拿个60分。
按照这个思路,我们就可以动手写代码了。
手写实现一个简单版的Dubbo
我们写代码的顺序还是按照服务暴露、服务引入、服务调用的顺序来,但是我们实现的迷你版Dubbo是要与Spring对接的,所以最后还要实现与Spring对接的逻辑。
这里要注意,下面还是以一边讲解一边画图的方式进行,不会贴代码。因为如果贴代码的话,这文章没个几万字是结束不了的。想看代码的可以到代码仓去下载,文末会附上代码仓的地址。
服务暴露
ServiceBean
服务暴露的逻辑从ServiceBean开始,我们就先从ServiceBean开始实现我们的迷你版Dubbo。
我们的ServiceBean也是实现ApplicationListener<ContextRefreshedEvent>接口,在onApplicationEvent(ContextRefreshedEvent event)方法中调用export()方法触发服务暴露。
export()方法首先要读取注册中心的配置,Dubbo是通过RegistryConfig类型封装注册中心的配置的,我们也定义一个RegistryConfig类封装注册中心的配置,这里通过RegistryConfig获取到注册中心的ip地址和端口等信息。
Dubbo是通过URL对象去封装服务暴露的信息,比如协议、ip地址、端口、访问路径(调哪个类的哪个方法)、方法参数等信息,然后注册到注册中心的也是这个URL对象转成的url格式的字符串。我们这里也定义一个URL对象去存这些信息,我们组装我们的URL对象。
URL中包含的协议、端口等信息,可以通过ProtocolConfig进行配置,然后从ProtocolConfig中取出放入到URL对象中。
封装好URL对象后,接下来就是通过ProxyFactory(代理工厂)生成服务提供方的Invoker,这个Invoker保存了真正的服务实现类。
最后就是通过Protocol对象的export方法进行服务暴露,这里也是参考Dubbo的,Dubbo真正的服务暴露逻辑是封装在Protocol的export方法里面的,本地注册、开启Netty、注册相关信息到注册中心等动作都是在这里面进行。我们也定义一个Protocol接口。
这样看来,ServiceBean需要RegistryConfig和ProtocolConfig这两个对象,我们可以通过ApplicationContext去获取,因此ServiceBean需要实现ApplicationContextAware这个接口,通过这个接口获取到ApplicationContext,然后ServiceBean还要实现InitializingBean接口,在InitializingBean接口的afterPropertiesSet()方法里面通过ApplicationContext获取RegistryConfig和ProtocolConfig这两个对象。
这里的ApplicationContextAware、InitializingBean、ApplicationListener等接口都是Spring的接口,不熟悉的可以去补一下Spring的知识。
ProxyFactory#getInvoker
ProxyFactory.getInvoker(ref, this.interfaceClass, url) 这一步是封装具体实现类ref为Invoker的。这个Invoker的invoke方法会从Invocation中获取方法名、方法参数类型和方法参数,然后通过反射调用具体实现类ref。
Protocol#export
protocol.export(registryURL, invoker);这一步是真正进行服务暴露的,但是这里的protocol其实是一个代理类,Dubbo通过他自己实现的SPI机制,会调用到具体的Protocol实现类。我们这里也是一个代理类,使用的时JDK的动态代理,但是我们就不实现自己的SPI机制了,我们就使用Java的SPI机制。在InvocationHandler的invoke方法中通过Java的SPI机制加载所有的实现类,循环遍历进行匹配,匹配逻辑是看URL中的protocol属性,也就是url中的协议,然后反射调用匹配到的具体实现类。
现在的整体流程就走到这里:
RegistryProtocol#export
这里的URL的protocol属性是registry,因此这里匹配到的是RegistryProtocol类型的Protocol,于是会调用到RegistryProtocol的export方法。
RegistryProtocol的export方法又会再次调用protocol的export方法,这里的protocol依然是代理对象,但是这次的URL的protocol属性是dubbo,因此会调用到DubboProtocol的export方法。
DubboProtocol的export方法会把Invoker包装成Exporter,用接口类全限定名作为key,Exporter作为value,放入到一个map,这样当接收到远程调用请求时,就可以通过接口名找到对应的Invoker,进而调用里面的实现类。
DubboProtocol的export方法接下来会开启Netty服务端,用于接收远程调用请求。
DubboProtocol的export方法结束后返回到RegistryProtocol的export方法,接下来会调用注册中心的客户端把服务暴露信息注册到注册中心,比如注册中心是Zookeeper,则通过Zookeeper的客户端把服务暴露信息发布到Zookeeper注册中心。这里注册中心的具体类型还是通过动态代理加上Java的SPI机制来动态进行匹配的,而注册到注册中心的信息也是url格式的。
到这里,服务暴露的流程就结束了:
服务引入
服务引入的入口是在ReferenceBean的get()方法,我们ReferenceBean的get()的会返回一个代理对象,注入到被@Reference注解修饰的属性上。这里先不用管ReferenceBean的get()方法如何被调用到,我们先完成ReferenceBean的get()方法的服务引入逻辑。
ReferenceBean的get()方法第一步也是通过RegistryConfig获取到注册中心的ip和端口等信息。
然后调用Protocol的refer方法进行服务引入,这个方法会返回一个Invoker对象,这个Invoker对象的invoke方法会通过Netty向远端发起远程调用。
最后通过ProxyFactory的getProxy方法把Invoker封装到一个代理对象中,这里我们还是使用JDK的动态代理,InvocationHandler的invoke方法会调用Invoker的invoke方法。
RegistryProto#refer
Protocol的refer方法是真正进行服务引入的方法,这里还是通过动态代理,首先走到RegistryProto的refer方法
RegistryProto的refer方法返回的Invoker对象是比较复杂的,有个几层的嵌套。
考虑到服务提供者有可能是以集群的形式部署的,因此我们这里定义了一个ClusterInvoker类型的Invoker与之对应。ClusterInvoker包装了一个RegistryDirectory对象,它是一个服务目录,里面保存了一个List<Invoker>,这里的一个Invoker对应一个服务提供者。这样我们的服务就具备一定的容错能力,可以通过负载均衡选出一个Invoker,一个调不通,可以换下一个。
那List<Invoker>里面的Invoker是怎么来的呢?
首先,我们会监听注册中心,一旦服务提供者上下线导致注册中心中的信息有变动,我们会收到通知,收到通知后我们重新从注册中心中查询服务提供者的url。同时在创建RegistryDirectory对象时,RegistryDirectory的构造方法也会主动去注册中心查一次。
然后,我们定义了一个NotifyListener接口,我们的RegistryDirectory实现了NotifyListener接口,NotifyListener的notify方法接收从注册中心查询回来的url,循环调用protocol.refer(url, serviceType)方法。protocol.refer(url, serviceType)会调用到DubboProtocol的refer方法,DubboProtocol的refer方法就会返回与服务提供者对应的一个Invoker。
DubboProtocol#refer
DubboProtocol的refer方法会从参数url中解析出服务提供者的ip地址和端口号,开启Netty客户端,连接到服务提供者,然后包装成一个Invoker返回。
那么服务引入的整体逻辑就是这样:
服务调用
服务消费者
服务消费者的代理对象会通过InvocationHandler的invoke方法,调用到ClusterInvoker的invoke方法。
ClusterInvoker的invoke方法调用RegistryDirectory的list方法获取List<Invoker>,然后通过负载均衡算法选出一个进行调用,调用失败则切换下一个。
负载均衡选出一个Invoker后,调用Invoker的invoke方法就会进入到DubboInvoker的invoke方法。DubboInvoker的invoke方法把接口类全限定名、方法名、方法参数类型、方法参数等信息包装成Invocation对象,调用Netty客户端发送请求。
Netty客户端的编解码器会把Invocation对象序列化成二进制,然后发送到网络。
因为Netty是异步的,因此这里要创建一个Future对象然后绑定一个id,发送请求时把这id带上,服务提供者处理完后返回处理结果时同时返回这个id,服务消费者就能通过id拿到Future,设置返回结果到这个Future上,那么当时发送请求的线程就能通过这个Future拿到返回结果。
服务提供者
服务提供者的Netty接收到请求后,反序列成Invocation对象,根据Invocation中的接口类全限定名,从map中取出Exporter。然后从Exporter中取出Invoker,调用Invoker的invoke方法,Invoker的invoke方法从Invocation取出方法名,方法参数类型、方法参数,反射调用具体实现类,具体实现类处理完成后返回的结果,带上服务消费者传过来的id,一起返回给服务提供者。
与Spring对接
这些都写好以后,一个RPC框架大体就形成了,但是我们还差最后一步,就是与Spring对接。
我们参考Dubbo,定义了一个@EnableDubbo注解,@EnableDubbo注解通过@Import注解Spring容器导入一个DubboComponentScanRegistrar类,DubboComponentScanRegistrar实现了Spring的ImportBeanDefinitionRegistrar接口,重写了ImportBeanDefinitionRegistrar接口的registerBeanDefinitions方法,DubboComponentScanRegistrar的registerBeanDefinitions方法会被Spring回调到。DubboComponentScanRegistrar的registerBeanDefinitions方法往Spring容器中注入两个bean,一个是ServiceAnnotationBeanPostProcessor类型,实现了Spring的BeanDefinitionRegistryPostProcessor接口,用于扫描@Service注解然后注册BeanDefinition到Spring容器中;另一个是ReferenceAnnotationBeanPostProcessor类型,是一个Bean后置处理器,用于扫描@Reference注解进行服务引入的。
ServiceAnnotationBeanPostProcessor通过Spring的ClassPathBeanDefinitionScanner类扫描出@Service注解修饰的类的BeanDefinition,然后创建一个ServiceBean类型的BeanDefinition,配置相应的interfaceClass和ref等属性,然后注册到容器中。
ClassPathBeanDefinitionScanner是Spring提供的一个用来扫描指定包路径获取BeanDefinition的工具类。
ReferenceAnnotationBeanPostProcessor参考Spring处理@Autowired的逻辑,ReferenceAnnotationBeanPostProcessor继承了InstantiationAwareBeanPostProcessorAdapter,并重写了InstantiationAwareBeanPostProcessorAdapter的postProcessPropertyValues方法,ReferenceAnnotationBeanPostProcessor的postProcessPropertyValues方法会被Spring回调到,给当前bean进行属性注入,然后postProcessPropertyValues方法中收集当前bean中被@Reference注解修饰的属性,创建ReferenceBean,对ReferenceBean进行相应配置,调用ReferenceBean的get()方法返回一个代理对象,注入到该属性中。
InstantiationAwareBeanPostProcessorAdapter是Spring提供的一个Bean后置处理器,可以通过继承InstantiationAwareBeanPostProcessorAdapter并重写postProcessPropertyValues方法定制我们的属性注入逻辑。
代码
到这里,我们的RPC框架就大功告成了。基本上是参考Dubbo来写的,保留了Dubbo的核心逻辑,又去掉了一些细枝末节,比起原来的Dubbo代码简化了许多,但是确实能够帮助我们比较深刻的理解Dubbo的原理和逻辑。
本文章涉及的所有代码,都上传到git代码仓库了,由于篇幅有限,这些代码就不贴到本篇文章中了,有兴趣的可以到代码仓库下载下来看一下。
git代码仓库地址:https://gitcode.net/weixin_43889578/mini-dubbo