SpringBoot和SpringCloud的配置文件的加载(源码级解读)

SpringBoot版本 2.3.5.RELEASE

SpringCloud版本 Hoxton.SR9


本文只讨论配置文件加载,以bootstrap.yml和application.yml为例,后缀名的加载顺序可以通过源码看到.


bootstrap.yml是SpringCloud使用的配置文件,SpringBoot中其实并没有加载bootStrap.yml的默认实现



1. 概述

SpringBoot加载配置文件的方式是使用了观察者模式,在启动时发出一个事件(ApplicationEnvironmentPreparedEvent),然后基于这个事件,来做配置文件的加载或者其他的一些操作,这种模式扩展性较强.

而bootstrap.yml的加载就借助了这种模式,SpringCloud扩展了一个BootstrapApplicationListener监听器,来处理该事件,在这个监听器里做加载.

2. application.yml的加载

先看Springboot配置文件的加载

从springBoot启动起

2.1 启动类xxxApplication中 SpringApplication.run()

public static void main(String[] args) {
        SpringApplication.run(Demo1Application.class, args);
    }


2.2 进入run方法 内部实现再次进入run方法,再次进入run方法

public static ConfigurableApplicationContext run(Class<?> primarySource, String... args) {
    return run(new Class<?>[] { primarySource }, args);
 }
 /**
  * Static helper that can be used to run a {@link SpringApplication} from the
  * specified sources using default settings and user supplied arguments.
  * @param primarySources the primary sources to load
  * @param args the application arguments (usually passed from a Java main method)
  * @return the running {@link ApplicationContext}
  */
 public static ConfigurableApplicationContext run(Class<?>[] primarySources, String[] args) {
        // 此处new SpringApplication 进行一些默认的初始化
    return new SpringApplication(primarySources).run(args);
 }


2.3 prepareEnvironment

/**
  * Run the Spring application, creating and refreshing a new
  * {@link ApplicationContext}.
  * @param args the application arguments (usually passed from a Java main method)
  * @return a running {@link ApplicationContext}
  */
 public ConfigurableApplicationContext run(String... args) {
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();
    ConfigurableApplicationContext context = null;
    Collection<SpringBootExceptionReporter> exceptionReporters = new ArrayList<>();
    configureHeadlessProperty();
    SpringApplicationRunListeners listeners = getRunListeners(args);
    listeners.starting();
    try {
   ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
            // 在此处进行环境的处理 也就是配置文件的的读取和加载
   ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments); 
   configureIgnoreBeanInfo(environment);
   Banner printedBanner = printBanner(environment);
   context = createApplicationContext();
   exceptionReporters = getSpringFactoriesInstances(SpringBootExceptionReporter.class,
     new Class[] { ConfigurableApplicationContext.class }, context);
   prepareContext(context, environment, listeners, applicationArguments, printedBanner);
   refreshContext(context);
   afterRefresh(context, applicationArguments);
   stopWatch.stop();
   if (this.logStartupInfo) {
    new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch);
   }
   listeners.started(context);
   callRunners(context, applicationArguments);
    }
    catch (Throwable ex) {
   handleRunFailure(context, ex, exceptionReporters, listeners);
   throw new IllegalStateException(ex);
    }
    try {
   listeners.running(context);
    }
    catch (Throwable ex) {
   handleRunFailure(context, ex, exceptionReporters, null);
   throw new IllegalStateException(ex);
    }
    return context;
 }


2.4 进入prepareEnvironment方法,此时会进入执行监听器的environmentPrepared方法

private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners,
   ApplicationArguments applicationArguments) {
    // Create and configure the environment
    ConfigurableEnvironment environment = getOrCreateEnvironment();
    configureEnvironment(environment, applicationArguments.getSourceArgs());
    ConfigurationPropertySources.attach(environment);
     
     // 执行监听器的方法
    listeners.environmentPrepared(environment);
    
    bindToSpringApplication(environment);
    if (!this.isCustomEnvironment) {
   environment = new EnvironmentConverter(getClassLoader()).convertEnvironmentIfNecessary(environment,
     deduceEnvironmentClass());
    }
    ConfigurationPropertySources.attach(environment);
    return environment;
 }


2.5 进入environmentPrepared方法,此处循环所有的监听器,并执行方法

void environmentPrepared(ConfigurableEnvironment environment) {
    for (SpringApplicationRunListener listener : this.listeners) {
   listener.environmentPrepared(environment);
    }
 }


可以通过debug看到只有一个监听器SpringBoot和SpringCloud的配置文件的加载(源码级解读)

2.6 进入listener.environmentPrepared,创建一个事件并执行事件

@Override
 public void environmentPrepared(ConfigurableEnvironment environment) {
    this.initialMulticaster
    .multicastEvent(new ApplicationEnvironmentPreparedEvent(this.application, this.args, environment));
 }


2.7 进入该方法 multicastEvent,并再次进入重载方法 multicastEvent


@Override
 public void multicastEvent(final ApplicationEvent event, @Nullable ResolvableType eventType) {
    ResolvableType type = (eventType != null ? eventType : resolveDefaultEventType(event));
    Executor executor = getTaskExecutor();
        // getApplicationListeners 获取监听器列表
    for (ApplicationListener<?> listener : getApplicationListeners(event, type)) {
   if (executor != null) {
    executor.execute(() -> invokeListener(listener, event));
   }
   else {
                // 执行处理方法
    invokeListener(listener, event);
   }
    }
 }


可以通过debug进入getApplicationListeners看一下,这里根据事件的类型查询可以处理该事件的监听器

SpringBoot和SpringCloud的配置文件的加载(源码级解读)

其中ConfigFileApplicationListener就是重点对象,就是这个监听器加载了配置文件

2.8 invokeListener 调用监听器

继续进入重载方法

/**
  * Invoke the given listener with the given event.
  * @param listener the ApplicationListener to invoke
  * @param event the current event to propagate
  * @since 4.1
  */
 protected void invokeListener(ApplicationListener<?> listener, ApplicationEvent event) {
    ErrorHandler errorHandler = getErrorHandler();
    if (errorHandler != null) {
   try {
    doInvokeListener(listener, event);
   }
   catch (Throwable err) {
    errorHandler.handleError(err);
   }
    }
    else {
   doInvokeListener(listener, event);
    }
 }
// 重载方法
private void doInvokeListener(ApplicationListener listener, ApplicationEvent event) {
    try {
            // 可以看到是调用了对应监听器的onApplicationEvent方法
   listener.onApplicationEvent(event);
    }
    catch (ClassCastException ex) {
   String msg = ex.getMessage();
   if (msg == null || matchesClassCastMessage(msg, event.getClass())) {
    // Possibly a lambda-defined listener which we could not resolve the generic event type for
    // -> let's suppress the exception and just log a debug message.
    Log logger = LogFactory.getLog(getClass());
    if (logger.isTraceEnabled()) {
     logger.trace("Non-matching event type for listener: " + listener, ex);
    }
   }
   else {
    throw ex;
   }
    }
 }


2.9 ConfigFileApplicationListener$onApplicationEvent方法

ConfigFileApplicationListener就是最终进行操作的类,在这个类里定义了配置文件默认目录和默认名字

SpringBoot和SpringCloud的配置文件的加载(源码级解读)

进入onApplicationEvent方法

看到基于不同的事件,进行不同的处理

@Override
 public void onApplicationEvent(ApplicationEvent event) {
    if (event instanceof ApplicationEnvironmentPreparedEvent) {
            // 进入此方法,加载配置文件
   onApplicationEnvironmentPreparedEvent((ApplicationEnvironmentPreparedEvent) event);
    }
    if (event instanceof ApplicationPreparedEvent) {
   onApplicationPreparedEvent(event);
    }
 }


2.10 配置文件的加载

接下来就是配置文件的加载,接下面的源码就是分析SpringBoot如何加载application-xxx.yml

2.10.1 onApplicationEnvironmentPreparedEvent方法

紧跟上文进入onApplicationEnvironmentPreparedEvent方法

private void onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event) {
    // 根据spring.factories文件加载处理器. 有兴趣可以debug进去看下
     List<EnvironmentPostProcessor> postProcessors = loadPostProcessors();
    // 把ConfigFileApplicationListener也加进去
     postProcessors.add(this);
     // 根据Order排序处理器
    AnnotationAwareOrderComparator.sort(postProcessors);
     
     // 循环执行处理器的处理方法
    for (EnvironmentPostProcessor postProcessor : postProcessors) {
   postProcessor.postProcessEnvironment(event.getEnvironment(), event.getSpringApplication());
    }
 }


此处重点在于把ConfigFileApplicationListener加载到了postProcessors中,

可以debug看一下postProcessors

SpringBoot和SpringCloud的配置文件的加载(源码级解读)

然后接下来就会执行ConfigFileApplicationListener.postProcessEnvironment方法

2.10.2 postProcessEnvironment

进入ConfigFileApplicationListener.postProcessEnvironment方法

在进入addPropertySources方法,

可以看到new Loader(environment, resourceLoader).load();

Loader是ConfigFileApplicationListener的一个内部类,在load方法内进行配置文件的加载

@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
    addPropertySources(environment, application.getResourceLoader());
}
/**
  * Add config file property sources to the specified environment.
  * @param environment the environment to add source to
  * @param resourceLoader the resource loader
  * @see #addPostProcessors(ConfigurableApplicationContext)
  */
protected void addPropertySources(ConfigurableEnvironment environment, ResourceLoader resourceLoader) {
    RandomValuePropertySource.addToEnvironment(environment);
    new Loader(environment, resourceLoader).load();
}


2.10.3 load方法

这串代码就是配置文件的加载

void load() {
    FilteredPropertySource.apply(this.environment, DEFAULT_PROPERTIES, LOAD_FILTERED_PROPERTY,
                                 (defaultProperties) -> {
                                     
                                     // 初始化配置
                                     this.profiles = new LinkedList<>();
                                     this.processedProfiles = new LinkedList<>();
                                     
                                     // 默认启用false 
                                     this.activatedProfiles = false;
                                     this.loaded = new LinkedHashMap<>();
                                     
                                     // 初始化配置, 也就是default
                                     initializeProfiles();
                                     
                                     // 初始化后this.profiles会有一个default的配置
                                     while (!this.profiles.isEmpty()) {
                                         Profile profile = this.profiles.poll();
                                         if (isDefaultProfile(profile)) {
                                             addProfileToEnvironment(profile.getName());
                                         }
                                         // 进入此方法就可以看到配置文件的加载了 addToLoaded是一个回调,主要是配置属性的合并
                                         load(profile, this::getPositiveProfileFilter,
                                              addToLoaded(MutablePropertySources::addLast, false));
                                         this.processedProfiles.add(profile);
                                     }
                                     load(null, this::getNegativeProfileFilter, addToLoaded(MutablePropertySources::addFirst, true));
                                     addLoadedPropertySources();
                                     applyActiveProfiles(defaultProperties);
                                 });
}


initializeProfiles后会默认初始化一个default的配置

SpringBoot和SpringCloud的配置文件的加载(源码级解读)

循环配置调用重载的load方法

2.10.4 根据profile获取配置文件目录和配置文件名

进入上文中的load方法

private void load(Profile profile, DocumentFilterFactory filterFactory, DocumentConsumer consumer) {
    // getSearchLocations这个会获取默认的配置文件路径 
    getSearchLocations().forEach((location) -> {
        // 是否是目录,默认配置文件路径都是以/结尾的
        boolean isDirectory = location.endsWith("/");
        // 获取默认的配置文件名称也就是spring.config.name属性 默认application
        Set<String> names = isDirectory ? getSearchNames() : NO_SEARCH_NAMES;
        // 再次循环 调用load重载方法
        names.forEach((name) -> load(location, name, profile, filterFactory, consumer));
    });
}


可以看到getSearchLocations方法会获取配置文件路径,如下,正是SpringBoot默认的配置文件加载顺序,但是这个顺序是反过来的

SpringBoot和SpringCloud的配置文件的加载(源码级解读)


2.10.5 循环配置文件后缀名

进入load重载方法

private void load(String location, String name, Profile profile, DocumentFilterFactory filterFactory,
    DocumentConsumer consumer) {
    // 如果配置文件名称为空进行处理,默认都是有值的 application
    if (!StringUtils.hasText(name)) {
        for (PropertySourceLoader loader : this.propertySourceLoaders) {
            if (canLoadFileExtension(loader, location)) {
                load(loader, location, profile, filterFactory.getDocumentFilter(profile), consumer);
                return;
            }
        }
        throw new IllegalStateException("File extension of config file location '" + location
                                        + "' is not known to any PropertySourceLoader. If the location is meant to reference "
                                        + "a directory, it must end in '/'");
    }
    Set<String> processed = new HashSet<>();
    // this.propertySourceLoaders 配置文件加载器默认有两种实现 yaml和properties
    for (PropertySourceLoader loader : this.propertySourceLoaders) {
        // 根据配置文件加载器循环
        for (String fileExtension : loader.getFileExtensions()) {
            if (processed.add(fileExtension)) {
                // 根据路径+名字+文件后缀名加载配置文件 
                loadForFileExtension(loader, location + name, "." + fileExtension, profile, filterFactory,
                                     consumer);
            }
        }
    }
}


this.propertySourceLoaders看一下

SpringBoot和SpringCloud的配置文件的加载(源码级解读)


2.10.6 loadForFileExtension方法

继续重载

private void loadForFileExtension(PropertySourceLoader loader, String prefix, String fileExtension,
    Profile profile, DocumentFilterFactory filterFactory, DocumentConsumer consumer) {
    DocumentFilter defaultFilter = filterFactory.getDocumentFilter(null);
    DocumentFilter profileFilter = filterFactory.getDocumentFilter(profile);
    // 当profile不为空时处理即 active.profiles=xxx时 第一次进行不走这,还是默认的
    if (profile != null) {
        // Try profile-specific file & profile section in profile file (gh-340)
        String profileSpecificFile = prefix + "-" + profile + fileExtension;
        load(loader, profileSpecificFile, profile, defaultFilter, consumer);
        load(loader, profileSpecificFile, profile, profileFilter, consumer);
        // Try profile specific sections in files we've already processed
        for (Profile processedProfile : this.processedProfiles) {
            if (processedProfile != null) {
                String previouslyLoaded = prefix + "-" + processedProfile + fileExtension;
                load(loader, previouslyLoaded, profile, profileFilter, consumer);
            }
        }
    }
    // 默认的配置文件处理 default
    // Also try the profile-specific section (if any) of the normal file
    load(loader, prefix + fileExtension, profile, profileFilter, consumer);
}


2.10.7 配置文件的读取和属性合并

这次重载就是到头了,在这个方法里就会进行属性的读取

private void load(PropertySourceLoader loader, String location, Profile profile, DocumentFilter filter,
    DocumentConsumer consumer) {
    // 根据2.10.6 中拼接的路径加载
    Resource[] resources = getResources(location);
    for (Resource resource : resources) {
        try {
            // 当该拼接的文件不存在时,会直接进行下一次循环
            if (resource == null || !resource.exists()) {
                if (this.logger.isTraceEnabled()) {
                    StringBuilder description = getDescription("Skipped missing config ", location, resource,
                                                               profile);
                    this.logger.trace(description);
                }
                continue;
            }
            // 配置文件为空时也不加载
            if (!StringUtils.hasText(StringUtils.getFilenameExtension(resource.getFilename()))) {
                if (this.logger.isTraceEnabled()) {
                    StringBuilder description = getDescription("Skipped empty config extension ", location,
                                                               resource, profile);
                    this.logger.trace(description);
                }
                continue;
            }
            // 隐藏路径时返回 安全?
            if (resource.isFile() && hasHiddenPathElement(resource)) {
                if (this.logger.isTraceEnabled()) {
                    StringBuilder description = getDescription("Skipped location with hidden path element ",
                                                               location, resource, profile);
                    this.logger.trace(description);
                }
                continue;
            }
            
            // 开始正式加载
            String name = "applicationConfig: [" + getLocationName(location, resource) + "]";
            // 加载配置文件的元素
            List<Document> documents = loadDocuments(loader, name, resource);
            
            // 如果配置文件里没有解析出元素
            if (CollectionUtils.isEmpty(documents)) {
                if (this.logger.isTraceEnabled()) {
                    StringBuilder description = getDescription("Skipped unloaded config ", location, resource,
                                                               profile);
                    this.logger.trace(description);
                }
                continue;
            }
            List<Document> loaded = new ArrayList<>();
            
            // 默认active profile 为default
            for (Document document : documents) {
                if (filter.match(document)) {
                    addActiveProfiles(document.getActiveProfiles());
                    addIncludedProfiles(document.getIncludeProfiles());
                    loaded.add(document);
                }
            }
            
            // 反转属性顺序 目前没看到啥作用.
            Collections.reverse(loaded);
            if (!loaded.isEmpty()) {
                // consumer.accept(profile, document)在回调里合并属性 即高优先级的覆盖低优先级的属性
                loaded.forEach((document) -> consumer.accept(profile, document));
                if (this.logger.isDebugEnabled()) {
                    StringBuilder description = getDescription("Loaded config file ", location, resource,
                                                               profile);
                    this.logger.debug(description);
                }
            }
        }
        catch (Exception ex) {
            StringBuilder description = getDescription("Failed to load property source from ", location,
                                                       resource, profile);
            throw new IllegalStateException(description.toString(), ex);
        }
    }
}


  1. loadDocuments方法可以进去看下,PropertiesPropertySourceLoader 和 YamlPropertySourceLoader加载配置文件的详细代码,其实就是读取文件
  2. Collections.reverse(loaded);这个方法印证了SpringBoot配置文件的加载顺序,在上文中我们看到了SpringBoot读取配置文件目录的顺序是
    1. SpringBoot和SpringCloud的配置文件的加载(源码级解读)
    2. 按这个顺序加载文件.这也印证了SpringBoot配置文件的加载优先级
    3. consumer.accept(profile, document),回调中合并

2.10.8 回调合并属性 addToLoaded

private DocumentConsumer addToLoaded(BiConsumer<MutablePropertySources, PropertySource<?>> addMethod,
    boolean checkForExisting) {
    return (profile, document) -> {
        if (checkForExisting) { // 检查属性是否存在,如果是第一次加载默认的配置文件这个参数为false,2.10.3中可以看到
            for (MutablePropertySources merged : this.loaded.values()) {
                // 如果参数已存在 就不在加载了
                if (merged.contains(document.getPropertySource().getName())) {
                    return;
                }
            }
        }
        // 第一次加载或者属性不存在的化直接放入this.loaded
        MutablePropertySources merged = this.loaded.computeIfAbsent(profile,
                                                                    (k) -> new MutablePropertySources());
        // 调用回调 MutablePropertySources::addLast
        addMethod.accept(merged, document.getPropertySource());
    };
}


addToLoaded 主要就是为了高优先级的属性覆盖低优先级的属性

2.11 总结

SpringBoot基于观察者模式,在ApplicationEnvironmentPreparedEvent事件中,对配置文件加载,配置文件的加载主要在ConfigFileApplicationListener这个类中,基于PropertiesPropertySourceLoader 和 YamlPropertySourceLoader加载配置文件的属性,并根据加载文件的顺序来做高优先级的覆盖低优先级的属性.


3.bootstrap.yml的加载

bootstrap.yml的加载其实还是使用SpringBoot加载配置文件,只不过在SpringBoot的加载之前,先创建了一个名为bootstrap(默认)的context

3.1 BootstrapApplicationListener

引入SpringCloud的依赖后

从上文中2.7开始,会发现listeners多了一个,并且优先级相当高

SpringBoot和SpringCloud的配置文件的加载(源码级解读)

进入该监听器

3.2 BootstrapApplicationListener的onApplicationEvent

进入BootstrapApplicationListener的onApplicationEvent方法,在此方法中进行bootsrap.yml的读取


String configLocation = environment
    .resolvePlaceholders("${spring.cloud.bootstrap.location:}");
String configAdditionalLocation = environment
    .resolvePlaceholders("${spring.cloud.bootstrap.additional-location:}");
Map<String, Object> bootstrapMap = new HashMap<>();
// 给配置文件一个名字 加载bootstrap,yml就是靠这个
bootstrapMap.put("spring.config.name", configName);
@Override
 public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
        // 获取上下文信息
    ConfigurableEnvironment environment = event.getEnvironment();
        
        // 判断是否启用spring.cloud.bootstrap.enabled属性,默认启用,如果不启用就不加载bootstrap.yml文件,直接结束
    if (!environment.getProperty("spring.cloud.bootstrap.enabled", Boolean.class,
    true)) {
   return;
    }
        // 如果已经执行过bootstrap的监听事件,就不再重复执行了,这也是为什么bootstrap.yml属性不变的原因.
    // don't listen to events in a bootstrap context
    if (environment.getPropertySources().contains(BOOTSTRAP_PROPERTY_SOURCE_NAME)) {
   return;
    }
        
        // 开始初始化上下文
    ConfigurableApplicationContext context = null;
        // 获取配置文件名称 默认bootstrap (bootstrap.yml) 就是这里给配置文件复制了
    String configName = environment
    .resolvePlaceholders("${spring.cloud.bootstrap.name:bootstrap}");
        
        // 寻找是否有父容器上下文的初始化器 ParentContextApplicationContextInitializer 正常情况下是没有的 直接往下走
    for (ApplicationContextInitializer<?> initializer : event.getSpringApplication()
    .getInitializers()) {
   if (initializer instanceof ParentContextApplicationContextInitializer) {
    context = findBootstrapContext(
        (ParentContextApplicationContextInitializer) initializer,
        configName);
   }
    }
        // 没有ParentContextApplicationContextInitializer 父容器初始化的化,就创建一个
    if (context == null) {
   context = bootstrapServiceContext(environment, event.getSpringApplication(),
     configName);
   event.getSpringApplication()
     .addListeners(new CloseContextOnFailureApplicationListener(context));
    }
    apply(context, event.getSpringApplication(), environment);
 }


进入bootstrapServiceContext方法,方法太长不再全部粘贴,这个方法里最重要的就是根据bootstrap.yml创建出一个SpringApplication对象

// 创建SpringApplication对象 
SpringApplicationBuilder builder = new SpringApplicationBuilder()
    .profiles(environment.getActiveProfiles()).bannerMode(Mode.OFF)
    .environment(bootstrapEnvironment)
    // Don't use the default properties in this builder
    .registerShutdownHook(false).logStartupInfo(false)
    .web(WebApplicationType.NONE);
final SpringApplication builderApplication = builder.application();
// 创建context对象 注意: 此处是又执行了一次 SpringApplication.run()方法.
builder.sources(BootstrapImportSelectorConfiguration.class);
final ConfigurableApplicationContext context = builder.run();
// 设置父容器对象
addAncestorInitializer(application, context);
// It only has properties in it now that we don't want in the parent so remove
// it (and it will be added back later)
bootstrapProperties.remove(BOOTSTRAP_PROPERTY_SOURCE_NAME);
// 合并属性
mergeDefaultProperties(environment.getPropertySources(), bootstrapProperties);


3.3 加载bootStarp.yml配置文件原理

加载配置文件还是上面SpringBoot加载application那一套,但是不同的是SpringBoot给的默认的application在这里并没有使用,而是使用了BootStrapApplicationListener中设置的spring.config.name 如下图.

SpringBoot和SpringCloud的配置文件的加载(源码级解读)

在上文中2.10.4中使用getSearchNames获取要加载的文件名,看下这个方法

private Set<String> getSearchNames() {
    if (this.environment.containsProperty(CONFIG_NAME_PROPERTY)) { //SpringBoot默认不走这个 bootstrap.yml的加载就是依赖这里
        String property = this.environment.getProperty(CONFIG_NAME_PROPERTY); 
        Set<String> names = asResolvedSet(property, null);
        names.forEach(this::assertValidConfigName);
        return names;
    }
    return asResolvedSet(ConfigFileApplicationListener.this.names, DEFAULT_NAMES); // 返回默认的Application
}


这样一来,除了加载文件的名字改变了,其他的都没变,还是SpringBoot这一套.



4. 总结

通过对SpringBoot2.3.5的源码的阅读,学习到如下:

  1. SpringBoot启动时基于事件处理,增加了扩展性,SpringCloud配置文件的加载就是这样
  2. SpringBoot和SpringCloud配置文件的加载,从源码角度上分析配置文件的加载,还有顺序,到底是如何覆盖的
  3. SpringBoot的代码写的真好呀!!
上一篇:Java数据类型


下一篇:使用Python Shapefile Library创建和编辑Shapefile文件