前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Dubbo源码篇05---SPI神秘的面纱---使用篇

Dubbo源码篇05---SPI神秘的面纱---使用篇

作者头像
大忽悠爱学习
发布2023-10-11 08:32:28
2290
发布2023-10-11 08:32:28
举报
文章被收录于专栏:c++与qt学习

引言

SPI全称是Service Provider Interface,其中服务提供者定义一个服务接口,并允许第三方实现进行插入。这种机制常用于预留一些关键口子或扩展点,以让调用方按照规范进行自由实现。

在 Java 开发中,JDK 提供了 SPI 机制,Dubbo中也大量使用了SPI机制,但是并没有直接使用JDK提供的SPI实现,这是为什么呢? Dubbo又是如何实现SPI机制的呢?下面一起来看看吧 !

Jdk提供的SPI机制

核心思想:

  • SPI的全名为Service Provider Interface,面向对象的设计里面,模块之间推荐基于接口编程,而不是对实现类进行硬编码,这样做也是为了模块设计的可拔插原则。
  • 为了在模块装配的时候不在程序里指明是哪个实现,就需要一种服务发现的机制,jdk的spi就是为某个接口寻找服务实现。
  • jdk提供了服务实现查找的工具类:java.util.ServiceLoader,它会去加载META-INF/service/目录下的配置文件。

基本流程

Jdk提供的SPI机制实现流程如何下所示:

  1. 指明要加载哪个服务接口的第三方实现类
代码语言:javascript
复制
        ServiceLoader<Robot> serviceLoader = ServiceLoader.load(Robot.class);
  1. 使用线程上下文类加载器来加载第三方实现类,目的是为了打破双亲委派机制的局限性
代码语言:javascript
复制
    public static <S> ServiceLoader<S> load(Class<S> service) {
        //线程上下文累类加载器默认为应用程序上下文类加载器,也被称为系统类加载器
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return ServiceLoader.load(service, cl);
    }

JDK暴露的很多顶层服务接口使用的是启动类加载器进行加载,但是启动类加载器显然无法加载第三方提供的服务接口实现,因此不得不借助线程上下文类加载器来打破双亲委派加载机制。

  1. 每个ServiceLoader实例负责管理当前服务接口的实现类集合
代码语言:javascript
复制
    public static <S> ServiceLoader<S> load(Class<S> service,
                                            ClassLoader loader)
    {
        return new ServiceLoader<>(service, loader);
    }
代码语言:javascript
复制
    private ServiceLoader(Class<S> svc, ClassLoader cl) {
        service = Objects.requireNonNull(svc, "Service interface cannot be null");
        //如果我们将当前线程上下文类加载器设置为null,那么默认还是会取应用程序上下文类加载器
        loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl;
        acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
        reload();
    }
代码语言:javascript
复制
    //缓存被实例化好的第三方服务接口实现类,在集合中按照实例化的顺序存储
    private LinkedHashMap<String,S> providers = new LinkedHashMap<>();
    //懒加载迭代器
    private LazyIterator lookupIterator;
   
    public void reload() {
        //清空provider集合
        providers.clear();
        //初始化LazyIterator
        lookupIterator = new LazyIterator(service, loader);
    }

LazyIterator表明会将加载第三方提供的资源文件的操作推迟到第一次获取时进行。

代码语言:javascript
复制
    private class LazyIterator
        implements Iterator<S>
    {
        //暴露的服务接口
        Class<S> service;
        //负责加载第三方服务接口实现类的加载器
        ClassLoader loader;
        //所有第三方服务接口实现类的资源文件位置
        Enumeration<URL> configs = null;
        //存放所有待处理的第三方服务提供者的实现类全类名的集合
        Iterator<String> pending = null;
        //下一个待处理的第三方服务提供者的实现类全类名
        String nextName = null;

        private LazyIterator(Class<S> service, ClassLoader loader) {
            this.service = service;
            this.loader = loader;
        }
       ...
  1. 利用LazyIterator迭代器提供的方法遍历第三方提供的服务接口实现类
代码语言:javascript
复制
private boolean hasNextService() {
            if (nextName != null) {
                return true;
            }
            if (configs == null) {
                try {
                    //"META-INF/services/"+暴露的服务接口全类名 
                    String fullName = PREFIX + service.getName();
                    //利用类加载器从类路径下搜索指定SPI资源文件
                    if (loader == null)
                        configs = ClassLoader.getSystemResources(fullName);
                    else
                        configs = loader.getResources(fullName);
                } catch (IOException x) {
                    fail(service, "Error locating configuration files", x);
                }
            }
            //在Java SPI中,配置文件的格式要求每行只包含一个服务实现类的完整类名,并且不包含任何注释或空格。
            //等到当前config对应的SPI文件中涉及到的所有服务实现类全部被遍历完后,开始处理下一个SPI文件
            while ((pending == null) || !pending.hasNext()) {
                if (!configs.hasMoreElements()) {
                    return false;
                }
                //按顺序解析每个待处理的第三方服务提供方实现类集合,解析后返回的是第三方提供的实现类全类名(每调用hasNextService方法,向后解析一次)
                //parse方法解析当前SPI文件,获取文件中提供的所有实现类全类名,
                //依次判断当前第三方实现类全类名是否已经存在于providers集合中,如果存在则跳过
                //如果不存在,则加入providers集合中
                pending = parse(service, configs.nextElement());
            }
            //nextName拿到下一个需要处理的第三方实现类全类名
            nextName = pending.next();
            return true;
        }

        private S nextService() {
            if (!hasNextService())
                throw new NoSuchElementException();
            //拿到当前待处理的第三方实现类全类名
            String cn = nextName;
            nextName = null;
            Class<?> c = null;
            try {
                //尝试实例化第三方实现类,可能存在找不到实现类的情况
                c = Class.forName(cn, false, loader);
            } catch (ClassNotFoundException x) {
                fail(service,
                     "Provider " + cn + " not found");
            }
            //实现类必须是暴露的服务接口的实现类
            if (!service.isAssignableFrom(c)) {
                fail(service,
                     "Provider " + cn  + " not a subtype");
            }
            try {
                //通过无参构造直接实例化一个第三方实现类出来,然后加入服务提供者集合中
                S p = service.cast(c.newInstance());
                providers.put(cn, p);
                return p;
            } catch (Throwable x) {
                fail(service,
                     "Provider " + cn + " could not be instantiated",
                     x);
            }
            throw new Error();          // This cannot happen
        }

JDK SPI流程简单概括:

  • ServiceLoader类是JDK SPI机制实现的核心,其中内部类LazyIterator作为对外暴露的资源懒加载迭代器,提供了hasNextService和nextService方法用于遍历第三方提供的服务实现类
  • hashNextService初次调用时,会根据接口的名称获取所有jar、classes中 META-INF/services 下的配置文件
  • hashNextService每次调用时,当上一个SPI文件中涉及到的实现类都遍历完后,会按顺序解析下一个SPI文件,获取SPI文件中提供的所有实现类全类名,然后依次赋值给nextName进行处理
  • nextService每次调用时,都会获取nextName,然后实例化一个实例返回,同时会加入providers集合,避免对同一个全类名的两次实例化,确保其单例性

providers集合只在hasNextService方法的parse中被使用到,用于避免重复实例化全类名相同的两个第三方实现类


缺陷

JDK提供的SPI机制缺陷如下:

  • 只支持迭代器的单向遍历,一旦遍历结束,再次遍历需要重新调用load
  • 不提供缓存已经创建出来的对象的操作,每次load调用都会重新创建和加载相关对象和资源

如何解决上面两个问题呢?

  • 增加缓存,来降低磁盘IO访问和对象的创建
  • 使用Map的hash查找,提升检索指定实现类的性能

Dubbo的SPI机制

Dubbo SPI相较于 JAVA SPI 有如下几个增强点:

  • JAVA SPI 是一次性加载了所有的的实现类,但是很多时候我们这些实现类并不会被用到,Dubbo SPI在配置文件中为每个实现类指定key,可通过key去加载对应的实现类,实现了按需加载
  • JAVA SPI 一次性获取出来所有的实现类,但是我们无法对他进行分类,也就是说我们无法确定究竟需要使用哪个实现类。
  • Dubbo SPI 增加扩展类的 IOC 能力。Dubbo 的扩展能力并不仅仅只是发现扩展服务实现类,而是在此基础上更进一步,如果该扩展类的属性依赖其他对象,则 Dubbo 会自动的完成该依赖对象的注入功能。
  • Dubbo SPI 增加扩展类的 AOP 能力。Dubbo 扩展能力会自动的发现扩展类的包装类,完成包装类的构造,增强扩展类的功能。

Dubbo 设计出了一个 ExtensionLoader 类,实现了 SPI 思想,也被称为 Dubbo SPI 机制。

SPI扩展机制实现的结构目录如下所示:

在这里插入图片描述
在这里插入图片描述

实例演示

Dubbo SPI简单使用如下:

  • 定义一个 IDemoSpi 接口,并在该接口上添加 @SPI 注解。

在某个接口上加上@SPI注解后,表明该接口为可扩展接口,这就像RMI中暴露的远程服务接口需要继承Remote接口,JDK可序列化类需要继承Serializable接口一样,只起到标记作用。

代码语言:javascript
复制
@SPI
public interface IDemoSpi {
    String hello();
}
  • 定义一个 CustomSpi 实现类来实现该接口,然后通过 ExtensionLoader 的 getExtension 方法传入指定别名来获取具体的实现类。
代码语言:javascript
复制
public class CustomSpi implements IDemoSpi {
    @Override
    public String hello() {
        return "hello dubbo spi";
    }
}
  • “/META-INF/services/com.dhy.IDemoSpi”这个资源文件中,添加实现类的类路径,并为类路径取一个别名(customSpi)。
代码语言:javascript
复制
customSpi=com.dhy.CustomSpi 
  • 使用ExtensionLoader加载扩展接口实现类
代码语言:javascript
复制
public class DubboSpiTest {
    @Test
    void dubboSpiTest() {
        ApplicationModel applicationModel = ApplicationModel.defaultModel();
        ExtensionLoader<IDemoSpi> extensionLoader = applicationModel.getExtensionLoader(IDemoSpi.class);
        // 通过指定的名称从加载器中获取指定的实现类
        IDemoSpi customSpi = extensionLoader.getExtension("customSpi");
        System.out.println(customSpi + ", " + customSpi.hello());
        // 再次通过指定的名称从加载器中获取指定的实现类,看看打印的引用是否创建了新对象
        IDemoSpi customSpi2 = extensionLoader.getExtension("customSpi");
        System.out.println(customSpi2 + ", " + customSpi2.hello());
    }
}
在这里插入图片描述
在这里插入图片描述

Dubbo VS JDK SPI 小结

JDK SPI 的使用三部曲:

  • 首先,定义一个接口。
  • 然后,定义一些类来实现该接口,并将多个实现类的类路径添加到“/META-INF/services/ 接口类路径”文件中。
  • 最后,使用 ServiceLoader 的 load 方法加载接口的所有实现类。

但 JDK SPI 在高频调用时,可能会出现磁盘 IO 吞吐下降、大量对象产生和查询指定实现类的 O(n) 复杂度等问题,采用缓存 +Map 的组合方式来解决,就是 Dubbo SPI 的核心思想。

Dubbo SPI 的使用步骤三部曲:

  • 首先,同样也是定义一个接口,但是需要为该接口添加 @SPI 注解。
  • 然后,定义一些类来实现该接口,并将多个实现类的类路径添加到“/META-INF/services/ 接口类路径”文件中,并在文件中为每个类路径取一个别名。
  • 最后,使用 ExtensionLoader 的 getExtension 方法就能拿到指定的实现类使用。

Adaptive自适应扩展点

上面讲到,Dubbo提供的SPI机制对原生JDK提供的SPI机制主要有三个改进:

  • O(1)复杂度获取SPI接口实现类
  • 缓存SPI实现接口实现类
  • 按需加载指定的实现类

其实Dubbo除了以上三个改进外,还提供了在程序运行时根据RPC请求上下文中的URL中携带的信息进行动态选择SPI接口实现类的功能,该功能就是本节重点要讲解的Adaptive动态适配。

dubbo以URL为总线,运行过程中所有的状态数据信息都可以通过URL来获取,比如当前系统采用什么序列化,采用什么通信,采用什么负载均衡等信息,都是通过URL的参数来呈现的,所以在框架运行过程中,运行到某个阶段需要相应的数据,都可以通过对应的Key从URL的参数列表中获取。

demo演示

我们可以在SPI服务接口中需要动态适配的接口方法上标注@Adaptive注解,并且方法参数类型为URL,对于不需要在运行时通过RPC请求上下文中URL携带信息进行动态SPI实现类选择的接口方法而言,则无需标注@Adaptive注解:

代码语言:javascript
复制
@SPI("spring")
public interface FrameWork {
    @Adaptive
    String getName(URL url);
    String getInfo();
}

分别提供下面这些SPI接口实现类:

代码语言:javascript
复制
public class Spring implements FrameWork{
    @Override
    public String getName(URL url) {
        return "spring";
    }

    @Override
    public String getInfo() {
        return "流行的Spring框架";
    }
}
代码语言:javascript
复制
public class SpringBoot implements FrameWork{
    @Override
    public String getName(URL url) {
        return "springBoot";
    }

    @Override
    public String getInfo() {
        return "自动化的SpringBoot框架";
    }
}
代码语言:javascript
复制
public class Guice implements FrameWork{
    @Override
    public String getName(URL url) {
        return "guice";
    }

    @Override
    public String getInfo() {
        return "google 开源的轻量级IOC框架";
    }
}

Dubbo SPI配置文件:

在这里插入图片描述
在这里插入图片描述
代码语言:javascript
复制
spring=com.adaptive.Spring
springBoot=com.adaptive.SpringBoot
guice=com.adaptive.Guice

测试类:

代码语言:javascript
复制
class AdaptiveTest {
    @Test
    void adaptiveTest() {
        //URL中通过frame.work作为key,指明运行时需要获取的接口实现类为SPI配置文件中key=spring的实现类
        ApplicationModel applicationModel = ApplicationModel.defaultModel();
        ExtensionLoader<FrameWork> extensionLoader = applicationModel.getExtensionLoader(FrameWork.class);
        FrameWork frameWork = extensionLoader.getAdaptiveExtension();
        print(frameWork.getName(URL.valueOf("dubbo://127.0.0.1:80/?frame.work=spring")));
        //URL中通过frame.work作为key,指明运行时需要获取的接口实现类为SPI配置文件中key=springBoot的实现类
        print(frameWork.getName(URL.valueOf("dubbo://127.0.0.1:80/?frame.work=springBoot")));
        //URL中通过frame.work作为key,指明运行时需要获取的接口实现类为SPI配置文件中key=guice的实现类
        print(frameWork.getName(URL.valueOf("dubbo://127.0.0.1:80/?frame.work=guice")));
        //URL中不通过frame.work指明需要的第三方实现类,此时改为选择默认实现--SPI注解中指定的value值
        print(frameWork.getName(URL.valueOf("dubbo://127.0.0.1:80/")));
    }

    private void print(String name) {
        System.out.println("dubbo SPI动态适配拿到的结果为: "+name);
    }
}

运行结果:

在这里插入图片描述
在这里插入图片描述

如何做到动态适配的

Dubbo能够对SPI实现类进行动态选择,做到动态适配,其实是对getAdaptiveExtension方法返回扩展类实例进行了一层动态代理,在动态代理类内部处理了根据URL参数进行动态选择实现类的逻辑。

在这里插入图片描述
在这里插入图片描述

我们这里以上文列举的FrameWork接口为例,看看dubbo为其返回生成的代理对象实例具体代码:

代码语言:javascript
复制
public class FrameWork$Adaptive implements FrameWork {
    public String getName(URL uRL) {
        if (uRL == null) {
            throw new IllegalArgumentException("url == null");
        }
        //从URL中取出key为frame.work的键值对,key为服务接口类名按照大写字母分割成多个单词,并以"."相连接
        //val就是要选择的第三方扩展实现类---默认值为SPI注解中指明的val值
        String string = uRL.getParameter("frame.work", "spring");
        //如果url中没有获取到key=frame.work的键值对,并且SPI注解中没有指定默认值,那么会抛出异常
        if (string == null) {
            throw new IllegalStateException("Failed to get extension (com.adaptive.FrameWork) name from url (" + uRL.toString() + ") use keys([frame.work])");
        }
        ScopeModel scopeModel = ScopeModelUtil.getOrDefault(uRL.getScopeModel(), FrameWork.class);
        //调用ExtensionLoader的getExtension方法,根据key获取扩展实现类
        FrameWork frameWork = scopeModel.getExtensionLoader(FrameWork.class).getExtension(string);
        //调用扩展实现类的getName方法
        return frameWork.getName(uRL);
    }
    
    //未加@Adaptive注解的方法,表明无需运行时动态适配,那么dubbo不会为其生成动态选择代码逻辑,而是抛出不支持操作的异常信息
    public String getInfo() {
        throw new UnsupportedOperationException("The method public abstract java.lang.String com.adaptive.FrameWork.getInfo() of interface com.adaptive.FrameWork is not adaptive method!");
    }
}

可以看到所谓动态适配,就是dubbo通过动态代理为我们生成一个自适应扩展点,该自适应扩展点将根据URL中携带的参数来选择扩展实现类的代码模板化了而已。

如果后期对于某个扩展接口,我们不想使用dubbo为我们提供的默认URL动态匹配逻辑了,而是想要自定义动态匹配规则,也就是说我们想要指定一个实现类作为自适应扩展点,这时候我们可以将@Adaptive注解加在某个实现类上,如下所示:

代码语言:javascript
复制
@Adaptive
public class Guice implements FrameWork{
    @Override
    public String getName(URL url) {
        return "guice";
    }

    @Override
    public String getInfo() {
        return "google 开源的轻量级IOC框架";
    }
}

此时,再运行上面的测试用例,结果如下:

在这里插入图片描述
在这里插入图片描述

此时,dubbo就不会通过动态代理生成一个自适应扩展点了,而是选择标注了@Adaptive注解的实现类作为自适应扩展点,我们可以在自适应扩展点中实现自定义的动态匹配逻辑。

@Adaptive注解最多只能标注在某个扩展接口的某一个实现类上,如果大于一个,则会抛出异常 (默认情况下)

注解中的value值有什么作用

通过上面生成的代理对象可知,默认情况下,是根据SpiServiceName作为key去URL中获取对应的SpiServiceImplKey,那么如果我们想要自定义key,该怎么做呢?

我们可以在@Adaptive注解中指明自定义的key,可以指定多个key:

代码语言:javascript
复制
@SPI("spring")
public interface FrameWork {
    @Adaptive({"key1","key2"})
    String getName(URL url);
    String getInfo();
}

如果我们在注解中指明了key,那么生成的代理对象中会根据我们指定的key,依次尝试从URL中获取val作为SpiServiceImplKey,如果所有指定的key都从URL中获取val失败,那么采用@SPI注解中指定的默认值作为SpiServiceImplKey:

代码语言:javascript
复制
public class FrameWork$Adaptive implements FrameWork {
    public String getName(URL uRL) {
        if (uRL == null) {
            throw new IllegalArgumentException("url == null");
        }
        String string = uRL.getParameter("key1", uRL.getParameter("key2", "spring"));
        if (string == null) {
            throw new IllegalStateException("Failed to get extension (com.adaptive.FrameWork) name from url (" + uRL + ") use keys([key1, key2])");
        }
        ScopeModel scopeModel = ScopeModelUtil.getOrDefault(uRL.getScopeModel(), FrameWork.class);
        FrameWork frameWork = scopeModel.getExtensionLoader(FrameWork.class).getExtension(string);
        return frameWork.getName(uRL);
    }

    public String getInfo() {
        throw new UnsupportedOperationException("The method public abstract java.lang.String com.adaptive.FrameWork.getInfo() of interface com.adaptive.FrameWork is not adaptive method!");
    }
}

按条件批量激活扩展点

上文我们看到的都是根据key查找唯一个实现类的场景,但是如果有如下一个需求:

  • 该需求要求我们获取满足某个条件的所有扩展实现类,又怎么实现呢?

dubbo使用@Activate实现了上面的需求,@Activate标注在扩展实现类上,用于说明当前实现类满足什么条件方可被激活:

代码语言:javascript
复制
public @interface Activate {
    String[] group() default {};
    String[] value() default {};
    //既然可以获取满足条件的所有实现类,那么必然涉及排序问题,这里的order就是多个扩展实现类用于排序的依据
    int order() default 0;
}

对于满足条件的定义,这里的条件dubbo精确定义了一个group字段,用于指明当前扩展实现类属于什么分组,这里的分组一般用来区分当前是客户端还是服务端。

例如下面这段代码表示如果当前为所处环境为PROVIDER服务提供方,则激活当前扩展实现类:

代码语言:javascript
复制
@Activate(group =  CommonConstants.PROVIDER)
public class Guice implements FrameWork{
    @Override
    public String getName(URL url) {
        return "guice";
    }

    @Override
    public String getInfo() {
        return "google 开源的轻量级IOC框架";
    }
}

value字段则用于用户自定义激活条件,例如下面这段代码表示URL参数中携带了guice键值对时,并且当前所处环境为PROVIDER时,才会激活当前扩展实现类:

代码语言:javascript
复制
@Activate(value = {"guice"},group =  CommonConstants.PROVIDER)
public class Guice implements FrameWork{
    @Override
    public String getName(URL url) {
        return "guice";
    }

    @Override
    public String getInfo() {
        return "google 开源的轻量级IOC框架";
    }
}

下面给出一个简单的测试案例, 基于上面给出的测试用例,我们将@Adaptive注解统一替换为@Activate注解,并且注解中的value统一赋值为framework,表示当URL中携带了key为frameWork的键值对时,激活下面的扩展实现类:

代码语言:javascript
复制
@Activate("frameWork")
public class Guice implements FrameWork{
    @Override
    public String getName(URL url) {
        return "guice";
    }

    @Override
    public String getInfo() {
        return "google 开源的轻量级IOC框架";
    }
}
//另外两个类处理方式一致
...

测试类:

代码语言:javascript
复制
class ActivateTest {
    @Test
    void activateTest() {
        //URL中通过frame.work作为key,指明运行时需要获取的接口实现类为SPI配置文件中key=spring的实现类
        ApplicationModel applicationModel = ApplicationModel.defaultModel();
        ExtensionLoader<FrameWork> extensionLoader = applicationModel.getExtensionLoader(FrameWork.class);
        List<FrameWork> frameWorkList = extensionLoader.getActivateExtension(URL.valueOf("dubbo://127.0.0.1:80/?frameWork=true"), "", "");
        frameWorkList.forEach(frameWork -> {
            System.out.println(frameWork.getInfo());
        });
    }
}
在这里插入图片描述
在这里插入图片描述

通过上面的测试用例可知,@Activate注解可以在SPI服务发现的场景下,提供按条件批量激活的功能,相比于@Adaptive注解而言,增加了按条件批量激活的功能。

@Activate注解的更多使用细节可以参考后续的原理篇下: Dubbo源码篇06—SPI神秘的面纱—原理篇—下

小结

本文主要给大家讲解了一下dubbo spi的基本使用和相关扩展,下篇文章将会深入源码探究其背后的实现。

当然,包括dubbo的Wrapper机制,和依赖注入等功能,后续原理篇中也都进行了源码解释,后面也会单独再写一篇文章,介绍具体用法。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2023-05-25,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 引言
  • Jdk提供的SPI机制
    • 基本流程
      • 缺陷
      • Dubbo的SPI机制
        • 实例演示
        • Dubbo VS JDK SPI 小结
        • Adaptive自适应扩展点
          • demo演示
            • 如何做到动态适配的
              • 注解中的value值有什么作用
              • 按条件批量激活扩展点
              • 小结
              相关产品与服务
              负载均衡
              负载均衡(Cloud Load Balancer,CLB)提供安全快捷的流量分发服务,访问流量经由 CLB 可以自动分配到云中的多台后端服务器上,扩展系统的服务能力并消除单点故障。负载均衡支持亿级连接和千万级并发,可轻松应对大流量访问,满足业务需求。
              领券
              问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档