Young's blog Young's blog
首页
Spring
  • 前端文章1

    • JavaScript
  • 学习笔记

    • 《JavaScript教程》
    • 《JavaScript高级程序设计》
    • 《ES6 教程》
    • 《Vue》
    • 《React》
    • 《TypeScript 从零实现 axios》
    • 《Git》
    • TypeScript
    • JS设计模式总结
  • HTML
  • CSS
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

Young

首页
Spring
  • 前端文章1

    • JavaScript
  • 学习笔记

    • 《JavaScript教程》
    • 《JavaScript高级程序设计》
    • 《ES6 教程》
    • 《Vue》
    • 《React》
    • 《TypeScript 从零实现 axios》
    • 《Git》
    • TypeScript
    • JS设计模式总结
  • HTML
  • CSS
  • 技术文档
  • GitHub技巧
  • Nodejs
  • 博客搭建
  • 学习
  • 面试
  • 心情杂货
  • 实用技巧
  • 友情链接
关于
收藏
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • Dubbo入门
  • Dubbo SPI 详解
  • Dubbo SPI的自适应扩展原理
    • 生成方法体
  • Dubbo的6 种扩展机制
  • Dubbo面试题
  • Dubbo
andanyang
2023-04-11
目录

Dubbo SPI的自适应扩展原理

很多人在学习 SPI 的时候将@SPI和@Adaptive注解混在一起学习,最后学得晕晕乎乎看完之后似懂非懂,如果你也有这种困扰,请继续阅读。

并不是说不该将这两个内容一起学习,而是要有个先后顺序再加上自己的推理。是先有 SPI 机制,然后才有的自适应扩展,自适应扩展是基于 SPI 机制的高级特性。

# 为什么需要自适应扩展点

在 Dubbo 中,很多拓展都是通过 SPI 机制进行加载的,比如 Protocol、Cluster、LoadBalance 等。有时,有些拓展并不想在框架启动阶段被加载,而是希望在拓展方法被调用时,根据运行时参数进行加载。这听起来有些矛盾。拓展未被加载,那么拓展方法就无法被调用(静态方法除外)。拓展方法未被调用,拓展就无法被加载。对于这个矛盾的问题,Dubbo 通过自适应拓展机制很好的解决了。

对于这个问题,以之前 demo 为例进行我们进行推演:

public interface MySPI {
    void say();
}
public class HelloMySPI implements MySPI{
    @Override
    public void say() {
        System.out.println("HelloMySPI say:hello");
    }
}
public class GoodbyeMySPI implements MySPI {
    @Override
    public void say() {
        System.out.println("GoodbyeMySPI say:Goodbye");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

现在要增加一个接口 Person,他可以和人打招呼。他有一个实现类是Man,他可以动态的跟人说 hello 或者 goodbye

public interface Person {

    void greeting();
}

public class Man implements Person{
    private MySPI adaptive;

    public void setAdaptive(MySPI adaptive) {
        this.adaptive = adaptive;
    }

    @Override
    public void greeting(URL url) {
        adaptive.say(url);
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

但是adaptive成员要么是HelloMySPI的实例化对象,要么是GoodbyeMySPI的实例化对象,怎么实现动态的去根据需要获取呢?解决这个问题就可以增加一个代理,作为自适应类。所以增加自适应扩展实现如下:

public class AdaptiveMySPI implements MySPI {

    @Override
    public void say() {
        // 1.通过某种方式获得拓展实现的名称
        String mySpiName;

        // 2.通过 SPI 加载具体的 mySpi
        MySPI myspi = ExtensionLoader.getExtensionLoader(MySPI.class).getExtension(mySpiName);

        // 3.调用目标方法
        myspi.say();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

将代理类AdaptiveMySPI作为Man的成员对象,这样就可以实现按需调用了。按需加载如何实现呢?之前我们在getExtension()方法中提到过,只要在根据名字查找的时候,才会按照需要懒加载,所以这个问题天然被 Dubbo SPI 解决了。那么剩下的关键就是如何按需调用,也就是如何获得名字。

  1. 可以在当前线程的上下文中获得,比如通过 ThreadLocal 保存。
  2. 可以通过接口参数传递,但是这样就需要实现自适应扩展的接口按照约定去定义参数,否则就无法拿到名字,这样对于被代理的接口是有一定限制的。

Dubbo 用的是第二种方式,也就是他总有办法从参数中动态拿到扩展类名。

# 2.模拟原理 demo

再具体一些 Dubbo 是怎么实现的呢?

自适应拓展机制的实现逻辑比较复杂,首先 Dubbo 会为拓展接口生成具有代理功能的代码。然后通过 javassist 或 jdk 编译这段代码,得到 Class 类。最后再通过反射创建代理类,整个过程比较复杂。为了让大家对自适应拓展有一个感性的认识,按照之前的知识,下面我们继续对之前 demo 为例进行改造:

@SPI
public interface MySPI {
    void say(URL url);
}
public class HelloMySPI implements MySPI{
    @Override
    public void say(URL url) {
        System.out.println("HelloMySPI say:hello");
    }
}
public class GoodbyeMySPI implements MySPI {
    @Override
    public void say(URL url) {
        System.out.println("GoodbyeMySPI say:Goodbye");
    }
}
public class AdaptiveMySPI implements MySPI {

    @Override
    public void say(URL url) {
        if (url == null) {
            throw new IllegalArgumentException("url == null");
        }

        // 1.从 URL 中获取 mySpi 名称
        String mySpiName = url.getParameter("myspi.type");
        if (mySpiName == null) {
            throw new IllegalArgumentException("MySPI == null");
        }

        // 2.通过 SPI 加载具体的 mySpi
        MySPI myspi = ExtensionLoader.getExtensionLoader(MySPI.class).getExtension(mySpiName);

        // 3.调用目标方法
        myspi.say(url);
    }
}
@SPI("man")
public interface Person {

    void greeting(URL url);
}
public class Man implements Person {
    private MySPI adaptive = = ExtensionLoader.getExtensionLoader(MySPI.class).getExtension(“adaptive”);

    public void setAdaptive(MySPI adaptive) {
        this.adaptive = adaptive;
    }

    @Override
    public void greeting(URL url) {
        adaptive.say(url);
    }
}
public static void main(String[] args) {
        ExtensionLoader<Person> extensionLoader = ExtensionLoader.getExtensionLoader(Person.class);
        Person hello = extensionLoader.getExtension("man");
        hello.greeting(URL.valueOf("dubbo://192.168.0.101:100?myspi.type=hello"));
        hello.greeting(URL.valueOf("dubbo://192.168.0.101:100?myspi.type=goodbye"));
    }
//输出
HelloMySPI say:hello
GoodbyeMySPI say:Goodbye
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

大家与之前的代码对比就可以发现区别,MySPI 的方法增加了 URL 参数,因为 dubbo 中 url 就是作为一个配置总线贯穿整个调用链路的。这样便可以拿到扩展名,动态调用和加载了。

比如 demo 中的一个 URL

dubbo://192.168.0.101:100?myspi.type=hello
1

AdaptiveMySPI 动态的人从 url 中拿到了myspi.type=hello,然后根据 name 拿到了扩展实现,以此完成动态调用。

上面的示例展示了自适应拓展类的核心实现 —- 在拓展接口的方法被调用时,通过 SPI 加载具体的拓展实现类,并调用拓展对象的同名方法。所以接下来的关键就在于,自适应拓展类是如何生成的,Dubbo 是怎么做的。

# 3.@Adaptive 注解

关于 Dubbo 的自适应扩展,一定避不开这个关键注解@Adaptive。

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Adaptive {
    String[] value() default {};
}
1
2
3
4
5
6

从上面的代码中可知,Adaptive 可注解在类或方法上。

  • 当 Adaptive 注解在类上时,Dubbo 不会为该类生成代理类。Adaptive 注解在类上的情况很少,在 Dubbo 中,仅有两个类被 Adaptive 注解了,分别是 AdaptiveCompiler 和 AdaptiveExtensionFactory。此种情况,表示拓展的加载逻辑由人工编码完成。
  • 注解在方法(接口方法)上时,Dubbo 则会为该方法生成代理逻辑。更多时候,Adaptive 是注解在接口方法上的,表示拓展的加载逻辑需由框架自动生成。

Adaptive 注解的地方不同,相应的处理逻辑也是不同的。注解在类上时,处理逻辑比较简单,本文就不分析了。注解在接口方法上时,处理逻辑较为复杂,本章将会重点分析此块逻辑。

# 4.源码解读

在 Dubbo 中如何获得一个自适应扩展类呢?只需要一行代码。

MySPI adaptive = ExtensionLoader.getExtensionLoader(MySPI.class).getAdaptiveExtension();
@SPI
public interface MySPI {

    @Adaptive(value = {"myspi.type"})
    void say(URL url);
}
1
2
3
4
5
6
7

getExtensionLoader()之前我们依旧分析过了,这里直接从getAdaptiveExtension()开始。

getAdaptiveExtension() 方法是获取自适应拓展的入口方法,首先会检查缓存cachedAdaptiveInstance,缓存未命中,则会执行双重检查,调用 createAdaptiveExtension() 方法创建自适应拓展。

public T getAdaptiveExtension() {
    // 从缓存中查找自适应扩展类实例,命中直接返回
    Object instance = cachedAdaptiveInstance.get();
   //缓存没有命中
    if (instance == null) {
            …… 异常处理
        //双重检查
        synchronized (cachedAdaptiveInstance) {
            instance = cachedAdaptiveInstance.get();
            if (instance == null) {
                try {
                    // 创建自适应实例
                    instance = createAdaptiveExtension();
                    cachedAdaptiveInstance.set(instance);
                } catch (Throwable t) {
                   …… 异常处理
    return (T) instance;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

createAdaptiveExtension()会首先查找只适应扩展类,然后通过反射进行实例化,再调用injectExtension对扩展实例中注入依赖。

你可能会问直接返回依赖不就行了?为什么还需要注入?

这是因为任何利用 Dubbo SPI 机制加载的用户创建类都是有可能有成员依赖于其他拓展类的,用户实现的自适应扩展类也不例外。而另一种 Dubbo 自己生成的自适应扩展类则不可能出现依赖其他类的情况。

这里只关注重点方法getAdaptiveExtensionClass()。

  1. 首先getExtensionClasses()会获取该接口所有的拓展类,
  2. 然后会检查缓存是否为空,cachedAdaptiveClass缓存着自适应扩展类的类型。
  3. 如果缓存中不存在则调用createAdaptiveExtensionClass开始创建自适应扩展类。
// 用于缓存自适应扩展类的类型
private volatile Class<?> cachedAdaptiveClass = null;
private Class<?> getAdaptiveExtensionClass() {
    // 加载所有的拓展类配置
    getExtensionClasses();
    if (cachedAdaptiveClass != null) {
        return cachedAdaptiveClass;
    }
    // 创建自适应拓展类
    return cachedAdaptiveClass = createAdaptiveExtensionClass();
}
1
2
3
4
5
6
7
8
9
10
11

getExtensionClasses之前讲过,你肯定会奇怪为什么还需要加载所有扩展类。在这里有个关键逻辑,在调用 loadResource 方法时候会解析@Adaptive 注解,如果被标注了,就表示这个类是一个自适应扩展类实现,会被设置到缓存cacheAdaptiveClass中。

所以有两个原因:

1、是自定义自适应扩展类需要 SPI 机制加载

2、是设置缓存

# 5. 生成自适应扩展类

非自己实现的自适应扩展类,都要走createAdaptiveExtensionClass逻辑。

主要逻辑如下:

  1. 动态生成自适应扩展类代码
  2. 获取类加载器和编译器类(Dubbo 默认使用 javassist 作为编译器)
  3. 编译、加载动态生成的类
private Class<?> createAdaptiveExtensionClass() {
    // 动态生成自适应扩展类代码
    String code = new AdaptiveClassCodeGenerator(type, cachedDefaultName).generate();
    // 获取类加载器
    ClassLoader classLoader = findClassLoader();
    // 获取编译器类 ⚠️ AdaptiveCompiler 也是自己定义的
    org.apache.dubbo.common.compiler.Compiler compiler =  ExtensionLoader.getExtensionLoader(org.apache.dubbo.common.compiler.Compiler.class).getAdaptiveExtension();
    //编译、加载、生产Class
    return compiler.compile(code, classLoader);
}
1
2
3
4
5
6
7
8
9
10

生成自适应扩展类的代码都在AdaptiveClassCodeGenerator中,generate()方法会生成和返回一个自适应扩展类。之前的版本代码其实比较复杂,逻辑都写在了一起,并没有 generate 方法,进入 Apache 孵化之后对代码结构进行了调整,结构清晰了许多。

主要逻辑如下:

  1. 检查接口是否有方法被@Adaptive 修饰。
  2. 生产 class 头部的 package 信息。
  3. 生成依赖类的 import 信息。
  4. 生成方法声明信息。
  5. 遍历接口方法依次生成实现方法。
  6. 类结束用}收尾,类信息转换为字符串返回。
public String generate() {
    // no need to generate adaptive class since there's no adaptive method found.
    // 检查接口是否有注解了Adaptive的方法,至少需要有一个
    if (!hasAdaptiveMethod()) {
        throw new IllegalStateException("No adaptive method exist on extension " + type.getName() + ", refuse to create the adaptive class!");
    }

    StringBuilder code = new StringBuilder();
    // 生成package信息
    code.append(generatePackageInfo());
    // 生成依赖类的import信息
    code.append(generateImports());
    // 生成类的声明信息 public class 接口名$Adaptive implements 接口名
    code.append(generateClassDeclaration());

    // 遍历接口方法 按需生产实现类
    Method[] methods = type.getMethods();
    for (Method method : methods) {
        code.append(generateMethod(method));
    }
  // 类结尾
    code.append("}");

    if (logger.isDebugEnabled()) {
        logger.debug(code.toString());
    }
  // 转换为字符串返回
    return code.toString();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

下面依次介绍上述步骤的主要逻辑。

# 检查@Adaptive 注解

遍历接口方法依次检查是否被@Adaptive 标注,至少需要有一个方法被注解,否则抛出异常。

private boolean hasAdaptiveMethod() {
    return Arrays.stream(type.getMethods()).anyMatch(m -> m.isAnnotationPresent(Adaptive.class));
}
1
2
3

# 生成 package 和 import 信息

按照接口的路径名生成对应的 package 信息,并且生成导入信息,目前只是 import 了ExtensionLoader这个类。

private static final String CODE_PACKAGE = "package %s;\n";
private String generatePackageInfo() {
    return String.format(CODE_PACKAGE, type.getPackage().getName());
}
private static final String CODE_IMPORTS = "import %s;\n";
private String generateImports() {
    return String.format(CODE_IMPORTS, ExtensionLoader.class.getName());
}
// import org.apache.dubbo.common.extension.ExtensionLoader;
1
2
3
4
5
6
7
8
9

# 生成类的声明信息

生成的类名 = 拓展接口名+$Adaptive,实现的接口就是拓展接口的全限定名。比如 public class MySPI$Adaptive implements org.daley.spi.demo.MySPI

private String generateClassDeclaration() {
    return String.format(CODE_CLASS_DECLARATION, type.getSimpleName(), type.getCanonicalName());
}
private static final String CODE_CLASS_DECLARATION = "public class %s$Adaptive implements %s {\n";
1
2
3
4

# 生成方法体

generateMethod方法是自适应拓展类生成代理类的核心逻辑所在。它主要会分别拿到方法返回类型、方法名、生成方法体、生成方法参数、生成方法异常,然后按照方法的模板的占位符生成代理方法。很明显,重中之重是生成方法体内容。

private String generateMethod(Method method) {
    // 分别拿到方法返回类型、方法名、方法体、方法参数、方法异常
    String methodReturnType = method.getReturnType().getCanonicalName();
    String methodName = method.getName();
    // 生成方法体
    String methodContent = generateMethodContent(method);
    String methodArgs = generateMethodArguments(method);
    String methodThrows = generateMethodThrows(method);
    // 按照方法模板替换占位符,生成方法内容
    return String.format(CODE_METHOD_DECLARATION, methodReturnType, methodName, methodArgs, methodThrows, methodContent);
}
1
2
3
4
5
6
7
8
9
10
11

generateMethodContent主要做了如下几件事:

  1. 检查是否被@Adaptive 注解,如果没有被注解则生产一段抛出异常的代码。如果被注解,则继续后面逻辑。
  2. 找到 URL 类型参数的 index,并且生成检查 URL 参数是否为空的逻辑。
  3. 如果没有 URL 参数,则检查是否方法参数有 public 类型无参 get 方法可以直接拿到 URL。
  4. 拿到@Adaptive 注解配置的 value,如果没有配置就用接口名默认。
  5. 检查是否有 Invocation 类型参数。
  6. 根据不同的情况拿到拓展名。
  7. 根据扩展名从getExtension中拿到真正的扩展类。
  8. 执行扩展类目标方法,按需返回结果。

这些步骤逻辑都不算复杂,需要格外注意的是第 6 点,这里详细再说明下。

private String generateMethodContent(Method method) {
    Adaptive adaptiveAnnotation = method.getAnnotation(Adaptive.class);
    StringBuilder code = new StringBuilder(512);
    // 检查是否被@Adaptive注解
    if (adaptiveAnnotation == null) {
        // 不是自适应方法生成的代码是一段抛出异常的代码。
        return generateUnsupported(method);
    } else {
        // 找到 URL.class 类型参数位置
        int urlTypeIndex = getUrlTypeIndex(method);

        // found parameter in URL type
        if (urlTypeIndex != -1) {
            // Null Point check
            // 找到 URL 生成代码逻辑
            code.append(generateUrlNullCheck(urlTypeIndex));
        } else {
            // did not find parameter in URL type
            // 未找到 URL 生成代码逻辑
            // 再找找是否有方法参数有get方法可以返回URL.class的,并且还是不需要入参的public方法
            code.append(generateUrlAssignmentIndirectly(method));
        }
        // 拿到 Adaptive注解的value
        String[] value = getMethodAdaptiveValue(adaptiveAnnotation);

        // 有 Invocation 类型参数
        boolean hasInvocation = hasInvocationArgument(method);

        // 检查参数不为null
        code.append(generateInvocationArgumentNullCheck(method));

        code.append(generateExtNameAssignment(value, hasInvocation));
        // check extName == null?
        code.append(generateExtNameNullCheck(value));
        // getExtension 根据name拿到扩展类
        code.append(generateExtensionAssignment());

        // return statement 生成方法调用语句并在必要时返回
        code.append(generateReturnAndInvocation(method));
    }

    return code.toString();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

在generateExtNameAssignment中会有如下几种不同的情况:是否最后一个参数,是否有 Invocation 类型参数、是否配置了名字为protocol的注解 value。

将上面的三种条件组合,生成对应不同的代码,核心其实都是如何正确的从 URL 参数中拿到动态扩展名,具体已做注释。

private String generateExtNameAssignment(String[] value, boolean hasInvocation) {
    // TODO: refactor it
    String getNameCode = null;
    // 从最后一个开始遍历
    for (int i = value.length - 1; i >= 0; --i) {
        if (i == value.length - 1) {
            // 如果是最后一个参数,设置了默认拓展名
            if (null != defaultExtName) {
                // 配置的value不等于"protocol"
                if (!"protocol".equals(value[i])) {
                    if (hasInvocation) {
                        // 有invocation 根据配置名字从url获取getMethodParameter
                        getNameCode = String.format("url.getMethodParameter(methodName, \"%s\", \"%s\")", value[i], defaultExtName);
                    } else {
                        // 没有invocation,getParameter获取参数
                        getNameCode = String.format("url.getParameter(\"%s\", \"%s\")", value[i], defaultExtName);
                    }
                } else {
                    // 直接取协议名
                    getNameCode = String.format("( url.getProtocol() == null ? \"%s\" : url.getProtocol() )", defaultExtName);
                }
            } else {
                //没有设置默认拓展名,和上面的区别就是 没有默认值处理的逻辑。上面获取不到可以直接用默认值。
                if (!"protocol".equals(value[i])) {
                    if (hasInvocation) {
                        getNameCode = String.format("url.getMethodParameter(methodName, \"%s\", \"%s\")", value[i], defaultExtName);
                    } else {
                        getNameCode = String.format("url.getParameter(\"%s\")", value[i]);
                    }
                } else {
                    getNameCode = "url.getProtocol()";
                }
            }
        } else {
            // 如果不是最后一个参数
            if (!"protocol".equals(value[i])) {
                if (hasInvocation) {
                    getNameCode = String.format("url.getMethodParameter(methodName, \"%s\", \"%s\")", value[i], defaultExtName);
                } else {
                    getNameCode = String.format("url.getParameter(\"%s\", %s)", value[i], getNameCode);
                }
            } else {
                getNameCode = String.format("url.getProtocol() == null ? (%s) : url.getProtocol()", getNameCode);
            }
        }
    }

    return String.format(CODE_EXT_NAME_ASSIGNMENT, getNameCode);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

对于上述条件,可以生成如下几种情况的代码。

String extName = (url.getProtocol() == null ? "dubbo" : url.getProtocol());
String extName = url.getMethodParameter(methodName, "loadbalance", "random");
String extName = url.getParameter("client", url.getParameter("transporter", "netty"));
1
2
3

还有一个疑问点是如果有多个参数怎么办?按照代码逻辑,最终的表现效果就是如果有多个参数,且非 Invocation,会生成多层嵌套代码,并且以最外层也就是最左边的参数为准,右边的参数作为默认值。

举个例子:

@Adaptive(value = {"protocol","param2","myspi.type"})
void say(URL url);
1
2

生成代码如下:

url.getProtocol() == null ? (url.getParameter("param2", url.getParameter("myspi.type"))) : url.getProtocol()
1

当上述一切代码执行完成后,就生成了最终的代理类,并且经过编译和加载最终完成实例化,可以被程序所调用,实现动态按需调用。

最终生成的代理类如下:

package org.daley.spi.demo;
import org.apache.dubbo.common.extension.ExtensionLoader;

public class MySPI$Adaptive implements org.daley.spi.demo.MySPI {
 public void say(org.apache.dubbo.common.URL arg0)  {
 if (arg0 == null) throw new IllegalArgumentException("url == null");
 org.apache.dubbo.common.URL url = arg0;
 String extName = url.getProtocol() == null ?(url.getParameter("param2",url.getParameter("myspi.type"))) : url.getProtocol();
 if(extName == null) throw new IllegalStateException("Failed to get extension  (org.daley.spi.demo.MySPI) name from url (" + url.toString() + ") use keys([protocol, param2, myspi.type])");
 org.daley.spi.demo.MySPI extension = (org.daley.spi.demo.MySPI)ExtensionLoader.getExtensionLoader(org.daley.spi.demo.MySPI.class).getExtension(extName);
 extension.say(arg0);
 }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

到这里,Dubbo 自适应扩展的原理就讲解结束了。

编辑 (opens new window)
上次更新: 2024/04/19, 08:52:45
Dubbo SPI 详解
Dubbo的6 种扩展机制

← Dubbo SPI 详解 Dubbo的6 种扩展机制→

最近更新
01
idea 热部署插件 JRebel 安装及破解,不生效问题解决
04-10
02
spark中代码的执行位置(Driver or Executer)
12-12
03
大数据技术之 SparkStreaming
12-12
更多文章>
Theme by Vdoing | Copyright © 2019-2024 Young | MIT License
浙ICP备20002744号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式