Skip to content

Commit 6a137fe

Browse files
author
Joey Yang
committed
refactor codes, add badge commit step.
1 parent fd3cc92 commit 6a137fe

File tree

5 files changed

+331
-267
lines changed

5 files changed

+331
-267
lines changed

.github/workflows/gradle.yml

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,20 @@ jobs:
3131
with:
3232
token: ${{ secrets.CODECOV_TOKEN }}
3333
files: build/reports/jacoco/report.xml
34-
verbose: true
34+
verbose: false
3535
- name: jacoco-badge
36+
id: jacoco
3637
uses: cicirello/jacoco-badge-generator@v2
3738
with:
3839
generate-branches-badge: true
39-
jacoco-csv-file: build/reports/jacoco/test/jacocoTestReport.csv
40+
jacoco-csv-file: build/reports/jacoco/test/jacocoTestReport.csv
41+
- name: log-jacoco-coverage-percent
42+
run: |
43+
echo "coverage = ${{ steps.jacoco.outputs.coverage }}"
44+
echo "branch coverage = ${{ steps.jacoco.outputs.branches }}"
45+
- name: commit-badges
46+
uses: EndBug/add-and-commit@v7
47+
with:
48+
default_author: github_actions
49+
message: 'commit badges'
50+
add: '*.svg'
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package top.code2life.config;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.springframework.beans.BeanUtils;
5+
import org.springframework.beans.TypeConverter;
6+
import org.springframework.beans.factory.BeanFactory;
7+
import org.springframework.beans.factory.config.BeanExpressionContext;
8+
import org.springframework.beans.factory.config.BeanExpressionResolver;
9+
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
10+
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
11+
import org.springframework.boot.context.properties.ConfigurationPropertiesBindingPostProcessor;
12+
import org.springframework.boot.origin.OriginTrackedValue;
13+
import org.springframework.context.ApplicationContext;
14+
import org.springframework.context.event.EventListener;
15+
import org.springframework.util.StringUtils;
16+
17+
import java.lang.reflect.Field;
18+
import java.lang.reflect.Modifier;
19+
import java.util.HashMap;
20+
import java.util.List;
21+
import java.util.Map;
22+
import java.util.Objects;
23+
24+
import static top.code2life.config.ConfigurationUtils.*;
25+
import static top.code2life.config.DynamicConfigBeanPostProcessor.DYNAMIC_FIELD_BINDER_MAP;
26+
27+
/**
28+
* @author Code2Life
29+
**/
30+
@Slf4j
31+
@ConditionalOnBean(DynamicConfigPropertiesWatcher.class)
32+
public class ConfigurationChangedEventHandler {
33+
34+
private static final String DOT_SYMBOL = ".";
35+
private static final String INDEXED_PROP_PATTERN = "\\[\\d{1,3}]";
36+
37+
private final BeanExpressionResolver exprResolver;
38+
private final BeanExpressionContext exprContext;
39+
private final ConfigurationPropertiesBindingPostProcessor processor;
40+
private final ConfigurableListableBeanFactory beanFactory;
41+
42+
ConfigurationChangedEventHandler(ApplicationContext applicationContext, BeanFactory beanFactory) {
43+
if (!(beanFactory instanceof ConfigurableListableBeanFactory)) {
44+
throw new IllegalArgumentException(
45+
"DynamicConfig requires a ConfigurableListableBeanFactory");
46+
}
47+
ConfigurableListableBeanFactory factory = (ConfigurableListableBeanFactory) beanFactory;
48+
this.beanFactory = factory;
49+
this.processor = applicationContext.getBean(ConfigurationPropertiesBindingPostProcessor.class);
50+
this.exprResolver = (factory).getBeanExpressionResolver();
51+
this.exprContext = new BeanExpressionContext(factory, null);
52+
}
53+
54+
/**
55+
* Listen config changed event, to process related beans and set latest values for their fields
56+
*
57+
* @param event ConfigurationChangedEvent indicates a configuration file changed event
58+
*/
59+
@EventListener
60+
@SuppressWarnings("unchecked")
61+
public synchronized void handleEvent(ConfigurationChangedEvent event) {
62+
try {
63+
Map<Object, Object> diff = getPropertyDiff((Map<Object, OriginTrackedValue>) event.getPrevious().getSource(), (Map<Object, OriginTrackedValue>) event.getCurrent().getSource());
64+
Map<String, ValueBeanFieldBinder> toRefreshProps = new HashMap<>(4);
65+
for (Map.Entry<Object, Object> entry : diff.entrySet()) {
66+
String key = entry.getKey().toString();
67+
processConfigPropsClass(toRefreshProps, key);
68+
processValueField(key, entry.getValue());
69+
}
70+
for (Map.Entry<String, ValueBeanFieldBinder> entry : toRefreshProps.entrySet()) {
71+
String beanName = entry.getKey();
72+
ValueBeanFieldBinder binder = entry.getValue();
73+
Object bean = binder.getBeanRef().get();
74+
if (bean != null) {
75+
processor.postProcessBeforeInitialization(bean, beanName);
76+
// AggregateBinder - MapBinder will merge properties while binding
77+
// need to check deleted keys and remove from map fields
78+
removeMissingPropsMapFields(diff, bean, binder.getExpr());
79+
log.debug("changes detected, re-bind ConfigurationProperties bean: {}", beanName);
80+
}
81+
}
82+
log.info("config changes of {} have been processed", event.getSource());
83+
} catch (Exception ex) {
84+
log.warn("config changes of {} can not be processed, error:", event.getSource(), ex);
85+
if (log.isDebugEnabled()) {
86+
log.error("error detail is:", ex);
87+
}
88+
}
89+
}
90+
91+
/**
92+
* loop current properties and prev properties, find diff
93+
* removed properties won't impact existing bean values
94+
*/
95+
private Map<Object, Object> getPropertyDiff(Map<Object, OriginTrackedValue> prev, Map<Object, OriginTrackedValue> current) {
96+
Map<Object, Object> diff = new HashMap<>(4);
97+
filterAddOrUpdatedKeys(prev, current, diff);
98+
filterMissingKeys(prev, current, diff);
99+
return diff;
100+
}
101+
102+
private void filterAddOrUpdatedKeys(Map<Object, OriginTrackedValue> prev, Map<Object, OriginTrackedValue> current, Map<Object, Object> diff) {
103+
for (Map.Entry<Object, OriginTrackedValue> entry : current.entrySet()) {
104+
Object k = entry.getKey();
105+
OriginTrackedValue v = entry.getValue();
106+
if (prev.containsKey(k)) {
107+
if (!Objects.equals(v, prev.get(k))) {
108+
diff.put(k, v.getValue());
109+
log.debug("found changed key of dynamic config: {}", k);
110+
}
111+
} else {
112+
diff.put(k, v.getValue());
113+
log.debug("found new added key of dynamic config: {}", k);
114+
}
115+
}
116+
}
117+
118+
private void filterMissingKeys(Map<Object, OriginTrackedValue> prev, Map<Object, OriginTrackedValue> current, Map<Object, Object> diff) {
119+
for (Map.Entry<Object, OriginTrackedValue> entry : prev.entrySet()) {
120+
Object k = entry.getKey();
121+
if (!current.containsKey(k)) {
122+
diff.put(k, null);
123+
log.debug("found deleted k of dynamic config: {}", k);
124+
}
125+
}
126+
}
127+
128+
private void processConfigPropsClass(Map<String, ValueBeanFieldBinder> result, String key) {
129+
DynamicConfigBeanPostProcessor.DYNAMIC_CONFIG_PROPS_BINDER_MAP.forEach((prefix, binder) -> {
130+
if (StringUtils.startsWithIgnoreCase(normalizePropKey(key), prefix)) {
131+
log.debug("prefix matched for ConfigurationProperties bean: {}, prefix: {}", binder.getBeanName(), prefix);
132+
result.put(binder.getBeanName(), binder);
133+
}
134+
});
135+
}
136+
137+
private void processValueField(String keyRaw, Object val) throws IllegalAccessException {
138+
String key = normalizePropKey(keyRaw);
139+
if (!DYNAMIC_FIELD_BINDER_MAP.containsKey(key)) {
140+
log.debug("no bound field of changed property found, skip dynamic config processing of key: {}", keyRaw);
141+
return;
142+
}
143+
List<ValueBeanFieldBinder> valueFieldBinders = DYNAMIC_FIELD_BINDER_MAP.get(key);
144+
for (ValueBeanFieldBinder binder : valueFieldBinders) {
145+
Object bean = binder.getBeanRef().get();
146+
if (bean == null) {
147+
continue;
148+
}
149+
convertAndBindFieldValue(val, binder, bean);
150+
}
151+
}
152+
153+
private void convertAndBindFieldValue(Object val, ValueBeanFieldBinder binder, Object bean) throws IllegalAccessException {
154+
Field field = binder.getDynamicField();
155+
field.setAccessible(true);
156+
String expr = binder.getExpr();
157+
String newExpr = beanFactory.resolveEmbeddedValue(expr);
158+
if (expr.startsWith(SP_EL_PREFIX)) {
159+
Object evaluatedVal = exprResolver.evaluate(newExpr, exprContext);
160+
field.set(bean, convertIfNecessary(field, evaluatedVal));
161+
} else {
162+
field.set(bean, convertIfNecessary(field, val));
163+
}
164+
if (log.isDebugEnabled()) {
165+
log.debug("dynamic config found, set field: '{}' of class: '{}' with new value", field.getName(), bean.getClass().getSimpleName());
166+
}
167+
}
168+
169+
private void removeMissingPropsMapFields(Map<Object, Object> diff, Object rootBean, String prefix) throws IllegalAccessException {
170+
for (Map.Entry<Object, Object> entry : diff.entrySet()) {
171+
Object propKey = entry.getKey();
172+
Object value = entry.getValue();
173+
if (value != null) {
174+
// only null value prop need to be removed from field value
175+
continue;
176+
}
177+
String rawKey = propKey.toString();
178+
// 'a.b[1].c.d' liked changes would be refreshed wholly, no need to handle
179+
if (rawKey.matches(INDEXED_PROP_PATTERN)) {
180+
continue;
181+
}
182+
183+
// if key 'a.b.c.d' is removed, need to check if 'a.b.c' is a map, if so, remove map key 'd'
184+
String normalizedFieldPath = findParentPath(prefix, rawKey);
185+
String leafKey = rawKey.substring(rawKey.lastIndexOf(DOT_SYMBOL) + 1);
186+
removeMissingMapKeyIfMatch(getTargetClassOfBean(rootBean), rootBean, normalizedFieldPath, leafKey);
187+
}
188+
}
189+
190+
private void removeMissingMapKeyIfMatch(Class<?> clazz, Object obj, String path, String mapKey) throws IllegalAccessException {
191+
int pos = path.indexOf(DOT_SYMBOL);
192+
boolean onLeaf = pos == -1;
193+
Field[] fields = clazz.getDeclaredFields();
194+
for (Field f : fields) {
195+
if (isIgnorableField(f)) {
196+
continue;
197+
}
198+
String fieldName = f.getName();
199+
boolean matchObjPath = StringUtils.startsWithIgnoreCase(path, normalizePropKey(fieldName));
200+
if (matchObjPath && onLeaf && Map.class.isAssignableFrom(f.getType())) {
201+
f.setAccessible(true);
202+
((Map<?, ?>) f.get(obj)).remove(mapKey);
203+
log.info("key {} has been removed from {} because of configuration change.", mapKey, path);
204+
break;
205+
}
206+
// dive to next level for case: path: a.b.c, field: b
207+
if (matchObjPath && !onLeaf) {
208+
f.setAccessible(true);
209+
Object subObj = f.get(obj);
210+
removeMissingMapKeyIfMatch(subObj.getClass(), subObj, path.substring(pos + 1), mapKey);
211+
}
212+
}
213+
}
214+
215+
private boolean isIgnorableField(Field f) {
216+
int modifiers = f.getModifiers();
217+
Class<?> type = f.getType();
218+
return Modifier.isStatic(modifiers) || Modifier.isFinal(modifiers) || BeanUtils.isSimpleValueType(type);
219+
}
220+
221+
private String findParentPath(String prefix, String rawKey) {
222+
String normalizedFieldPath = normalizePropKey(rawKey).substring(prefix.length() + 1);
223+
int pathPos = normalizedFieldPath.lastIndexOf(DOT_SYMBOL);
224+
if (pathPos != -1) {
225+
normalizedFieldPath = normalizedFieldPath.substring(0, pathPos);
226+
} else {
227+
normalizedFieldPath = "";
228+
}
229+
return normalizedFieldPath;
230+
}
231+
232+
private Object convertIfNecessary(Field field, Object value) {
233+
TypeConverter converter = beanFactory.getTypeConverter();
234+
return converter.convertIfNecessary(value, field.getType(), field);
235+
}
236+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package top.code2life.config;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.springframework.aop.support.AopUtils;
5+
import org.springframework.util.ClassUtils;
6+
import org.springframework.util.StringUtils;
7+
8+
import java.util.ArrayList;
9+
import java.util.List;
10+
import java.util.Locale;
11+
import java.util.regex.Matcher;
12+
import java.util.regex.Pattern;
13+
14+
/**
15+
* @author Code2Life
16+
**/
17+
@Slf4j
18+
public class ConfigurationUtils {
19+
20+
static final String VALUE_EXPR_PREFIX = "$";
21+
static final String SP_EL_PREFIX = "#";
22+
23+
private static final Pattern VALUE_PATTERN = Pattern.compile("\\$\\{([^:}]+):?([^}]*)}");
24+
private static final Pattern CAMEL_CASE_PATTERN = Pattern.compile("([^A-Z-])([A-Z])");
25+
26+
static List<String> extractValueFromExpr(String valueExpr) {
27+
List<String> keys = new ArrayList<>(2);
28+
Matcher matcher = VALUE_PATTERN.matcher(valueExpr);
29+
while (matcher.find()) {
30+
try {
31+
// normalized into kebab case (abc-def.g-h)
32+
keys.add(normalizePropKey(matcher.group(1).trim()));
33+
} catch (Exception ex) {
34+
log.warn("can not extract target property from @Value declaration, expr: {}. error: {}", valueExpr, ex.getMessage());
35+
}
36+
}
37+
return keys;
38+
}
39+
40+
/**
41+
* Convert camelCase or snake_case key into kebab-case
42+
*
43+
* @param name the key name
44+
* @return normalized key name
45+
*/
46+
static String normalizePropKey(String name) {
47+
if (!StringUtils.hasText(name)) {
48+
return name;
49+
}
50+
Matcher matcher = CAMEL_CASE_PATTERN.matcher(name);
51+
StringBuffer result = new StringBuffer();
52+
while (matcher.find()) {
53+
matcher.appendReplacement(result, matcher.group(1) + '-' + StringUtils.uncapitalize(matcher.group(2)));
54+
}
55+
matcher.appendTail(result);
56+
return result.toString().replaceAll("_", "-").toLowerCase(Locale.ENGLISH);
57+
}
58+
59+
static Class<?> getTargetClassOfBean(Object bean) {
60+
Class<?> clazz = AopUtils.getTargetClass(bean);
61+
if (clazz.getName().contains(ClassUtils.CGLIB_CLASS_SEPARATOR)) {
62+
clazz = clazz.getSuperclass();
63+
}
64+
return clazz;
65+
}
66+
67+
}

src/main/java/top/code2life/config/DynamicConfigAutoConfiguration.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@
55
/**
66
* @author Code2Life
77
*/
8-
@Import({DynamicConfigPropertiesWatcher.class, DynamicConfigBeanPostProcessor.class, FeatureGate.class})
8+
@Import({DynamicConfigPropertiesWatcher.class, DynamicConfigBeanPostProcessor.class, FeatureGate.class, ConfigurationChangedEventHandler.class})
99
public class DynamicConfigAutoConfiguration {
1010
}

0 commit comments

Comments
 (0)