Commit f0bfecd3 authored by Phillip Webb's avatar Phillip Webb

Refactor PropertySource support

Locate PropertySourcesLoaders using SpringFactoriesLoader and refactor
the interface to expose file extensions and support 'profiles' within
documents.

Rework ConfigFileApplicationListener for consistent profile loading.
Profiles are now loaded in a consistent order for both profile specific
files, and contained profile documents (i.e. YAML sub-sections).

Also update ConfigFileApplicationListener so that it no longer directly
processes @ProperySource annotations. Instead the standard Spring
ConfigurationClassPostProcessor will insert @PropertySource items with
ConfigFileApplicationListener later re-ordering them.

The SpringApplication can no longer be configured using @ProperySource
annotations, however, application.properties may still be used.

Fixes gh-322
parent 06494e06
......@@ -98,7 +98,7 @@ public class VcapApplicationListener implements
private static final String VCAP_SERVICES = "VCAP_SERVICES";
// Before ConfigFileApplicationListener so values there can use these ones
private int order = ConfigFileApplicationListener.DEFAULT_CONFIG_LISTENER_ORDER - 1;;
private int order = ConfigFileApplicationListener.DEFAULT_ORDER - 1;;
private final JsonParser parser = JsonParserFactory.getJsonParser();
......
......@@ -16,30 +16,46 @@
package org.springframework.boot.config;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import org.springframework.core.env.Environment;
import org.springframework.util.ClassUtils;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.PropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.util.DigestUtils;
/**
* Default implementation of {@link PropertySourceLoadersFactory}. Provides a
* {@link PropertiesPropertySourceLoader} and when possible a
* {@link YamlPropertySourceLoader}.
* {@link PropertySource} that returns a random value for any property that starts with
* {@literal "random."}. Return a {@code byte[]} unless the property name ends with
* {@literal ".int} or {@literal ".long"}.
*
* @author Dave Syer
*/
public class DefaultPropertySourceLoadersFactory implements PropertySourceLoadersFactory {
public class RandomValuePropertySource extends PropertySource<Random> {
public RandomValuePropertySource(String name) {
super(name, new Random());
}
@Override
public List<PropertySourceLoader> getLoaders(Environment environment) {
ArrayList<PropertySourceLoader> loaders = new ArrayList<PropertySourceLoader>();
loaders.add(new PropertiesPropertySourceLoader());
if (ClassUtils.isPresent("org.yaml.snakeyaml.Yaml", null)) {
loaders.add(YamlPropertySourceLoader.springProfileAwareLoader(environment
.getActiveProfiles()));
public Object getProperty(String name) {
if (!name.startsWith("random.")) {
return null;
}
if (name.endsWith("int")) {
return getSource().nextInt();
}
return loaders;
if (name.endsWith("long")) {
return getSource().nextLong();
}
byte[] bytes = new byte[32];
getSource().nextBytes(bytes);
return DigestUtils.md5DigestAsHex(bytes);
}
public static void addToEnvironment(ConfigurableEnvironment environment) {
environment.getPropertySources().addAfter(
StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME,
new RandomValuePropertySource("random"));
}
}
/*
* 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.config;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import java.util.Set;
import org.springframework.boot.yaml.DefaultProfileDocumentMatcher;
import org.springframework.boot.yaml.SpringProfileDocumentMatcher;
import org.springframework.boot.yaml.YamlProcessor.DocumentMatcher;
import org.springframework.boot.yaml.YamlProcessor.MatchStatus;
import org.springframework.boot.yaml.YamlPropertiesFactoryBean;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.Resource;
import org.springframework.util.StringUtils;
/**
* Strategy to load '.yml' files into a {@link PropertySource}.
*
* @author Dave Syer
*/
public class YamlPropertySourceLoader extends PropertiesPropertySourceLoader {
private final List<DocumentMatcher> matchers;
/**
* Create a {@link YamlPropertySourceLoader} instance with the specified matchers.
* @param matchers the document matchers
*/
public YamlPropertySourceLoader(DocumentMatcher... matchers) {
this.matchers = Arrays.asList(matchers);
}
@Override
public boolean supports(Resource resource) {
return resource.getFilename().endsWith(".yml");
}
@Override
protected Properties loadProperties(final Resource resource) throws IOException {
YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
if (this.matchers != null && !this.matchers.isEmpty()) {
factory.setMatchDefault(false);
factory.setDocumentMatchers(this.matchers);
}
factory.setResources(new Resource[] { resource });
return factory.getObject();
}
/**
* A property source loader that loads all properties and matches all documents.
* @return a property source loader
*/
public static YamlPropertySourceLoader matchAllLoader() {
return new YamlPropertySourceLoader();
}
/**
* A property source loader that matches documents that have no explicit profile or
* which have an explicit "spring.profiles.active" value in the current active
* profiles.
* @param activeProfiles the active profiles to match independent of file contents
* @return a property source loader
*/
public static YamlPropertySourceLoader springProfileAwareLoader(
String[] activeProfiles) {
final SpringProfileDocumentMatcher matcher = new SpringProfileDocumentMatcher();
for (String profile : activeProfiles) {
matcher.addActiveProfiles(profile);
}
return new YamlPropertySourceLoader(matcher, new DefaultProfileDocumentMatcher() {
@Override
public MatchStatus matches(Properties properties) {
MatchStatus result = super.matches(properties);
if (result == MatchStatus.FOUND) {
Set<String> profiles = StringUtils.commaDelimitedListToSet(properties
.getProperty("spring.profiles.active", ""));
for (String profile : profiles) {
// allow document with no profile to set the active one
matcher.addActiveProfiles(profile);
}
}
return result;
}
});
}
}
......@@ -16,6 +16,8 @@
package org.springframework.boot.context.properties;
import java.io.IOException;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.beans.factory.BeanFactory;
......@@ -26,9 +28,7 @@ import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.boot.bind.PropertiesConfigurationFactory;
import org.springframework.boot.config.PropertiesPropertySourceLoader;
import org.springframework.boot.config.PropertySourceLoader;
import org.springframework.boot.config.YamlPropertySourceLoader;
import org.springframework.boot.env.PropertySourcesLoader;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ConfigurableApplicationContext;
......@@ -332,26 +332,22 @@ public class ConfigurationPropertiesBindingPostProcessor implements BeanPostProc
return this.validator;
}
private PropertySources loadPropertySources(String[] path) {
MutablePropertySources propertySources = new MutablePropertySources();
PropertySourceLoader[] loaders = {
new PropertiesPropertySourceLoader(),
YamlPropertySourceLoader.springProfileAwareLoader(this.environment
.getActiveProfiles()) };
for (String location : path) {
location = this.environment.resolvePlaceholders(location);
Resource resource = this.resourceLoader.getResource(location);
if (resource != null && resource.exists()) {
for (PropertySourceLoader loader : loaders) {
if (loader.supports(resource)) {
PropertySource<?> propertySource = loader.load(
resource.getDescription(), resource);
propertySources.addFirst(propertySource);
}
private PropertySources loadPropertySources(String[] locations) {
try {
PropertySourcesLoader loader = new PropertySourcesLoader();
for (String location : locations) {
Resource resource = this.resourceLoader.getResource(this.environment
.resolvePlaceholders(location));
for (String profile : this.environment.getActiveProfiles()) {
loader.load(resource, null, profile);
}
loader.load(resource, null, null);
}
return loader.getPropertySources();
}
catch (IOException ex) {
throw new IllegalStateException(ex);
}
return propertySources;
}
private ConversionService getDefaultConversionService() {
......
/*
* Copyright 2012-2013 the original author or authors.
* Copyright 2012-2014 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.
......@@ -14,13 +14,10 @@
* limitations under the License.
*/
package org.springframework.boot.config;
package org.springframework.boot.env;
import java.io.IOException;
import java.util.Properties;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.env.PropertiesPropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.Resource;
......@@ -30,34 +27,22 @@ import org.springframework.core.io.support.PropertiesLoaderUtils;
* Strategy to load '.properties' files into a {@link PropertySource}.
*
* @author Dave Syer
* @author Phillip Webb
*/
public class PropertiesPropertySourceLoader implements PropertySourceLoader {
private static Log logger = LogFactory.getLog(PropertiesPropertySourceLoader.class);
@Override
public boolean supports(Resource resource) {
return resource.getFilename().endsWith(".properties");
public String[] getFileExtensions() {
return new String[] { "properties" };
}
@Override
public PropertySource<?> load(String name, Resource resource) {
try {
Properties properties = loadProperties(resource);
// N.B. this is off by default unless user has supplied logback config in
// standard location
if (logger.isDebugEnabled()) {
logger.debug("Properties loaded from " + resource + ": " + properties);
}
return new PropertiesPropertySource(name, properties);
}
catch (IOException ex) {
throw new IllegalStateException("Could not load properties from " + resource,
ex);
public PropertySource<?> load(String name, Resource resource, String profile)
throws IOException {
if (profile != null) {
return null;
}
return new PropertiesPropertySource(name,
PropertiesLoaderUtils.loadProperties(resource));
}
protected Properties loadProperties(Resource resource) throws IOException {
return PropertiesLoaderUtils.loadProperties(resource);
}
}
\ No newline at end of file
}
......@@ -14,29 +14,39 @@
* limitations under the License.
*/
package org.springframework.boot.config;
package org.springframework.boot.env;
import java.io.IOException;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.SpringFactoriesLoader;
/**
* Strategy interface used to load a {@link PropertySource}.
* Strategy interface located via {@link SpringFactoriesLoader} and used to load a
* {@link PropertySource}.
*
* @author Dave Syer
* @author Phillip Webb
*/
public interface PropertySourceLoader {
/**
* Returns {@code true} if the {@link Resource} is supported.
* @return if the resource is supported
* Returns the file extensions that the loader supports (excluding the '.').
*/
boolean supports(Resource resource);
String[] getFileExtensions();
/**
* Load the resource into a property source.
* @param name the name of the property source
* @return a property source
* @param resource the resource to load
* @param profile the name of the profile to load or {@code null}. The profile can be
* used to load multi-document files (such as YAML). Simple property formats should
* {@code null} when asked to load a profile.
* @return a property source or {@code null}
* @throws IOException
*/
PropertySource<?> load(String name, Resource resource);
PropertySource<?> load(String name, Resource resource, String profile)
throws IOException;
}
/*
* Copyright 2012-2014 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.io.IOException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.SpringFactoriesLoader;
import org.springframework.util.Assert;
/**
* Utiltiy that can be used to {@link MutablePropertySources} using
* {@link PropertySourceLoader}s.
*
* @author Phillip Webb
*/
public class PropertySourcesLoader {
private final MutablePropertySources propertySources;
private final List<PropertySourceLoader> loaders;
/**
* Create a new {@link PropertySourceLoader} instance backed by a new
* {@link MutablePropertySources}.
*/
public PropertySourcesLoader() {
this(new MutablePropertySources());
}
/**
* Create a new {@link PropertySourceLoader} instance backed by the specified
* {@link MutablePropertySources}.
* @param propertySources the destination property sources
*/
public PropertySourcesLoader(MutablePropertySources propertySources) {
Assert.notNull(propertySources, "PropertySources must not be null");
this.propertySources = propertySources;
this.loaders = SpringFactoriesLoader.loadFactories(PropertySourceLoader.class,
null);
}
/**
* Load the specified resource (if possible) 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}
* @throws IOException
*/
public PropertySource<?> load(Resource resource, String name, String profile)
throws IOException {
if (resource != null && resource.exists()) {
name = generatePropertySourceName(resource, name, profile);
for (PropertySourceLoader loader : this.loaders) {
if (canLoadFileExtension(loader, resource)) {
PropertySource<?> source = loader.load(name, resource, profile);
addPropertySource(source);
return source;
}
}
}
return null;
}
private String generatePropertySourceName(Resource resource, String name,
String profile) {
if (name == null) {
name = resource.getDescription();
}
return (profile == null ? name : name + "#" + profile);
}
private boolean canLoadFileExtension(PropertySourceLoader loader, Resource resource) {
String filename = resource.getFilename().toLowerCase();
for (String extension : loader.getFileExtensions()) {
if (filename.endsWith("." + extension.toLowerCase())) {
return true;
}
}
return false;
}
private void addPropertySource(PropertySource<?> propertySource) {
if (propertySource != null) {
this.propertySources.addLast(propertySource);
}
}
/**
* Return the {@link MutablePropertySources} being loaded.
*/
public MutablePropertySources getPropertySources() {
return this.propertySources;
}
/**
* Returns all file extensions that could be loaded.
*/
public Set<String> getAllFileExtensions() {
Set<String> fileExtensions = new HashSet<String>();
for (PropertySourceLoader loader : this.loaders) {
fileExtensions.addAll(Arrays.asList(loader.getFileExtensions()));
}
return fileExtensions;
}
}
......@@ -14,26 +14,51 @@
* limitations under the License.
*/
package org.springframework.boot.config;
package org.springframework.boot.env;
import java.util.List;
import java.io.IOException;
import java.util.Properties;
import org.springframework.core.env.Environment;
import org.springframework.boot.yaml.SpringProfileDocumentMatcher;
import org.springframework.boot.yaml.YamlPropertiesFactoryBean;
import org.springframework.core.env.PropertiesPropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.Resource;
import org.springframework.util.ClassUtils;
/**
* Factory to return {@link PropertySourceLoader}s.
* Strategy to load '.yml' files into a {@link PropertySource}.
*
* @author Dave Syer
* @see DefaultPropertySourceLoadersFactory
* @author Phillip Webb
*/
public interface PropertySourceLoadersFactory {
public class YamlPropertySourceLoader implements PropertySourceLoader {
/**
* Return a list of {@link PropertySourceLoader}s in the order that they should be
* tried.
* @param environment the source environment
* @return a list of loaders
*/
List<PropertySourceLoader> getLoaders(Environment environment);
@Override
public String[] getFileExtensions() {
return new String[] { "yml" };
}
@Override
public PropertySource<?> load(String name, Resource resource, String profile)
throws IOException {
if (ClassUtils.isPresent("org.yaml.snakeyaml.Yaml", null)) {
YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean();
if (profile == null) {
factory.setMatchDefault(true);
factory.setDocumentMatchers(new SpringProfileDocumentMatcher());
}
else {
factory.setMatchDefault(false);
factory.setDocumentMatchers(new SpringProfileDocumentMatcher(profile));
}
factory.setResources(new Resource[] { resource });
Properties properties = factory.getObject();
if (profile == null || !properties.isEmpty()) {
return new PropertiesPropertySource(name, properties);
}
}
return null;
}
}
......@@ -37,6 +37,13 @@ public class SpringProfileDocumentMatcher implements DocumentMatcher {
private String[] activeProfiles = new String[0];
public SpringProfileDocumentMatcher() {
}
public SpringProfileDocumentMatcher(String... profiles) {
addActiveProfiles(profiles);
}
public void addActiveProfiles(String... profiles) {
LinkedHashSet<String> set = new LinkedHashSet<String>(
Arrays.asList(this.activeProfiles));
......@@ -55,4 +62,4 @@ public class SpringProfileDocumentMatcher implements DocumentMatcher {
return new ArrayDocumentMatcher("spring.profiles", profiles).matches(properties);
}
}
\ No newline at end of file
}
......@@ -17,6 +17,8 @@
package org.springframework.boot.yaml;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
......@@ -37,7 +39,7 @@ import org.yaml.snakeyaml.Yaml;
*
* @author Dave Syer
*/
public class YamlProcessor {
public abstract class YamlProcessor {
private final Log logger = LogFactory.getLog(getClass());
......@@ -75,14 +77,15 @@ public class YamlProcessor {
* </pre>
* @param matchers a map of keys to value patterns (regular expressions)
*/
public void setDocumentMatchers(List<? extends DocumentMatcher> matchers) {
this.documentMatchers = Collections.unmodifiableList(matchers);
public void setDocumentMatchers(DocumentMatcher... matchers) {
this.documentMatchers = Collections
.unmodifiableList(new ArrayList<DocumentMatcher>(Arrays.asList(matchers)));
}
/**
* Flag indicating that a document for which all the
* {@link #setDocumentMatchers(List) document matchers} abstain will nevertheless
* match.
* {@link #setDocumentMatchers(DocumentMatcher...) document matchers} abstain will
* nevertheless match.
* @param matchDefault the flag to set (default true)
*/
public void setMatchDefault(boolean matchDefault) {
......@@ -111,10 +114,10 @@ public class YamlProcessor {
/**
* Provides an opportunity for subclasses to process the Yaml parsed from the supplied
* resources. Each resource is parsed in turn and the documents inside checked against
* the {@link #setDocumentMatchers(List) matchers}. If a document matches it is passed
* into the callback, along with its representation as Properties. Depending on the
* {@link #setResolutionMethod(ResolutionMethod)} not all of the documents will be
* parsed.
* the {@link #setDocumentMatchers(DocumentMatcher...) matchers}. If a document
* matches it is passed into the callback, along with its representation as
* Properties. Depending on the {@link #setResolutionMethod(ResolutionMethod)} not all
* of the documents will be parsed.
* @param callback a callback to delegate to once matching documents are found
*/
protected void process(MatchCallback callback) {
......@@ -172,6 +175,7 @@ public class YamlProcessor {
result.put("document", object);
return result;
}
Map<Object, Object> map = (Map<Object, Object>) object;
for (Entry<Object, Object> entry : map.entrySet()) {
Object value = entry.getValue();
......@@ -191,43 +195,42 @@ public class YamlProcessor {
}
private boolean process(Map<String, Object> map, MatchCallback callback) {
Properties properties = new Properties();
assignProperties(properties, map, null);
if (this.documentMatchers.isEmpty()) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Merging document (no matchers set)" + map);
}
callback.process(properties, map);
return true;
}
else {
boolean valueFound = false;
MatchStatus result = MatchStatus.ABSTAIN;
for (DocumentMatcher matcher : this.documentMatchers) {
MatchStatus match = matcher.matches(properties);
result = MatchStatus.getMostSpecific(match, result);
if (match == MatchStatus.FOUND) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Matched document with document matcher: "
+ properties);
}
callback.process(properties, map);
valueFound = true;
// No need to check for more matches
break;
}
}
if (result == MatchStatus.ABSTAIN && this.matchDefault) {
MatchStatus result = MatchStatus.ABSTAIN;
for (DocumentMatcher matcher : this.documentMatchers) {
MatchStatus match = matcher.matches(properties);
result = MatchStatus.getMostSpecific(match, result);
if (match == MatchStatus.FOUND) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Matched document with default matcher: " + map);
this.logger.debug("Matched document with document matcher: "
+ properties);
}
callback.process(properties, map);
return true;
}
else if (!valueFound) {
this.logger.debug("Unmatched document");
return false;
}
if (result == MatchStatus.ABSTAIN && this.matchDefault) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Matched document with default matcher: " + map);
}
callback.process(properties, map);
return true;
}
return true;
this.logger.debug("Unmatched document");
return false;
}
private void assignProperties(Properties properties, Map<String, Object> input,
......@@ -300,7 +303,21 @@ public class YamlProcessor {
* Status returned from {@link DocumentMatcher#matches(Properties)}
*/
public static enum MatchStatus {
FOUND, NOT_FOUND, ABSTAIN;
/**
* A match was found.
*/
FOUND,
/**
* No match was found.
*/
NOT_FOUND,
/**
* The matcher should not be considered.
*/
ABSTAIN;
/**
* Compare two {@link MatchStatus} items, returning the most specific status.
......
# ProperySource Loaders
org.springframework.boot.env.PropertySourceLoader=\
org.springframework.boot.env.PropertiesPropertySourceLoader,\
org.springframework.boot.env.YamlPropertySourceLoader
# Run Participants
org.springframework.boot.SpringApplicationRunParticipant=\
org.springframework.boot.event.EventPublishingRunParticipant
......
......@@ -43,6 +43,36 @@ public class ReproTests {
assertThat(context.getEnvironment().acceptsProfiles("a"), equalTo(true));
}
@Test
public void activeProfilesWithYaml() throws Exception {
// gh-322
SpringApplication application = new SpringApplication(Config.class);
application.setWebEnvironment(false);
String configName = "--spring.config.name=activeprofilerepro";
assertVersionProperty(application.run(configName, "--spring.profiles.active=B"),
"B", "B");
assertVersionProperty(application.run(configName), "B", "B");
assertVersionProperty(application.run(configName, "--spring.profiles.active=A"),
"A", "A");
assertVersionProperty(application.run(configName, "--spring.profiles.active=C"),
"C", "C");
assertVersionProperty(
application.run(configName, "--spring.profiles.active=A,C"), "A", "A",
"C");
assertVersionProperty(
application.run(configName, "--spring.profiles.active=C,A"), "C", "C",
"A");
}
private void assertVersionProperty(ConfigurableApplicationContext context,
String expectedVersion, String... expectedActiveProfiles) {
assertThat(context.getEnvironment().getActiveProfiles(),
equalTo(expectedActiveProfiles));
assertThat("version mismatch", context.getEnvironment().getProperty("version"),
equalTo(expectedVersion));
context.close();
}
@Configuration
public static class Config {
......
......@@ -36,7 +36,8 @@ import static org.junit.Assert.assertEquals;
*/
public class YamlProcessorTests {
private final YamlProcessor processor = new YamlProcessor();
private final YamlProcessor processor = new YamlProcessor() {
};
@Rule
public ExpectedException exception = ExpectedException.none();
......
spring.profiles.active: B
---
spring.profiles: A
version: A
---
spring.profiles: B
version: B
---
spring.profiles: C
version: C
---
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment