SpringBoot(1.5.6.RELEASE)源码解析(三)

请尊重作者劳动成果,转载请标明原文链接:http://www.cnblogs.com/dylan-java/p/7455699.html

上一篇分析了SpringApplication的初始化,接下来看一下它的run方法

 1 public ConfigurableApplicationContext run(String... args) {
 2     StopWatch stopWatch = new StopWatch();
 3     stopWatch.start();
 4     ConfigurableApplicationContext context = null;
 5     FailureAnalyzers analyzers = null;
 6     configureHeadlessProperty();
 7     SpringApplicationRunListeners listeners = getRunListeners(args);
 8     listeners.starting();
 9     try {
10         ApplicationArguments applicationArguments = new DefaultApplicationArguments(
11                 args);
12         ConfigurableEnvironment environment = prepareEnvironment(listeners,
13                 applicationArguments);
14         Banner printedBanner = printBanner(environment);
15         context = createApplicationContext();
16         analyzers = new FailureAnalyzers(context);
17         prepareContext(context, environment, listeners, applicationArguments,
18                 printedBanner);
19         refreshContext(context);
20         afterRefresh(context, applicationArguments);
21         listeners.finished(context, null);
22         stopWatch.stop();
23         if (this.logStartupInfo) {
24             new StartupInfoLogger(this.mainApplicationClass)
25                     .logStarted(getApplicationLog(), stopWatch);
26         }
27         return context;
28     }
29     catch (Throwable ex) {
30         handleRunFailure(context, listeners, analyzers, ex);
31         throw new IllegalStateException(ex);
32     }
33 }

首先创建一个StopWatch对象并调用它的start方法,该类是Spring提供的一个计时器类,与本篇要讨论的东西无关,所在在这里不对它进行分析

第6行调用了configureHeadlessProperty方法,该方法只有一行代码,就是设置系统属性java.awt.headless,这里设置为true,表示运行在服务器端,在没有显示器和鼠标键盘的模式下工作,模拟输入输出设备功能

第7行通过getRunListeners方法获取SpringApplicationRunListeners对象,这个对象是一个SpringBoot事件广播器的管理者,它可以包含多个SpringApplicationRunListener,SpringApplication类中使用它们来间接调用ApplicationListener

在classpath下的JAR文件中包含的/META/spring.factories文件里找到org.springframework.boot.SpringApplicationRunListener对应的属性,然后实例化并排序

在jar:file:/C:/Users/guiqingqing/.m2/repository/org/springframework/boot/spring-boot/1.5.6.RELEASE/spring-boot-1.5.6.RELEASE.jar!/META-INF/spring.factories文件找到如下配置

# Run Listeners
org.springframework.boot.SpringApplicationRunListener=\
org.springframework.boot.context.event.EventPublishingRunListener

这里只有一个,就是EventPublishingRunListener,然后作为参数调用SpringApplicationRunListeners的构造函数,创建对象并返回,EventPublishingRunListener对象的实例被添加到SpringApplicationRunListeners对象的listeners属性中

看一下EventPublishingRunListener的构造函数

1 public EventPublishingRunListener(SpringApplication application, String[] args) {
2     this.application = application;
3     this.args = args;
4     this.initialMulticaster = new SimpleApplicationEventMulticaster();
5     for (ApplicationListener<?> listener : application.getListeners()) {
6         this.initialMulticaster.addApplicationListener(listener);
7     }
8 }

内部会创建一个Spring广播器SimpleApplicationEventMulticaster对象,它实际上是一个事件广播器,EventPublishingRunListener实现了SpringApplicationRunListener接口,我们看一下SpringApplicationRunListener接口的定义

 1 public interface SpringApplicationRunListener {
 2     void starting();
 3 
 4     void environmentPrepared(ConfigurableEnvironment environment);
 5 
 6     void contextPrepared(ConfigurableApplicationContext context);
 7 
 8     void contextLoaded(ConfigurableApplicationContext context);
 9 
10     void finished(ConfigurableApplicationContext context, Throwable exception);
11 }

该接口规定了SpringBoot的生命周期,在各个生命周期广播相应的事件,调用实际的ApplicationListener的onApplicationEvent方法

回到SpringApplication的run方法

第8行调用listeners的starting方法

1 public void starting() {
2     for (SpringApplicationRunListener listener : this.listeners) {
3         listener.starting();
4     }
5 }

该方法遍历所有的listeners,当然这里只有一个(EventPublishingRunListener),调用它的starting方法

1 @Override
2 @SuppressWarnings("deprecation")
3 public void starting() {
4     this.initialMulticaster.multicastEvent(new ApplicationStartedEvent(this.application, this.args));
5 }

这里会使用广播器(SimpleApplicationEventMulticaster)去广播事件,创建一个ApplicationStartedEvent对象作为参数

 1 @Override
 2 public void multicastEvent(ApplicationEvent event) {
 3     multicastEvent(event, resolveDefaultEventType(event));
 4 }
 5 
 6 @Override
 7 public void multicastEvent(final ApplicationEvent event, ResolvableType eventType) {
 8     ResolvableType type = (eventType != null ? eventType : resolveDefaultEventType(event));
 9     for (final ApplicationListener<?> listener : getApplicationListeners(event, type)) {
10         Executor executor = getTaskExecutor();
11         if (executor != null) {
12             executor.execute(new Runnable() {
13                 @Override
14                 public void run() {
15                     invokeListener(listener, event);
16                 }
17             });
18         }
19         else {
20             invokeListener(listener, event);
21         }
22     }
23 }

可以看出multicastEvent方法会找到ApplicationListener的集合,然后依次调用invokeListener方法,而invokeListener方法内部则会调用ApplicationListener对象的onApplicationEvent方法

第10行创建一个DefaultApplicationArguments对象,它持有着args参数,就是main函数传进来的参数

第12行调用prepareEnvironment方法,该方法准备运行的环境,比如开发环境dev,测试环境test,还是生产环境prd,然后根据环境解析不同的配置文件

 1 private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners, ApplicationArguments applicationArguments) {
 2     // Create and configure the environment
 3     ConfigurableEnvironment environment = getOrCreateEnvironment();
 4     configureEnvironment(environment, applicationArguments.getSourceArgs());
 5     listeners.environmentPrepared(environment);
 6     if (!this.webEnvironment) {
 7         environment = new EnvironmentConverter(getClassLoader())
 8                 .convertToStandardEnvironmentIfNecessary(environment);
 9     }
10     return environment;
11 }

getOrCreateEnvironment方法会检查之前设置的webEnvironment属性,如果是web程序,那么创建一个StandardServletEnvironment对象并返回,如果不是web程序,那么创建一个StandardEnvironment对象并返回

接下来是configureEnvironment方法,该方法会对上一步返回的environment做进一步的配置,调用configurePropertySources和configureProfiles,比如main函数传进来参数"--spring.profiles.active=dev",那么会在configurePropertySources方法里被添加到CommandLineArgs对象的optionArgs属性,不是以"--"开头的参数会被添加到nonOptionArgs属性。而configureProfiles方法这个时候就会获取到dev作为当前有效的profile并添加到environment的activeProfiles属性中,本例不传参数,所以基本上configureEnvironment方法什么都不做

然后调用listeners的environmentPrepared方法,发布一个ApplicationEnvironmentPreparedEvent事件,通过事件广播器,依次调用每个ApplicationListener对象的onApplicationEvent方法,这里我们重点分析ConfigFileApplicationListener的onApplicationEvent方法

 1 @Override
 2 public void onApplicationEvent(ApplicationEvent event) {
 3     if (event instanceof ApplicationEnvironmentPreparedEvent) {
 4         onApplicationEnvironmentPreparedEvent(
 5                 (ApplicationEnvironmentPreparedEvent) event);
 6     }
 7     if (event instanceof ApplicationPreparedEvent) {
 8         onApplicationPreparedEvent(event);
 9     }
10 }
1 private void onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event) {
2     List<EnvironmentPostProcessor> postProcessors = loadPostProcessors();
3     postProcessors.add(this);
4     AnnotationAwareOrderComparator.sort(postProcessors);
5     for (EnvironmentPostProcessor postProcessor : postProcessors) {
6         postProcessor.postProcessEnvironment(event.getEnvironment(),
7                 event.getSpringApplication());
8     }
9 }

首先loadPostProcessors方法会去classpath下的JAR文件中包含的/META/spring.factories文件里找到org.springframework.boot.env.EnvironmentPostProcessor对应的属性,然后实例化并排序,设置到postProcessors变量,在把当前对象(ConfigFileApplicationListener的实例)也添加到postProcessors变量中,然后对postProcessors排序,遍历postProcessors,依次调用它们的postProcessEnvironment方法。我们来看ConfigFileApplicationListener的postProcessEnvironment方法

1 @Override
2 public void postProcessEnvironment(ConfigurableEnvironment environment,
3         SpringApplication application) {
4     addPropertySources(environment, application.getResourceLoader());
5     configureIgnoreBeanInfo(environment);
6     bindToSpringApplication(environment, application);
7 }

addPropertySources方法最终会调用到ConfigFileApplicationListener的内部类Loader的load方法,这个方法是解析我们配置的application.yml和application-dev.yml文件的实现所在

 1 public void load() {
 2     this.propertiesLoader = new PropertySourcesLoader();
 3     this.activatedProfiles = false;
 4     this.profiles = Collections.asLifoQueue(new LinkedList<Profile>());
 5     this.processedProfiles = new LinkedList<Profile>();
 6 
 7     // Pre-existing active profiles set via Environment.setActiveProfiles()
 8     // are additional profiles and config files are allowed to add more if
 9     // they want to, so don't call addActiveProfiles() here.
10     Set<Profile> initialActiveProfiles = initializeActiveProfiles();
11     this.profiles.addAll(getUnprocessedActiveProfiles(initialActiveProfiles));
12     if (this.profiles.isEmpty()) {
13         for (String defaultProfileName : this.environment.getDefaultProfiles()) {
14             Profile defaultProfile = new Profile(defaultProfileName, true);
15             if (!this.profiles.contains(defaultProfile)) {
16                 this.profiles.add(defaultProfile);
17             }
18         }
19     }
20 
21     // The default profile for these purposes is represented as null. We add it
22     // last so that it is first out of the queue (active profiles will then
23     // override any settings in the defaults when the list is reversed later).
24     this.profiles.add(null);
25 
26     while (!this.profiles.isEmpty()) {
27         Profile profile = this.profiles.poll();
28         for (String location : getSearchLocations()) {
29             if (!location.endsWith("/")) {
30                 // location is a filename already, so don't search for more
31                 // filenames
32                 load(location, null, profile);
33             }
34             else {
35                 for (String name : getSearchNames()) {
36                     load(location, name, profile);
37                 }
38             }
39         }
40         this.processedProfiles.add(profile);
41     }
42 
43     addConfigurationProperties(this.propertiesLoader.getPropertySources());
44 }

25行之前基本上是一些变量的实例化,会往profiles集合中添加default和null,直接跳过,接下来从profiles集合中取出对应的值进行解析,首先取出null

第28行getSearchLocations方法,首先会检查当前环境是否配置了spring.config.location,如果没有,那么会使用默认的搜索路径classpath:/,classpath:/config/,file:./,file:./config/,这里调用了Collections.reverse方法进行倒序,也就是先查找file:./config/,在查找file:./,再是classpath:/config/,最后查找classpath:/。笔者的application.yml和application-dev.yml都是直接在src/main/resources目录下,所以对应的是在location为classpath:/时调用load(String location, String name, Profile profile)方法(在没有配置spring.config.name的情况下,name默认为application)

 1 private void load(String location, String name, Profile profile) {
 2     String group = "profile=" + (profile == null ? "" : profile);
 3     if (!StringUtils.hasText(name)) {
 4         // Try to load directly from the location
 5         loadIntoGroup(group, location, profile);
 6     }
 7     else {
 8         // Search for a file with the given name
 9         for (String ext : this.propertiesLoader.getAllFileExtensions()) {
10             if (profile != null) {
11                 // Try the profile-specific file
12                 loadIntoGroup(group, location + name + "-" + profile + "." + ext,
13                         null);
14                 for (Profile processedProfile : this.processedProfiles) {
15                     if (processedProfile != null) {
16                         loadIntoGroup(group, location + name + "-"
17                                 + processedProfile + "." + ext, profile);
18                     }
19                 }
20                 // Sometimes people put "spring.profiles: dev" in
21                 // application-dev.yml (gh-340). Arguably we should try and error
22                 // out on that, but we can be kind and load it anyway.
23                 loadIntoGroup(group, location + name + "-" + profile + "." + ext,
24                         profile);
25             }
26             // Also try the profile-specific section (if any) of the normal file
27             loadIntoGroup(group, location + name + "." + ext, profile);
28         }
29     }
30 }

调用this.propertiesLoader.getAllFileExtensions()方法获取配置文件后缀名,分别是在PropertiesPropertySourceLoader类和YamlPropertySourceLoader类中getFileExtensions方法定义的

1 public class PropertiesPropertySourceLoader implements PropertySourceLoader {
2     @Override
3     public String[] getFileExtensions() {
4         return new String[] { "properties", "xml" };
5     }
6 }
1 public class YamlPropertySourceLoader implements PropertySourceLoader {
2     @Override
3     public String[] getFileExtensions() {
4         return new String[] { "yml", "yaml" };
5     }
6 }

第23行调用的loadIntoGroup方法里又调用doLoadIntoGroup方法,这个方法会检查配置文件是否存在,比如file:./config/下不存在,或是classpath:/下的application.properties不存在,最后发现classpath:/下的application.yml存在,然后对其进行解析

 1 public PropertySource<?> load(Resource resource, String group, String name,
 2             String profile) throws IOException {
 3     if (isFile(resource)) {
 4         String sourceName = generatePropertySourceName(name, profile);
 5         for (PropertySourceLoader loader : this.loaders) {
 6             if (canLoadFileExtension(loader, resource)) {
 7                 PropertySource<?> specific = loader.load(sourceName, resource,
 8                         profile);
 9                 addPropertySource(group, specific, profile);
10                 return specific;
11             }
12         }
13     }
14     return null;
15 }

这里有2个loader,PropertiesPropertySourceLoader和YamlPropertySourceLoader,先检查是否该由此loader去解析,检查的规则就是看文件后缀名是否在getFileExtensions方法返回的数组中存在,很显然,这里应该由YamlPropertySourceLoader去解析,笔者的application.yml很简单,内容如下

spring:
  profiles:
    active: dev

解析完成之后,调用了一个handleProfileProperties方法,这个方法里又会调用maybeActivateProfiles方法,此方法的addProfiles方法会把解析到的dev添加到profiles中去,removeUnprocessedDefaultProfiles方法会删除之前添加的default(此时只剩下dev),如果配置了include,也会被解析

然后再次遍历file:./config/,file:./,classpath:/config/,classpath:/,这次是查找到application-dev.yml并解析,过程就不重复分析了

回到SpringApplication的run方法

第14行调用printBanner方法,打印SpringBoot的LOGO

 1 private Banner printBanner(ConfigurableEnvironment environment) {
 2     if (this.bannerMode == Banner.Mode.OFF) {
 3         return null;
 4     }
 5     ResourceLoader resourceLoader = this.resourceLoader != null ? this.resourceLoader
 6             : new DefaultResourceLoader(getClassLoader());
 7     SpringApplicationBannerPrinter bannerPrinter = new SpringApplicationBannerPrinter(
 8             resourceLoader, this.banner);
 9     if (this.bannerMode == Mode.LOG) {
10         return bannerPrinter.print(environment, this.mainApplicationClass, logger);
11     }
12     return bannerPrinter.print(environment, this.mainApplicationClass, System.out);
13 }

方法内部会创建一个SpringApplicationBannerPrinter的对象,并调用print方法

1 public Banner print(Environment environment, Class<?> sourceClass, PrintStream out) {
2     Banner banner = getBanner(environment, this.fallbackBanner);
3     banner.printBanner(environment, sourceClass, out);
4     return new PrintedBanner(banner, sourceClass);
5 }

通过getBanner方法获取Banner对象

 1 private Banner getBanner(Environment environment, Banner definedBanner) {
 2     Banners banners = new Banners();
 3     banners.addIfNotNull(getImageBanner(environment));
 4     banners.addIfNotNull(getTextBanner(environment));
 5     if (banners.hasAtLeastOneBanner()) {
 6         return banners;
 7     }
 8     if (this.fallbackBanner != null) {
 9         return this.fallbackBanner;
10     }
11     return DEFAULT_BANNER;
12 }

第一步,调用Banners的无参构造创建一个Banners的对象,然后我们先看一下SpringApplicationBannerPrinter类里定义的几个静态属性

1 static final String BANNER_LOCATION_PROPERTY = "banner.location";
2 
3 static final String BANNER_IMAGE_LOCATION_PROPERTY = "banner.image.location";
4 
5 static final String DEFAULT_BANNER_LOCATION = "banner.txt";
6 
7 static final String[] IMAGE_EXTENSION = { "gif", "jpg", "png" };

先看是否配置了系统属性banner.image.location,没有配置则在classpath中查找banner.gif,banner.jpg,banner.png,如果找到,则创建一个ImageBanner对象并添加到Banners对象的banners属性中,该属性是一个List类型,接下来看是否配置了banner.location属性,如果没有,则用默认的banner.txt,然后在classpath中查找banner.txt,如果找到,则创建一个ResourceBanner对象并添加到Banners对象的banners属性中,最后,如果Banners对象的banners不为空,也就是至少找到了banner.gif,banner.jpg,banner.png,banner.txt其中的一个,那么返回该Banners对象,否则返回默认的SpringBootBanner对象

接下来根据返回的不同Banners类型的对象调用具体的printBanner实现方法,所以如果要想打印自定义的LOGO,要么你在classpath下添加图片,要么添加banner.txt的文本文件,默认的SpringBootBanner会打印如下图

回到SpringApplication的run方法

第15行调用createApplicationContext方法,该方法创建SpringBoot的上下文

 1 protected ConfigurableApplicationContext createApplicationContext() {
 2     Class<?> contextClass = this.applicationContextClass;
 3     if (contextClass == null) {
 4         try {
 5             contextClass = Class.forName(this.webEnvironment
 6                     ? DEFAULT_WEB_CONTEXT_CLASS : DEFAULT_CONTEXT_CLASS);
 7         }
 8         catch (ClassNotFoundException ex) {
 9             throw new IllegalStateException(
10                     "Unable create a default ApplicationContext, "
11                             + "please specify an ApplicationContextClass",
12                     ex);
13         }
14     }
15     return (ConfigurableApplicationContext) BeanUtils.instantiate(contextClass);
16 }

通过判断当前是否是web环境决定创建什么类,如果是web程序,那么创建org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext的实例,否则创建org.springframework.context.annotation.AnnotationConfigApplicationContext的实例,这些都是定义在SpringApplication类的静态属性中

第16行创建FailureAnalyzers的对象,FailureAnalyzers的构造函数里调用了loadFailureAnalyzers方法,还是老规矩,在classpath下的JAR文件中包含的/META/spring.factories文件中找到org.springframework.boot.diagnostics.FailureAnalyzer对应的属性,实例化并排序,赋值给FailureAnalyzers对象的analyzers属性,主要是用来处理启动时发生一些异常时的一些分析

第17行调用prepareContext方法,准备上下文

 1 private void prepareContext(ConfigurableApplicationContext context,
 2             ConfigurableEnvironment environment, SpringApplicationRunListeners listeners,
 3             ApplicationArguments applicationArguments, Banner printedBanner) {
 4     context.setEnvironment(environment);
 5     postProcessApplicationContext(context);
 6     applyInitializers(context);
 7     listeners.contextPrepared(context);
 8     if (this.logStartupInfo) {
 9         logStartupInfo(context.getParent() == null);
10         logStartupProfileInfo(context);
11     }
12 
13     // Add boot specific singleton beans
14     context.getBeanFactory().registerSingleton("springApplicationArguments",
15             applicationArguments);
16     if (printedBanner != null) {
17         context.getBeanFactory().registerSingleton("springBootBanner", printedBanner);
18     }
19 
20     // Load the sources
21     Set<Object> sources = getSources();
22     Assert.notEmpty(sources, "Sources must not be empty");
23     load(context, sources.toArray(new Object[sources.size()]));
24     listeners.contextLoaded(context);
25 }

先给上下文设置环境,然后调用postProcessApplicationContext方法设置上下文的beanNameGenerator和resourceLoader(如果SpringApplication有的话)

接下来调用applyInitializers方法,拿到之前实例化SpringApplication对象的时候设置的ApplicationContextInitializer,调用它们的initialize方法,对上下文做初始化

介绍几个ApplicationContextInitializer,其他的不过多介绍,有兴趣的可以自己DEBUG

DelegatingApplicationContextInitializer: 从环境中(配置的context.initializer.classes)取出所有的ApplicationContextInitializer并执行

ContextIdApplicationContextInitializer: 设置上下文的id(name + ":" + profiles + ":" + index),笔者调试的id为(application:dev:8080)

下面一行调用listeners的contextPrepared方法,跟之前调用listeners的starting方法一样,只是EventPublishingRunListener的contextPrepared方法是个空实现

然后打印启动日志

2017-09-02 22:08:07.196 [INFO ][main]:com.dylan.java.springboot.template.Application[logStarting:48] - Starting Application on Dylan-PC with PID 5764 (F:\Dylan\workspace\template\target\classes started by Dylan in F:\Dylan\workspace\template)
2017-09-02 22:08:12.388 [INFO ][main]:com.dylan.java.springboot.template.Application[logStartupProfileInfo:597] - The following profiles are active: dev

打印完日志往上下文的beanFactory中注册一个singleton的bean,bean的名字是springApplicationArguments,bean的实例是之前实例化的ApplicationArguments对象

如果之前获取的printedBanner不为空,那么往上下文的beanFactory中注册一个singleton的bean,bean的名字是springBootBanner,bean的实例就是这个printedBanner

prepareContext方法的倒数第2行调用load方法注册启动类的bean定义,也就是调用SpringApplication.run(Application.class, args);的类,SpringApplication的load方法内会创建BeanDefinitionLoader的对象,并调用它的load()方法

1 public int load() {
2     int count = 0;
3     for (Object source : this.sources) {
4         count += load(source);
5     }
6     return count;
7 }

对所有的source(这里只有一个: class com.dylan.java.springboot.template.Application)都执行一次load(Object source)方法,这个方法又会调用load(Class<?> source)方法

 1 private int load(Class<?> source) {
 2     if (isGroovyPresent()) {
 3         // Any GroovyLoaders added in beans{} DSL can contribute beans here
 4         if (GroovyBeanDefinitionSource.class.isAssignableFrom(source)) {
 5             GroovyBeanDefinitionSource loader = BeanUtils.instantiateClass(source,
 6                     GroovyBeanDefinitionSource.class);
 7             load(loader);
 8         }
 9     }
10     if (isComponent(source)) {
11         this.annotatedReader.register(source);
12         return 1;
13     }
14     return 0;
15 }

由于@SpringBootApplication注解继承了@SpringBootConfiguration注解,@SpringBootConfiguration注解继承了@Configuration注解,@Configuration注解又继承了@Component注解,所以上面代码的第10行返回true,于是执行第11行和第12行代码

 1 class BeanDefinitionLoader {
 2     private final Object[] sources;
 3 
 4     private final AnnotatedBeanDefinitionReader annotatedReader;
 5 
 6     private final XmlBeanDefinitionReader xmlReader;
 7 
 8     private BeanDefinitionReader groovyReader;
 9 
10     ...
11 }

可以看出BeanDefinitionLoader中有多个加载BeanDefinition的Reader类,这里针对@SpringBootApplication注解使用了annotatedReader,调用register方法,因为启动类没有@Conditional注解,所以不能跳过注册的步骤

那么就老老实实的注册该BeanDefinition吧,没有设置bean是singleton还是prototype,那么默认使用singleton,而bean的名字,则默认是把类名的首字母变小写,也就是application,然后检查是否有@Lazy、@Primary、@DependsOn注解并设置AnnotatedBeanDefinition的属性,如果是AbstractBeanDefinition,还要检查是否有@Role、@Description注解并设置其属性,最后通过BeanDefinitionReaderUtils类的registerBeanDefinition方法注册BeanDefinition

最后调用listeners的contextLoaded方法,说明上下文已经加载,该方法先找到所有的ApplicationListener,遍历这些listener,如果该listener继承了ApplicationContextAware类,那么在这一步会调用它的setApplicationContext方法,设置context

遍历完ApplicationListener之后,创建ApplicationPreparedEvent事件对象,并广播出去,也就是调用所有ApplicationListener的onApplicationEvent方法

最后再回到SpringApplication的run方法,第19行调用refreshContext方法,该方法内容较多,我们放下一篇再分析

转载于:https://www.cnblogs.com/dylan-java/p/7455699.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值