关于 RPC 调用,大家肯定都是比较熟悉的了,就是在微服务架构下解决系统间通信问题的一个玩意。
其中的典型代表之一就是 Dubbo 了:
图片
在微服务架构下,我们针对某个 RPC 接口,我们一般有两个角色。
- 服务消费者 (Dubbo Consumer),发起业务调用或 RPC 通信的 Dubbo 进程
- 服务提供者 (Dubbo Provider),接收业务调用或 RPC 通信的 Dubbo 进程
假设我是服务消费者,想要调用某个服务,只要我们链接到的是同一个服务注册中心,那么找对应服务要到 API 包对应的 Maven 坐标,引入到项目中,就类似于这样的东西:
org.apache.dubbo
dubbo-spring-boot-demo-interface
${project.parent.version}
那么对于这个 API 包中的接口,虽然我们没有具体的实现类,但是我们还是能像调用本地方法一样调用该服务提供的接口。
这些都是常规的东西了,你肯定是门清。
那我现在问你一个问题啊:
我是服务消费者,我要调用一个服务提供者的 RPC 接口,但是我又不想引入它的 API 包,或者我根本就拉取不到它的 API 包,那么我应该怎么办?
如果你要非给我说:这不可能,既然是要消费别人的接口,那么肯定要拿到 API 包才对,你不拿就是你偷懒。
那我再给你举个歪师傅在实际开发过程中遇到的具体的例子:网关服务。
网关是个什么玩意?
是你对外请求的统一入口,做接受请求、分发请求用的,作为链接各个微服务的角色,你势必要使用到下游的若干个 RPC 服务。
你怎么办?
引入所有的服务提供方的 API 包,然后发起调用吗?
图片
可以是可以,但是不够优雅。
你想,如果有一个服务提供方发布了新的 API 包,你也需要更新版本,重新发版?
或者新来一个服务提供者 E,你需要引入其 API 包,然后重新发版?
网关应该是一个稳定的基础服务,它提供的是聚拢 API 接口、转发调用的基础功能,不应该频繁发版,不应该主动去关注下游的服务接口变化。平台本身不应该依赖于服务提供方的接口 API。
不主动,才能更加优雅,也能让自己更加轻松。
那么怎么才能做到不主动关注呢?
这个事情,总有一方要主动的,所以网关层不主动,那么服务提供者就需要主动起来。
我们可以搞成这样:
图片
网关层提供一个 API 接口发布平台,当服务提供者的接口有新增或者发生变化的时候,由对应系统的接口管理人员把接口信息,比如接口路径、方法、入参、出参、方法功能说明、方法负责团队、接口对接人等等这些消息维护到 API 接口发布平台上。
这样网关层就可以从 API 接口发布平台获取到所有服务的所有接口,并不需要引入任何服务提供者的 API 包。
这样就解决了“主动”的问题,如果接口有变化,请在 API 接口发布平台进行登记,从而解决了网关频繁发布的问题。
在官网上,除了网关的场景外,还提到一个测试平台的场景,道理是一样的,我就不赘述了:
图片
解决了“主动”的问题,那么下一个问题就随之而来了:知道所有服务的所有接口然后呢,怎么发起调用呢?
这个时候泛化调用,啪的一下就站出来了:铺垫了这么多,终于该老子上场了。
泛化调用
啥是泛化调用呢?
在 Dubbo 官网上是这样介绍的:
图片
首先需要强调的是“泛化调用”不是 Dubbo 特有的,它是一个功能,很多的框架都支持泛化调用,只是我这里用的 Dubbo 做演示而已。
老规矩,先花五分钟时间搭个 Demo 出来再说。
这个 Demo 我也是跟着网上的 quick start 搞的:
https://cn.dubbo.apache.org/zh-cn/overview/quickstart/java/spring-boot/
图片
可以说写的非常详细了,你就跟着官网的步骤一步步的搞就行了。
我这个 Demo 稍微不一样的是我在消费者模块里面搞了一个 Http 接口:
图片
在接口里面发起了 RPC 调用,模拟从前端页面发起请求的场景,更加符合我们的开发习惯。
为了起到强调作用,我再次把这个部分给你框起来:
图片
DemoService 是 RPC 接口,它的实现类是这样的:
图片
在我的消费者模块里面为什么能注入这个 DemoService 并调用它的 sayHello 方法呢?
因为我引入了对应的依赖包。
那么,如果我把这个依赖包去掉,也就是模拟我们前面说的“不主动”的动作,这个 DemoService 肯定会报错,找不到这个类:
图片
那么我们应该怎么去修改一下这个 Demo,让它泛化起来呢?
非常简单:
图片
注入 DemoService 修改为注入 GenericService。
有的小伙伴可能会问 GenericService 是怎么冒出来的?
你先别管它是怎么冒出来的,我现在是在给你铺垫 Demo,后面要撕给你看。你现在只需要知道它是 Dubbo 框架里面的包,并不会让你引用额外的包就行了:
图片
现在 Demo 就算是搭好了,本地启动一个 zk,然后把服务提供者启动起来,再把消费者启动起来,最后轻轻的发起一个调用:
图片
朋友,它不就跑起来了吗?
我没有引用接口的 api 包,我不也正常发起了调用,然后拿到了返回值吗?
啥原理
你就想,远程调用,你把一些花里胡哨的东西都拿掉之后,它的本质是什么?
本质就是帮助解决微服务组件之间的通信问题,不管是基于 HTTP、HTTP/2、TCP 还是什么其他的通信协议,解决的是网络连接管理、数据传输等基础问题。
虽然我没有引用 API 的对应的包,但是我前面我不是说了吗,我们有一个 API 接口发布平台,这个平台里面有接口维护人员提供的接口路径、方法、入参、出参这些关键信息。
所以我在调用的时候可以拿到相关的信息,以一种通用的方式,比如字符串的方式告诉 RPC 框架,我要调用的是 DemoService 接口的 sayHello 方法,入参是 String 类型的 world 字符串:
如果是你来开发一个 RPC 框架,调用方都把这些关键信息给你了,无非就是你帮忙多做几步类似于反射、序列化之类的处理。而处理的这个过程,就是泛化调用的过程。
泛化调用不是 Dubbo 特有的,但是具体到 Dubbo 这个框架里面,具体是这样的。
首先,Dubbo 里面有一层 Filter,这些 Filter 构成了一个 Filter 链条:
图片
Filter 用来对每次服务调用做一些预处理、后处理动作,使用 Filter 可以完成访问日志、加解密、流量统计、参数验证等任务。
一次请求过程中可以植入多个 Filter,Filter 之间相互独立没有依赖。
图片
从消费端视角,它在请求发起前基于请求参数等做一些预处理工作,在接收到响应后,对响应结果做一些后置处理。
从提供者视角,在接收到访问请求后,在返回响应结果前做一些预处理。
所以我们的泛化调用,也是通过下面这两个 Filter 来搞事情的:
- org.apache.dubbo.rpc.filter.GenericFilter
- org.apache.dubbo.rpc.filter.GenericImplFilter
那么问题就来了?
为什么要两个 Filter 呢?
因为要完成一次泛化调用,消费端和服务提供者都需要感知到并做相关的处理,所以一个是消费端的 Fliter,一个是服务提供者的 Fliter:
图片
图片
知道了对应的 Filter,关于泛化调用的所有秘密都藏在 Filter 对应的源码里面。
歪师傅带着你简单的看一眼。
GenericImplFilter.invoke
首先,我们在方法的消费者对应的 Fliter 的入口处打上断点:
org.apache.dubbo.rpc.filter.GenericImplFilter#invoke
可以看到分为了三个模块。
- isCallingGenericImpl:calling a generic impl service,判断是否调用的是一个实现了泛化接口的接口。
- isMakingGenericCall:making a generic call to a normal service,把泛化调用转换为一个常规调用。
- invoker.invoke(invocation):常规调用。
我们研究的情况属于 isMakingGenericCall 这个分支。
既然是要把泛化调用转换为一个常规调用,那么 Dubbo 是怎么判断这是一个泛化调用的呢?
org.apache.dubbo.rpc.filter.GenericImplFilter#isMakingGenericCall
图片
- 判断本次调用的方法名称是否是 或者invokeAsync
- 判断本次调用的入参个数是否是 3 个
- 判断容器上下文中的 generic 参数是否对应着泛化调用的序列化方法。
我们一个个的看。
或者invokeAsync 方法是 GenericService 这个接口里面的方法。而这两个方法的入参个数都是三个。
然后有个 generic 参数,在我的 Demo 里面这个参数是 true:
图片
当我啪的一下跟进到 isGeneric 方法中,才发现这里面别有洞天:
图片
原来 generic 这个参数不只是可以为 “true”,它不同的值,代表着不同的序列化方式。
图片
通过这部分源码可以看出来,泛化调用对于客户端,即在 GenericImplFilter 里面,并没有做什么特别的操作,注意还是参数校验。
如果入参和对应的序列化方法不能匹配起来,即使的抛出异常,这样符合 Dubbo 框架的 fast-fail 思想。
但是其实看到这里的时候,我有一个小疑问,如果我写一个这样的类:
public interface WhyService {
Object $invoke(String a,String b,String c);
}
和 GenericService 类一样,有 $invoke 方法,而且也是三个参数。
然后在上下文中塞个 generic=true 进去,那么是不是也能骗过这段代码呢,也能进入到 isMakingGenericCall 方法里面呢?
从代码上看确实是这样的,那么 Dubbo 到底是怎么规避这些“恶意”冒充者的呢?
我也不知道。
先存个疑吧,接着往下看。
GenericFilter.invoke
我们同样在服务端打上断点,当这个请求来到服务端的时候,我们再看看服务端的情况。
org.apache.dubbo.rpc.filter.GenericFilter#invoke
可以看到这个方法逻辑都在 if 判断为 true 的时候。
而这个判断我们刚刚在客户端已经解析过了,只是多了一个判断:
!GenericService.class.isAssignableFrom(invoker.getInterface())
看看发起调用的接口类是不是 GenericService 类的子类,如果是,则进入到 if 分支里面。
朋友,这就有点意思了。几秒钟之前我们还在存疑,然后啪的一下疑问就解开了。
直接就是恍然大悟了。
我这个类:
public interface WhyService {
Object $invoke(String a,String b,String c);
}
过不了服务提供者的 GenericFilter 里面的这个判断:
!GenericService.class.isAssignableFrom(invoker.getInterface())
在 invoke 方法里面,可以看到经过了一个 findMethodByMethodSignature 方法,获取了我们想要调用的 method 方法:
图片
这个方法,从名字上也可以看出,是根据方法签名反射出具体的方法:
图片
在服务端,是有 DemoService 接口对应的类的,所以可以通过反射找到它。
然后再解析出入参的具体值:
图片
这样你就有了构建一个 RpcInvocation 对象,即发起 RPC 调用的对象的所有关键消息。
直接就是发动一招“狸猫换太子”的大动作,重新构建一个 RpcInvocation 对象,然后自己发起一个 invoke 调用。
图片
这样整体看起来似乎一次泛化调用也是很简单的,当你去看服务提供端的源码的时候,你会发现这里面的源码特别多。
不过是因为 Dubbo 支持了多种不同的序列化方式而已,本质是一样的:
图片
onResponse 方法也是同理,就不赘述了:
org.apache.dubbo.rpc.filter.GenericFilter#onResponse
图片
到这里就算是扯下了泛化调用的神秘面纱,和我们预想的一样,无非是拿到接口调用的关键信息之后,重新构建一个请求而已,整体逻辑并不复杂。
复杂的逻辑是什么?
我演示的是最简单的,入参是一个 String 类型的情况。如果我是一个复杂对象呢,对象里面的成员变量特别多,对象里面套对象,对象里面有 List 或者 Map 的情况呢?
复杂的地方在于怎么处理这些复杂对象,把复杂对象搞成服务提供者的 Java 对象入参。
我这里只是一个导读而已,如果你对这部分有兴趣的话,自己搞个复杂对象去研究研究吧,老有意思了。
就当是家庭作业了。
意外收获
歪师傅在扯面纱的时候,没想到还有意外收获。
给你看一段代码,也是前面出现过的一个方法,我把完整的代码都截图放出来:
org.apache.dubbo.common.utils.ReflectUtils#findMethodByMethodSignature
图片
你瞅瞅我框起来部分的 signature 字段,是不是没有任何卵用?
自信一点,不要怀疑,确实没有任何用处,signature 只是赋了个值而已,后续的代码中并没有使用。
所以,我小脑瓜子一转,立刻察觉到这又是一个水 pr 的好机会。
于是...
图片
晚上 10 点半的时候,直接就是一个贡献源码的大动作,小手一挥,带走四行代码:
图片
当时我没细想,但是后来躺在床上的时候我突然想起来:不应该啊,这个地方为什么会留着几行看起来是没有删除不干净的代码呢?
隐隐觉得这里面应该是有故事的。
于是看了这个类的提交记录,主要找两个地方:这个 signature 是什么时候有的,又是什么时候没的。
在 2012 年 6 月 15 日,针对这个类做了一次性能优化:
图片
优化的具体内容就是用 Map 把方法缓存起来,以免每次都需要去走反射的逻辑。
图片
图片
看完这个提交之后我觉得很合理啊,使用 Map 缓存一下确实属于性能优化。
那么为什么又把这个 Map 拿走了呢?
于是我在 2021 年 9 月 6 日的提交中找到了拿走 Map 对应的提交记录:
图片
图片
这次提交的内容非常的多,而从提交记录的 log 中并没有找到为什么要移除这个 Map 的原因:
图片
怎么办?
很简单,社区提问就行了。
于是我在我的 pr 下面抛出了自己的问题:
图片
我查看了该类的提交历史,发现 #8684 删除了 ReflectUtils.java 中的所有 Map 缓存,遗留了对 signature 字段的处理。
但是我不明白为什么要删除缓存,在我看来应该保留缓存。能说一下官方是怎么考虑的吗?
很快我就得到了官方的回复:
图片
删除缓存的原因是因为这些 Map 缓存是全局变量,这会导致从 Dubbo 的类(通常是 GC root)到对应类的引用,而这些类在 ClassLoader 被闲置后无法释放。
啥意思呢?
我大概的解释一下。
首先,我们看一下这个 Map 的定义是怎么样的:
private static final ConcurrentMap
它是个 static 对象,那么它是不是会被作为一个 GC root?
如果它作为一个 GC root,它里面缓存的这些方法,是不是都是“可达的”?
方法是可达的,那么这些方法对应的 Class 类是不是也是“可达的”?
但是在这些方法对应的 Class 类的 ClassLoader 完成自己的使命,被回收之后,那么这个 Class 类是不是理论上也可以被回收了?
但是实际情况是什么呢?
实际情况是因为这个 static 对象还持有其引用,导致它不会被回收。
基于这个考虑,官方决定移除这个 Map。
其实我个人觉得,如果我上面的理解没有错的话,那么讨论这个 Map 的效果,可以得两个分情况:
如果一个泛化调用的调用频率非常低,那么你把对应的方法缓存起来,导致 GC 一直回收不了,确实没啥意思。
如果一个泛化调用的调用频率比较高,那么你把对应的方法缓存起来,确实能起到“性能优化”的效果。
那么 Dubbo 作为一个框架怎么知道你的这个方法调用的频率高不高呢?
它也不知道,所以干脆不要替用户多做这一步,做多了,反而容易出错。
其实它也是可以知道的,比如可以提供一个参数给用户进行配置,把选择权给到用户,让用户通过配置来告诉你。甚至它可以不用用户提供信息,可以自己来做数据收集,来评判这个方法是否应该被缓存起来。
但是,这玩意收益也不高啊。
本来泛化调用就不是 RPC 调用里面非常核心的东西,在这上面搞这么多心思,投入产出比不高啊。
有这时间,还不如想想主链路上还有没有什么地方可以优化优化,在主链路上干事情,才是收益最大的事情。
就像是你在公司里面,在边缘部门里面干得再出色,也很少能让人注意到。但是如果你在核心部门里面,做出一点稍微亮眼的成绩,大家都能看到。
所以,你以为你敲的只是代码吗?
不是的,你敲的,是人情世故。
最后,这个 pr 也合并到源码中去了,再次查看这个类的提交记录,你会发现一个熟悉的名称:
图片
说真的,删除这三行代码没有任何技术含量,这部分代码让任何一个有 Java 基础的人来看,都会发现这个问题。
我不过是在调试源码的过程中捡了个漏而已。
但是为什么这部分代码存在了很久时间了,是我捡到了这个漏呢?
我想,大概是我真的搭了个 Demo 然后一行行的跟了一下源码吧。
所以,朋友,别只是看,要动手,说不定有意外收获。
好了,价值也上完了,本文的技术部分就到这里啦。