刨析 SpringBoot 自动装配原理,其实很简单

  • J3
  • SpringBoot # 源码 # 自动装配

一日我在愉快得遨游时,看到有鱼友在问:SpringBoot 中引入了 Nacos 依赖为啥就可以直接使用 Nacos 中的相关功能呀!

认真思考了一番,我立马就想说,自动装配,但这种回答怎么能体现我的牛逼呢!(牛逼症犯了)

思索万千,我给出了下面的回答:因为 SpringBoot 的宗旨是,约定大于配置,一切都由约定所决定。当你引入 Nacos 的依赖时,就和 SpringBoot 达成了一个约定环境中需要有 Nacos 相关功能,所以 SpringBoot 就在启动的时候将 Nacos 中所需要的功能自动装配好了。

洋洋洒洒一段话抛出,然而并没有达到那种溅起千层浪花的效果,反倒是下面的一句句”这就是 SpringBoot 的自动装配“ 来的效果好。

what?

早知道他们都是那么简单的人,我又何必装深沉,小丑竟然是我自己。

在这里插入图片描述

既然提到了自动装配,而日常工作中基本离不开 SpringBoot 并且自动装配又是 SpringBoot 中的一个重要功能,那我有什么理由不去深入研究一番呢!

我常教导你们要扒光内裤看个究竟,今天咱就来扒一扒自动装配的功能始末。

说明

  1. JDK为 1.8
  2. SpringBoot 为 2.5.5

1、手写一个 Starter

在分析之前,我认为有必要先手动实现一个 Starter 。因为自动装配这个功能我们要理解它到底是在自动装配个什么东西,对吧!

很简单,就是自动装配我们引入的一个个 Starter 。那是什么 Starter 都能够被装配嘛,我写个 Hello World 它也能给我自动装配?

当然是什么都能够自动装配了,但前提是我们编写的 Starter 必须要符合 SpringBoot 的自动装配规则,否则是不行的,那到底是什么规则呢!

1、在 resources/META-INF 目录下必须要有 spring.factories

2、spring.factories 文件中必须要有 org.springframework.boot.autoconfigure.EnableAutoConfiguration 对应的配置类

3、要被使用的 Bean 必须正常被扫描到

ok,了解这么多那就开始手写一个 Starter 。

1.1 先创建一个 SpringBoot 项目

项目可以不需要启动类,因为本来也没打算让他运行,项目目录结构如下:

在这里插入图片描述

MyStarterApplication:主配置类。

spring.factories(重点):配置文件,告知 SpringBoot 要装配的配置类在哪。

MyStarterService:业务类。

1、MyStarterApplication

// 扫描规则,将要被管理的 Bean 交给 IOC 容器
@ComponentScan("cn.j3.mystarter")
public class MyStarterApplication {
    // 配置
}

2、spring.factories

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
cn.j3.mystarter.MyStarterApplication

3、MyStarterService

@Service
public class MyStarterService {

    public void myService(String example) {
        System.out.println("您传入得值为:" + example);
    }
}

ok,就这点东西一个 Starter 就算是写好了,下面我们就在另外一个项目中引入这个项目依赖,并启动另外一个项目,最终可以发现 MyStarterService 业务类可以正常使用。

另一个项目 pom 文件引入下面依赖:

<dependency>
    <groupId>cn.j3</groupId>
    <artifactId>my-starter</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

测试 controller

@RestController
@RequestMapping("/test")
public class MvcTestController {
    
    // 正常注入
    @Autowired
    private MyStarterService myStarterService;

    @GetMapping("/mvc")
    public String mvcTest(){
        System.out.println("mvc success!");
        // 正常调用
        myStarterService.myService("测试自动装配!");
        return "success!";
    }
}

控制台输出

mvc success!
您传入得值为:测试自动装配!

自定义 starter 编写完成,是不是觉得非常简单。

在这里插入图片描述

2、探究自动装配原理

上面我们自定义的 starter 生效的过程就是 SpringBoot 自动装配的过程,因为我们只是在一个项目中引入了我们自定义的 starter 而里面的功能就可以生效。

那我们先来分析一下自定义的 starter 。

在 SpringBoot 中要想让一个功能被 IOC 容器管理我们应该怎么做,对,就是让类被 SpringBoot 扫描到并放入容器中。

在我们自定义的 starter 中我们可以看到这样的一个配置:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
cn.j3.mystarter.MyStarterApplication

这是核心,它是功能生效的源头。

因为我们有了上面的配置,使得 MyStarterApplication 类成为了 SpringBoot 的一个配置类当配置类被加载到容器中时,配置类上的注解从而就生效,进而带动了整个 starter 中的 Bean 都被容器所管理。

那源头我们是找到了,就是 spring.factories 文件,只要配置的这个文件生效,starter 就会生效。

所以 spring.factories 文件是如何生效的呢!

这里我说结果把!就是 @SpringBootApplication 这个注解。可以说,在整个 SpringBoot 中这个注解是非常重要,如果没有它,那么像自动装配、包扫描等功能都会失效,所以本次我们分析源码的入口就是这个注解了。

在我们步步为营,层层递进的情况下,终于是分析到了 @SpringBootApplication 注解,可喜可贺!

在这里插入图片描述

2.1 @SpringBootApplication

这个注解不用我多说,大伙也都知道它在哪吧!—— 主类

// 我们要分析的注解
@SpringBootApplication
public class StudySpringbootApplication {
    public static void main(String[] args) {
        SpringApplication.run(StudySpringbootApplication.class, args);
    }
}

在 IDEA 中点进这个注解看看它的源码:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
      @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {

   @AliasFor(annotation = EnableAutoConfiguration.class)
   Class<?>[] exclude() default {};

   @AliasFor(annotation = EnableAutoConfiguration.class)
   String[] excludeName() default {};

   @AliasFor(annotation = ComponentScan.class, attribute = "basePackages")
   String[] scanBasePackages() default {};

   @AliasFor(annotation = ComponentScan.class, attribute = "basePackageClasses")
   Class<?>[] scanBasePackageClasses() default {};

   @AliasFor(annotation = ComponentScan.class, attribute = "nameGenerator")
   Class<? extends BeanNameGenerator> nameGenerator() default BeanNameGenerator.class;

   @AliasFor(annotation = Configuration.class)
   boolean proxyBeanMethods() default true;
}

简单的说一下这个注解所包含的源码功能

  1. @SpringBootConfiguration:标注的类为 SpringBoot 的一个配置类
  2. @EnableAutoConfiguration:自动配置注解(重点!!!!!!!!!)
  3. @ComponentScan:包扫描注解
  4. 注解属性主要是定义了排除一些不用装配的类,根据 Class 、name 等规则进行排除
  5. 激活 @Component 扫描的 @ComponentScan

很明显我们接下来要分析的重点是 @EnableAutoConfiguration 注解

2.2 @EnableAutoConfiguration

看名字我们就能猜出这个注解的作用:开启自动配置。它位于 spring-boot-autoconfigure 包内,当我们使用 @SpringBootApplication 注解时,它就会自动生效。

下面来看看它的源码:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration {
    // 用来覆盖配置开启 / 关闭自动配置的功能
    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";
    // 根据类排除指定的自动配置
    Class<?>[] exclude() default {};
    // 根据类名排除指定的自动配置
    String[] excludeName() default {};
}

对于 @EnableAutoConfiguration 注解的属性大伙看我标注的注释就行,但我们还看到这个注解式又标注了其它两个注解分别为:

  1. @AutoConfigurationPackage
  2. @Import(AutoConfigurationImportSelector.class)

@AutoConfigurationPackage 注解的功能我就简单说一下,将注解所在的包路径加入到一个专门记录packageNames的集合里,那至于为啥加到这个集合里,我想就是为了包扫描吧!

下面就是 @Import 注解了,在学习 Spring 的时候我们也经常使用 标签,不知道大家还有没有印象了。这个注解的作用就是向项目中导入什么什么配置类的功能,它位于 spring-context 包内,所以它在这里的所用是,向项目中导入了 AutoConfigurationImportSelector 配置类。

2.3 AutoConfigurationImportSelector

先来看看这个类的继承关系图:

在这里插入图片描述

咋一看这个类实现的接口还挺多的,其实就两大类

  1. ImportSelector 类接口
  2. Aware 类接口

先说第一类,AutoConfigurationImportSelector 并没有直接实现 ImportSelector 接口而是其子接口 DeferredImportSelector 接口,那这两个接口有什么区别吗?DeferredImportSelector 接口会在所有的 @Configuration 类加载完成之后在加载返回的配置类,而 ImportSelector 是在加载完 @Configuration 之前先去加载返回的配置类。

第二类,XXXAware 类接口则是在 Bean 实例化的时候为其传入相关的环境对象,如 BeanFactory 、Environment 、ResourceLoader 等。

在 Spring 中它会保证在执行 ImportSelector 接口的方法之前,先执行 XXXAware 接口的方法,保证对应的属性都被附上对应的值。

那现在回到接口 ImportSelector 它是干什么的,又是在什么时候被执行的。

接口源码:

public interface ImportSelector {

    // 根据导入的Configuration类的注解元数据选择哪一个类需要被导入,返回的数组就是需要被导入的类名
    String[] selectImports(AnnotationMetadata importingClassMetadata);
    // 排除不需要导入的类
    @Nullable
    default Predicate<String> getExclusionFilter() {
        return null;
    }
}

接口只是定义了规范,selectImports 方法的具体实现在 AutoConfigurationImportSelector 类中,我们来看看实现源码:

@Override
public String[] selectImports(AnnotationMetadata annotationMetadata) {
    // 检查自动配置功能是否开启,默认开启
    if (!isEnabled(annotationMetadata)) {
        return NO_IMPORTS;
    }
    // 封装将被引入的自动配置信息
    AutoConfigurationEntry autoConfigurationEntry = getAutoConfigurationEntry(annotationMetadata);
    // 返回符合条件的配置类的全限定类名数组
    return StringUtils.toStringArray(autoConfigurationEntry.getConfigurations());
}

看到这里,我们知道 getAutoConfigurationEntry(annotationMetadata) 方法就是我们要深入研究的点,因为它返回的是所有需要自动配置的全限定类名,只有把这个返回给了 SpringBoot ,它才知道需要自动装配那些功能。

至于何时执行这段代码,我也是 Debug 的时候才搞明白个所以然

还记得我们之前提到的 ImportSelector 和 DeferredImportSelector 的区别吗?因为我们的 AutoConfigurationImportSelector 类实现的是后者,所以程序在执行的时候不会去执行 selectImports 方法,而是会去执行其静态内部类 AutoConfigurationGroup 中的 process 方法并且最终会调到 getAutoConfigurationEntry 方法获取到所有的自动装配类。

至于为什么是从 org.springframework.boot.autoconfigure.AutoConfigurationImportSelector.AutoConfigurationGroup # process 调进来,在这里不做过多的分析,不是本次的重点,下面分析自动装配的类是如何被加载进来的。

2.3.1 getAutoConfigurationEntry

protected AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) {
    // 是否开启自动装配
    if (!isEnabled(annotationMetadata)) {
        // 未开启,返回空
        return EMPTY_ENTRY;
    }
    AnnotationAttributes attributes = getAttributes(annotationMetadata);
    // 通过 SpringFactoriesLoader 类提供的方法加载类路径中 META-INF 目录下的 spring.factories 文件中针对 EnableAutoConfiguration 的注册配置类
    List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);
    // 队获得的注册配置类集合进行去重处理,防止多个项目引入同样的配置类
    configurations = removeDuplicates(configurations);
    // 获得注解中被 exclude 或 excludeName 所派出的类的集合
    Set<String> exclusions = getExclusions(annotationMetadata, attributes);
    // 检查被排除类是否可实例化,是否被自动注册配置所使用,不符合条件则抛出异常
    checkExcludedClasses(configurations, exclusions);
    // 从自动配置类集合中取出被排除的类
    configurations.removeAll(exclusions);
    // 检查配置类的注解是否符合 spring.factories 文件中 AutoConfigurationImportFilter 指定的注解检查条件
    configurations = getConfigurationClassFilter().filter(configurations);
    // 将赛选完成的配置类和排查的配置类构建为事件类,并传入监听器。监听器的配置在于 spring.factories 文件中,通过 AutoConfigurationImportListener z
    fireAutoConfigurationImportEvents(configurations, exclusions);
    return new AutoConfigurationEntry(configurations, exclusions);
}

通过这段代码注释,我们可以从整体上了解获取自动装配类的概况及操作流程,下面我会对这些流程做细致的分析。

首先是自动装配开关功能,很好理解,SpringBoot 给我们预留了 spring.boot.enableautoconfiguration = true/false 这一配置项来控制是否开启自动装配功能,默认是开启。

下面来到这行代码:getAttributes(annotationMetadata)

这行代码的作用就是获取到主类中配置的 exclude 、 excludeName 两个属性的值,为了对后面读出的自动装配类做排除作用,排除这些指定不需要装配的类。

继续分析这行代码:getCandidateConfigurations(annotationMetadata, attributes)

protected List<String> getCandidateConfigurations(AnnotationMetadata metadata, AnnotationAttributes attributes) {
    // 重点,获取 META-INF/spring.factories 文件中 EnableAutoConfiguration 类型的值
    List<String> configurations = SpringFactoriesLoader.loadFactoryNames(getSpringFactoriesLoaderFactoryClass(),
                                                                         getBeanClassLoader());
    Assert.notEmpty(configurations, "No auto configuration classes found in META-INF/spring.factories. If you "
                    + "are using a custom packaging, make sure that file is correct.");
    return configurations;
}

SpringFactoriesLoader 是 Spring-Core 提供的一个工具类,它是专门读取 META-INF/spring.factories 文件中相关值得功能,比如 loadFactoryNames 方法,如果向其传入 EnableAutoConfiguration. Class 类型的值,那它就会读取下面这部分的值:

在这里插入图片描述

这里说明一点的是,它会读取所有依赖包下 META-INF/spring.factories 文件的值,如果大家 Debug 的话可以看到我们自定义 starter 中自动装配类也是被加载进来了,如图:

在这里插入图片描述

注意:loadFactoryNames 方法是根据传入的类型去读取对应的值,所以在读取 spring.factories 文件值时,它是一个通用方法。

下面接着分析这个方法:removeDuplicates(configurations)

这句代码非常简单,我是不想贴代码了。它的作用是去重,因为 getCandidateConfigurations 是加载所用依赖包下的自动装配类,难保这些依赖包不会引入重复的自动装配类,所以这里要将重复的类进行排除。排除方法也是很简单,使用 Set 结构的特性进行排除。

接着看下面这个方法:getExclusions(annotationMetadata, attributes)

protected Set<String> getExclusions(AnnotationMetadata metadata, AnnotationAttributes attributes) {
   Set<String> excluded = new LinkedHashSet<>();
    // 排除配置的自动装配类
   excluded.addAll(asList(attributes, "exclude"));
   excluded.addAll(Arrays.asList(attributes.getStringArray("excludeName")));
    // 排除通过配置文件 spring.autoconfigure.exclude = 类,的方法进行排除
   excluded.addAll(getExcludeAutoConfigurationsProperty());
   return excluded;
}

上面说过 attributes 是主类中配置的需要移除的自动装配类,而 getExcludeAutoConfigurationsProperty 则是通过 spring.autoconfigure.exclude = 全限定类名 的方式排除,所以这个方法的作用就是返回主类中配置的和 spring.autoconfigure.exclude 配置的需要排除的自动装配类。

在获取了需要排除的自动装配类之后则是需要对这些排除类进行检查,检查排除的类是否属于当前 ClassLoad 和不存在于需要自动装配类的集合中,如果条件满足,那就要报错了。

检查通过后,就是排除了,代码体现是这个:configurations.removeAll(exclusions) ,常规的集合操作,就不过多讲了。

至此呢,自动装配的初步功能算是完成了,我总结一下初步功能干了些啥:

  1. 判断是否需要自动装配
  2. 加载所有包下的 META-INF/spring.factories 文件中 EnableAutoConfiguration. Class 类型的值
  3. 获取需要排除的自动装配类集合
  4. 检查需要排除的自动装配类是否符合排除要求
  5. 对加载的自动装配类集合进行排除

SpringBoot 后续还要判断那些自动装配的类需要起作用,总不至于所有的配置都生效吧!所以往下看。

在这里插入图片描述

2.4 过滤自动装配

正如上面说的那样,加载的自动装配类不可能全部都生效,所以要在下面这行代码处进行过滤,将那些不需要生效的自动装配剔除掉。

configurations = getConfigurationClassFilter().filter(configurations);

这行代码分为两部分:

  1. 获取过滤器
  2. 执行过滤器的过滤方法

先来说说第一部分,getConfigurationClassFilter() 方法主要是获取 META-INF/spring.factories 文件中配置了 key 为 org.springframework.boot.autoconfigure.AutoConfigurationImportFilter 的值,并封装成 ConfigurationClassFilter 对象返回出去,源码如下。

private ConfigurationClassFilter getConfigurationClassFilter() {
    if (this.configurationClassFilter == null) {
        // 加载文件中的过滤器
        List<AutoConfigurationImportFilter> filters = getAutoConfigurationImportFilters();
        for (AutoConfigurationImportFilter filter : filters) {
            // 给过滤器配置相应的环境属性值,可以认为是初始化过滤器的一些属性值
            invokeAwareMethods(filter);
        }
        // 封装过滤器配置对象
        this.configurationClassFilter = new ConfigurationClassFilter(this.beanClassLoader, filters);
    }
    return this.configurationClassFilter;
}
// 具体的加载 META-INF/spring.factories 中的 AutoConfigurationImportFilter 值
protected List<AutoConfigurationImportFilter> getAutoConfigurationImportFilters() {
    return SpringFactoriesLoader.loadFactories(AutoConfigurationImportFilter.class, this.beanClassLoader);
}

如图,SpringFactoriesLoader.loadFactories(AutoConfigurationImportFilter.class, this.beanClassLoader) 获取的是文件中这部分的值:

在这里插入图片描述

再来说说第二部分,在第一部分中获取了所有的过滤器之后给我们返回了 ConfigurationClassFilter 对象,然后在调用他的 filter 方法进行过滤的,那我们就来看看这个方法的源码。

org.springframework.boot.autoconfigure.AutoConfigurationImportSelector.ConfigurationClassFilter # filter

List<String> filter(List<String> configurations) {
    long startTime = System.nanoTime();
    // 将所有加载的自动配置类封装成数组
    String[] candidates = StringUtils.toStringArray(configurations);
    boolean skipped = false;
    // 挨个遍历过滤器进行判断,自动装配是否生效
    for (AutoConfigurationImportFilter filter : this.filters) {
        // 这是判断生效逻辑,candidates 是自动装配类数组,this.autoConfigurationMetadata 这个是 META-INF/spring-autoconfigure-metadata.properties 文件中的值
        boolean[] match = filter.match(candidates, this.autoConfigurationMetadata);
        for (int i = 0; i < match.length; i++) {
            if (!match[i]) {
                // 将不生效的自动配置类替换为 null 值
                candidates[i] = null;
                // 这个是一个标志位,保证下面需要进行剔除
                skipped = true;
            }
        }
    }
    // 判断标志位,有改动,说明要往下走,如果没有改动那就全部生效,不进行剔除
    if (!skipped) {
        return configurations;
    }
    // 剔除操作
    List<String> result = new ArrayList<>(candidates.length);
    // 遍历所有的自动装配类数组
    for (String candidate : candidates) {
        // 将不为 null 的值返回出去
        if (candidate != null) {
            result.add(candidate);
        }
    }
    if (logger.isTraceEnabled()) {
        int numberFiltered = configurations.size() - result.size();
        logger.trace("Filtered " + numberFiltered + " auto configuration class in "
                     + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startTime) + " ms");
    }
    // 最终需要自动装配类的集合
    return result;
}

这一段是判断自动装配类是否生效的核心代码,而主要的判断逻辑则是 match 方法

从代码我中我们可以看到调用的是 AutoConfigurationImportFilter 类的 match 方法,那我们来看看它的类继承结构图。

在这里插入图片描述

  • AutoConfigurationImportFilter :定义了 match 方法的函数式接口。
  • FilteringSpringBootCondition : match 方法的直接实现类。
  • OnBeanCondition :具体过滤器,其实 match 中会调用每个具体过滤器中的 getOutcomes 方法判断生效逻辑。
  • OnClassCondition :具体过滤器,其实 match 中会调用每个具体过滤器中的 getOutcomes 方法判断生效逻辑。
  • OnWebApplicationCondition :具体过滤器,其实 match 中会调用每个具体过滤器中的 getOutcomes 方法判断生效逻辑。

我们知道 match 是被 getConfigurationClassFilter 方法加载出来的具体过滤器进行调用的,再结合上面我介绍 AutoConfigurationImportFilter 类结构体可知,最终判断生效的逻辑出自过滤器中的 getOutcomes 方法。

因为每个过滤器都有对应的 getOutcomes 处理逻辑,所以就不贴这个方法的代码了,但万变不离其宗,所有的处理逻辑都是通过判断自动装配类 + ”.“ + OnBeanCondition 或 OnClassCondition 或 OnWebApplicationCondition 形成 key 去 this.autoConfigurationMetadata 集合中找对应的值,如果没有找到则默认加载反之则将获取到的值通过加载器进行加载,加载成功则自动装配类生效反之则不生效过滤掉,最终会返回一个对应自动装配类集合的 true / false 数组。

至此,我们的 match 方法就分析结束了,它最终返回的是和自动装配类集合对应的 true / false 集合,对应位置的值为 true 说明自动装配类集合中对应的自动装配类生效。

2.5 事件封装和广播

上面我们已经分析出了自动装配需要生效的自动装配全限定类名集合了,但还没完呢!

getAutoConfigurationEntry 方法还有最后一行代码:fireAutoConfigurationImportEvents(configurations, exclusions) 。

下面来看看它的源码:

private void fireAutoConfigurationImportEvents(List<String> configurations, Set<String> exclusions) {
    // 加载 META-INF/spring.factories 文件中类型为 AutoConfigurationImportListener 的监听器,
    List<AutoConfigurationImportListener> listeners = getAutoConfigurationImportListeners();
    if (!listeners.isEmpty()) {
        // 封装自动装配导入事件,AutoConfigurationImportEvent
        AutoConfigurationImportEvent event = new AutoConfigurationImportEvent(this, configurations, exclusions);
        // 遍历监听器
        for (AutoConfigurationImportListener listener : listeners) {
            invokeAwareMethods(listener);
            // 广播事件,所有监听 AutoConfigurationImportEvent 事件的都会做出相关的反应
            listener.onAutoConfigurationImportEvent(event);
        }
    }
}

对于事件监听和广播不是我们分析的重点,而这里再加载完自动配置类之后就是做了一个事件广播。将生效的自动配置类和被排除的自动装配类封装成事件向外广播,当遇到对这件事感兴趣的监听器就会做出相关的反应。

META-INF/spring.factories 文件中类型为 AutoConfigurationImportListener 的监听器如下:

在这里插入图片描述

3、最后

对于 SpringBoot 的自动装配我由浅入深的分析了其整个的运行流程,整体来说偏向简单没有晦涩难懂的代码。大家可以对着本篇讲解的步骤自己手动 Debug 几次相信掌握它不成问题,最后附上自动装配核心代码的处理逻辑流程图,便于整体理解。

在这里插入图片描述

好了,今天的内容到这里就结束了,关注我,我们下期见

有任何问题,根据下面联系方式找到我:

QQ:1491989462

微信:13207920596


  • 由于博主才疏学浅,难免会有纰漏,假如你发现了错误或偏见的地方,还望留言给我指出来,我会对其加以修正。
  • 如果你觉得文章还不错,你的转发、分享、点赞、留言就是对我最大的鼓励。
  • 感谢您的阅读,十分欢迎并感谢您的关注。
  • 6
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

J3code

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值