Spring Boot 轻量配置中心实战:基于 EnvironmentPostProcessor 实现数据库配置自动加载

0 阅读4分钟

相信大家平时的配置都是直接写到代码里面的吧,并且根据运行环境拆分了多个配置文件。

如果我想要整个配置中心,又不想部署一个单独的配置中心服务,有什么好的办法吗?

于是,我就想到了,数据库不是现成的吗?何不好好利用呢?

实现原理

目标,可以将配置迁移到数据库中,且不影响项目的正常启动。

说起来简单,实际要求可不低。

首先,启动项目时,配置的加载太靠前了。

  • 想要读取数据库配置 → 需要 DataSource 数据源
  • 想要创建 DataSource → 需要完整加载所有配置

感觉陷入了鸡生蛋,蛋生鸡的循环里。

要打破整个死循环,只能手搓一个数据库连接了,先行一步读取配置。后面的事情就顺理成章了。

实现步骤

准备配置表和数据

-- PostgreSQL 适配版 系统配置表
CREATE TABLE sys_config (
    id BIGSERIAL PRIMARY KEY,
    config_key VARCHAR(200) NOT NULL UNIQUE,
    config_value VARCHAR(1000) DEFAULT '',
    status SMALLINT DEFAULT 1,
    remark VARCHAR(500) DEFAULT ''
);
COMMENT ON TABLE sys_config IS '系统数据库配置表(替代yml业务配置)';
COMMENT ON COLUMN sys_config.id IS '主键';
COMMENT ON COLUMN sys_config.config_key IS '配置Key(与yml一致)';
COMMENT ON COLUMN sys_config.config_value IS '配置值';
COMMENT ON COLUMN sys_config.status IS '状态 0禁用 1启用';
COMMENT ON COLUMN sys_config.remark IS '备注';

写入配置数据,这里使用 server.port 有现成的日志方便观察。

INSERT INTO public.sys_config
(id, config_key, config_value, status, remark)
VALUES(4, 'server.port', '8998', 1, '启动端口号');

实现接口EnvironmentPostProcessor

接口注释写明了用途

Allows for customization of the application's Environment prior to the application context being refreshed.

翻译:在应用上下文刷新之前定制 Environment。

注意,此时数据库的连接配置仍然是加密的,也需要和上篇的DataSourceConfig类一样,先解密再创建数据库连接。实现类如下:

public class DbConfigEnvironmentProcessor implements EnvironmentPostProcessor {

    private static final String ENC_PREFIX = "{enc}";
    private static final String DB_PROPERTY_SOURCE_NAME = "db-custom-config";

    @Override
    public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
        // 从 environment 获取解密密钥(命令行参数 > 环境变量)
        String encryptKey = environment.getProperty("db.encrypt.key", System.getenv("ENCRYPT_KEY"));
        try {
            // 1. 读取本地yml中的数据库基础配置
            String url = environment.getProperty("spring.datasource.url");
            String encryptedUsername = environment.getProperty("spring.datasource.username");
            String encryptedPassword = environment.getProperty("spring.datasource.password");
            // 默认适配PostgreSQL驱动
            String driver = environment.getProperty("spring.datasource.driver-class-name", "org.postgresql.Driver");

            // 验证必要配置
            if (url == null || url.isEmpty()) {
                throw new IllegalStateException("数据库 URL 不能为空");
            }
            if (encryptedUsername == null || encryptedUsername.isEmpty()) {
                throw new IllegalStateException("数据库用户名不能为空");
            }
            if (encryptedPassword == null || encryptedPassword.isEmpty()) {
                throw new IllegalStateException("数据库密码不能为空");
            }

            // 检查是否需要解密
            String username;
            String password;

            if (encryptedUsername.startsWith(ENC_PREFIX)) {
                // 需要解密用户名
                logger.info("检测到加密的用户名,开始解密...");
                validateEncryptKey(encryptKey);
                String encryptedUser = encryptedUsername.substring(ENC_PREFIX.length());
                username = decrypt(encryptedUser, encryptKey);
                logger.info("用户名解密成功");
            } else {
                username = encryptedUsername;
                logger.info("用户名未加密,直接使用");
            }

            if (encryptedPassword.startsWith(ENC_PREFIX)) {
                // 需要解密密码
                logger.info("检测到加密的密码,开始解密...");
                validateEncryptKey(encryptKey);
                String encryptedPass = encryptedPassword.substring(ENC_PREFIX.length());
                password = decrypt(encryptedPass, encryptKey);
                logger.info("密码解密成功");
            } else {
                password = encryptedPassword;
                logger.info("密码未加密,直接使用");
            }

            // 2. 手动原生JDBC查询配置(此时无容器、无数据源Bean)
            Class.forName(driver);
            Map<String, Object> configMap = new HashMap<>();
            try (Connection conn = DriverManager.getConnection(url, username, password);
                 PreparedStatement ps = conn.prepareStatement("SELECT config_key,config_value FROM sys_config WHERE status = 1");
                 ResultSet rs = ps.executeQuery()) {

                while (rs.next()) {
                    String key = rs.getString("config_key");
                    String value = rs.getString("config_value");
                    configMap.put(key, value);
                }
            }

            // 3. 封装为配置源,加入Spring环境(优先级高于本地yml)
            if (!configMap.isEmpty()) {
                PropertySource<?> propertySource = new MapPropertySource(DB_PROPERTY_SOURCE_NAME, configMap);
                // addFirst:数据库配置优先覆盖本地配置,符合配置中心逻辑
                environment.getPropertySources().addFirst(propertySource);
            }
        } catch (Exception e) {
            throw new RuntimeException("【数据库配置中心】加载配置失败,请检查数据库配置", e);
        }
    }

    /**
     * 验证解密密钥是否存在
     */
    private void validateEncryptKey(String encryptKey) {
        if (encryptKey == null || encryptKey.isEmpty()) {
            logger.error("❌ 未找到解密密钥!");
            logger.error("   请通过以下方式之一提供密钥:");
            logger.error("   1. 命令行参数: -Ddb.encrypt.key=YOUR_KEY");
            logger.error("   2. 环境变量: ENCRYPT_KEY=YOUR_KEY");
            throw new IllegalStateException("解密密钥不能为空,请通过命令行参数或环境变量提供");
        }
        logger.info("已获取解密密钥(长度: {})", encryptKey.length());
    }
}

代码中使用 addFirst(),优先级如下:

数据库配置 > 本地 application.yml 配置

注册扩展点

根据文档提示注册实现类

EnvironmentPostProcessor implementations have to be registered in META-INF/spring.factories, using the fully qualified name of this class as the key.

resources/META-INF/ 目录下创建文件:spring.factories。文件内写入当前加载器全类名。

org.springframework.boot.EnvironmentPostProcessor=com.xxx.xxx.config.DbConfigEnvironmentProcessor

验证效果

改造前

在配置文件中配置启动端口号为8999,输出日志如下:

o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8999 (http) with context path '/'

改造后

o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8998 (http) with context path '/'

既然启动端口都被覆盖了,其他配置也就能正常注入bean对象了。

对DbConfigEnvironmentProcessor类打断点,可以看到从数据库中读取到的配置:

image.png

到此已经实现了数据库作为配置中心的所有步骤了。

找找EnvironmentPostProcessor是在哪里执行的

从run方法找起

作为javaer,应该对run方法再熟悉不过了。这里只列出关键代码。

ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);

// 关键位置
ConfigurableEnvironment environment = prepareEnvironment(listeners, bootstrapContext, applicationArguments);

context = createApplicationContext();

prepareEnvironment方法

listeners.environmentPrepared(bootstrapContext, environment);

发布ApplicationEnvironmentPreparedEvent事件

public void environmentPrepared(ConfigurableBootstrapContext bootstrapContext,ConfigurableEnvironment environment) {
		multicastInitialEvent(
				new ApplicationEnvironmentPreparedEvent(bootstrapContext, this.application, this.args, environment));
	}

处理ApplicationEnvironmentPreparedEvent事件

监听类是EnvironmentPostProcessorApplicationListener

private void onApplicationEnvironmentPreparedEvent(ApplicationEnvironmentPreparedEvent event) {
	ConfigurableEnvironment environment = event.getEnvironment();
	SpringApplication application = event.getSpringApplication();
	List<EnvironmentPostProcessor> postProcessors = getEnvironmentPostProcessors(application.getResourceLoader(),
			event.getBootstrapContext());
	addAotGeneratedEnvironmentPostProcessorIfNecessary(postProcessors, application);
	for (EnvironmentPostProcessor postProcessor : postProcessors) {
		postProcessor.postProcessEnvironment(environment, application);
	}
}

总结

虽说手动创建数据库连接,不那么优雅。

但是,既然都实现配置中心了,我觉得瑕不掩瑜了,可以接受。