Carregar propriedades de inicialização do Spring de um arquivo JSON

Carregar propriedades de inicialização do Spring de um arquivo JSON

1. Introdução

Usar propriedades de configuração externa é um padrão bastante comum.

E, uma das perguntas mais comuns é a capacidade de alterar o comportamento de nosso aplicativo em vários ambientes - como desenvolvimento, teste e produção - sem precisar alterar o artefato de implantação.

Neste tutorial,we’ll focus onhow you can load properties from JSON files in a Spring Boot application.

2. Carregando propriedades no Spring Boot

Spring e Spring Boot têm forte suporte para carregar configurações externas - você pode encontrar uma ótima visão geral do básico emthis article.

Comothis support mainly focuses on .properties and .yml files – working with JSON typically needs extra configuration.

Vamos assumir que os recursos básicos são bem conhecidos - e nos concentraremos nos aspectos específicos deJSON, aqui.

3. Carregar propriedades via linha de comando

Podemos fornecer dadosJSON na linha de comando em três formatos predefinidos.

Primeiro, podemos definir a variável de ambienteSPRING_APPLICATION_JSON em um shellUNIX:

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

Os dados fornecidos serão preenchidos em SpringEnvironment. Com este exemplo, obteremos uma propriedadeenvironment.name com o valor "produção".

Além disso, podemos carregar nossoJSON comoSystem property,  por exemplo:

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

A última opção é usar um argumento de linha de comando simples:

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

Com as duas últimas abordagens, a propriedadespring.application.json será preenchida com os dados fornecidos comoString não analisados.

Estas são as opções mais simples para carregar dados deJSON em nosso aplicativo. The drawback of this minimalistic approach is the lack of scalability.

Carregar uma grande quantidade de dados na linha de comando pode ser complicado e propenso a erros.

4. Carregar propriedades via anotaçãoPropertySource

O Spring Boot fornece um ecossistema poderoso para criar classes de configuração por meio de anotações.

Primeiro, definimos uma classe de configuração com alguns membros simples:

public class JsonProperties {

    private int port;

    private boolean resend;

    private String host;

   // getters and setters

}

Podemos fornecer os dados no formatoJSON padrão em um arquivo externo (vamos chamá-lo deconfigprops.json):

{
  "host" : "[email protected]",
  "port" : 9090,
  "resend" : true
}

Agora temos que conectar nosso arquivo JSON à classe de configuração:

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

Temos um acoplamento frouxo entre a classe e o arquivo JSON. Essa conexão é baseada em cadeias e nomes de variáveis. Portanto, não temos uma verificação de tempo de compilação, mas podemos verificar as ligações com testes.

Como os campos devem ser preenchidos pela estrutura, precisamos usar um teste de integração.

Para uma configuração minimalista, podemos definir o principal ponto de entrada da aplicação:

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

Agora podemos criar nosso teste de integração:

@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());
    }
}

Como resultado, este teste irá gerar um erro. Mesmo o carregamento deApplicationContext falhará com a seguinte causa:

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

O mecanismo de carregamento conecta com sucesso a classe com o arquivo JSON por meio da anotaçãoPropertySource. Mas o valor da propriedaderesend é avaliado como “true,” (com uma vírgula), que não pode ser convertido em booleano.

Therefore, we have to inject a JSON parser into the loading mechanism. Felizmente, Spring Boot vem com a biblioteca Jackson e podemos usá-la por meio dePropertySourceFactory.

5. UsandoPropertySourceFactory para analisar JSON

Temos que fornecer umPropertySourceFactory personalizado com a capacidade de analisar dados 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);
    }
}

Nós podemos fornecer esta fábrica para carregar nossa classe de configuração. Para isso, temos que fazer referência à fábrica a partir da anotaçãoPropertySource:

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

    // same code as before

}

Como resultado, nosso teste será aprovado. Além disso, essa fábrica de origem de propriedades também analisará os valores da lista.

Portanto, agora podemos estender nossa classe de configuração com um membro da lista (e com os getters e setters correspondentes):

private List topics;
// getter and setter

Nós podemos fornecer os valores de entrada no arquivo JSON:

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

Podemos testar facilmente a ligação de valores de lista com um novo caso de teste:

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

5.1. Estruturas aninhadas

Lidar com estruturas JSON aninhadas não é uma tarefa fácil. Como a solução mais robusta, o mapeador da biblioteca Jackson mapeará os dados aninhados em umMap. 

Portanto, podemos adicionar um membroMap à nossa classeJsonProperties com getters e setters:

private LinkedHashMap sender;
// getter and setter

No arquivo JSON, podemos fornecer uma estrutura de dados aninhada para este campo:

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

Agora podemos acessar os dados aninhados através do mapa:

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

6. Usando umContextInitializer personalizado

Se quisermos ter mais controle sobre o carregamento das propriedades, podemos usarContextInitializers personalizados.

Essa abordagem manual é mais entediante. Mas, como resultado, teremos controle total de carregamento e análise dos dados.

Usaremos os mesmos dados JSON de antes, mas carregaremos em uma classe de configuração diferente:

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

    private String host;

    private int port;

    private boolean resend;

    // getters and setters

}

Observe que não usamos mais a anotaçãoPropertySource. Mas dentro da anotaçãoConfigurationProperties, definimos um prefixo.

Na próxima seção, investigaremos como podemos carregar as propriedades no namespace‘custom'.

6.1. Carregar propriedades em um namespace personalizado

Para fornecer a entrada para a classe de propriedades acima, carregaremos os dados do arquivo JSON e, após a análise, preencheremos SpringEnvironment comMapPropertySources:

public class JsonPropertyContextInitializer
 implements ApplicationContextInitializer {

    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 set = readValue.entrySet();
            List 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);
        }
    }
}

Como podemos ver, requer um pouco de código bastante complexo, mas esse é o preço da flexibilidade. No código acima, podemos especificar nosso próprio analisador e decidir o que fazer com cada entrada.

Nesta demonstração, apenas colocamos as propriedades em um namespace personalizado.

Para usar este inicializador, precisamos conectá-lo ao aplicativo. Para uso de produção, podemos adicionar isso noSpringApplicationBuilder:

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

Além disso, observe que a classeCustomJsonProperties foi adicionada abasePackageClasses.

Para nosso ambiente de teste, podemos fornecer nosso inicializador personalizado dentro da anotaçãoContextConfiguration:

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

    // same code as before

}

Depois de conectar automaticamente nossa classeCustomJsonProperties, podemos testar a vinculação de dados a partir do namespace personalizado:

@Test
public void whenLoadedIntoEnvironment_thenFlatValuesPopulated() {
    assertEquals("[email protected]", customJsonProperties.getHost());
    assertEquals(9090, customJsonProperties.getPort());
    assertTrue(customJsonProperties.isResend());
}

6.2. Achatamento de estruturas aninhadas

A estrutura do Spring fornece um mecanismo poderoso para vincular as propriedades aos membros dos objetos. A base desse recurso são os prefixos de nome nas propriedades.

Se estendermos nossoApplicationInitializer personalizado para converter os valoresMap em uma estrutura de namespace, a estrutura poderá carregar nossa estrutura de dados aninhada diretamente em um objeto correspondente.

A classeCustomJsonProperties aprimorada:

@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

}

OApplicationContextInitializer aprimorado:

public class JsonPropertyContextInitializer
  implements ApplicationContextInitializer {

    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 set = readValue.entrySet();
            List propertySources = convertEntrySet(set, Optional.empty());
            for (PropertySource propertySource : propertySources) {
                configurableApplicationContext.getEnvironment()
                  .getPropertySources()
                  .addFirst(propertySource);
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

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

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

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

Como resultado, nossa estrutura de dados JSON aninhada será carregada em um objeto de configuração:

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

7. Conclusão

A estrutura do Spring Boot fornece uma abordagem simples para carregar dados JSON externos através da linha de comando. Em caso de necessidade, podemos carregar dados JSON por meio dePropertySourceFactory devidamente configurado.

Embora, carregar propriedades aninhadas seja solucionável, mas requer cuidados extras.

Como sempre, o código está disponívelover on GitHub.