从JSON文件加载Spring Boot属性

2023/05/12

1. 简介

使用外部配置属性是一种非常常见的模式,而且,最常见的问题之一是能够在多个环境(例如开发、测试和生产)中更改我们的应用程序的行为,而无需更改部署工件。

在本教程中,我们将重点介绍如何在Spring Boot应用程序中从JSON文件加载属性

2. 在Spring Boot中加载属性

Spring和Spring Boot对加载外部配置提供了强大的支持-你可以在本文中找到对基础知识的全面概述。

由于此支持主要集中在.properties和.yml文件上,因此使用JSON通常需要额外的配置

我们将假设基本特性是众所周知的-并将在这里重点关注JSON特定方面。

3. 通过命令行加载属性

我们可以在命令行中以三种预定义格式提供JSON数据。

首先,我们可以在UNIX shell中设置环境变量SPRING_APPLICATION_JSON:

$ SPRING_APPLICATION_JSON='{"environment":{"name":"production"}}' java -jar app.jar

提供的数据将填充到Spring Environment中,在此示例中,我们将获得值为“production”的属性environment.name。

此外,我们可以将JSON作为系统属性加载,例如:

$ java -Dspring.application.json='{"environment":{"name":"production"}}' -jar app.jar

最后一个选项是使用一个简单的命令行参数:

$ java -jar app.jar --spring.application.json='{"environment":{"name":"production"}}'

使用最后两种方法,spring.application.json属性将使用给定数据作为未解析的字符串填充。

这些是将JSON数据加载到我们的应用程序中的最简单的选项,这种简约方法的缺点是缺乏可扩展性

在命令行中加载大量数据可能很麻烦且容易出错。

4. 通过@PropertySource注解加载属性

Spring Boot提供了一个强大的生态系统来通过注解创建配置类。

首先,我们定义一个带有一些简单成员变量的配置类:

public class JsonProperties {

	private int port;

	private boolean resend;

	private String host;

	// getters and setters
}

我们可以在外部文件中提供标准JSON格式的数据(我们将其命名为configprops.json):

{
	"host": "mailer@mail.com",
	"port": 9090,
	"resend": true
}

现在我们必须将我们的JSON文件连接到配置类:

@Component
@PropertySource(value = "classpath:configprops.json")
@ConfigurationProperties
public class JsonProperties {
	// same code as before
}

我们在类和JSON文件之间有一个松耦合,此连接基于字符串和变量名称,因此我们没有编译时检查,但我们可以通过测试来验证绑定。

由于字段应该由框架填充,所以我们需要使用集成测试。

对于简约的设置,我们可以定义应用程序的主要入口点:

@SpringBootApplication
@ComponentScan(basePackageClasses = {JsonProperties.class})
public class ConfigPropertiesDemoApplication {
	public static void main(String[] args) {
		new SpringApplicationBuilder(ConfigPropertiesDemoApplication.class).run();
	}
}

现在可以创建我们的集成测试:

@RunWith(SpringRunner.class)
@ContextConfiguration(classes = ConfigPropertiesDemoApplication.class)
public class JsonPropertiesIntegrationTest {

	@Autowired
	private JsonProperties jsonProperties;

	@Test
	public void whenPropertiesLoadedViaJsonPropertySource_thenLoadFlatValues() {
		assertEquals("mailer@mail.com", jsonProperties.getHost());
		assertEquals(9090, jsonProperties.getPort());
		assertTrue(jsonProperties.isResend());
	}
}

因此,该测试将产生一个错误,即使加载ApplicationContext也会失败,原因如下:

ConversionFailedException: 
Failed to convert from type [java.lang.String] 
to type [boolean] for value 'true,'

加载机制通过@PropertySource注解成功地将类与JSON文件关联起来,但是resend属性的值被评估为“true”(带逗号),无法转换为布尔值。

因此,我们必须在加载机制中注入一个JSON解析器;幸运的是,Spring Boot自带了Jackson库,我们可以通过PropertySourceFactory使用它。

5. 使用PropertySourceFactory解析JSON

我们必须提供一个具有解析JSON数据能力的自定义PropertySourceFactory

public class JsonPropertySourceFactory implements PropertySourceFactory {

	@Override
	public PropertySource<?> createPropertySource(String name, EncodedResource resource) throws IOException {
		Map readValue = new ObjectMapper().readValue(resource.getInputStream(), Map.class);
		return new MapPropertySource("json-property", readValue);
	}
}

我们可以提供这个工厂来加载我们的配置类,为此,我们必须从@PropertySource注解中引用工厂:

@Configuration
@PropertySource(value = "classpath:configprops.json", factory = JsonPropertySourceFactory.class)
@ConfigurationProperties
public class JsonProperties {

	// same code as before
}

结果,我们的测试将通过。此外,这个属性源工厂也可以很好地解析集合值。

所以现在我们可以使用集合成员(以及相应的getter和setter)来扩展我们的配置类:

private List<String> topics;
// getter and setter

我们可以在JSON文件中提供输入值:

{
    // same fields as before
    "topics" : ["spring", "boot"]
}

我们可以使用新的测试用例轻松测试集合值的绑定:

@Test
public void whenPropertiesLoadedViaJsonPropertySource_thenLoadListValues() {
    assertThat(jsonProperties.getTopics(), Matchers.is(Arrays.asList("spring", "boot")));
}

5.1 嵌套结构

处理嵌套的JSON结构并不是一件容易的事,作为更健壮的解决方案,Jackson库的映射器会将嵌套数据映射到一个Map中。

因此,我们可以使用getter和setter将Map成员添加到我们的JsonProperties类:

private LinkedHashMap<String, ?> sender;
// getter and setter

在JSON文件中,我们可以为该字段提供嵌套数据结构:

{
	// same fields as before
	"sender": {
		"name": "sender",
		"address": "street"
	}
}

现在我们可以通过Map访问嵌套数据:

@Test
public void whenPropertiesLoadedViaJsonPropertySource_thenNestedLoadedAsMap() {
    assertEquals("sender", jsonProperties.getSender().get("name"));
    assertEquals("street", jsonProperties.getSender().get("address"));
}

6. 使用自定义ContextInitializer

如果我们想更好地控制属性的加载,我们可以使用自定义的ContextInitializers。这种手动方法比较乏味,但是我们能够完全控制数据的加载和解析。

我们将使用与之前相同的JSON数据,但我们将加载到不同的配置类中:

@Configuration
@ConfigurationProperties(prefix = "custom")
public class CustomJsonProperties {

	private String host;

	private int port;

	private boolean resend;

	// getters and setters
}

请注意,我们不再使用@PropertySource注解,但是在@ConfigurationProperties注解中,我们定义了一个前缀。

在下一节中,我们将研究如何将属性加载到“custom”命名空间中。

6.1 将属性加载到自定义命名空间

为了为上面的属性类提供输入,我们将从JSON文件加载数据,解析后我们将使用MapPropertySources填充Spring Environment:

public class JsonPropertyContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

	private static String CUSTOM_PREFIX = "custom.";

	@Override
	@SuppressWarnings("unchecked")
	public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
		try {
			Resource resource = configurableApplicationContext.getResource("classpath:configpropscustom.json");
			Map readValue = new ObjectMapper()
				.readValue(resource.getInputStream(), Map.class);
			Set<Map.Entry> set = readValue.entrySet();
			List<MapPropertySource> propertySources = set.stream()
				.map(entry-> new MapPropertySource(
					CUSTOM_PREFIX + entry.getKey(), Collections.singletonMap(CUSTOM_PREFIX + entry.getKey(), entry.getValue()
				)))
				.collect(Collectors.toList());
			for (PropertySource propertySource : propertySources) {
				configurableApplicationContext.getEnvironment()
					.getPropertySources()
					.addFirst(propertySource);
			}
		} catch (IOException e) {
			throw new RuntimeException(e);
		}
	}
}

正如我们所看到的,它需要一些相当复杂的代码,但这是灵活性的代价。在上面的代码中,我们可以指定我们自己的解析器并决定如何处理每个条目。

在此演示中,我们只是将属性放入custom命名空间中

要使用这个初始化器,我们必须将它连接到应用程序。对于生产使用,我们可以在SpringApplicationBuilder中添加以下内容:

@EnableAutoConfiguration
@ComponentScan(basePackageClasses = {JsonProperties.class, CustomJsonProperties.class})
public class ConfigPropertiesDemoApplication {
	public static void main(String[] args) {
		new SpringApplicationBuilder(ConfigPropertiesDemoApplication.class)
			.initializers(new JsonPropertyContextInitializer())
			.run();
	}
}

另请注意,CustomJsonProperties类已添加到basePackageClasses属性中。

对于我们的测试环境,我们可以在@ContextConfiguration注解中提供我们的自定义初始化程序:

@RunWith(SpringRunner.class)
@ContextConfiguration(classes = ConfigPropertiesDemoApplication.class, initializers = JsonPropertyContextInitializer.class)
public class JsonPropertiesIntegrationTest {

	// same code as before
}

自动连接我们的CustomJsonProperties类后,我们可以从custom命名空间测试数据绑定:

@Test
public void whenLoadedIntoEnvironment_thenFlatValuesPopulated() {
    assertEquals("mailer@mail.com", customJsonProperties.getHost());
    assertEquals(9090, customJsonProperties.getPort());
    assertTrue(customJsonProperties.isResend());
}

6.2 展平嵌套结构

Spring框架提供了一种强大的机制,可以将属性绑定到对象成员中,此功能的基础是属性中的名称前缀。

如果我们扩展我们的自定义ApplicationInitializer以将Map值转换为命名空间结构,那么框架可以将我们的嵌套数据结构直接加载到相应的对象中。

下面是增强的CustomJsonProperties类:

@Configuration
@ConfigurationProperties(prefix = "custom")
public class CustomJsonProperties {

	// same code as before

	private Person sender;

	public static class Person {

		private String name;
		private String address;

		// getters and setters for Person class
	}

	// getters and setters for sender member
}

以及改进后的ApplicationContextInitializer:

public class JsonPropertyContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

	private final static String CUSTOM_PREFIX = "custom.";

	@Override
	@SuppressWarnings("unchecked")
	public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
		try {
			Resource resource = configurableApplicationContext.getResource("classpath:configpropscustom.json");
			Map readValue = new ObjectMapper()
				.readValue(resource.getInputStream(), Map.class);
			Set<Map.Entry> set = readValue.entrySet();
			List<MapPropertySource> propertySources = convertEntrySet(set, Optional.empty());
			for (PropertySource propertySource : propertySources) {
				configurableApplicationContext.getEnvironment()
					.getPropertySources()
					.addFirst(propertySource);
			}
		} catch (IOException e) {
			throw new RuntimeException(e);
		}
	}

	private static List<MapPropertySource> convertEntrySet(Set<Map.Entry> entrySet, Optional<String> parentKey) {
		return entrySet.stream()
			.map((Map.Entry e) -> convertToPropertySourceList(e, parentKey))
			.flatMap(Collection::stream)
			.collect(Collectors.toList());
	}

	private static List<MapPropertySource> convertToPropertySourceList(Map.Entry e, Optional<String> parentKey) {
		String key = parentKey
			.map(s -> s + ".")
			.orElse("") + (String) e.getKey();
		Object value = e.getValue();
		return covertToPropertySourceList(key, value);
	}

	@SuppressWarnings("unchecked")
	private static List<MapPropertySource> covertToPropertySourceList(String key, Object value) {
		if (value instanceof LinkedHashMap) {
			LinkedHashMap map = (LinkedHashMap) value;
			Set<Map.Entry> entrySet = map.entrySet();
			return convertEntrySet(entrySet, Optional.ofNullable(key));
		}
		String finalKey = CUSTOM_PREFIX + key;
		return Collections.singletonList(new MapPropertySource(finalKey, Collections.singletonMap(finalKey, value)));
	}
}

因此,我们嵌套的JSON数据结构将被加载到一个配置对象中

@Test
public void whenLoadedIntoEnvironment_thenValuesLoadedIntoClassObject() {
    assertNotNull(customJsonProperties.getSender());
    assertEquals("sender", customJsonProperties.getSender().getName());
    assertEquals("street", customJsonProperties.getSender().getAddress());
}

7. 总结

Spring Boot框架提供了一种通过命令行加载外部JSON数据的简单方法,如果需要,我们可以通过适当配置的PropertySourceFactory加载JSON数据。

虽然,加载嵌套属性是可以解决的,但需要格外小心。

与往常一样,本教程的完整源代码可在GitHub上获得。

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章