1. 为什么要使用RPC?
结合项目来看的话,我认为有下面的几点可以说一下:
第一点,就是编码人员调用接口的编码量下降了,在用RPC
之前,我是使用HTTPClient
来对计算节点上的client
进行操作的,因此在代码中可以看到有大量的POST
之类的请求,这个做法也不是不行,但是存在一个非常明显的问题:加大了双方通信的成本,比如说需要通过HTTPCode
来标志当前服务的状态,很不直观,但是如果使用RPC
框架的时候,就可以将对等端抛出的异常直接在server
中显示出来。这样的话调试起来非常方便
第二点,就是减轻了中控节点主链路
的服务压力,比如说本来在主链路中要把全部的事情做完,在一开始编写这个中控节点的代码的时候,我也实现了相关的接口,但是这些代码对主节点来说是非常重的负担
比如说创建容器,需要主节点去开辟一个新的线程去创建容器,并且监听它发回来的状态码,做判断,返回给前端…这种其实就是命令式API
但是在使用了RPC
之后,就只需要向计算节点发送一条指令,就可以去干别的事情了,这个过程同时也实现了解耦和负载均衡,不至于在一个实例上完成过多的操作,这个其实就是声明式API
第三点,从编码的体验来看,就是Dubbo
的使用使得调用远程方法像本地方法一样简单,对于异常之类的处理,更加自然。
这就是使用RPC
的意义
2. 如何来理解RPC?
RPC
是远程过程调用
,它的作用通常是将一个不在同一个主机/进程上的方法进行远程调用,就好像调用这些方法就是在本主机/主进程中使用一样。
那么如何来规定这两端之间的通信,就是十分关键的问题了,这些方法之间的调用有相同语言的,有不同语言的,有相同通信底层的,也有不同的,这也就 规定了RPC
远程调用必须设计一种协议,这种协议规定了对等端之间是如何通信的,确保两端能够同时解析出正确的信息。
总而言之,RPC实际上就是约定了远程过程的过程中,数据的格式以及数据是如何传输的,RPC协议是应用层之上的协议,可扩展性很强
3. 对注册中心的理解
注册中心的内容通常是这样的:
Map<接口名:List<URL>>
假如说基于Redis
做注册中心
说明一下这个Map
的含义,它的key就是它的接口名比如说是/dubbo/xxx.xx.XxxService/providers:
然后它的每一个entry
就是{key,value}
,每个hash
的字段就是URL->expireTime
实际上,在Dubbo+Redis
中,存储的信息通常更多,比如说还有方法名等
通常来说,注册中心分为本地缓存注册中心(存储在主机内存中)
,还有分为远程注册中心,这是为了避免每次去请求服务都通过网络的I/O
来查询服务,那么就设计了本地缓存,通过本地缓存减少了网络请求的次数,提高性能
同时,为了应对注册中心和本地缓存的一致性,可以基于Redis
的发布订阅机制,来对变化进行监听,从而及时修改本地缓存中的内容
4. 为什么要使用Dubbo?
当网站流量很小时,只需一个应用,将所有功能都部署在一起,运行在一个服务器上,以减少部署节点和成本。此时,用于简化增删改查工作量的数据访问框架(ORM)是关键。这种架构就被称为是单体架构
将传统的单体架构应用拆分成互不相干的几个小应用,这些小应用独立的部署到不同的服务器上,以提升效率。此时,用于加速前端页面开发的Web框架(MVC)是关键。
它的缺点是当服务节点发生变化,就需要重新部署,比如说一个商城的控制层controller
发生了变化,但是业务层没有发生变化,此时也需要将整个商城
的这一个小模块的进程进行重新部署,从而影响了其他的业务。
分布式服务架构下,系统被拆分成不同的服务比如短信服务、安全服务,每个服务独立提供系统的某个核心服务。就相当于将原来十分复杂的大模块,拆分成了不同的模块,并且能够以最小的粒度来管理这些模块
Dubbo
能够解决什么问题?
负载均衡:同一个服务器部署在不同的机器时,应该要调用哪一台机器上的服务?
服务调用链路生成: 随着系统的发展,服务越来越多,服务间依赖关系变得错踪复杂,甚至分不清哪个应用要在哪个应用之前启动,架构师都不能完整的描述应用的架构关系。Dubbo可以为我们解决服务之间互相是如何调用的。
服务访问压力以及时长统计、资源调度和治理 :基于访问压力实时管理集群容量,提高集群利用率。
5. 说一说Dubbo的架构设计
Dubbo的核心架构设计我认为可以分为以下五个部分
- Provider:服务的提供者,通常作为服务的被调用方使用
- Consumer:服务的消费者,通常是服务的调用方
- Registry:注册中心,是整个
RPC
的中介和核心,服务于消费者和提供者,对于消费者而言,它是拉取服务的来源,对于提供者而言,它是推送服务的目的地。 - Container:运行服务的容器,比如说
Tomcat
等服务容器 - Minitor:系统监控器,非必须的,主要用来监控服务的调用次数和调用时间
注册中心有什么用?
注册中心相当于根目录服务
,它的作用就是来给服务提供者和服务消费者进行寻址,因为服务者进程和消费者进程通常处于不同的进程中,甚至是不同的主机中,需要通过网络通信的方法来进行方法的调用,因此这时候有一个中介站来提供地址的查询服务。一般来说,这个注册中心还需要有一个自动感知服务的上线和服务下限的功能,并且将这些变更及时推送到各个节点上。比如说Redis
在对这个功能进行实现的时候,一般是通过发布与订阅的机制进行实现的。
监控中心有什么用?
监控中心负责一个服务监控的功能,它能够统计服务调用次数和调用时间
服务提供者宕机之后,注册中心会做什么?
以Redis
作为注册中心的为例,分为两种情况,一种情况是进程非正常退出,因此在这个情况下,收尾的代码没有执行,因此在注册中心中是还能看到这个服务提供者的,这是因为在使用Redis
做注册中心的时候,每一个dubbo
服务提供者都会内置一个心跳维护线程
,它默认是1分钟过期,因此就是说,当1分钟没有收到任何心跳,就认为服务挂掉了,将服务下线。
这个收尾代码就是
JVM
中的shutDownHook
第二种情况就是进程的正常退出,收尾的代码被执行,这个过程的设计是比较复杂的,简单来说,就是对远程注册中心中的key,找到并且删除。对本地缓存的map进行更新。
6. 详细说说使用Redis做注册中心是怎么实现的?
以Dubbo
为例,我们首先分析它存储在Redis
上的信息,比如说是
/dubbo/xxx.xx.XxxService/providers: 以hash类型存放所有提供者列表, 每个hash的字段为 url -> expireTime
然后根据SPI
中的配置来创建一个RedisRegistryFactory
,这个过程就是选中了Redis
作为注册中心了
在Dubbo如何使用SPI机制?
第一步,在resources
下创建一个dubbo/META-INF
文件夹
第二步,配置SPI
配置文件,比如说是redis
的,那么文件名就是这个具体使用redis
注册中心的全路径类名,内容是一个key->value
,key就是redis
,value就是全限定类名
Redis作为注册中心,服务提供者是如何注册上去的?
第一步,按照服务名称组装key,然后以全服务路径作为hash的key,然后它的value就是服务的expire
。
第二步,将key向redis中添加,然后关键的是向客户端发布一条消息,通知有新服务上限,然后所有客户端的本地缓存更新
这个发布的消息就是publish <key> register
,也就是说所有订阅服务的客户端,都会订阅register
这个频道,以便及时更新本地缓存
Redis作为注册中心,服务消费者是如何进行服务的订阅的?
首先要搞清楚服务订阅是在干啥,首要目标就是要订阅到目标服务的地址,然后还有一个机制,就是要能够动态感知到哪些服务上线了,哪些服务下线了,然后好知道下一次要请求服务的格式是怎么样的,它的订阅要做两件事情
首先判断是不是首次订阅服务,如果是首次订阅服务,那么就要将服务信息全部缓存到本地,否则的话就是将新上限的服务加入到本地缓存中,或者是有服务下线了,这时候触发publish-subscibre
的回调函数,将服务从里面删除
Redis作为注册中心,服务消费者是如何进行服务的下线的?
应用服务的主动下线操作是由 ShutdownHookCallbacks 和在判断服务不可用时进行的 invoker.destroy() 来实现优雅下线。然后pushlish一个下线通知其他应用。
Redis作为注册中心,服务心跳是如何处理的?
前面提到,它会通过一个心跳机制来维护自己的服务是否过期,那么具体是怎么做的呢?就是开启一个定时任务,定期地去刷新属于自己的这个服务的URL的{key->value}
,当注册中心检测到有key
过期的时候,就会publish<key>UNREGISTER
,将服务删除
服务信息变更通知处理notify
- redis 做初次subscribe时,notify会通过redis-keys 命令获取所有需要的key, 然后依次将其提供者、路由、配置等信息都缓存起来。
- 针对每个服务,都会开启相关的订阅线程Notifier处理订阅工作。
- 最终的listener处理默认会由 RegistryDirectory 处理。
7. 说说JDK的SPI机制
首先在项目就使用了自己的SPI
机制,先谈谈我自己的理解:
我运用SPI
机制的场景是在自定义负载均衡的时候,由于dubbo
提供的四种负载均衡算法都不能够很好的完成我的需求,因此就只好使用自己定义的负载均衡机制了,实现是这样的:首先先找到负载均衡算法定义的接口:
com.alibaba.dubbo.rpc.cluster.LoadBalance
package com.alibaba.dubbo.rpc.cluster;
@SPI("random")
public interface LoadBalance {
@Adaptive({"loadbalance"})
<T> Invoker<T> select(List<Invoker<T>> var1, URL var2, Invocation var3) throws RpcException;
}
在dubbo
的LoadBalacne
上注解了一个SPI
,然后它的默认值是random
,含义为默认使用随机权重算法
然后如果要实现自己的负载均衡算法,那么就需要定义一个接口
public class LoadBalanceConsistence implements LoadBalance {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public <T> Invoker<T> select(List<Invoker<T>> list, URL url, Invocation invocation) throws RpcException {
//1. 将containerId拿出来
Object[] arguments = invocation.getArguments();
if(!(arguments[0] instanceof String)){
throw new RpcException("参数有误!");
}
//2. 从redis中取出nodeId
String containerId = (String) arguments[0];
String ip = stringRedisTemplate.opsForValue().get(CONTAINER_ALIVE + containerId);
log.info("对应的IP地址是{}",ip);
//3. 获取对应的服务
Invoker<T> invoker = null;
for (Invoker<T> tInvoker : list) {
if(tInvoker.getUrl().getIp().equals(ip)){
invoker = tInvoker;
break;
}
}
return invoker;
}
}
然后在相关的目录下添加配置,也就是添加一个META-INF
的信息
public static void main(String[] args) {
ServiceLoader<Serializer> serviceLoader = ServiceLoader.load(Serializer.class);
Iterator<Serializer> iterator = serviceLoader.iterator();
while (iterator.hasNext()) {
Serializer serializer= iterator.next();
System.out.println(serializer.getClass().getName());
}
}
使用的话就是这样的,通过JDK
自带的这个Loader
,读取这个配置文件中的信息,就可以获取到这个类的全路径了,然后通过反射就可以获取到这个类,就可以完成动态调用类的效果。
为什么要这样做?
首先来思考一下有没有别的办法?肯定有,我简单说一种,就是你定义一个实现类,然后框架的目标函数的参数中,设置一个参数为这个实现类的接口,然后就可以接受这个实现类作为参数,然后调用方法即可
如果使用SPI机制的话,是这样做的:首先写实现类->在注解上定义你想要用的实现类,结束
两种的区别在于:前一种方面编码者还需要去了解接口的参数,如何传参,显得代码很冗余。
8. 如果让自己实现SPI,你会如何实现呢?
自己实现SPI,可以参考SpringBoot的SPI实现
JDK提供的SPI机制的弊端
- Java内置方法方式只能够通过遍历来获取具体的实现类
- 服务提供接口必须放到
META-INF/services/
目录下 - 无法按照需求加载实现类
那么Spring中的SPI是如何使用的呢?
interface DBApi{
Connection getConnection();
}
实现这个接口
#DB2实现
public class DB2DataBase implements DataBaseSPI
{
@Override
public void getConnection()
{
System.out.println("this database is db2");
}
}
#Mysql实现
public class MysqlDataBase implements DataBaseSPI
{
@Override
public void getConnection()
{
System.out.println("this is mysql database");
}
}
然后在项目的META-INF
的目录下,新增一个spring.factories
文件中
填写相关的接口信息,内容是:
com.skywares.fw.juc.springspi.DataBaseSPI = com.skywares.fw.juc.springspi.DB2DataBase, com.skywares.fw.juc.springspi.MysqlDataBase
注意,不同的实现类是用逗号隔开的,Spring采用的spring.factories
实现SPI和Java实现SPI还是比较相似的,但是spring的spi方式针对java的spi进行的相关优化如下:
Java SPI是一个服务提供接口对应一个配置文件,配置文件中存放当前接口的所有实现类,多个服务提供接口对应多个配置文件,所有配置都在services
目录下
就是约定一个目录,根据接口名去那个目录找到文件,文件解析得到实现类的全限定名,然后循环加载实现类和创建其实例。
Spring factories SPI是一个spring.factories配置文件存放多个接口以及对应的实现类,以接口全限定类名作为{key},实现类作为{value},多个实现类用逗号隔开,仅spring.factories一个配置文件
spring是如何通过spring.factories来实现SPI的呢?
第一步:确定类加载器,通常是将加载类的任务由SpringFactoriesLoader来做
第二步:解析和加载META-INF下的文件
第三步:获取所有jar包中META-INF/spring.factories文件路径,以枚举值返回
关于DubboSPI
首先按需求加载
的话首先要先给每一个实现类一个名字
通过名字去文件里面找到对应的实现类的全限定路径名
然后加载实例化即可
DubboSPI除了可以按照需求加载实现类之外,增加了IOC和AOP的特性,还有一个自适应的扩展机制
Dubbo对配置文件目录的约定,分成了三类目录
**META-INF/services/目录:**该目录下的SPI配置文件是为了用来兼容JDKSPI的
**META-INF/dubbo/目录:**该目录存放的的是用户自定义的SPI配置文件
**META-INF/dubbo/internal/目录:**dubbo内部使用的SPI配置文件
关于ExtensionLoader.getExtension(name)的流程分析
首先拿到你的这个接口的类,然后做了一些判断之后从缓存中找到是否已经存在这个类型的ExtensionLoader
,如果没有的话就新建一个放入到缓存中,最后返回接口类对应的ExtensionLoader
然后当拿到这个ExtensionLoader
之后,就能够基于getExtension()
方法通过名字来找到实例化完的实现类,关于获取类的实例过程一般有两步:
- 先去缓存看看有没有这个实例,没有的话就要调用createExtension()来创建实例
这个过程是这样描述的:通过getExtensionClasses
来得到classes
,这就确定了要实例化哪个对象了,然后通过,如果没有得到classes
的话,那么就要去目录下寻找了,如果是Adaptive
就记录在cachedAdaptiveClass
上,如果是Wrapper
类就记录在一个map
中
然后得到了classes
之后就通过反射建立一个对象,然后遍历set方法执行依赖注入。
什么是自适应扩展?
根据配置来进行SPI扩展的加载,不想在启动的时候让扩展被加载,想根据请求时的参数来动态选择对应的扩展
Dubbo通过一个代理机制来实现自适应扩展,简单来说就是想扩展的接口生成一个代理类,可以通过JDK
或者javassit
编译生成的代理类代码,然后通过反射创建实例
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Adaptive {
String[] value() default {};
}