URL
date
AI summary
slug
status
tags
summary
type

背景

我们的配置中心一直是自研的,并且也用了很久,一直也没出现过什么问题。直到最近这段时间,出了两个同类型的问题,于是乎准备来研究并解决一下。
问题原因都是因为引入了某个新包,并且这个新包的classpath根路径下包含了1个application.properties文件,这个时候Spring会加载这个application.properties配置文件。如果该配置文件里有配置中心里没有配置的key-value,那么该配置就会生效(如果配置中心有,那么还是配置中心的优先级更高)。

问题分析

问题的原因很简单。我们知道如果SpringBoot不接入外部配置中心,那么默认就是加载classpath下的application.properties文件。而我们自研的配置中心的client包的实现其实和默认的ConfigFileApplicationListener一样,都是利用了EnvironmentPostProcessor这个扩展点,不过我们的Bean加载的优先级会比默认的更高一些。
所以产生上述问题的原因就是:
  1. 首先我们自定义的实现类优先加载了配置中心的配置文件
  1. 我们的项目的classpath根路径下没有放置application.properties
  1. SpringBoot默认的 ConfigFileApplicationListener加载了classpath下的application.properties,这个时候就可能会加载到其他jar包里的位于classpath下的的application.properties。当然由于jar包加载顺序的不确定性,也没有办法确定具体是加载到哪个application.properties文件
大概原因已经了解了。下面我们再来看看源代码,看看如何解决这个问题。比如看看ConfigFileApplicationListener能不能禁用掉?

SpringBoot配置文件的加载流程——ConfigFileApplicationListener源码分析

public class ConfigFileApplicationListener implements EnvironmentPostProcessor, SmartApplicationListener, Ordered { @Override public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) { addPropertySources(environment, application.getResourceLoader()); } protected void addPropertySources(ConfigurableEnvironment environment, ResourceLoader resourceLoader) { RandomValuePropertySource.addToEnvironment(environment); new Loader(environment, resourceLoader).load(); } }
private class Loader { void load() { // 这里会去enviroment里找名为defaultProperties的PropertySource,如果不存在的话就会根据最后一个参数的Consumer逻辑去加载配置(defaultProperties怎么来的可以看附录) FilteredPropertySource.apply(this.environment, DEFAULT_PROPERTIES, LOAD_FILTERED_PROPERTY, (defaultProperties) -> { this.profiles = new LinkedList<>(); this.processedProfiles = new LinkedList<>(); this.activatedProfiles = false; this.loaded = new LinkedHashMap<>(); // 初始化this.profiles 会给里面添加1个null元素,一系列的profiles(从spring.profiles.active/spring.profiles.include以及通过其他途径设置的profiles) initializeProfiles(); while (!this.profiles.isEmpty()) { Profile profile = this.profiles.poll(); if (isDefaultProfile(profile)) { addProfileToEnvironment(profile.getName()); } // 这里根据每个profile去load对应的配置文件,并加入到this.loaded对应的map里,key为profile,值为MutablePropertySources // positiveProfileFilter指的是加载的配置文件里配置的spring.profiles包含profile,当然我们一般情况下不会在配置文件里配置spring.profiles,所以在后面会看到有2个positiveProfileFilter load(profile, this::getPositiveProfileFilter, addToLoaded(MutablePropertySources::addLast, false)); this.processedProfiles.add(profile); } // 这里的negativeProfileFilter指的就是配置了spring.profiles的默认的配置文件,这种情况在前面的加载流程里是覆盖不到的,所以这里补充一下 load(null, this::getNegativeProfileFilter, addToLoaded(MutablePropertySources::addFirst, true)); // 到这里才会一次性地把整个加载到的PropertySources添加到环境变量里 addLoadedPropertySources(); // 这里主要是根据processedProfiles(过滤掉profile为null的情况),重新设置一下activeProfiles applyActiveProfiles(defaultProperties); }); } }
核心都在这个Loader.load()方法里,这个类比较长,所以相关的解释我就以注释的形式直接加在对应代码附近,这样阅读起来体验会比较好一些。这里对load方法做一个简单的描述,整个load方法其实只有一行代码:FilteredPropertySource.apply,这个方法接受4个参数:
  1. enviroment
  1. propertySourceName(固定为"default.properties")
  1. filteredProperties(固定为Set["spring.profiles.active", "spring.profiles.include"]),
  1. Consumer回调
这个apply方法的大致逻辑为:
  1. 首先会从enviroment里去找名为default.properties的PropertySource
    1. 如果没找到,会直接调用回调函数,且入参为null。
    2. 如果找到了,也会调用回调函数,但是入参为找到的PropertySource。但是回调前后会替换enviroment里对应的PropertySource,调用前会过滤掉Set,调用后又会复原(TODO 这一步暂时没有理解具体是什么用意,有了解的朋友可以指教一下)
这里看得不是太明白也没关系,核心还是在Loader类里,结合上面的Consumer回调函数一起看下面的这些代码:
private class Loader { // 从spring.profiles.active/spring.profiles.include这2个配置或者是容器里设置的profiles(比如通过SpringApplicationBuilder来配置profiles)来初始化this.profiles private void initializeProfiles() { // 看到后面就会了解,这个null是为了加载不带profile的配置文件的,比如application.properties this.profiles.add(null); Binder binder = Binder.get(this.environment); // 通过spring.profiles.active配置的Profile Set<Profile> activatedViaProperty = getProfiles(binder, ACTIVE_PROFILES_PROPERTY); // 通过spring.profiles.include配置的Profile Set<Profile> includedViaProperty = getProfiles(binder, INCLUDE_PROFILES_PROPERTY); // Spring容器里通过其他方式配置的Profile List<Profile> otherActiveProfiles = getOtherActiveProfiles(activatedViaProperty, includedViaProperty); // 注意,这里配置文件的加载顺序是反着的 this.profiles.addAll(otherActiveProfiles); this.profiles.addAll(includedViaProperty); // 这个方法里面主要也是做了this.profiles.addAll(activatedViaProperty) addActiveProfiles(activatedViaProperty); // 如果说没有主动配置profiles,那么就使用默认的,默认的可以通过spring.profiles.default配置,不配置的话就是default,那么此时就是this.profiles就等于[null,"default"] if (this.profiles.size() == 1) { // only has null profile for (String defaultProfileName : this.environment.getDefaultProfiles()) { Profile defaultProfile = new Profile(defaultProfileName, true); this.profiles.add(defaultProfile); } } } // load方法会根据profile,DocumentFilterFactory以及DocumentConsumer来加载配置文件 private void load(Profile profile, DocumentFilterFactory filterFactory, DocumentConsumer consumer) { // 搜索SearchLocations指定的目录下的所有文件,加载满足propertySourceLoaders加载规则的文件 getSearchLocations().forEach((location) -> { boolean isDirectory = location.endsWith("/"); // 这个searchNames就是要搜索的目标文件名列表,可以通过spring.config.name配置,默认为application Set<String> names = isDirectory ? getSearchNames() : NO_SEARCH_NAMES; names.forEach((name) -> load(location, name, profile, filterFactory, consumer)); }); } private void load(String location, String name, Profile profile, DocumentFilterFactory filterFactory, DocumentConsumer consumer) { // 整个方法分成2种情况 // 1. name为空,那么尝试以location作为文件路径去加载 // 2. name不为空,那么以location目录,name作为文件去加载 if (!StringUtils.hasText(name)) { for (PropertySourceLoader loader : this.propertySourceLoaders) { // 以location为文件名去加载的时候不会去拼接后缀,所以这里根据文件扩展名去判断是否满足对应的加载规则 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默认支持两种,Properties和Yaml // 这种情况会直接拼接上对应的后缀去加载文件 for (PropertySourceLoader loader : this.propertySourceLoaders) { for (String fileExtension : loader.getFileExtensions()) { if (processed.add(fileExtension)) { // 这里继续调用loadForFileExtension加载文件 loadForFileExtension(loader, location + name, "." + fileExtension, profile, filterFactory, consumer); } } } } // prefix是 location + name,比如 /config/application,不包括profile和后缀,这里下面要做的就是拼上缺失的profile和fileExtension private void loadForFileExtension(PropertySourceLoader loader, String prefix, String fileExtension, Profile profile, DocumentFilterFactory filterFactory, DocumentConsumer consumer) { // 这里就是前面提到的,会准备2个DocumentFilter // 1个用来处理包含spring.profiles的配置文件,1个用来处理不包含spring.profiles的配置文件 DocumentFilter defaultFilter = filterFactory.getDocumentFilter(null); DocumentFilter profileFilter = filterFactory.getDocumentFilter(profile); if (profile != null) { // Try profile-specific file & profile section in profile file (gh-340) String profileSpecificFile = prefix + "-" + profile + fileExtension; // 第1次load特定profile的配置文件,主要是不带spring.profiles指定profiles的场景 load(loader, profileSpecificFile, profile, defaultFilter, consumer); // 第2次load特定profile的配置文件,主要是有带spring.profiles指定profiles的场景。这种用法应该不太常规,通过在配置文件里再指定一下spring.profiles做过滤,但是配置文件名又是带profile的 load(loader, profileSpecificFile, profile, profileFilter, consumer); // Try profile specific sections in files we've already processed // 第3次load对于之前已经处理过的profile,还要检查一下对应的配置文件是否匹配当前的profile,主要也是针对spring.profiles这种配置设计的 for (Profile processedProfile : this.processedProfiles) { if (processedProfile != null) { String previouslyLoaded = prefix + "-" + processedProfile + fileExtension; load(loader, previouslyLoaded, profile, profileFilter, consumer); } } } // 第4次load,不带profile的配置文件,但是有profileFilter,也就是必须得文件里定义spring.profiles且匹配指定的profile,才有可能被load // 这里可以看到,这个文件可能会被加载N+1次(N=指定的profiles的数量),所以设计了一个缓存,避免每次都去磁盘加载文件 load(loader, prefix + fileExtension, profile, profileFilter, consumer); } // 这里的第2个入参location已经是文件名维度的了,不是目录 private void load(PropertySourceLoader loader, String location, Profile profile, DocumentFilter filter, DocumentConsumer consumer) { // 这里根据location去获取资源,如果包含*通配,那可能拿到的是一个数组,否则就是一个具体的文件资源,但是Resource并不代表存在,还要根据resource.exists()判断 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; } String name = "applicationConfig: [" + getLocationName(location, resource) + "]"; // loadDocuments去加载资源文件 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<>(); for (Document document : documents) { // 这里还要经过一层过滤,过滤逻辑主要就是profile是否匹配 if (filter.match(document)) { // 这2个addProfiles主要是处理自己定义了spring.profiles的配置文件,这些个profiles需要以文件里的为准 addActiveProfiles(document.getActiveProfiles()); addIncludedProfiles(document.getIncludeProfiles()); loaded.add(document); } } Collections.reverse(loaded); if (!loaded.isEmpty()) { 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); } } } private List<Document> loadDocuments(PropertySourceLoader loader, String name, Resource resource) throws IOException { DocumentsCacheKey cacheKey = new DocumentsCacheKey(loader, resource); List<Document> documents = this.loadDocumentsCache.get(cacheKey); if (documents == null) { // 使用PropertySourceLoader去加载具体的资源 List<PropertySource<?>> loaded = loader.load(name, resource); documents = asDocuments(loaded); this.loadDocumentsCache.put(cacheKey, documents); } return documents; } // 把PropertySource转换成Document private List<Document> asDocuments(List<PropertySource<?>> loaded) { if (loaded == null) { return Collections.emptyList(); } return loaded.stream().map((propertySource) -> { Binder binder = new Binder(ConfigurationPropertySources.from(propertySource), this.placeholdersResolver); // spring.profiles这里表明这个配置文件是表示哪些profiles的 // 加载具体配置的时候,需要对这个profiles做过滤 String[] profiles = binder.bind("spring.profiles", STRING_ARRAY).orElse(null); Set<Profile> activeProfiles = getProfiles(binder, ACTIVE_PROFILES_PROPERTY); Set<Profile> includeProfiles = getProfiles(binder, INCLUDE_PROFILES_PROPERTY); return new Document(propertySource, profiles, activeProfiles, includeProfiles); }).collect(Collectors.toList()); } private Set<String> getSearchLocations() { // 取spring.config.additional-location的配置作为searchLocation Set<String> locations = getSearchLocations(CONFIG_ADDITIONAL_LOCATION_PROPERTY); // 如果有配置spring.config.location,那么增加spring.config.location作为searchLocation if (this.environment.containsProperty(CONFIG_LOCATION_PROPERTY)) { locations.addAll(getSearchLocations(CONFIG_LOCATION_PROPERTY)); } else { // 如果没有配置,则用默认值:classpath:/,classpath:/config/,file:./,file:./config/*/,file:./config/ locations.addAll( asResolvedSet(ConfigFileApplicationListener.this.searchLocations, DEFAULT_SEARCH_LOCATIONS)); } return locations; } // 根据propertyName去环境变量找对应的值,并用逗号分割成列表作为searchLocations private Set<String> getSearchLocations(String propertyName) { Set<String> locations = new LinkedHashSet<>(); if (this.environment.containsProperty(propertyName)) { for (String path : asResolvedSet(this.environment.getProperty(propertyName), null)) { if (!path.contains("$")) { path = StringUtils.cleanPath(path); Assert.state(!path.startsWith(ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX), "Classpath wildcard patterns cannot be used as a search location"); validateWildcardLocation(path); if (!ResourceUtils.isUrl(path)) { path = ResourceUtils.FILE_URL_PREFIX + path; } } locations.add(path); } } return locations; } // 一次性把所有加载的配置文件添加到环境变量的PropertySources种 private void addLoadedPropertySources() { MutablePropertySources destination = this.environment.getPropertySources(); List<MutablePropertySources> loaded = new ArrayList<>(this.loaded.values()); // 这里需要reverse一下,也就是后load的配置文件拥有更高的解析优先级,这里对于PropertySources来说,是只要解析到了就返回了,所以越靠前的优先级越高 // 还记得loaded的顺序么? // 1. 先是null 2. 非spring.profiles.active、spring.profiles.include相关的profile 3. include 4. active // 所以配置优先级是 active > include > 其他 > 不带profile的配置文件 Collections.reverse(loaded); String lastAdded = null; Set<String> added = new HashSet<>(); for (MutablePropertySources sources : loaded) { for (PropertySource<?> source : sources) { if (added.add(source.getName())) { addLoadedPropertySource(destination, lastAdded, source); lastAdded = source.getName(); } } } } private void addLoadedPropertySource(MutablePropertySources destination, String lastAdded, PropertySource<?> source) { // lastAdded为null,为此次添加的第一个PropertySource // 这里的位置很讲究,要加在DEFAULT_PROPERTIES之前 // 如果没有,就加在最后 if (lastAdded == null) { if (destination.contains(DEFAULT_PROPERTIES)) { destination.addBefore(DEFAULT_PROPERTIES, source); } else { destination.addLast(source); } } else { // 后续的,就加在最后,只有第一个需要定位 destination.addAfter(lastAdded, source); } } void addActiveProfiles(Set<Profile> profiles) { if (profiles.isEmpty()) { return; } if (this.activatedProfiles) { if (this.logger.isDebugEnabled()) { this.logger.debug("Profiles already activated, '" + profiles + "' will not be applied"); } return; } this.profiles.addAll(profiles); if (this.logger.isDebugEnabled()) { this.logger.debug("Activated activeProfiles " + StringUtils.collectionToCommaDelimitedString(profiles)); } this.activatedProfiles = true; removeUnprocessedDefaultProfiles(); } }
上面这段的逻辑确实有些复杂,主要是因为支持了在文件里配置spring.profiles这种骚操作,不然的话代码逻辑会简单清晰很多。我们再把上面的流程提炼一下:
  1. 构建profiles列表,来源有几个:
    1. 固定的一个null元素,是为了加载不带profile的配置文件,如application.properties
    2. spring.profiles.active
    3. spring.profiles.include
    4. Spring容器里通过其他方式配置的Profile,比如通过SpringApplicationBuilder来配置profiles
  1. 根据1中构建的profiles里的每一个profile,都会应用以下的加载逻辑
    1. 去searchLocations为目录去加载配置文件,可以通过spring.config.additional-location或者spring.config.location来指定
    2. 可以通过spring.config.name来指定基础文件名
    3. 会根据propertySourceLoaders结合基础文件名来加载不同扩展名的配置文件(如xml、properties)
    4. 每一个组合都包含了4次加载动作
      1. 加载文件名带profile但是配置文件里没有配置spring.profiles的配置文件
      2. 加载文件名带profile,配置文件里有配置spring.profiles并且配置的spring.profiles里包含了本profile的配置文件
      3. 处理之前已经加载过的profile对应的配置文件(加载过profile不代表对应的配置文件被加载进来了,因为还有过滤规则),这个时候如果配置文件里配置的spring.profiles包含了本次的profile,那也会加载该配置文件
      4. 加载文件名不带profile,这里分两种情况
        1. profile为null时,此时配置文件里没有配置spring.profiles才会加载
        2. profile不为null时,此时配置文件里必须配置了spring.profiles并且spring.profiles包含profile时才会加载 上述4种情况都还有一个共同的前提是该profile被active了
  1. 再一次去searchLocations下加载配置文件,和第2步差不多,但是此时的profile恒为null
    1. 去searchLocations为目录去加载配置文件,可以通过spring.config.additional-location或者spring.config.location来指定
    2. 可以通过spring.config.name来指定基础文件名
    3. 会根据propertySourceLoaders结合基础文件名来加载不同扩展名的配置文件(如xml、properties)
    4. 由于profile恒为null,所以只会加载文件名不带profile,但是配置文件里配置了spring.profiles,且配置的spring.profiles不为空的配置文件
  1. 再一次性地把整个加载到的PropertySources添加到环境变量里
  1. 根据processedProfiles(过滤掉profile为null的情况),重新设置一下activeProfiles
可以看到虽然我已经尽量用精炼的语言总结了,但是整个流程还是相对比较复杂,并且逻辑也不是非常清晰,包括对这部分代码的设计和想法感觉还是不能完全理解到位。希望对这块有更深入理解的同学可以在评论区提点一二。
另外,关于spring.profiles这种配置的用法我着实没有见过,于是也去请教了一下chatgpt
notion image

自研配置中心client包的代码分析

下面这一段是我们自研配置中心client包加载完远程配置文件之后注入到环境变量的源代码
private void loadToEnv() { Properties result = new Properties(); DisconfConfig.getInstance().getDisconfAppPropList().forEach(disconfAppProp -> { log.info("loading file:{}, app:{}, version:{}", disconfAppProp.getKey(), disconfAppProp.getName(), disconfAppProp.getVersion()); Object content = disconfAppProp.getContent(); if (content instanceof Properties) { CollectionUtils.mergePropertiesIntoMap((Properties) content, result); } }); PropertiesPropertySource propertySource = new PropertiesPropertySource( DISCONF_PROPERTY_SOURCE_NAME, result); MutablePropertySources propertySources = environment.getPropertySources(); if (propertySources.contains(DISCONF_PROPERTY_SOURCE_NAME)) { propertySources.replace(DISCONF_PROPERTY_SOURCE_NAME, propertySource); } else { propertySources.addFirst(propertySource); } }
可以看到这里的实现也有一些问题,因为这里把配置中心的配置放在了第一位(First)。这样一来,配置中心的配置就是第一优先级了,甚至超过了CommandLine参数。所以这里也需要调整一下,也算是这次review代码的时候看出来的一个问题。这里参考ConfigFileApplicationListener的实现调整一下即可:
notion image

优化方案

其实上面讲了半天,主要是为了全面的了解SpringBoot加载配置文件的整个流程。如果你对前文的内容已经消化得差不多了,比较容易能想到一些优化方案:
  1. 修改spring.config.name的配置值,替换成一个不可能用到的名字,如never_exist_999_xxx
  1. 修改spring.config.location的配置值,替换成一个不可能用到的目录,如never_exist_999_xxx
并且这个还不能写死,因为我们既有使用配置中心的项目,也有使用SpringBoot原生配置文件的项目。所以我们把这一段逻辑放到配置中心client包里:
notion image

附录

ClassLoader.getResource

这一段是javadoc告诉我们,通过ClassLoader.getResource
  1. 如果指定name的资源在多个jar包中存在,只能返回其中一个
  1. 具体返回哪一个是根据jar包加载的顺序决定的,但是这个顺序是不可预测的
ConfigFileApplicationListener加载配置文件的时候底层调用的就是这个方法
notion image

defaultProperties

找了半天我说defaultProperties在哪呢
org.springframework.boot.SpringApplication#configurePropertySources
notion image
刷一张亿级表带来的思考Canal解析binlog文件的设计缺陷