Dubbo高级实战(SPI、负载均衡)

本文知识点较多,篇幅较长,请耐心学习

题记:
文章内容输出来源:拉勾教育Java高薪训练营。
本篇文章是 Dubbo 学习课程中的一部分笔记。

Dubbo高级实战

一、SPI

1、SPI简介

SPI 全称为 (Service Provider Interface) ,是JDK内置的一种服务提供发现机制。 目前有不少框架用它来做服务的扩展发现,简单来说,它就是一种动态替换发现的机制。使用SPI机制的优势是实现解耦,使得第三方服务模块的装配控制逻辑与调用者的业务代码分离。

2、JDK中的SPI
Dubbo高级实战(SPI、负载均衡)_第1张图片
Java中如果想要使用SPI功能,先提供标准服务接口,然后再提供相关接口实现和调用者。这样就可以通过SPI机制中约定好的信息进行查询相应的接口实现。
SPI遵循如下约定:

  • 当服务提供者提供了接口的一种具体实现后,在META-INF/services目录下创建一个以“接口全限定名”为命名的文件,内容为实现类的全限定名;
  • 接口实现类所在的jar包放在主程序的classpath中;
  • 主程序通过java.util.ServiceLoader动态装载实现模块,它通过扫描META-INF/services目录下的配置文件找到实现类的全限定名,把类加载到JVM;
  • SPI的实现类必须携带一个无参构造方法;

3、Dubbo中的SPI

dubbo中大量的使用了SPI来作为扩展点,通过实现同一接口的前提下,可以进行定制自己的实现类。比如比较常见的协议,负载均衡,都可以通过SPI的方式进行定制化,自己扩展。Dubbo中已经存在的所有已经实现好的扩展点。
不过,Dubbo 并未使用 Java 原生的 SPI 机制,而是对其进行了增强,使其能够更好的满足需求。在 Dubbo 中,SPI 是一个非常重要的模块。基于 SPI,我们可以很容易的对 Dubbo 进行拓展。
下图中是Dubbo中默认提供的负载均衡策略。
Dubbo高级实战(SPI、负载均衡)_第2张图片
Dubbo 改进了 JDK 标准的 SPI 的以下问题:

  • JDK 标准的 SPI 会一次性实例化扩展点所有实现,如果有扩展实现初始化很耗时,但如果没用上也加载,会很浪费资源。
  • 如果扩展点加载失败,连扩展点的名称都拿不到了。比如:JDK 标准的 ScriptEngine,通过 getName() 获取脚本类型的名称,但如果 RubyScriptEngine 因为所依赖的 jruby.jar 不存在,导致 RubyScriptEngine 类加载失败,这个失败原因被吃掉了,和 ruby 对应不起来,当用户执行 ruby 脚本时,会报不支持 ruby,而不是真正失败的原因。
  • 增加了对扩展点 IoC 和 AOP 的支持,一个扩展点可以直接 setter 注入其它扩展点。

4、Dubbo SPI 示例
Dubbo SPI 的相关逻辑被封装在了 ExtensionLoader 类中,通过 ExtensionLoader,我们可以加载指定的实现类。Dubbo SPI 所需的配置文件需放置在 META-INF/dubbo 路径下,配置内容如下。

optimusPrime = org.apache.spi.OptimusPrime
bumblebee = org.apache.spi.Bumblebee

与 Java SPI 实现类配置不同,Dubbo SPI 是通过键值对的方式进行配置,这样我们可以按需加载指定的实现类。另外,在测试 Dubbo SPI 时,需要在 Robot 接口上标注 @SPI 注解。下面来演示 Dubbo SPI 的用法:

public class DubboSPITest {
     

    @Test
    public void sayHello() throws Exception {
     
        ExtensionLoader<Robot> extensionLoader = 
            ExtensionLoader.getExtensionLoader(Robot.class);
        Robot optimusPrime = extensionLoader.getExtension("optimusPrime");
        optimusPrime.sayHello();
        Robot bumblebee = extensionLoader.getExtension("bumblebee");
        bumblebee.sayHello();
    }
}

5、Dubbo SPI中的Adaptive功能

Dubbo中的Adaptive功能,主要解决的问题是如何动态的选择具体的扩展点。通过getAdaptiveExtension 统一对指定接口对应的所有扩展点进行封装,通过URL的方式对扩展点来进行动态选择。 (dubbo中所有的注册信息都是通过URL的形式进行处理的。)这里同样采用相同的方式进行实现。
在 Dubbo 中,很多拓展都是通过 SPI 机制进行加载的,比如 Protocol、Cluster、LoadBalance 等。有时,有些拓展并不想在框架启动阶段被加载,而是希望在拓展方法被调用时,根据运行时参数进行加载。这听起来有些矛盾。拓展未被加载,那么拓展方法就无法被调用(静态方法除外)。拓展方法未被调用,拓展就无法被加载。对于这个矛盾的问题,Dubbo 通过自适应拓展机制很好的解决了。自适应拓展机制的实现逻辑比较复杂,首先 Dubbo 会为拓展接口生成具有代理功能的代码。然后通过 javassist 或 jdk 编译这段代码,得到 Class 类。最后再通过反射创建代理类,整个过程比较复杂。为了让大家对自适应拓展有一个感性的认识,下面我们通过一个示例进行演示。这是一个与汽车相关的例子,我们有一个车轮制造厂接口 WheelMaker:

public interface WheelMaker {
     
    Wheel makeWheel(URL url);
}

WheelMaker 接口的自适应实现类如下:

public class AdaptiveWheelMaker implements WheelMaker {
     
    public Wheel makeWheel(URL url) {
     
        if (url == null) {
     
            throw new IllegalArgumentException("url == null");
        }
        
    	// 1.从 URL 中获取 WheelMaker 名称
        String wheelMakerName = url.getParameter("Wheel.maker");
        if (wheelMakerName == null) {
     
            throw new IllegalArgumentException("wheelMakerName == null");
        }
        
        // 2.通过 SPI 加载具体的 WheelMaker
        WheelMaker wheelMaker = ExtensionLoader
            .getExtensionLoader(WheelMaker.class).getExtension(wheelMakerName);
        
        // 3.调用目标方法
        return wheelMaker.makeWheel(url);
    }
}

AdaptiveWheelMaker 是一个代理类,与传统的代理逻辑不同,AdaptiveWheelMaker 所代理的对象是在 makeWheel 方法中通过 SPI 加载得到的。makeWheel 方法主要做了三件事情:

  • 从 URL 中获取 WheelMaker 名称
  • 通过 SPI 加载具体的 WheelMaker 实现类
  • 调用目标方法

接下来,我们来看看汽车制造厂 CarMaker 接口与其实现类。

public interface CarMaker {
     
    Car makeCar(URL url);
}

public class RaceCarMaker implements CarMaker {
     
    WheelMaker wheelMaker;
 
    // 通过 setter 注入 AdaptiveWheelMaker
    public setWheelMaker(WheelMaker wheelMaker) {
     
        this.wheelMaker = wheelMaker;
    }
 
    public Car makeCar(URL url) {
     
        Wheel wheel = wheelMaker.makeWheel(url);
        return new RaceCar(wheel, ...);
    }
}

RaceCarMaker 持有一个 WheelMaker 类型的成员变量,在程序启动时,我们可以将 AdaptiveWheelMaker 通过 setter 方法注入到 RaceCarMaker 中。在运行时,假设有这样一个 url 参数传入:

dubbo://192.168.0.101:20880/XxxService?wheel.maker=MichelinWheelMaker

RaceCarMaker 的 makeCar 方法将上面的 url 作为参数传给 AdaptiveWheelMaker 的 makeWheel 方法,makeWheel 方法从 url 中提取 wheel.maker 参数,得到 MichelinWheelMaker。之后再通过 SPI 加载配置名为 MichelinWheelMaker 的实现类,得到具体的 WheelMaker 实例。

6、Dubbo调用时拦截操作

与很多框架一样,Dubbo也存在拦截(过滤)机制,可以通过该机制在执行目标程序前后执行我们指定的代码。
Dubbo的Filter机制,是专门为服务提供方和服务消费方调用过程进行拦截设计的,每次远程方法执行,该拦截都会被执行。这样就为开发者提供了非常方便的扩展性,比如为dubbo接口实现ip白名单功能、监控功能 、日志记录等。
步骤如下:
(1)实现 org.apache.dubbo.rpc.Filter 接口
(2)使用 org.apache.dubbo.common.extension.Activate 接口进行对类进行注册 通过group 可以指定生产端 消费端 如:

@Activate(group = {
     CommonConstants.CONSUMER)

(3)计算方法运行时间的代码实现
(4)在 META-INF.dubbo 中新建 org.apache.dubbo.rpc.Filter 文件,并将当前类的全名写入

timerFilter=包名.过滤器的名字

注意:一般类似于这样的功能都是单独开发依赖的,所以再使用方的项目中只需要引入依赖,在调用接口时,该方法便会自动拦截。

二、负载均衡策略

1、负载均衡基本配置

负载均衡(Load Balance), 其实就是将请求分摊到多个操作单元上进行执行,从而共同完成工作任务。
负载均衡策略主要用于客户端存在多个提供者时进行选择某个提供者。
在集群负载均衡时,Dubbo 提供了多种均衡策略(包括随机、轮询、最少活跃调用数、一致性Hash),缺省为random随机调用。
配置负载均衡策略,既可以在服务提供者一方配置,也可以在服务消费者一方配置,如下:

//在服务消费者一方配置负载均衡策略 
@Reference(check = false,loadbalance = "random")
//在服务提供者一方配置负载均衡 
@Service(loadbalance = "random") 
public class HelloServiceImpl implements HelloService {
      
	public String sayHello(String name) {
      
		return "hello " + name; 
	} 
}

2、自定义负载均衡器

负载均衡器在Dubbo中的SPI接口是 org.apache.dubbo.rpc.cluster.LoadBalance , 可以通过实现这个接口来实现自定义的负载均衡规则。
(1)自定义负载均衡器
在上一节的案例基础上创建名称为dubbo-spi-loadbalance的Maven模块,并创建负载均衡器OnlyFirstLoadbalancer。这里功能只是简单的选取所有机器中的第一个(按照字母排序 + 端口排序)。
(2)配置负载均衡器
在dubbo-spi-loadbalance工程的 META-INF/dubbo 目录下新建org.apache.dubbo.rpc.cluster.LoadBalance 文件,并将当前类的全名写入

onlyFirst=包名.负载均衡器

(3)在服务提供者工程实现类中编写用于测试负载均衡效果的方法 启动不同端口时 方法返回的信息不同
(4)启动多个服务 要求他们使用同一个接口注册到同一个注册中心 但是他们的dubbo通信端口不同
(5)在服务消费方指定自定义负载均衡器 onlyFirst
(6)测试自定义负载均衡的效果

3、异步调用

Dubbo不只提供了堵塞式的的同步调用,同时提供了异步调用的方式。这种方式主要应用于提供者接口响应耗时明显,消费者端可以利用调用接口的时间去做一些其他的接口调用,利用 Future 模式来异步等待和获取结果即可。这种方式可以大大的提升消费者端的利用率。 目前这种方式可以通过XML的方式进行引入。
异步调用实现:
(1)为了能够模拟等待,通过 int timeToWait参数,标明需要休眠多少毫秒后才会进行返回。

String sayHello(String name, int timeToWait);

(2)接口实现 为了模拟调用耗时 可以让线程等待一段时间
(3)在消费者端,配置异步调用 注意消费端默认超时时间1000毫秒 如果提供端耗时大于1000毫秒会出现超时
可以通过改变消费端的超时时间 通过timeout属性设置即可单位毫秒

<dubbo:reference id="helloService" interface="com.lagou.service.HelloService"> 
	<dubbo:method name="sayHello" async="true" /> 
</dubbo:reference>

(4)测试,我们休眠100毫秒,然后再去进行获取结果。方法在同步调用时的返回值是空,我们可以通过 RpcContext.getContext().getFuture() 来进行获取Future对象来进行后续的结果等待操作。
异步调用特殊说明:
需要特别说明的是,该方式的使用,请确保dubbo的版本在2.5.4及以后的版本使用。 原因在于在2.5.3及之前的版本使用的时候,会出现异步状态传递问题。
比如我们的服务调用关系是 A -> B -> C , 这时候如果A向B发起了异步请求,在错误的版本时,B向C发起的请求也会连带的产生异步请求。这是因为在底层实现层面,他是通过 RPCContext 中的attachment 实现的。在A向B发起异步请求时,会在 attachment 中增加一个异步标示字段来表明异步等待结果。B在接受到A中的请求时,会通过该字段来判断是否是异步处理。但是由于值传递问题,B向C发起时同样会将该值进行传递,导致C误以为需要异步结果,导致返回空。这个问题在2.5.4及以后的版本进行了修正。

4、线程池

  • Dubbo已有线程池
    dubbo在使用时,都是通过创建真实的业务线程池进行操作的。目前已知的线程池模型有两个和java中的相互对应:

    • fix: 表示创建固定大小的线程池。也是Dubbo默认的使用方式,默认创建的执行线程数为200,并且是没有任何等待队列的。所以再极端的情况下可能会存在问题,比如某个操作大量执行时,可能存在堵塞的情况。后面也会讲相关的处理办法。
    • cache: 创建非固定大小的线程池,当线程不足时,会自动创建新的线程。但是使用这种的时候需要注意,如果突然有高TPS的请求过来,方法没有及时完成,则会造成大量的线程创建,对系统的CPU和负载都是压力,执行越多反而会拖慢整个系统。
  • 自定义线程池
    在真实的使用过程中可能会因为使用fix模式的线程池,导致具体某些业务场景因为线程池中的线程数量不足而产生错误,而很多业务研发是对这些无感知的,只有当出现错误的时候才会去查看告警或者通过客户反馈出现严重的问题才去查看,结果发现是线程池满了。所以可以在创建线程池的时,通过某些手段对这个线程池进行监控,这样就可以进行及时的扩缩容机器或者告警。下面的这个程序就是这样子的,会在创建线程池后进行对其监控,并且及时作出相应处理。
    (1)线程池实现, 这里主要是基于对 FixedThreadPool 中的实现做扩展出线程监控的部分

public class WatchingThreadPool extends FixedThreadPool implements Runnable {
      
	private static final Logger LOGGER = LoggerFactory.getLogger(WatchingThreadPool.class); 
	private static final double ALARM_PERCENT = 0.90; 
	private final Map<URL, ThreadPoolExecutor> THREAD_POOLS = new ConcurrentHashMap<>(); 
	public WatchingThreadPool() {
      
		// 每隔3秒打印线程使用情况 
		Executors.newSingleThreadScheduledExecutor() .scheduleWithFixedDelay(this, 1,3, TimeUnit.SECONDS); 
	}
	@Override 
	public Executor getExecutor(URL url) {
      
		// 从父类中创建线程池 
		final Executor executor = super.getExecutor(url); 
		if (executor instanceof ThreadPoolExecutor) {
      
			THREAD_POOLS.put(url, ((ThreadPoolExecutor) executor)); 
		}
		return executor; 
	}
	@Override 
	public void run() {
      
		// 遍历线程池,如果超出指定的部分,进行操作,比如接入公司的告警系统或者短信平台 
		for (Map.Entry<URL, ThreadPoolExecutor> entry : THREAD_POOLS.entrySet()) {
      
			final URL url = entry.getKey(); 
			final ThreadPoolExecutor executor = entry.getValue(); 
			// 当前执行中的线程数 
			final int activeCount = executor.getActiveCount(); 
			// 总计线程数 
			final int poolSize = executor.getCorePoolSize(); 
			double used = (double)activeCount / poolSize; 
			final int usedNum = (int) (used * 100); 
			LOGGER.info("线程池执行状态:[{}/{}]:{}%", activeCount, poolSize, usedNum);
			if (used >= ALARM_PERCENT) {
      
				LOGGER.error("超出警戒值!host:{}, 当前已使用量:{}%, URL:{}", url.getIp(), usedNum, url); 
			} 
		} 
	} 
}

(2)SPI声明,创建文件 META-INF/dubbo/org.apache.dubbo.common.threadpool.ThreadPool

watching=包名.线程池名

(3)在服务提供方项目引入该依赖
(4)在服务提供方项目中设置使用该线程池生成器

dubbo.provider.threadpool=watching

(5)接下来需要做的就是模拟整个流程,因为该线程当前是每1秒抓一次数据,所以我们需要对该方法的提供者超过1秒的时间(比如这里用休眠 Thread.sleep ),消费者则需要启动多个线程来并行执行,来模拟整个并发情况。
(6)在调用方则尝试简单通过for循环启动多个线程来执行 查看服务提供方的监控情况

5、路由规则

路由是决定一次请求中需要发往目标机器的重要判断,通过对其控制可以决定请求的目标机器。我们可以通过创建这样的规则来决定一个请求会交给哪些服务器去处理。

  • 路由规则快速入门
    (1)提供两个提供者(一台本机作为提供者,一台为其他的服务器),每个提供者会在调用时可以返回不同的信息 以区分提供者。
    (2)针对于消费者,我们这里通过一个死循环,每次等待用户输入,再进行调用,来模拟真实的请求情况。通过调用的返回值确认具体的提供者。
    (3)我们通过ipconfig来查询到我们的IP地址,并且单独启动一个客户端,来进行如下配置(这里假设我们希望隔离掉本机的请求,都发送到另外一台机器上)。
public class DubboRouterMain {
     
	public static void main(String[] args) {
     
		RegistryFactory registryFactory = ExtensionLoader.getExtensionLoader(RegistryFactory.class).getAdaptiveExtension();
		Registry registry = registryFactory.getRegistry(URL.valueOf("zookeeper://127.0.0.1:2181"));
		registry.register(URL.valueOf("condition://0.0.0.0/com.lagou.service.HelloServi ce?category=routers&force=true&dynamic=true&rule=" + URL.encode("=> host != 你的 机器ip不能是127.0.0.1"))); 
	}
}

(4)通过这个程序执行后,我们就通过消费端不停的发起请求,看到真实的请求都发到了除去本机以外的另外一台机器上。

  • 路由规则详解
    通过上面的程序,我们实际本质上就是通过在zookeeper中保存一个节点数据,来记录路由规则。消费者会通过监听这个服务的路径,来感知整个服务的路由规则配置,然后进行适配。这里主要介绍路由配置的参数。
    (1)route:// 表示路由规则的类型,支持条件路由规则和脚本路由规则,可扩展,必填。
    (2)0.0.0.0 表示对所有 IP 地址生效,如果只想对某个 IP 的生效,请填入具体 IP,必填。
    (3)com.lagou.service.HelloService 表示只对指定服务生效,必填。
    (4)category=routers 表示该数据为动态配置类型,必填。
    (5)dynamic : 是否为持久数据,当指定服务重启时是否继续生效。必填。
    (6)runtime : 是否在设置规则时自动缓存规则,如果设置为true则会影响部分性能。
    (7)rule : 是整个路由最关键的配置,用于配置路由规则。
    … => … 在这里 => 前面的就是表示消费者方的匹配规则,可以不填(代表全部)。 => 后方则必须填写,表示当请求过来时,如果选择提供者的配置。官方这块儿也给出了详细的示例,可以按照那里来讲。
    其中使用最多的便是 host 参数。 必填。

  • 路由与上线系统结合
    当公司到了一定的规模之后,一般都会有自己的上线系统,专门用于服务上线。方便后期进行维护和记录的追查。我们去想象这样的一个场景,一个dubbo的提供者要准备进行上线,一般都提供多台提供者来同时在线上提供服务。这时候一个请求刚到达一个提供者,提供者却进行了关闭操作。那么此次请求就应该认定为失败了。所以基于这样的场景,我们可以通过路由的规则,把预发布(灰度)的机器进行从机器列表中移除。并且等待一定的时间,让其把现有的请求处理完成之后再进行关闭服务。同时,在启动时,同样需要等待一定的时间,以免因为尚未重启结束,就已经注册上去。等启动到达一定时间之后,再进行开启流量操作。
    实现主体思路

1.利用zookeeper的路径感知能力,在服务准备进行重启之前将当前机器的IP地址和应用名写入 zookeeper。
2.服务消费者监听该目录,读取其中需要进行关闭的应用名和机器IP列表并且保存到内存中。
3.当前请求过来时,判断是否是请求该应用,如果是请求重启应用,则将该提供者从服务列表中移除。

(1)引入 Curator 框架,用于方便操作Zookeeper
(2)编写Zookeeper的操作类,用于方便进行zookeeper处理
(3)编写需要进行预发布的路径管理器,用于缓存和监听所有的待灰度机器信息列表。
(4)编写路由类(实现 org.apache.dubbo.rpc.cluster.Router ),主要目的在于对ReadyRestartInstances 中的数据进行处理,并且移除路由调用列表中正在重启中的服务。
(5)由于 Router 机制比较特殊,所以需要利用一个专门的 RouterFactory 来生成,原因在于并不是所有的都需要添加路由,所以需要利用 @Activate 来锁定具体哪些服务才需要生成使用。

@Activate 
public class RestartingInstanceRouterFactory implements RouterFactory {
      
	@Override 
	public Router getRouter(URL url) {
     
		return new RestartingInstanceRouter(url); 
	} 
}

(6)对 RouterFactory 进行注册,同样放入到META-INF/dubbo/org.apache.dubbo.rpc.cluster.RouterFactory 文件中。

restartInstances=com.lagou.router.RestartingInstanceRouterFactory

(7)将dubbo-spi-router项目引入至 consumer 项目的依赖中。
(8)这时直接启动程序,还是利用上面中所写好的 consumer 程序进行执行,确认各个 provider 可以正常执行。
(9)单独写一个 main 函数来进行将某台实例设置为启动中的状态,比如这里我们认定为当前这台机器中的 service-provider 这个提供者需要进行重启操作。

ReadyRestartInstances.create().addRestartingInstance("service-provider", "正在 重新启动的机器IP");

(10)执行完成后,再次进行尝试通过 consumer 进行调用,即可看到当前这台机器没有再发送任何请求
(11)一般情况下,当机器重启到一定时间后,我们可以再通过 removeRestartingInstance 方法对这个机器设定为既可以继续执行。
(12)调用完成后,我们再次通过 consumer 去调用,即可看到已经再次恢当前机器的请求参数。

6、服务动态降级

  • 什么是服务降级
    服务降级,当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务有策略的降低服务级别,以释放服务器资源,保证核心任务的正常运行。
  • 为什么要服务降级
    而为什么要使用服务降级,这是防止分布式服务发生雪崩效应,什么是雪崩?就是蝴蝶效应,当一个请求发生超时,一直等待着服务响应,那么在高并发情况下,很多请求都是因为这样一直等着响应,直到服务资源耗尽产生宕机,而宕机之后会导致分布式其他服务调用该宕机的服务也会出现资源耗尽宕机,这样下去将导致整个分布式服务都瘫痪,这就是雪崩。
  • dubbo 服务降级实现方式
    第一种:在 dubbo 管理控制台配置服务降级
    屏蔽和容错
    • mock=force:return+null
      表示消费方对该服务的方法调用都直接返回 null 值,不发起远程调用。用来屏蔽不重要服务不可用时对调用方的影响。
    • mock=fail:return+null
      表示消费方对该服务的方法调用在失败后,再返回 null 值,不抛异常。用来容忍不重要服务不稳定时对调用方的影响。

Dubbo高级实战(SPI、负载均衡)_第3张图片

第二种:指定返回简单值或者null

<dubbo:reference id="xxService" check="false" interface="com.xx.XxService" timeout="3000" mock="return null" />
<dubbo:reference id="xxService2" check="false" interface="com.xx.XxService2" timeout="3000" mock="return 1234" />

如果是标注 则使用@Reference(mock=“return null”) @Reference(mock=“return 简单值”)也支持 @Reference(mock=“force:return null”)
第三种:使用java代码 动态写入配置中心

RegistryFactory registryFactory = ExtensionLoader.getExtensionLoader(RegistryFactory.class).getAdaptiveExtension();
Registry registry = registryFactory.getRegistry(URL.valueOf("zookeeper://IP:端 口"));
registry.register(URL.valueOf("override://0.0.0.0/com.foo.BarService? category=configurators&dynamic=false&application=foo&mock=force:return+null"));

第四种:整合整合 hystrix

至此,结束


最后

在这个知识付费的时代,每一位热爱技术分享、奋笔直书的人,都值得我们尊敬!所以,请不要吝啬您手中的鼠标,按下左键,为小编点个赞吧。
更多内容,请关注微信公众号:架构视角

特别鸣谢

感谢程道老师风趣幽默的讲解,让我对所学知识点记忆深刻!
感谢木槿导师的认真和负责,每一次作业点评都是我前进的动力!
感谢班主任毕老师的负责和耐心,每次不厌其烦的上课通知都是我不忘初心,保持良好学习状态的精神支柱!
感谢拉勾教育平台,给我这次花少量的钱就能报名第一期拉钩训练营,就能学习到很多深层次的技术精华的机会。而且,在学习过程中还认识了很多技术大佬,可以请教他们一些问题,比如张大佬、卢大佬、雨生大佬等等。。

你可能感兴趣的:(分布式,Dubbo,java,分布式)