引言
最近又在我的论坛项目中造轮子,场景是由于一些业务原因,比如接入了ChatGPT,openai_key
可能会频繁的更新,或者添加新的敏感词等等都会造成项目中配置的变化,每次变动都需要修改配置文件并重新部署应用,这样是非常不方便的,动态的更新这些内存中的配置是需要解决的问题。
常见的做法是接入一个配置中心,如Apollo、ZK等等,但是目前项目中是没有用到这些中间件的,接入这些配置中心可能会给项目带来一些风险,况且我这是个单体项目,用这些复杂的配置中心有点大材小用了,再说直接拿来就用没什么意思
鉴于此,我参考了网上和Apollo配置中心的一些技术方案,实现了一个非常实用的配置拓展,支持从自定义数据源中获取配置,并注入到Environment中,且优先级最高,同时也支持配置的动态刷新😎
为了更好的理解本文的逻辑,你可能需要以下前置知识点:Spring Environment体系
解决方案
直接说思路:
- 借助Mysql,维护一个配置表
global_config
,方便我们构造自定义属性源和持久化用户配置
- 在Spring启动时,读取数据库中的配置构造一个自定义属性源,注入到
Environment
中,并设置优先级为最高
- 再利用
Binder
更新内存中的ConfigurationProperties
对象,这样就可以实现覆盖properties
文件中的配置
具体细节
我的项目中配置的使用姿势主要分两种:
- 通过
@ConfigurationProperties
注入的Property
对象
- 通过
@Value
注入的配置字段
两个部分需要不同的更新方式,因此配置的更新方式分为以下两部分:
@ConfigurationProperties
- 在初始化阶段:读取数据库中的配置表 → 生成自定义
MapPropertySource
→ 将自定义MapPropertySource
添加到Environment中并设置为最高优先级
- 监听到配置变更:检查DB中的数据库与当前缓存中的
MapPropertySource
是否一致 ,如果不一致就调用refresh()
方法刷新Environment中的MapPropertySource
。refresh方法的逻辑其实就是重新使用Binder绑定内存中的ConfigurationProperties
@Value
这部分的配置更新参考了Apollo PR#972的实现:利用一个括号匹配算法,在BeanPostProcessor
阶段扫描所有带@Value
占位符的Bean,包括表达式、占位符等等,连带对应的Bean引用全部存起来
当有相关的key变化时,通过Bean的引用反射更新对应的Bean字段,你也可以使用观察者模式监听对应的key的变化,就可以做到当配置变化后自动触发对应Bean字段的更新
📌 这里两种方式不同是因为@ConfigurationProperties
是借助Environment
中的属性源实现的,而@Value
的实现则是依赖于ConfigurablePropertyResolver
,两者虽然都实现了PropertyResolver但是没有任何直接关系,属于不同的属性解析器,因此改变Environment
中的属性源并不能影响@Value
,需要额外对@Value
配置的属性做更新

代码实现
这部分的代码具体实现其实也参考了ConfigurationProperties
的注入原理——
(ConfigurationPropertiesBindingPostProcessor
)的实现。为了缩减篇幅删除了一些非核心逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
|
@Slf4j @Component public class DynamicConfigContainer implements ApplicationContextAware, InitializingBean, EnvironmentAware, CommandLineRunner {
private ConfigurableEnvironment environment; private ApplicationContext applicationContext;
@Getter public Map<String, Object> cache;
private DynamicConfigBinder binder;
...
@Override public void afterPropertiesSet() throws Exception { cache = Maps.newHashMap(); this.binder = new DynamicConfigBinder(this.applicationContext, environment.getPropertySources()); bindBeansFromLocalCache("dbConfig", cache); }
private boolean loadAllConfigFromDb() { String sql = "select `key`, `value` from global_conf where deleted = 0"; List<Map<String, Object>> list = applicationContext.getBean(JdbcTemplate.class).queryForList(sql); Map<String, Object> val = Maps.newHashMapWithExpectedSize(list.size()); for (Map<String, Object> conf : list) { val.put(conf.get("key").toString(), conf.get("value").toString()); } if (val.equals(cache)) { return false; } cache.clear(); cache.putAll(val); return true; }
private void bindBeansFromLocalCache(String namespace, Map<String, Object> cache) { MapPropertySource propertySource = new MapPropertySource(namespace, cache); environment.getPropertySources().addFirst(propertySource); }
public void bind(Bindable bindable) { binder.bind(bindable); }
public void reloadConfig() { String before = JsonUtil.toStr(cache); boolean toRefresh = loadAllConfigFromDb(); if (toRefresh) { refreshConfig(); log.info("config update! Old:{}, New:{}", before, JsonUtil.toStr(cache)); } }
private void refreshConfig() { applicationContext.getBeansWithAnnotation(ConfigurationProperties.class).values().forEach(bean -> { Bindable<?> target = Bindable.ofInstance(bean).withAnnotations(AnnotationUtils.findAnnotation(bean.getClass(), ConfigurationProperties.class)); bind(target); }); }
@Override public void run(String... args) throws Exception { reloadConfig(); } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| public class DynamicConfigBinder { private final ApplicationContext applicationContext; private final PropertySources propertySources;
private volatile Binder binder;
public DynamicConfigBinder(ApplicationContext applicationContext, PropertySources propertySources) { this.applicationContext = applicationContext; this.propertySources = propertySources; }
public <T> void bind(Bindable<T> bindable) { ConfigurationProperties propertiesAno = bindable.getAnnotation(ConfigurationProperties.class); if (propertiesAno != null) { BindHandler bindHandler = getBindHandler(propertiesAno); getBinder().bind(propertiesAno.prefix(), bindable, bindHandler); } }
public <T> void bind(String prefix, Bindable<T> bindable, BindHandler bindHandler) { getBinder().bind(prefix, bindable, bindHandler); }
private BindHandler getBindHandler(ConfigurationProperties annotation) { BindHandler handler = new IgnoreTopLevelConverterNotFoundBindHandler(); if (annotation.ignoreInvalidFields()) { handler = new IgnoreErrorsBindHandler(handler); } if (!annotation.ignoreUnknownFields()) { UnboundElementsSourceFilter filter = new UnboundElementsSourceFilter(); handler = new NoUnboundElementsBindHandler(handler, filter); } return handler; }
private Binder getBinder() { if (this.binder == null) { synchronized (this) { if (this.binder == null) { this.binder = new Binder(getConfigurationPropertySources(), getPropertySourcesPlaceholdersResolver(), getConversionService(), getPropertyEditorInitializer()); } } } return this.binder; }
... }
|

这里就可以看到我们自定义的属性源dbConfig
已经被注入成功啦😃
除此之外,当监听到对应的配置更新时,除了更新数据库,还需要推送对应的消息,在这里由于我项目中使用的是Spring Event实现的消息机制,因此监听者的操作如下:
1 2 3 4
| public void onApplicationEvent(ConfigRefreshEvent event) { dynamicConfigContainer.reloadConfig(); }
|
至于@Value
的部分,代码比较多就不贴了,具体可以参考在https://github.com/qing-wq/SAI-Forum-backend/tree/rabbitmq/sai-core/src/main/java/ink/whi/core/autoconf/apollo
一些思考
通过上面的操作,很方便的实现了配置动态更新的问题,但是经过实际使用后发现该方案还是存在一些隐患:不统一的配置可能会带来的难以管理的问题
相比于传统的配置管理方案,要么使用统一的注册中心,要么都写在配置文件中,上述方案同时使用两份配置(MySQL和配置文件),会存在优先级问题,可能会造成难以定位的线上Bug。但是对于一个用户量不大的一个小项目来说,该方案能快速实现迭代更新,我认为还是一个不错的选择
写在最后:其实在上文「解决方案」中存在一个问题,就是如果能在PropertySourcesPropertyResolver
的配置解析阶段就将自定义的属性源注入,那么初始化后的ConfigurationProperties
对象标记的字段本身就是我们自定义数据源中的配置,也就不需要第三步——即启动时用Binder再次更新一遍ConfigurationProperties
对象
但我尝试后发现无论如何都无法在解析前将自定义数据源注入到Environment中,因此目前的做法只能在项目完全启动后刷新所有配置(留个坑),如果有大佬知道解决方案欢迎指出🥳
