引言

最近又在我的论坛项目中造轮子,场景是由于一些业务原因,比如接入了ChatGPT,openai_key可能会频繁的更新,或者添加新的敏感词等等都会造成项目中配置的变化,每次变动都需要修改配置文件并重新部署应用,这样是非常不方便的,动态的更新这些内存中的配置是需要解决的问题。

常见的做法是接入一个配置中心,如Apollo、ZK等等,但是目前项目中是没有用到这些中间件的,接入这些配置中心可能会给项目带来一些风险,况且我这是个单体项目,用这些复杂的配置中心有点大材小用了,再说直接拿来就用没什么意思

鉴于此,我参考了网上和Apollo配置中心的一些技术方案,实现了一个非常实用的配置拓展,支持从自定义数据源中获取配置,并注入到Environment中,且优先级最高,同时也支持配置的动态刷新😎

为了更好的理解本文的逻辑,你可能需要以下前置知识点:Spring Environment体系

解决方案

直接说思路:

  1. 借助Mysql,维护一个配置表global_config,方便我们构造自定义属性源和持久化用户配置
  2. 在Spring启动时,读取数据库中的配置构造一个自定义属性源,注入到Environment中,并设置优先级为最高
  3. 利用Binder更新内存中的ConfigurationProperties对象,这样就可以实现覆盖properties文件中的配置

具体细节

我的项目中配置的使用姿势主要分两种:

  1. 通过@ConfigurationProperties注入的Property对象
  2. 通过@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
/**
* 自定义的动态配置工厂类
*
* @author qing
* @date 2024/8/10
*/
@Slf4j
@Component
public class DynamicConfigContainer
implements ApplicationContextAware, InitializingBean, EnvironmentAware, CommandLineRunner {

private ConfigurableEnvironment environment;
private ApplicationContext applicationContext;
/**
* 存储db中的全局配置,优先级最高
*/
@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);
}

/**
* 从db中获取全量的配置信息
*
* @return true 表示有信息变更; false 表示无信息变更
*/
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);
});
}

/**
* 应用启动之后,执行的动态配置初始化
*
* @param args
* @throws Exception
*/
@Override
public void run(String... args) throws Exception {
reloadConfig();
// SpringValueRegistry.updateAll();
}
}

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;
}

// 参考ConfigurationPropertiesBindingPostProcessor
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();
// SpringValueRegistry.updateValue(event.getKey());
}

至于@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中,因此目前的做法只能在项目完全启动后刷新所有配置(留个坑),如果有大佬知道解决方案欢迎指出🥳