Add additional grouping of property sources by profile

Before this change the PropertySources loaded from external config
files were just added to the list for resolution in the order that
they were loaded. That worked for simple cases, but when there are
profiles active, and files themselves can activate profiles, it led
to users not being able to change default settings easily (either
on command line or in files, mostly in files).

The solution proposed here is to group PropertySources by profile
and resolve them in order of profile first, and then in order of
the files being loaded.

There are additional shenanigans because the order of the files
being loaded also has to be carefully defined. The rule for users
is that in a list of files to load (e.g. if set via
spring.config.location), the last one wins (natural if you think of
it as a merge of multiple maps). In addition, anything specified
by a user takes precedence over the defaults (which was broken in
some scenarios before).

Additionally, fixes profile ordering in @ConfigurationProperties(path=...)

Fixes gh-483
This commit is contained in:
Dave Syer 2014-03-21 12:16:59 +00:00
parent 84cc110344
commit 48636e3d6e
7 changed files with 293 additions and 71 deletions

View File

@ -249,10 +249,10 @@ them using `SpringApplication.setAddCommandLineProperties(false)`.
`SpringApplication` will load properties from `application.properties` files in the
following locations and add them to the Spring `Environment`:
. The current directory
. A `/config` subdir of the current directory.
. The classpath root
. The current directory
. A classpath `/config` package
. The classpath root
The list is ordered by precedence (locations higher in the list override lower items).
@ -261,13 +261,29 @@ an alternative to '.properties'.
If you don't like `application.properties` as the configuration file name you can switch
to another by specifying a `spring.config.name` environment property. You can also refer
to an explicit location using the `spring.config.location` environment property.
to an explicit location using the `spring.config.location` environment property (comma-
separated list of directory locations, or file paths).
[indent=0]
----
$ java -jar myproject.jar --spring.config.name=myproject
----
or
[indent=0]
----
$ java -jar myproject.jar --spring.config.location=classpath:/default.properties,classpath:/override.properties
----
If `spring.config.location` contains directories (as opposed to files)
they should end in "/" (and will be appended with the names generated
from `spring.config.name` before being loaded). The default search
path `classpath:,classpath:/config,file:,file:config/` is always used,
irrespective of the value of `spring.config.location`. In that way you
can set up default values for your app in `application.properties` (or
whatever other basename you choose with `spring.config.name`) and
override it at runtime with a different file, keeping the defaults.
[[boot-features-external-config-profile-specific-properties]]

View File

@ -35,6 +35,7 @@ import org.springframework.boot.bind.PropertySourcesPropertyValues;
import org.springframework.boot.bind.RelaxedDataBinder;
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
import org.springframework.boot.context.event.ApplicationPreparedEvent;
import org.springframework.boot.env.EnumerableCompositePropertySource;
import org.springframework.boot.env.PropertySourcesLoader;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationListener;
@ -98,8 +99,8 @@ public class ConfigFileApplicationListener implements
private static final String CONFIG_LOCATION_PROPERTY = "spring.config.location";
private static final String DEFAULT_SEARCH_LOCATIONS = "file:./config/,file:./,"
+ "classpath:/config/,classpath:/";
// Note the order is from least to most specific (last one wins)
private static final String DEFAULT_SEARCH_LOCATIONS = "classpath:/,classpath:/config/,file:./,file:./config/";
private static final String DEFAULT_NAMES = "application";
@ -197,8 +198,8 @@ public class ConfigFileApplicationListener implements
* search location should be a directory path (ending in "/") and it will be prefixed
* by the file names constructed from {@link #setSearchNames(String) search names} and
* profiles (if any) plus file extensions supported by the properties loaders.
* Locations are considered in the order specified, with earlier items taking
* precedence.
* Locations are considered in the order specified, with later items taking precedence
* (like a map merge).
*/
public void setSearchLocations(String locations) {
Assert.hasLength(locations, "Locations must not be empty");
@ -288,15 +289,22 @@ public class ConfigFileApplicationListener implements
}
// The default profile for these purposes is represented as null. We add it
// last so that it is first out (active profiles will then override any
// settings in the defaults when the list is reversed later).
// last so that it is first out of the queue (active profiles will then
// override any settings in the defaults when the list is reversed later).
this.profiles.add(null);
while (!this.profiles.isEmpty()) {
String profile = this.profiles.poll();
for (String location : getSearchLocations()) {
for (String name : getSearchNames()) {
load(location, name, profile);
if (!location.endsWith("/")) {
// location is a filename already, so don't search for more
// filenames
load(location, null, profile);
}
else {
for (String name : getSearchNames()) {
load(location, name, profile);
}
}
}
}
@ -307,30 +315,39 @@ public class ConfigFileApplicationListener implements
private void load(String location, String name, String profile)
throws IOException {
// Try to load directly from the location
PropertySource<?> locationPropertySource = load(location, profile);
String group = "profile=" + (profile == null ? "" : profile);
// If that fails, try a search
if (locationPropertySource == null) {
if (!StringUtils.hasText(name)) {
// Try to load directly from the location
loadIntoGroup(group, location, profile);
}
else {
// Search for a file with the given name
for (String ext : this.propertiesLoader.getAllFileExtensions()) {
if (profile != null) {
// Try the profile specific file
load(location + name + "-" + profile + "." + ext, null);
load(location + name + "-" + profile + "." + ext, profile);
loadIntoGroup(group, location + name + "-" + profile + "." + ext,
null);
// Sometimes people put "spring.profiles: dev" in
// application-dev.yml (gh-340). Arguably we should try and error
// out on that, but we can be kind and load it anyway.
loadIntoGroup(group, location + name + "-" + profile + "." + ext,
profile);
}
// Try the profile (if any) specific section of the normal file
load(location + name + "." + ext, profile);
// Also try the profile specific section (if any) of the normal file
loadIntoGroup(group, location + name + "." + ext, profile);
}
}
}
private PropertySource<?> load(String resourceLocation, String profile)
throws IOException {
Resource resource = this.resourceLoader.getResource(resourceLocation);
private PropertySource<?> loadIntoGroup(String identifier, String location,
String profile) throws IOException {
Resource resource = this.resourceLoader.getResource(location);
if (resource != null) {
String name = "applicationConfig: " + resource.getDescription();
String name = "applicationConfig: [" + location + "]";
String group = "applicationConfig: [" + identifier + "]";
PropertySource<?> propertySource = this.propertiesLoader.load(resource,
name, profile);
group, name, profile);
if (propertySource != null) {
maybeActivateProfiles(propertySource
.getProperty(ACTIVE_PROFILES_PROPERTY));
@ -381,11 +398,9 @@ public class ConfigFileApplicationListener implements
environment.setActiveProfiles(profiles.toArray(new String[profiles.size()]));
}
public Set<String> getSearchLocations() {
private Set<String> getSearchLocations() {
Set<String> locations = new LinkedHashSet<String>();
locations.addAll(asResolvedSet(
ConfigFileApplicationListener.this.searchLocations,
DEFAULT_SEARCH_LOCATIONS));
// User-configured settings take precedence, so we do them first
if (this.environment.containsProperty(CONFIG_LOCATION_PROPERTY)) {
for (String path : asResolvedSet(
this.environment.getProperty(CONFIG_LOCATION_PROPERTY), null)) {
@ -398,10 +413,13 @@ public class ConfigFileApplicationListener implements
locations.add(path);
}
}
locations.addAll(asResolvedSet(
ConfigFileApplicationListener.this.searchLocations,
DEFAULT_SEARCH_LOCATIONS));
return locations;
}
public Set<String> getSearchNames() {
private Set<String> getSearchNames() {
if (this.environment.containsProperty(CONFIG_NAME_PROPERTY)) {
return asResolvedSet(this.environment.getProperty(CONFIG_NAME_PROPERTY),
null);
@ -410,16 +428,10 @@ public class ConfigFileApplicationListener implements
}
private Set<String> asResolvedSet(String value, String fallback) {
return asResolvedSet(value, fallback, true);
}
private Set<String> asResolvedSet(String value, String fallback, boolean reverse) {
List<String> list = Arrays.asList(StringUtils
.commaDelimitedListToStringArray(value != null ? this.environment
.resolvePlaceholders(value) : fallback));
if (reverse) {
Collections.reverse(list);
}
Collections.reverse(list);
return new LinkedHashSet<String>(list);
}
@ -428,7 +440,6 @@ public class ConfigFileApplicationListener implements
for (PropertySource<?> item : sources) {
reorderedSources.add(item);
}
Collections.reverse(reorderedSources);
this.environment.getPropertySources().addLast(
new ConfigurationPropertySources(reorderedSources));
}
@ -477,7 +488,15 @@ public class ConfigFileApplicationListener implements
.remove(ConfigurationPropertySources.NAME);
if (removed != null) {
for (PropertySource<?> propertySource : removed.sources) {
propertySources.addLast(propertySource);
if (propertySource instanceof EnumerableCompositePropertySource) {
EnumerableCompositePropertySource composite = (EnumerableCompositePropertySource) propertySource;
for (PropertySource<?> nested : composite.getSource()) {
propertySources.addLast(nested);
}
}
else {
propertySources.addLast(propertySource);
}
}
}
}

View File

@ -338,10 +338,12 @@ public class ConfigurationPropertiesBindingPostProcessor implements BeanPostProc
for (String location : locations) {
Resource resource = this.resourceLoader.getResource(this.environment
.resolvePlaceholders(location));
for (String profile : this.environment.getActiveProfiles()) {
loader.load(resource, null, profile);
String[] profiles = this.environment.getActiveProfiles();
for (int i = profiles.length; i-- > 0;) {
String profile = profiles[i];
loader.load(resource, profile);
}
loader.load(resource, null, null);
loader.load(resource);
}
return loader.getPropertySources();
}

View File

@ -0,0 +1,78 @@
/*
* Copyright 2012-2013 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.env;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.PropertySource;
/**
* An mutable, enumerable, composite property source. New sources are added last (and
* hence resolved with lowest priority).
*
* @see PropertySource
* @see EnumerablePropertySource
*
* @author Dave Syer
*/
public class EnumerableCompositePropertySource extends
EnumerablePropertySource<Collection<PropertySource<?>>> {
private volatile String[] names;
public EnumerableCompositePropertySource(String sourceName) {
super(sourceName, new LinkedHashSet<PropertySource<?>>());
}
@Override
public Object getProperty(String name) {
for (PropertySource<?> propertySource : getSource()) {
Object value = propertySource.getProperty(name);
if (value != null) {
return value;
}
}
return null;
}
@Override
public String[] getPropertyNames() {
String[] result = this.names;
if (result == null) {
List<String> names = new ArrayList<String>();
for (PropertySource<?> source : new ArrayList<PropertySource<?>>(getSource())) {
if (source instanceof EnumerablePropertySource) {
names.addAll(Arrays.asList(((EnumerablePropertySource<?>) source)
.getPropertyNames()));
}
}
this.names = names.toArray(new String[0]);
result = this.names;
}
return result;
}
public void add(PropertySource<?> source) {
getSource().add(source);
this.names = null;
}
}

View File

@ -64,6 +64,31 @@ public class PropertySourcesLoader {
/**
* Load the specified resource (if possible) and add it as the first source.
* @param resource the source resource (may be {@code null}).
* @return the loaded property source or {@code null}
* @throws IOException
*/
public PropertySource<?> load(Resource resource) throws IOException {
return this.load(resource, null);
}
/**
* Load the profile-specific properties from the specified resource (if any) and add
* it as the first source.
*
* @param resource the source resource (may be {@code null}).
* @param profile a specific profile to load or {@code null} to load the default.
* @return the loaded property source or {@code null}
* @throws IOException
*/
public PropertySource<?> load(Resource resource, String profile) throws IOException {
return this.load(resource, resource.getDescription(), profile);
}
/**
* Load the profile-specific properties from the specified resource (if any), give the
* name provided and add it as the first source.
*
* @param resource the source resource (may be {@code null}).
* @param name the root property name (may be {@code null}).
* @param profile a specific profile to load or {@code null} to load the default.
* @return the loaded property source or {@code null}
@ -71,13 +96,36 @@ public class PropertySourcesLoader {
*/
public PropertySource<?> load(Resource resource, String name, String profile)
throws IOException {
return this.load(resource, null, name, profile);
}
/**
* Load the profile-specific properties from the specified resource (if any), give the
* name provided and add it to a group of property sources identified by the group
* name. Property sources are added to the end of a group, but new groups are added as
* the first in the chain being assembled. This means the normal sequence of calls is
* to first create the group for the default (null) profile, and then add specific
* groups afterwards (with the highest priority last). Property resolution from the
* resulting sources will consider all keys for a given group first and then move to
* the next group.
*
* @param resource the source resource (may be {@code null}).
* @param group an identifier for the group that this source belongs to
* @param name the root property name (may be {@code null}).
* @param profile a specific profile to load or {@code null} to load the default.
* @return the loaded property source or {@code null}
* @throws IOException
*/
public PropertySource<?> load(Resource resource, String group, String name,
String profile) throws IOException {
if (isFile(resource)) {
name = generatePropertySourceName(resource, name, profile);
String sourceName = generatePropertySourceName(name, profile);
for (PropertySourceLoader loader : this.loaders) {
if (canLoadFileExtension(loader, resource)) {
PropertySource<?> source = loader.load(name, resource, profile);
addPropertySource(source);
return source;
PropertySource<?> specific = loader.load(sourceName, resource,
profile);
addPropertySource(group, specific, profile);
return specific;
}
}
}
@ -91,11 +139,7 @@ public class PropertySourcesLoader {
.getFilename()));
}
private String generatePropertySourceName(Resource resource, String name,
String profile) {
if (name == null) {
name = resource.getDescription();
}
private String generatePropertySourceName(String name, String profile) {
return (profile == null ? name : name + "#" + profile);
}
@ -109,10 +153,37 @@ public class PropertySourcesLoader {
return false;
}
private void addPropertySource(PropertySource<?> propertySource) {
if (propertySource != null) {
this.propertySources.addLast(propertySource);
private void addPropertySource(String basename, PropertySource<?> source,
String profile) {
if (source == null) {
return;
}
if (basename == null) {
this.propertySources.addLast(source);
return;
}
EnumerableCompositePropertySource group = getGeneric(basename);
group.add(source);
if (this.propertySources.contains(group.getName())) {
this.propertySources.replace(group.getName(), group);
}
else {
this.propertySources.addFirst(group);
}
}
private EnumerableCompositePropertySource getGeneric(String name) {
PropertySource<?> source = this.propertySources.get(name);
if (source instanceof EnumerableCompositePropertySource) {
return (EnumerableCompositePropertySource) source;
}
EnumerableCompositePropertySource composite = new EnumerableCompositePropertySource(
name);
return composite;
}
/**

View File

@ -36,6 +36,7 @@ import org.junit.rules.ExpectedException;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.context.config.ConfigFileApplicationListener.ConfigurationPropertySources;
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
import org.springframework.boot.env.EnumerableCompositePropertySource;
import org.springframework.boot.test.EnvironmentTestUtils;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Configuration;
@ -91,8 +92,7 @@ public class ConfigFileApplicationListenerTests {
@Test
public void loadTwoPropertiesFile() throws Exception {
EnvironmentTestUtils.addEnvironment(this.environment, "spring.config.location:"
+ "classpath:testproperties.properties,"
+ "classpath:application.properties");
+ "classpath:application.properties,classpath:testproperties.properties");
this.initializer.onApplicationEvent(this.event);
String property = this.environment.getProperty("my.property");
assertThat(property, equalTo("frompropertiesfile"));
@ -101,8 +101,7 @@ public class ConfigFileApplicationListenerTests {
@Test
public void loadTwoPropertiesFilesWithProfiles() throws Exception {
EnvironmentTestUtils.addEnvironment(this.environment, "spring.config.location:"
+ "classpath:enableprofile.properties,"
+ "classpath:enableother.properties");
+ "classpath:enableprofile.properties,classpath:enableother.properties");
this.initializer.onApplicationEvent(this.event);
assertEquals("other", StringUtils.arrayToCommaDelimitedString(this.environment
.getActiveProfiles()));
@ -120,7 +119,25 @@ public class ConfigFileApplicationListenerTests {
StringUtils.arrayToCommaDelimitedString(this.environment
.getActiveProfiles()));
String property = this.environment.getProperty("my.property");
assertThat(property, equalTo("fromtwopropertiesfile"));
// The value from the second file wins (no profile specific configuration is
// actually loaded)
assertThat(property, equalTo("frompropertiesfile"));
}
@Test
public void loadTwoPropertiesFilesWithProfilesAndSwitchOneOffFromSpecificLocation()
throws Exception {
EnvironmentTestUtils.addEnvironment(this.environment,
"spring.config.name:enabletwoprofiles",
"spring.config.location:classpath:enableprofile.properties");
this.initializer.onApplicationEvent(this.event);
assertEquals("myprofile",
StringUtils.arrayToCommaDelimitedString(this.environment
.getActiveProfiles()));
String property = this.environment.getProperty("my.property");
// The value from the second file wins (no profile specific configuration is
// actually loaded)
assertThat(property, equalTo("frompropertiesfile"));
}
@Test
@ -158,8 +175,8 @@ public class ConfigFileApplicationListenerTests {
@Test
public void loadTwoOfThreePropertiesFile() throws Exception {
EnvironmentTestUtils.addEnvironment(this.environment, "spring.config.location:"
+ "classpath:testproperties.properties,"
+ "classpath:application.properties,"
+ "classpath:testproperties.properties,"
+ "classpath:nonexistent.properties");
this.initializer.onApplicationEvent(this.event);
String property = this.environment.getProperty("my.property");
@ -178,7 +195,8 @@ public class ConfigFileApplicationListenerTests {
this.initializer.setSearchNames("moreproperties,testproperties");
this.initializer.onApplicationEvent(this.event);
String property = this.environment.getProperty("my.property");
assertThat(property, equalTo("frommorepropertiesfile"));
// The search order has highest precedence last (like merging a map)
assertThat(property, equalTo("frompropertiesfile"));
}
@Test
@ -273,6 +291,8 @@ public class ConfigFileApplicationListenerTests {
public void yamlSetsProfiles() throws Exception {
this.initializer.setSearchNames("testsetprofiles");
this.initializer.onApplicationEvent(this.event);
assertEquals("dev", StringUtils.arrayToCommaDelimitedString(this.environment
.getActiveProfiles()));
String property = this.environment.getProperty("my.property");
assertThat(Arrays.asList(this.environment.getActiveProfiles()), contains("dev"));
assertThat(property, equalTo("fromdevprofile"));
@ -283,13 +303,20 @@ public class ConfigFileApplicationListenerTests {
assertEquals(2, sources.size());
List<String> names = new ArrayList<String>();
for (org.springframework.core.env.PropertySource<?> source : sources) {
names.add(source.getName());
if (source instanceof EnumerableCompositePropertySource) {
for (org.springframework.core.env.PropertySource<?> nested : ((EnumerableCompositePropertySource) source)
.getSource()) {
names.add(nested.getName());
}
}
else {
names.add(source.getName());
}
}
assertThat(
names,
contains(
"applicationConfig: class path resource [testsetprofiles.yml]#dev",
"applicationConfig: class path resource [testsetprofiles.yml]"));
contains("applicationConfig: [classpath:/testsetprofiles.yml]#dev",
"applicationConfig: [classpath:/testsetprofiles.yml]"));
}
@Test
@ -320,10 +347,10 @@ public class ConfigFileApplicationListenerTests {
String property = this.environment.getProperty("my.property");
assertThat(property, equalTo("fromspecificlocation"));
assertThat(this.environment, containsProperySource("applicationConfig: "
+ "class path resource [specificlocation.properties]"));
+ "[classpath:specificlocation.properties]"));
// The default property source is still there
assertThat(this.environment, containsProperySource("applicationConfig: "
+ "class path resource [application.properties]"));
+ "[classpath:/application.properties]"));
assertThat(this.environment.getProperty("foo"), equalTo("bucket"));
}
@ -333,8 +360,8 @@ public class ConfigFileApplicationListenerTests {
EnvironmentTestUtils.addEnvironment(this.environment, "spring.config.location:"
+ location);
this.initializer.onApplicationEvent(this.event);
assertThat(this.environment, containsProperySource("applicationConfig: "
+ "URL [" + location + "]"));
assertThat(this.environment, containsProperySource("applicationConfig: ["
+ location + "]"));
}
@Test
@ -343,8 +370,8 @@ public class ConfigFileApplicationListenerTests {
EnvironmentTestUtils.addEnvironment(this.environment, "spring.config.location:"
+ location);
this.initializer.onApplicationEvent(this.event);
assertThat(this.environment, containsProperySource("applicationConfig: "
+ "URL [file:" + location + "]"));
assertThat(this.environment, containsProperySource("applicationConfig: [file:"
+ location + "]"));
}
@Test

View File

@ -277,6 +277,16 @@ public class EnableConfigurationPropertiesTests {
assertEquals("bar", this.context.getBean(ResourceBindingProperties.class).name);
}
@Test
public void testBindingDirectlyToFileWithTwoExplicitSpringProfiles() {
this.context.register(ResourceBindingProperties.class, TestConfiguration.class);
this.context.getEnvironment().setActiveProfiles("super", "other");
this.context.refresh();
assertEquals(1,
this.context.getBeanNamesForType(ResourceBindingProperties.class).length);
assertEquals("spam", this.context.getBean(ResourceBindingProperties.class).name);
}
@Test
public void testBindingWithTwoBeans() {
this.context.register(MoreConfiguration.class, TestConfiguration.class);
@ -357,7 +367,6 @@ public class EnableConfigurationPropertiesTests {
assertEquals("value3", bean.mymap.get("key3"));
// this should not fail!!!
// mymap looks to contain - {key1=, key3=value3}
System.err.println(bean.mymap);
assertEquals("value12", bean.mymap.get("key1.key2"));
}