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 中。通過這個例子,我們將會獲得一個屬性 environment.name,其值為“production”。
此外,我們還可以將我們的 JSON 作為 System property 加載,例如:
$ java -Dspring.application.json='{"environment":{"name":"production"}}' -jar app.jar以下是翻譯後的內容:
最後一個選項是使用簡單的命令行參數:
$ java -jar app.jar --spring.application.json='{"environment":{"name":"production"}}'採用這兩種最後兩種方法,spring.application.json 屬性將被提供的數據作為未解析的 String 填充。
這些是加載 JSON 數據到應用程序中最簡單的選項。 這種極簡主義方法的缺點是缺乏可擴展性。
在命令行中加載大量數據可能既繁瑣又容易出錯。
4. 通過 <em PropertySource 註解加載屬性
Spring Boot 提供了一個強大的生態系統,通過註解創建配置類。
首先,我們定義一個配置類,包含一些簡單的成員:
public class JsonProperties {
private int port;
private boolean resend;
private String host;
// getters and setters
}我們可以在外部文件中提供數據,格式為標準 JSON 格式(我們暫定名為 configprops.json):
{
"host" : "[email protected]",
"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("[email protected]", 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 數據
我們需要提供一個自定義的 PropertySourceFactory,具備解析 JSON 數據的功能:
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
}因此,我們的測試將通過。 此外,這個屬性源工廠也會樂於解析列表值。
現在,我們可以通過添加一個列表成員(以及相應的獲取器和設置器)來擴展我們的配置類:
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。
因此,我們可以將 Map 成員添加到我們的 JsonProperties 類中,並添加相應的 getter 和 setter 方法:
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"));
}使用自定義 ContextInitializer
如果我們希望對屬性的加載擁有更大的控制權,我們可以使用自定義的 ContextInitializer。
這種手動方法更繁瑣。但是,作為結果,我們將完全控制數據的加載和解析。
我們將使用之前相同的 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 文件中加載數據,並在解析後將數據填充到 Spring 的 Environment 中,使用 MapPropertySources。
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);
}
}
}如我們所見,這需要一些相當複雜的代碼,但這是靈活性的代價。在上述代碼中,我們可以自定義解析器並決定如何處理每個條目。
在本演示中,我們只是將屬性放入自定義命名空間。
要使用這個初始化器,我們需要將其連接到應用程序。對於生產環境,可以在 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 類後,我們可以測試從自定義命名空間的數據綁定:
@Test
public void whenLoadedIntoEnvironment_thenFlatValuesPopulated() {
assertEquals("[email protected]", 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 數據。
雖然加載嵌套屬性是可行的,但需要格外小心。