Introdução ao Desafio de Configurações em Projetos Spring
No desenvolvimento de projetos Spring em Java, é comum lidar com configurações que variam entre diferentes ambientes (desenvolvimento, teste, produção). A prática recomendada é nunca incluir configurações de produção diretamente no código-fonte, levantando a questão de como gerenciar essas configurações de forma eficiente e segura.
Este artigo demonstra uma abordagem para centralizar o gerenciamento de arquivos de configuração de projetos Spring (especialmente em arquiteturas de microsserviços) usando Git. Isso facilita o ciclo de vida de desenvolvimento e implantação, garantindo consistência e rastreabilidade.
Passo 1: Implementação no Código-Fonte
O ponto central desta implementação é a extensão da funcionalidade de carregamento de propriedades do Spring Boot, especificamente através da interface Service Provider Interface (SPI) do Spring Cloud, que permite a criação de fontes de propriedades personalizadas durante o estágio de bootstrap da aplicação. Para isso, criaremos um módulo ou biblioteca comum que será incluído em todos os microsserviços.
Definição do Bootstrap Configuration via SPI
Primeiro, precisamos registrar nossa classe de configuração de bootstrap no arquivo META-INF/spring.factories. Este arquivo é crucial para que o Spring Cloud descubra e inicialize nossa configuração personalizada antes mesmo do ambiente principal da aplicação ser totalmente carregado.
# Configuração de Bootstrap Customizada
org.springframework.cloud.bootstrap.BootstrapConfiguration=\
com.empresa.app.config.geral.AppConfigBootstrapper
Configuração do Bootstrap
A classe AppConfigBootstrapper é responsável por expor o bean do nosso localizador de fontes de propriedades. Utilizamos @ConditionalOnProperty para permitir que essa funcionalidade seja habilitada ou desabilitada via propriedades, oferecendo flexibilidade em diferentes cenários de implantação.
package com.empresa.app.config.geral;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Componente de inicialização para descoberta de fontes de propriedades externas.
*/
@Configuration("com.empresa.app.config.geral.AppConfigBootstrapper")
@ConditionalOnProperty(
prefix = "app.config.external",
value = "enabled",
havingValue = "true",
matchIfMissing = true // Habilita por padrão se a propriedade não for definida
)
public class AppConfigBootstrapper {
/**
* Define o bean para o localizador de fontes de propriedades.
* @return Uma instância de {@link GitPropertySourceDiscovery}.
*/
@Bean
public GitPropertySourceDiscovery gitPropertySourceDiscovery() {
return new GitPropertySourceDiscovery();
}
}
Localizador de Fontes de Propriedades
A classe GitPropertySourceDiscovery implementa a interface PropertySourceLocator do Spring Cloud. Sua função é escanear um diretório predefinido no sistema de arquivos em busca de arquivos de configuração (especificamente YAML neste exemplo) e adicioná-los como fontes de propriedade ao ambiente Spring.
package com.empresa.app.config.geral;
import org.springframework.cloud.bootstrap.config.PropertySourceLocator;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.CompositePropertySource;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.Environment;
import org.springframework.core.env.PropertySource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Objects;
/**
* {@link PropertySourceLocator} que localiza arquivos de configuração em um diretório predefinido
* e os adiciona ao ambiente Spring.
*/
@Order(-100) // Ordem alta para garantir que seja carregado antes de outras fontes
public class GitPropertySourceDiscovery implements PropertySourceLocator {
private static final Logger logger = LoggerFactory.getLogger(GitPropertySourceDiscovery.class);
private static final String DEFAULT_CONFIG_BASE_PATH = "/etc/app-configs";
@Override
public PropertySource> locate(Environment environment) {
// Assegura que o ambiente é configurável
if (!(environment instanceof ConfigurableEnvironment env)) {
return null;
}
// Define o caminho para o diretório de configurações externas
String appName = env.getProperty("spring.application.name");
String configDirPath = env.getProperty(
"app.config.external.path",
String.format("%s/%s", DEFAULT_CONFIG_BASE_PATH, Objects.requireNonNullElse(appName, "default-app"))
);
CompositePropertySource externalConfigSources = new CompositePropertySource("external-git-configs");
Path configDirectory = Paths.get(configDirPath);
try {
if (Files.exists(configDirectory) && Files.isDirectory(configDirectory)) {
Files.list(configDirectory)
.filter(Files::isRegularFile)
.forEach(filePath -> addFileAsPropertySource(externalConfigSources, filePath));
logger.info("Configurações externas carregadas com sucesso do diretório: {}", configDirPath);
} else {
logger.warn("Diretório de configurações externas não encontrado ou não é um diretório: {}", configDirPath);
}
} catch (Exception e) {
logger.error("Falha ao carregar configurações do diretório {}: {}", configDirPath, e.getMessage());
// Não retorna null aqui para que outras fontes de configuração ainda possam ser usadas
}
return externalConfigSources;
}
/**
* Adiciona um arquivo individual como uma PropertySource ao CompositePropertySource.
* Suporta atualmente arquivos YAML.
* @param composite O {@link CompositePropertySource} ao qual adicionar.
* @param configFilePath O {@link Path} do arquivo de configuração.
*/
private void addFileAsPropertySource(CompositePropertySource composite, Path configFilePath) {
try {
File configFile = configFilePath.toFile();
// A extensão do arquivo pode ser usada para determinar o tipo de loader, ex: .yaml, .properties
if (configFile.getName().endsWith(".yaml") || configFile.getName().endsWith(".yml")) {
ExternalYamlPropertySource yamlSource = new ExternalYamlPropertySource(configFile.getName(), configFile, new SimpleConfigDecryptor()); // Exemplo com decryptor
yamlSource.initialize(); // Chamada de inicialização renomeada
composite.addPropertySource(yamlSource);
logger.debug("Arquivo de configuração carregado: {}", configFile.getAbsolutePath());
} else {
logger.warn("Arquivo '{}' ignorado. Apenas arquivos .yaml/.yml são suportados atualmente.", configFile.getName());
}
} catch (Exception e) {
logger.error("Falha ao carregar arquivo de configuração '{}': {}", configFilePath.getFileName(), e.getMessage());
}
}
}
Fonte de Propriedades YAML Personalizada
A classe ExternalYamlPropertySource estende EnumerablePropertySource e é responsável por carregar e processar o conteúdo de um arquivo YAML. Esta implementação inclui um serviço de descriptografia opcional, permitindo que valores sensíveis sejam armazenados de forma encriptada no Git e descriptografados em tempo de execução.
package com.empresa.app.config.geral;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.util.CollectionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* {@link EnumerablePropertySource} que carrega propriedades de um arquivo YAML externo.
* Inclui suporte opcional para descriptografia de valores de propriedades.
*/
public class ExternalYamlPropertySource extends EnumerablePropertySource<File> {
private static final Logger logger = LoggerFactory.getLogger(ExternalYamlPropertySource.class);
private final Map<String, Object> resolvedProperties = new LinkedHashMap<>();
private final ConfigDecryptionService decryptionService;
/**
* Construtor.
* @param name Nome da fonte de propriedade.
* @param source O arquivo YAML de origem.
* @param decryptionService Serviço para descriptografia de valores. Pode ser {@code null} se a descriptografia não for necessária.
*/
public ExternalYamlPropertySource(String name, File source, ConfigDecryptionService decryptionService) {
super(name, source);
this.decryptionService = decryptionService != null ? decryptionService : new NoOpConfigDecryptor(); // Default no-op decryptor
}
/**
* Inicializa a fonte de propriedade, carregando e resolvendo os valores do arquivo YAML.
* Realiza a descriptografia se o serviço de descriptografia estiver habilitado e a propriedade for encriptada.
*/
public void initialize() {
YamlPropertySourceLoader yamlLoader = new YamlPropertySourceLoader();
try {
// O nome "external-config-yaml" é um identificador interno para o loader
List<PropertySource<?>> loadedSources = yamlLoader.load("external-config-yaml", new FileSystemResource(this.source));
if (CollectionUtils.isEmpty(loadedSources)) {
logger.warn("Nenhuma propriedade encontrada no arquivo YAML: {}", this.source.getName());
return;
}
// O YamlPropertySourceLoader geralmente retorna uma lista com um único PropertySource
PropertySource<?> rawSource = loadedSources.get(0);
Map<String, Object> rawProperties = (Map<String, Object>) rawSource.getSource();
rawProperties.forEach((key, value) -> {
if (decryptionService.isEncryptionEnabled() && decryptionService.isEncrypted(value)) {
try {
String decryptedValue = decryptionService.decrypt(value);
this.resolvedProperties.put(key, decryptedValue);
if (logger.isTraceEnabled()) {
logger.trace("Propriedade descriptografada: {} -> {}", key, decryptedValue);
}
} catch (Exception e) {
logger.error("Falha ao descriptografar propriedade '{}' no arquivo '{}': {}", key, this.source.getName(), e.getMessage());
this.resolvedProperties.put(key, value); // Fallback para valor original em caso de erro
}
} else {
this.resolvedProperties.put(key, value);
}
});
logger.debug("Propriedades carregadas e processadas do arquivo: {}", this.source.getName());
} catch (Exception e) {
logger.error("Erro ao carregar o arquivo de configuração YAML '{}': {}", this.source.getName(), e.getMessage());
}
}
@Override
public String[] getPropertyNames() {
Set<String> names = this.resolvedProperties.keySet();
return names.toArray(new String[0]);
}
@Override
public Object getProperty(String name) {
return this.resolvedProperties.get(name);
}
}
Serviço de Descriptografia de Configurações (Interface e Implementações)
Para suportar a descriptografia, definimos uma interface e duas implementações simples. Em um cenário real, SimpleConfigDecryptor seria substituído por uma solução robusta como Jasypt ou integração com um servidor de configurações como Spring Cloud Config Server.
package com.empresa.app.config.geral;
/**
* Interface para serviços de descriptografia de propriedades.
*/
public interface ConfigDecryptionService {
/**
* Verifica se a descriptografia está habilitada para o ambiente atual.
* @return true se a descriptografia estiver habilitada, false caso contrário.
*/
boolean isEncryptionEnabled();
/**
* Verifica se um determinado valor de propriedade parece estar encriptado.
* @param value O valor da propriedade a ser verificado.
* @return true se o valor for encriptado, false caso contrário.
*/
boolean isEncrypted(Object value);
/**
* Descriptografa um valor de propriedade.
* @param encryptedValue O valor encriptado.
* @return O valor descriptografado.
* @throws Exception Se ocorrer um erro durante a descriptografia.
*/
String decrypt(Object encryptedValue) throws Exception;
}
package com.empresa.app.config.geral;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
// import org.springframework.core.env.Environment;
// import org.springframework.beans.factory.annotation.Autowired;
// import org.springframework.stereotype.Component;
/**
* Implementação básica de {@link ConfigDecryptionService}.
* Presume que valores encriptados são prefixados com "{cipher}".
* Para fins de demonstração, esta implementação não realiza descriptografia real,
* apenas remove o prefixo. Em um ambiente real, integraria com uma solução como Jasypt ou Spring Cloud Config Server.
*/
// @Component // Pode ser instanciado via Spring, ou passado manualmente como no exemplo.
public class SimpleConfigDecryptor implements ConfigDecryptionService {
private static final Logger logger = LoggerFactory.getLogger(SimpleConfigDecryptor.class);
private static final String ENCRYPTED_PREFIX = "{cipher}"; // Exemplo de prefixo para valores encriptados
// Em um cenário real, você injetaria aqui um serviço de descriptografia ou Environment
// para verificar propriedades de habilitação/chaves.
// Para simplificar a demonstração e evitar dependências extras, faremos de conta.
// private Environment environment; // Em um cenário real, injetar Environment para verificar 'app.config.encryption.enabled'
// @Autowired
// public SimpleConfigDecryptor(Environment environment) {
// this.environment = environment;
// }
public SimpleConfigDecryptor() {
// Construtor padrão para o exemplo
}
@Override
public boolean isEncryptionEnabled() {
// Em um cenário real, você verificaria uma propriedade do ambiente, ex:
// return environment.getProperty("app.config.encryption.enabled", Boolean.class, false);
return true; // Habilita a simulação de descriptografia para demonstração
}
@Override
public boolean isEncrypted(Object value) {
return value instanceof String strValue && strValue.startsWith(ENCRYPTED_PREFIX);
}
@Override
public String decrypt(Object encryptedValue) throws Exception {
if (!isEncrypted(encryptedValue)) {
return (String) encryptedValue; // Não encriptado, retorna o original
}
String value = (String) encryptedValue;
String realContent = value.substring(ENCRYPTED_PREFIX.length());
// Aqui é onde a lógica de descriptografia real ocorreria.
// Ex: usando Jasypt, Spring Cloud Config Server com KeyStore, etc.
// Para este exemplo, apenas removemos o prefixo.
logger.debug("Simulando descriptografia: '{}' -> '{}'", value, realContent);
return realContent;
}
}
package com.empresa.app.config.geral;
/**
* Implementação "no-op" de {@link ConfigDecryptionService} que não realiza nenhuma descriptografia.
*/
public class NoOpConfigDecryptor implements ConfigDecryptionService {
@Override
public boolean isEncryptionEnabled() {
return false;
}
@Override
public boolean isEncrypted(Object value) {
return false;
}
@Override
public String decrypt(Object encryptedValue) throws Exception {
return (String) encryptedValue; // Retorna o valor original
}
}
Passo 2: Aspecto Operacional
Uma vez que as configurações são gerenciadas via Git, a etapa operacional envolve a integração desse repositório de configurações no processo de build da imagem Docker. Durante a construção da imagem, as configurações podem ser extraídas do repositório Git e copiadas para um diretório predefinido dentro da imagem. Por exemplo, em nossa abordagem, usamos o caminho /etc/app-configs/${spring.application.name}, onde ${spring.application.name} refere-se ao nome da aplicação Spring.
Um exemplo de Dockerfile poderia incluir uma etapa como esta:
FROM openjdk:17-jdk-slim
# Variáveis de ambiente para o Git e o diretório de configurações
ENV GIT_CONFIG_REPO="https://seu-repo-git/configs.git"
ENV APP_CONFIG_DIR="/etc/app-configs"
# Cria o diretório de configurações se não existir
RUN mkdir -p ${APP_CONFIG_DIR}
# Clona o repositório de configurações (em um ambiente de produção, use credenciais seguras)
# Para um ambiente Docker/Kubernetes, considere montar um volume ou usar um init container
# para buscar as configurações. Este é um exemplo simplificado.
RUN apt-get update && apt-get install -y git \
&& git clone ${GIT_CONFIG_REPO} /tmp/git-configs \
&& cp -R /tmp/git-configs/* ${APP_CONFIG_DIR}/ \
&& rm -rf /tmp/git-configs \
&& apt-get autoremove -y git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copia o JAR da aplicação
COPY target/your-application.jar /app/application.jar
# Define o ponto de entrada da aplicação
ENTRYPOINT ["java", "-jar", "/app/application.jar"]
No Kubernetes, essa abordagem pode ser refinada usando ConfigMaps para dados não sensíveis e Secrets para dados sensíveis, montados como volumes nos pods. Alternativamente, um sidecar container ou init container poderia ser responsável por clonar o repositório Git em um volume compartilhado, tornando as configurações acessíveis à aplicação principal sem incluí-las diretamente na imagem Docker.