feat: 新增 Spring Boot 3 客户端 SDK 及 demo
- 新增 kenaito-conf-springboot3-client,支持配置拉取、增量同步、@Value/@ConfigurationProperties 动态刷新和本地缓存 - 新增 kenaito-conf-springboot3-client-demo(端口 8084)及 Dockerfile.springboot3 - 完善中英文 README、Docker 部署文档,修正 Spring Boot 2 客户端配置键名与 demo 端口说明 - 修复增量同步将服务端 code=0 成功响应误判为错误的问题
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
||||
package cn.odboy.confcenter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Callback for remote config changes.
|
||||
*
|
||||
* <p>Implement this interface as a Spring bean to be notified whenever the config
|
||||
* center pushes a set of changed keys.</p>
|
||||
*
|
||||
* @author odboy
|
||||
* @version 3.0.0
|
||||
* @date 2026-10-21
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface ConfigChangeHanlder {
|
||||
|
||||
void onConfigChanged(Map<String, Object> changedConfigs);
|
||||
}
|
||||
+679
@@ -0,0 +1,679 @@
|
||||
package cn.odboy.confcenter;
|
||||
|
||||
import java.io.FileReader;
|
||||
import java.net.InetAddress;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.Validate;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.env.MutablePropertySources;
|
||||
import org.springframework.core.env.PropertiesPropertySource;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
|
||||
/**
|
||||
* Config center context.
|
||||
*
|
||||
* <p>Holds the client configuration, the remote configuration snapshot and the
|
||||
* Spring environment wiring used to refresh {@code @Value} and
|
||||
* {@code @ConfigurationProperties} fields at runtime.</p>
|
||||
*
|
||||
* @author odboy
|
||||
* @version 3.0.0
|
||||
* @date 2026-10-21
|
||||
*/
|
||||
public class ConfigcenterContext {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger("cn.odboy");
|
||||
|
||||
private static final ConfigcenterContext INSTANCE = new ConfigcenterContext();
|
||||
|
||||
public static final String MANAGER_PROPERTY_SOURCE = "kenaitoConfigPropertySource";
|
||||
|
||||
private static final String IGNORE_PLACEHOLDER = "[Ignore]";
|
||||
|
||||
private static final String LATEST_FETCH_TIMESTAMP = "latest.fetch.timestamp";
|
||||
|
||||
private static final List<String> DEFAULT_SENSITIVE_KEYS = Arrays.asList(
|
||||
"password", "passwd", "key", "secret", "token", "credentials");
|
||||
|
||||
/** Properties visible to the client (application config + remote config). */
|
||||
private static final Map<String, String> localProps = new ConcurrentHashMap<>();
|
||||
|
||||
private static volatile ApplicationContext applicationContext;
|
||||
|
||||
private static volatile RefreshScopeRegistry refreshScopeRegistry;
|
||||
|
||||
private final String nodeId;
|
||||
|
||||
private Boolean remoteEnabled;
|
||||
|
||||
private String[] apiBaseUrls;
|
||||
|
||||
private String app;
|
||||
|
||||
private String env;
|
||||
|
||||
private String version;
|
||||
|
||||
private boolean ignoreGlobal;
|
||||
|
||||
private String secret;
|
||||
|
||||
private String globalSecret;
|
||||
|
||||
private String token;
|
||||
|
||||
private boolean remoteFirst;
|
||||
|
||||
private boolean isSpringboot;
|
||||
|
||||
private int syncIntervalSeconds = 60;
|
||||
|
||||
private List<String> sensitiveKeys = DEFAULT_SENSITIVE_KEYS;
|
||||
|
||||
private InternalConfigChangeListener configChangeListener;
|
||||
|
||||
private List<ConfigChangeHanlder> configChangeHanlders;
|
||||
|
||||
private boolean processed;
|
||||
|
||||
private volatile Properties remoteProperties;
|
||||
|
||||
private volatile long lastFetchTimestamp;
|
||||
|
||||
private ConfigcenterContext() {
|
||||
String name;
|
||||
try {
|
||||
name = InetAddress.getLocalHost().getHostName();
|
||||
} catch (Exception e) {
|
||||
name = "unknown";
|
||||
}
|
||||
this.nodeId = name;
|
||||
}
|
||||
|
||||
public static ConfigcenterContext getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public static void setApplicationContext(ApplicationContext ctx) {
|
||||
applicationContext = ctx;
|
||||
}
|
||||
|
||||
public static ApplicationContext getApplicationContext() {
|
||||
return applicationContext;
|
||||
}
|
||||
|
||||
public static void setRefreshScopeRegistry(RefreshScopeRegistry registry) {
|
||||
refreshScopeRegistry = registry;
|
||||
}
|
||||
|
||||
public static RefreshScopeRegistry getRefreshScopeRegistry() {
|
||||
return refreshScopeRegistry;
|
||||
}
|
||||
|
||||
// ==================== Property Utilities ====================
|
||||
|
||||
public static void mergeProperties(Properties props) {
|
||||
if (props != null) {
|
||||
for (Entry<Object, Object> entry : props.entrySet()) {
|
||||
localProps.put(entry.getKey().toString(), entry.getValue().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void addProperty(String key, String value) {
|
||||
localProps.put(key, value);
|
||||
}
|
||||
|
||||
public static String getProperty(String key) {
|
||||
String val = localProps.get(key);
|
||||
if (val == null) {
|
||||
val = System.getProperty(key);
|
||||
}
|
||||
if (val == null) {
|
||||
val = System.getenv(key);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
public static String getProperty(String key, String defaultValue) {
|
||||
String val = getProperty(key);
|
||||
return val != null && !val.isEmpty() ? val : defaultValue;
|
||||
}
|
||||
|
||||
public static boolean getBoolean(String key, boolean defaultValue) {
|
||||
String val = getProperty(key);
|
||||
if (val == null || val.isEmpty()) {
|
||||
return defaultValue;
|
||||
}
|
||||
return Boolean.parseBoolean(val);
|
||||
}
|
||||
|
||||
public static int getInt(String key, int defaultValue) {
|
||||
String val = getProperty(key);
|
||||
if (val == null || val.isEmpty()) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(val.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
public static String getAnyProperty(String... keys) {
|
||||
for (String key : keys) {
|
||||
String val = getProperty(key);
|
||||
if (val != null && !val.isEmpty()) {
|
||||
return val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static boolean getBooleanAny(boolean defaultValue, String... keys) {
|
||||
String val = getAnyProperty(keys);
|
||||
return val == null ? defaultValue : Boolean.parseBoolean(val.trim());
|
||||
}
|
||||
|
||||
static int getIntAny(int defaultValue, String... keys) {
|
||||
String val = getAnyProperty(keys);
|
||||
if (val == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(val.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== HTTP Utilities ====================
|
||||
|
||||
public static String httpGet(String url) throws Exception {
|
||||
return HttpClientUtils.get(url);
|
||||
}
|
||||
|
||||
public static String httpPostJson(String url, String json) throws Exception {
|
||||
return HttpClientUtils.postJson(url, json);
|
||||
}
|
||||
|
||||
// ==================== Core Methods ====================
|
||||
|
||||
public boolean isProcessed() {
|
||||
return processed;
|
||||
}
|
||||
|
||||
public synchronized void init(Properties properties, boolean isSpringboot) {
|
||||
if (processed) {
|
||||
return;
|
||||
}
|
||||
processed = true;
|
||||
if (properties != null) {
|
||||
mergeProperties(properties);
|
||||
}
|
||||
|
||||
this.isSpringboot = isSpringboot;
|
||||
System.setProperty("client.nodeId", nodeId);
|
||||
System.setProperty("springboot", String.valueOf(isSpringboot));
|
||||
|
||||
if (remoteEnabled == null) {
|
||||
remoteEnabled = getBooleanAny(true,
|
||||
"kenaito.config.enabled", "kenaito.configcenter.enabled");
|
||||
}
|
||||
if (!isRemoteEnabled()) {
|
||||
logger.info("[ConfigClient] remote config is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
app = firstNonBlank(
|
||||
getProperty("kenaito.config.app-name"),
|
||||
getProperty("kenaito.config.appName"),
|
||||
getProperty("kenaito.configcenter.app-name"),
|
||||
getProperty("kenaito.configcenter.appName"),
|
||||
getProperty("spring.application.name"));
|
||||
Validate.notBlank(app, "[kenaito.config.app-name] is required");
|
||||
|
||||
env = firstNonBlank(
|
||||
getProperty("kenaito.config.profile"),
|
||||
getProperty("kenaito.config.env"),
|
||||
getProperty("kenaito.configcenter.profile"),
|
||||
getProperty("kenaito.configcenter.env"),
|
||||
"dev");
|
||||
Validate.notBlank(env, "[kenaito.config.profile] is required");
|
||||
|
||||
String baseUrl = firstNonBlank(
|
||||
getProperty("kenaito.config.base-url"),
|
||||
getProperty("kenaito.config.baseUrl"),
|
||||
getProperty("kenaito.configcenter.base-url"),
|
||||
getProperty("kenaito.configcenter.baseUrl"));
|
||||
setApiBaseUrl(baseUrl);
|
||||
|
||||
version = firstNonBlank(
|
||||
getProperty("kenaito.config.version"),
|
||||
getProperty("kenaito.configcenter.version"),
|
||||
"latest");
|
||||
ignoreGlobal = getBooleanAny(false,
|
||||
"kenaito.config.global-ignore", "kenaito.config.ignore-global",
|
||||
"kenaito.configcenter.global-ignore");
|
||||
syncIntervalSeconds = getIntAny(60,
|
||||
"kenaito.config.sync-interval-seconds", "kenaito.config.sync-interval",
|
||||
"kenaito.configcenter.sync-interval-seconds", "kenaito.configcenter.sync-interval");
|
||||
token = firstNonBlank(
|
||||
getProperty("kenaito.config.token"),
|
||||
getProperty("kenaito.configcenter.token"));
|
||||
String configuredSensitiveKeys = getAnyProperty(
|
||||
"kenaito.config.sensitive-keys", "kenaito.configcenter.sensitive-keys");
|
||||
if (StringUtils.isNotBlank(configuredSensitiveKeys)) {
|
||||
List<String> keys = new ArrayList<>();
|
||||
for (String key : configuredSensitiveKeys.split(",")) {
|
||||
if (StringUtils.isNotBlank(key)) {
|
||||
keys.add(key.trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
if (!keys.isEmpty()) {
|
||||
sensitiveKeys = Collections.unmodifiableList(keys);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("\n=====Configcenter config=====\nappName:{}\nenv:{}\nversion:{}\nremoteEnabled:{}\napiBaseUrls:{}\nsyncIntervalSeconds:{}\n=====Configcenter config=====",
|
||||
app, env, version, isRemoteEnabled(), JSON.toJSONString(apiBaseUrls), syncIntervalSeconds);
|
||||
}
|
||||
|
||||
public boolean isRemoteEnabled() {
|
||||
return remoteEnabled == null || remoteEnabled;
|
||||
}
|
||||
|
||||
public void setRemoteEnabled(boolean remoteEnabled) {
|
||||
this.remoteEnabled = remoteEnabled;
|
||||
}
|
||||
|
||||
public String[] getApiBaseUrls() {
|
||||
return apiBaseUrls == null ? new String[0] : apiBaseUrls.clone();
|
||||
}
|
||||
|
||||
public void setApiBaseUrl(String apiBaseUrl) {
|
||||
Validate.notBlank(apiBaseUrl, "[kenaito.config.base-url] is required");
|
||||
String[] urls = apiBaseUrl.split("[,;]");
|
||||
this.apiBaseUrls = new String[urls.length];
|
||||
for (int i = 0; i < urls.length; i++) {
|
||||
String url = urls[i].trim();
|
||||
if (url.endsWith("/")) {
|
||||
url = url.substring(0, url.length() - 1);
|
||||
}
|
||||
this.apiBaseUrls[i] = url;
|
||||
}
|
||||
}
|
||||
|
||||
public String getApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
public String getEnv() {
|
||||
return env;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public boolean isIgnoreGlobal() {
|
||||
return ignoreGlobal;
|
||||
}
|
||||
|
||||
public boolean isRemoteFirst() {
|
||||
return remoteFirst;
|
||||
}
|
||||
|
||||
public boolean isSpringboot() {
|
||||
return isSpringboot;
|
||||
}
|
||||
|
||||
public int getSyncIntervalSeconds() {
|
||||
return syncIntervalSeconds;
|
||||
}
|
||||
|
||||
public long getLastFetchTimestamp() {
|
||||
return lastFetchTimestamp;
|
||||
}
|
||||
|
||||
public void setLastFetchTimestamp(long lastFetchTimestamp) {
|
||||
this.lastFetchTimestamp = lastFetchTimestamp;
|
||||
}
|
||||
|
||||
public String getNodeId() {
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the remote configuration and injects it into the supplied properties.
|
||||
* Remote fetch failures are logged and never prevent the application from starting.
|
||||
*/
|
||||
public void mergeRemoteProperties(Properties properties) {
|
||||
if (!isRemoteEnabled()) {
|
||||
return;
|
||||
}
|
||||
Properties fetched = getAllRemoteProperties();
|
||||
if (fetched != null) {
|
||||
for (Entry<Object, Object> entry : fetched.entrySet()) {
|
||||
String key = entry.getKey().toString();
|
||||
if (!isRemoteFirst() && properties.containsKey(key)) {
|
||||
continue;
|
||||
}
|
||||
String value = Objects.toString(entry.getValue(), "");
|
||||
if (IGNORE_PLACEHOLDER.equals(value)) {
|
||||
continue;
|
||||
}
|
||||
properties.setProperty(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
for (Entry<Object, Object> entry : properties.entrySet()) {
|
||||
addProperty(entry.getKey().toString(), entry.getValue().toString());
|
||||
}
|
||||
|
||||
// Optional local override file, useful for local debugging.
|
||||
String path = System.getProperty("localtest.config.location");
|
||||
if (StringUtils.isNotBlank(path)) {
|
||||
try (FileReader fileReader = new FileReader(path)) {
|
||||
Properties localProperties = new Properties();
|
||||
localProperties.load(fileReader);
|
||||
if (!localProperties.isEmpty()) {
|
||||
properties.putAll(localProperties);
|
||||
mergeProperties(localProperties);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("[ConfigClient] failed to load local test config from {}: {}", path, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (configChangeListener != null) {
|
||||
configChangeListener.close();
|
||||
}
|
||||
configChangeListener = new InternalConfigChangeListener();
|
||||
printConfigs(properties);
|
||||
}
|
||||
|
||||
private Properties getAllRemoteProperties() {
|
||||
if (remoteProperties != null) {
|
||||
return remoteProperties;
|
||||
}
|
||||
Map<String, Object> map = fetchConfigFromServer();
|
||||
if (map == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
secret = Objects.toString(map.remove("kenaito.config.encrypt-secret"), null);
|
||||
globalSecret = Objects.toString(map.remove("kenaito.config.global-encrypt-secret"), null);
|
||||
remoteFirst = Boolean.parseBoolean(Objects.toString(map.remove("kenaito.config.remote-config-first"), "false"));
|
||||
Object timestamp = map.remove(LATEST_FETCH_TIMESTAMP);
|
||||
if (timestamp != null) {
|
||||
try {
|
||||
lastFetchTimestamp = Long.parseLong(timestamp.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
logger.warn("[ConfigClient] invalid {}: {}", LATEST_FETCH_TIMESTAMP, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
Properties properties = new Properties();
|
||||
for (Entry<String, Object> entry : map.entrySet()) {
|
||||
if (entry.getValue() != null) {
|
||||
properties.setProperty(entry.getKey(), entry.getValue().toString());
|
||||
}
|
||||
}
|
||||
remoteProperties = properties;
|
||||
return properties;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> fetchConfigFromServer() {
|
||||
if (apiBaseUrls == null || apiBaseUrls.length == 0) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> result = null;
|
||||
for (String apiBaseUrl : apiBaseUrls) {
|
||||
String url = apiBaseUrl + "/api/fetchAllConfigs";
|
||||
Map<String, Object> params = new java.util.HashMap<>();
|
||||
params.put("appName", app);
|
||||
params.put("env", env);
|
||||
params.put("version", version);
|
||||
params.put("ignoreGlobal", ignoreGlobal);
|
||||
if (StringUtils.isNotBlank(token)) {
|
||||
params.put("token", token);
|
||||
}
|
||||
logger.info("[ConfigClient] fetching configs from {}", url);
|
||||
try {
|
||||
String jsonString = httpPostJson(url, JSON.toJSONString(params));
|
||||
if (jsonString == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> parsed = JSON.parseObject(jsonString, Map.class);
|
||||
// Error responses use {"code": <non-zero number>, "msg": "..."} while
|
||||
// successful responses are a plain key/value map (which may legitimately
|
||||
// contain a string "code" entry).
|
||||
Object code = parsed.get("code");
|
||||
if (code instanceof Number codeNumber && codeNumber.intValue() != 0
|
||||
&& parsed.get("msg") instanceof String) {
|
||||
logger.error("[ConfigClient] fetch config error from {}: {} ({})",
|
||||
apiBaseUrl, parsed.get("msg"), code);
|
||||
continue;
|
||||
}
|
||||
result = parsed;
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
logger.error("[ConfigClient] fetch config error from {}: {}", apiBaseUrl, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (result == null) {
|
||||
logger.warn("[ConfigClient] remote config fetch failed, loading from local cache");
|
||||
result = LocalCacheUtils.read();
|
||||
} else {
|
||||
LocalCacheUtils.write(result);
|
||||
logger.info("[ConfigClient] fetched {} configs", result.size());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void printConfigs(Properties properties) {
|
||||
List<String> sortKeys = new ArrayList<>(properties.stringPropertyNames());
|
||||
Collections.sort(sortKeys);
|
||||
StringBuilder output = new StringBuilder("\n==================final config list start==================\n");
|
||||
for (String key : sortKeys) {
|
||||
output.append(key).append(" = ").append(hideSensitive(key, properties.getProperty(key))).append('\n');
|
||||
}
|
||||
output.append("==================final config list end====================");
|
||||
logger.info(output.toString());
|
||||
}
|
||||
|
||||
public synchronized void updateConfig(Map<String, Object> updateConfig) {
|
||||
if (updateConfig == null || updateConfig.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Entry<String, Object> entry : updateConfig.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
String newValue = Objects.toString(entry.getValue(), "");
|
||||
String oldValue = getProperty(key);
|
||||
addProperty(key, newValue);
|
||||
if (remoteProperties != null) {
|
||||
remoteProperties.setProperty(key, newValue);
|
||||
}
|
||||
logger.info("[ConfigClient] config [{}] changed, oldValue:{}, newValue:{}",
|
||||
key, oldValue, entry.getValue());
|
||||
}
|
||||
persistRemoteCache();
|
||||
|
||||
updateSpringPropertySource(updateConfig, Collections.emptySet());
|
||||
|
||||
if (configChangeHanlders == null) {
|
||||
configChangeHanlders = new ArrayList<>();
|
||||
if (applicationContext != null) {
|
||||
Map<String, ConfigChangeHanlder> handlers = applicationContext.getBeansOfType(ConfigChangeHanlder.class);
|
||||
configChangeHanlders.addAll(handlers.values());
|
||||
}
|
||||
}
|
||||
for (ConfigChangeHanlder handler : configChangeHanlders) {
|
||||
try {
|
||||
handler.onConfigChanged(updateConfig);
|
||||
logger.info("[ConfigClient] invoked {}.onConfigChanged", handler.getClass().getName());
|
||||
} catch (Exception e) {
|
||||
logger.warn("[ConfigClient] invoke {}.onConfigChanged error: {}", handler.getClass().getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
refreshDynamicConfigFields(new HashSet<>(updateConfig.keySet()));
|
||||
}
|
||||
|
||||
public synchronized void removeConfigs(List<String> keys) {
|
||||
if (keys == null || keys.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (String key : keys) {
|
||||
localProps.remove(key);
|
||||
if (remoteProperties != null) {
|
||||
remoteProperties.remove(key);
|
||||
}
|
||||
logger.info("[ConfigClient] config [{}] removed", key);
|
||||
}
|
||||
persistRemoteCache();
|
||||
updateSpringPropertySource(Collections.emptyMap(), new HashSet<>(keys));
|
||||
refreshDynamicConfigFields(new HashSet<>(keys));
|
||||
}
|
||||
|
||||
private void persistRemoteCache() {
|
||||
Properties snapshotSource = remoteProperties;
|
||||
if (snapshotSource == null) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||
for (String name : snapshotSource.stringPropertyNames()) {
|
||||
snapshot.put(name, snapshotSource.getProperty(name));
|
||||
}
|
||||
if (lastFetchTimestamp > 0) {
|
||||
snapshot.put(LATEST_FETCH_TIMESTAMP, String.valueOf(lastFetchTimestamp));
|
||||
}
|
||||
LocalCacheUtils.write(snapshot);
|
||||
}
|
||||
|
||||
private void updateSpringPropertySource(Map<String, Object> updates, Set<String> removals) {
|
||||
if (applicationContext == null) {
|
||||
return;
|
||||
}
|
||||
if (!(applicationContext.getEnvironment() instanceof ConfigurableEnvironment environment)) {
|
||||
return;
|
||||
}
|
||||
MutablePropertySources propertySources = environment.getPropertySources();
|
||||
synchronized (propertySources) {
|
||||
Properties properties = new Properties();
|
||||
if (propertySources.contains(MANAGER_PROPERTY_SOURCE)) {
|
||||
Object source = propertySources.get(MANAGER_PROPERTY_SOURCE);
|
||||
if (source instanceof MapPropertySource mapPropertySource) {
|
||||
mapPropertySource.getSource().forEach((k, v) -> properties.put(k, Objects.toString(v, "")));
|
||||
} else if (source instanceof PropertiesPropertySource propertiesPropertySource) {
|
||||
properties.putAll(propertiesPropertySource.getSource());
|
||||
}
|
||||
}
|
||||
for (String key : removals) {
|
||||
properties.remove(key);
|
||||
}
|
||||
updates.forEach((k, v) -> properties.put(k, Objects.toString(v, "")));
|
||||
PropertiesPropertySource propertySource =
|
||||
new PropertiesPropertySource(MANAGER_PROPERTY_SOURCE, properties);
|
||||
if (propertySources.contains(MANAGER_PROPERTY_SOURCE)) {
|
||||
propertySources.replace(MANAGER_PROPERTY_SOURCE, propertySource);
|
||||
} else {
|
||||
propertySources.addFirst(propertySource);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshDynamicConfigFields(Set<String> changedKeys) {
|
||||
if (applicationContext == null) {
|
||||
logger.warn("[RefreshScope] ApplicationContext is null, cannot refresh dynamic config fields");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
RefreshScopeRegistry registry = refreshScopeRegistry;
|
||||
if (registry == null) {
|
||||
// Fallback for environments where the auto-configuration is not active
|
||||
// (for example plain Spring applications).
|
||||
registry = new RefreshScopeRegistry(applicationContext.getEnvironment());
|
||||
for (String beanName : applicationContext.getBeanDefinitionNames()) {
|
||||
try {
|
||||
registry.postProcessAfterInitialization(applicationContext.getBean(beanName), beanName);
|
||||
} catch (Exception ignored) {
|
||||
// ignore beans that cannot be resolved
|
||||
}
|
||||
}
|
||||
refreshScopeRegistry = registry;
|
||||
}
|
||||
int refreshed = registry.refresh(changedKeys);
|
||||
if (refreshed > 0) {
|
||||
logger.info("[RefreshScope] refreshed {} @Value field(s) for keys: {}", refreshed, changedKeys);
|
||||
} else {
|
||||
logger.info("[RefreshScope] no registered @Value field references keys {}, "
|
||||
+ "their values are already updated in the Spring Environment", changedKeys);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("[RefreshScope] refresh dynamic config fields error: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public String buildTokenParameter(String url) {
|
||||
if (StringUtils.isBlank(token)) {
|
||||
return url;
|
||||
}
|
||||
return url + (url.contains("?") ? "&" : "?") + "token=" + token;
|
||||
}
|
||||
|
||||
public void close() {
|
||||
if (configChangeListener != null) {
|
||||
configChangeListener.close();
|
||||
}
|
||||
}
|
||||
|
||||
private String hideSensitive(String key, String origin) {
|
||||
if (StringUtils.isAnyBlank(key, origin)) {
|
||||
return "";
|
||||
}
|
||||
String lowerKey = key.toLowerCase();
|
||||
boolean sensitive = false;
|
||||
for (String k : sensitiveKeys) {
|
||||
if (lowerKey.contains(k)) {
|
||||
sensitive = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
int length = origin.length();
|
||||
if (sensitive && length > 1) {
|
||||
return origin.substring(0, length / 2) + "****";
|
||||
}
|
||||
return origin;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package cn.odboy.confcenter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.apache.http.HttpResponse;
|
||||
import org.apache.http.client.config.RequestConfig;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.entity.StringEntity;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.impl.client.HttpClients;
|
||||
import org.apache.http.util.EntityUtils;
|
||||
|
||||
/**
|
||||
* HTTP client utilities based on Apache HttpClient 4.x.
|
||||
*
|
||||
* @author odboy
|
||||
* @version 3.0.0
|
||||
* @date 2026-10-21
|
||||
*/
|
||||
public class HttpClientUtils {
|
||||
|
||||
private static final CloseableHttpClient HTTP_CLIENT;
|
||||
|
||||
static {
|
||||
RequestConfig config = RequestConfig.custom()
|
||||
.setConnectTimeout(10_000)
|
||||
.setSocketTimeout(30_000)
|
||||
.setConnectionRequestTimeout(5_000)
|
||||
.build();
|
||||
HTTP_CLIENT = HttpClients.custom()
|
||||
.setDefaultRequestConfig(config)
|
||||
.build();
|
||||
}
|
||||
|
||||
private HttpClientUtils() {
|
||||
}
|
||||
|
||||
public static String get(String url) throws IOException {
|
||||
HttpGet request = new HttpGet(url);
|
||||
return execute(request);
|
||||
}
|
||||
|
||||
public static String postJson(String url, String json) throws IOException {
|
||||
HttpPost request = new HttpPost(url);
|
||||
request.setHeader("Content-Type", "application/json;charset=UTF-8");
|
||||
request.setEntity(new StringEntity(json, "UTF-8"));
|
||||
return execute(request);
|
||||
}
|
||||
|
||||
private static String execute(org.apache.http.client.methods.HttpRequestBase request) throws IOException {
|
||||
HttpResponse response = HTTP_CLIENT.execute(request);
|
||||
int statusCode = response.getStatusLine().getStatusCode();
|
||||
String body = response.getEntity() == null ? null : EntityUtils.toString(response.getEntity(), "UTF-8");
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return body;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package cn.odboy.confcenter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
|
||||
/**
|
||||
* Internal config change listener.
|
||||
*
|
||||
* <p>Polls {@code /api/fetchChangedConfigs} at a fixed interval and applies
|
||||
* incremental changes (including deleted keys) to the config center context.</p>
|
||||
*
|
||||
* @author odboy
|
||||
* @version 3.0.0
|
||||
* @date 2026-10-21
|
||||
*/
|
||||
public class InternalConfigChangeListener {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger("cn.odboy.confcenter");
|
||||
|
||||
private static final String LATEST_FETCH_TIMESTAMP = "latest.fetch.timestamp";
|
||||
|
||||
private final ScheduledExecutorService scheduler;
|
||||
|
||||
private volatile boolean closed;
|
||||
|
||||
private volatile long lastTime;
|
||||
|
||||
public InternalConfigChangeListener() {
|
||||
ConfigcenterContext context = ConfigcenterContext.getInstance();
|
||||
this.scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "kenaito-config-listener");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
registerHttpListener(context);
|
||||
}
|
||||
|
||||
public void close() {
|
||||
closed = true;
|
||||
scheduler.shutdownNow();
|
||||
}
|
||||
|
||||
private void registerHttpListener(ConfigcenterContext context) {
|
||||
final String[] baseUrls = context.getApiBaseUrls();
|
||||
if (baseUrls.length == 0) {
|
||||
logger.warn("[ConfigClient] no config center base url configured, skip change listener");
|
||||
return;
|
||||
}
|
||||
|
||||
long initialLastTime = context.getLastFetchTimestamp();
|
||||
if (initialLastTime <= 0) {
|
||||
initialLastTime = System.currentTimeMillis();
|
||||
}
|
||||
this.lastTime = initialLastTime;
|
||||
final int interval = Math.max(1, context.getSyncIntervalSeconds());
|
||||
|
||||
scheduler.scheduleWithFixedDelay(() -> {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
poll(context, baseUrls);
|
||||
} catch (Exception e) {
|
||||
logger.error("[ConfigClient] error fetching config changes: {}", e.getMessage(), e);
|
||||
}
|
||||
}, interval, interval, TimeUnit.SECONDS);
|
||||
|
||||
logger.info("[ConfigClient] change listener registered, intervalSeconds:{}, lastFetchTime:{}",
|
||||
interval, initialLastTime);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void poll(ConfigcenterContext context, String[] baseUrls) {
|
||||
for (String baseUrl : baseUrls) {
|
||||
String url = context.buildTokenParameter(baseUrl + "/api/fetchChangedConfigs");
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("version", context.getVersion());
|
||||
params.put("appName", context.getApp());
|
||||
params.put("env", context.getEnv());
|
||||
params.put("ignoreGlobal", context.isIgnoreGlobal());
|
||||
params.put("lastTime", lastTime);
|
||||
|
||||
String response;
|
||||
try {
|
||||
response = ConfigcenterContext.httpPostJson(url, JSON.toJSONString(params));
|
||||
} catch (Exception e) {
|
||||
logger.error("[ConfigClient] fetch changed configs error from {}: {}", baseUrl, e.getMessage());
|
||||
continue;
|
||||
}
|
||||
if (response == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<String, Object> map = JSON.parseObject(response, Map.class);
|
||||
if (map == null) {
|
||||
continue;
|
||||
}
|
||||
Object code = map.get("code");
|
||||
// The server returns {"code": 0, "data": {...}} on success and a
|
||||
// non-zero code on error. Only non-zero codes are failures.
|
||||
if (code instanceof Number codeNumber && codeNumber.intValue() != 0) {
|
||||
logger.error("[ConfigClient] fetch changed configs error from {}: {} ({})",
|
||||
baseUrl, map.get("msg"), code);
|
||||
continue;
|
||||
}
|
||||
|
||||
Object dataObject = map.get("data");
|
||||
if (!(dataObject instanceof Map)) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> data = new HashMap<>((Map<String, Object>) dataObject);
|
||||
|
||||
Object timestamp = data.remove(LATEST_FETCH_TIMESTAMP);
|
||||
if (timestamp != null) {
|
||||
try {
|
||||
long parsedTimestamp = Long.parseLong(timestamp.toString());
|
||||
context.setLastFetchTimestamp(parsedTimestamp);
|
||||
this.lastTime = parsedTimestamp;
|
||||
} catch (NumberFormatException e) {
|
||||
logger.warn("[ConfigClient] invalid {}: {}", LATEST_FETCH_TIMESTAMP, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
Object deleted = data.remove("deletedKeys");
|
||||
if (deleted != null) {
|
||||
String deletedKeys = deleted.toString();
|
||||
if (!deletedKeys.isEmpty()) {
|
||||
List<String> keys = new ArrayList<>();
|
||||
for (String key : deletedKeys.split(",")) {
|
||||
if (!key.isBlank()) {
|
||||
keys.add(key.trim());
|
||||
}
|
||||
}
|
||||
if (!keys.isEmpty()) {
|
||||
context.removeConfigs(keys);
|
||||
logger.info("[ConfigClient] {} configs deleted: {}", keys.size(), keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.isEmpty()) {
|
||||
context.updateConfig(data);
|
||||
logger.info("[ConfigClient] {} configs changed: {}", data.size(), data.keySet());
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package cn.odboy.confcenter;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileWriter;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
|
||||
/**
|
||||
* Local disaster-recovery cache.
|
||||
*
|
||||
* <p>The last successfully fetched remote configuration is written to
|
||||
* {@code config-cache.log} so that the application can still start when the
|
||||
* config center is temporarily unavailable.</p>
|
||||
*
|
||||
* @author odboy
|
||||
* @version 3.0.0
|
||||
* @date 2026-10-21
|
||||
*/
|
||||
public class LocalCacheUtils {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(LocalCacheUtils.class);
|
||||
|
||||
private static final String CACHE_FILE_NAME = "config-cache.log";
|
||||
|
||||
private LocalCacheUtils() {
|
||||
}
|
||||
|
||||
private static String getStorageDir() {
|
||||
String dir = ConfigcenterContext.getAnyProperty(
|
||||
"kenaito.config.local-storage-dir",
|
||||
"kenaito.configcenter.local-storage-dir");
|
||||
return dir != null ? dir : System.getProperty("user.dir");
|
||||
}
|
||||
|
||||
public static void write(Map<String, Object> datas) {
|
||||
File dir = new File(getStorageDir());
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
logger.warn("[ConfigClient] cannot create local cache directory: {}", dir);
|
||||
return;
|
||||
}
|
||||
File file = new File(dir, CACHE_FILE_NAME);
|
||||
try (FileWriter fileWriter = new FileWriter(file, StandardCharsets.UTF_8, false)) {
|
||||
fileWriter.write(JSON.toJSONString(datas));
|
||||
} catch (Exception e) {
|
||||
logger.warn("[ConfigClient] write local cache error: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static Map<String, Object> read() {
|
||||
File dir = new File(getStorageDir());
|
||||
if (!dir.exists() && !dir.mkdirs()) {
|
||||
return null;
|
||||
}
|
||||
File file = new File(dir, CACHE_FILE_NAME);
|
||||
if (!file.exists()) {
|
||||
return null;
|
||||
}
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
|
||||
StringBuilder buffer = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
buffer.append(line);
|
||||
}
|
||||
return JSON.parseObject(buffer.toString(), Map.class);
|
||||
} catch (Exception e) {
|
||||
logger.warn("[ConfigClient] read local cache error: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
package cn.odboy.confcenter;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.core.annotation.AnnotatedElementUtils;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
/**
|
||||
* Refresh scope registry.
|
||||
*
|
||||
* <p>Collects {@code @Value} fields and {@code @ConfigurationProperties} beans while
|
||||
* the application context starts, then refreshes only the fields affected by a
|
||||
* config change. This is registered as a static bean by
|
||||
* {@link cn.odboy.confcenter.springboot.KenaitoRefreshAutoConfiguration}.</p>
|
||||
*
|
||||
* @author odboy
|
||||
* @version 3.0.0
|
||||
* @date 2026-10-21
|
||||
*/
|
||||
public class RefreshScopeRegistry implements BeanPostProcessor {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(RefreshScopeRegistry.class);
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
private final Map<String, ValueFieldHolder> valueFields = new LinkedHashMap<>();
|
||||
|
||||
private final Map<String, ConfigPropsHolder> configPropsBeans = new LinkedHashMap<>();
|
||||
|
||||
public RefreshScopeRegistry(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
public int getValueFieldCount() {
|
||||
return valueFields.size();
|
||||
}
|
||||
|
||||
public int getConfigPropsCount() {
|
||||
return configPropsBeans.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
|
||||
if (bean == null) {
|
||||
return null;
|
||||
}
|
||||
Class<?> clazz = ClassUtils.getUserClass(bean);
|
||||
|
||||
for (Field field : getAllDeclaredFields(clazz)) {
|
||||
if (Modifier.isStatic(field.getModifiers())) {
|
||||
continue;
|
||||
}
|
||||
Value valueAnnotation = field.getAnnotation(Value.class);
|
||||
if (valueAnnotation == null) {
|
||||
continue;
|
||||
}
|
||||
String key = parseKey(valueAnnotation.value());
|
||||
if (key == null) {
|
||||
continue;
|
||||
}
|
||||
valueFields.putIfAbsent(beanName + "#" + field.getName(), new ValueFieldHolder(bean, field, key));
|
||||
}
|
||||
|
||||
ConfigurationProperties annotation =
|
||||
AnnotatedElementUtils.findMergedAnnotation(clazz, ConfigurationProperties.class);
|
||||
if (annotation != null) {
|
||||
String prefix = firstNonBlank(annotation.prefix(), annotation.value());
|
||||
if (prefix != null) {
|
||||
configPropsBeans.putIfAbsent(beanName, new ConfigPropsHolder(bean, prefix));
|
||||
}
|
||||
}
|
||||
return bean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the fields affected by the supplied keys.
|
||||
*
|
||||
* @return number of refreshed {@code @Value} fields
|
||||
*/
|
||||
public synchronized int refresh(Set<String> changedKeys) {
|
||||
int count = 0;
|
||||
for (ValueFieldHolder holder : valueFields.values()) {
|
||||
if (changedKeys.contains(holder.key)) {
|
||||
if (holder.refresh(environment)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (ConfigPropsHolder holder : configPropsBeans.values()) {
|
||||
try {
|
||||
holder.refresh(environment, changedKeys);
|
||||
} catch (Exception e) {
|
||||
logger.warn("[RefreshScope] refresh @ConfigurationProperties bean {} error: {}",
|
||||
holder.bean.getClass().getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static String parseKey(String expression) {
|
||||
if (expression == null) {
|
||||
return null;
|
||||
}
|
||||
int start = expression.indexOf("${");
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
int end = expression.indexOf('}', start);
|
||||
if (end < 0) {
|
||||
return null;
|
||||
}
|
||||
String key = expression.substring(start + 2, end);
|
||||
int colon = key.indexOf(':');
|
||||
if (colon >= 0) {
|
||||
key = key.substring(0, colon);
|
||||
}
|
||||
key = key.trim();
|
||||
return key.isEmpty() ? null : key;
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<Field> getAllDeclaredFields(Class<?> clazz) {
|
||||
List<Field> fields = new ArrayList<>();
|
||||
Class<?> current = clazz;
|
||||
while (current != null && current != Object.class) {
|
||||
for (Field field : current.getDeclaredFields()) {
|
||||
fields.add(field);
|
||||
}
|
||||
current = current.getSuperclass();
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static String camelToKebab(String camelCase) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (int i = 0; i < camelCase.length(); i++) {
|
||||
char c = camelCase.charAt(i);
|
||||
if (Character.isUpperCase(c)) {
|
||||
if (i > 0) {
|
||||
result.append('-');
|
||||
}
|
||||
result.append(Character.toLowerCase(c));
|
||||
} else {
|
||||
result.append(c);
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static Object convertType(Class<?> type, String value) {
|
||||
if (type == String.class || type == Object.class) {
|
||||
return value;
|
||||
}
|
||||
if (type == int.class || type == Integer.class) {
|
||||
return Integer.parseInt(value.trim());
|
||||
}
|
||||
if (type == long.class || type == Long.class) {
|
||||
return Long.parseLong(value.trim());
|
||||
}
|
||||
if (type == boolean.class || type == Boolean.class) {
|
||||
return Boolean.parseBoolean(value.trim());
|
||||
}
|
||||
if (type == double.class || type == Double.class) {
|
||||
return Double.parseDouble(value.trim());
|
||||
}
|
||||
if (type == float.class || type == Float.class) {
|
||||
return Float.parseFloat(value.trim());
|
||||
}
|
||||
if (type == short.class || type == Short.class) {
|
||||
return Short.parseShort(value.trim());
|
||||
}
|
||||
if (type == char.class || type == Character.class) {
|
||||
return value.isEmpty() ? null : value.charAt(0);
|
||||
}
|
||||
if (type == byte.class || type == Byte.class) {
|
||||
return Byte.parseByte(value.trim());
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holder for a {@code @Value} annotated field.
|
||||
*/
|
||||
private static class ValueFieldHolder {
|
||||
|
||||
private final Object bean;
|
||||
|
||||
private final Field field;
|
||||
|
||||
private final String key;
|
||||
|
||||
ValueFieldHolder(Object bean, Field field, String key) {
|
||||
this.bean = bean;
|
||||
this.field = field;
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
boolean refresh(Environment env) {
|
||||
String newValue = env.getProperty(key);
|
||||
if (newValue == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
field.setAccessible(true);
|
||||
Object oldValue = field.get(bean);
|
||||
Object convertedValue = convertType(field.getType(), newValue);
|
||||
field.set(bean, convertedValue);
|
||||
logger.info("[RefreshScope] refreshed @Value field: {}.{} | old='{}' | new='{}'",
|
||||
bean.getClass().getName(), field.getName(), oldValue, newValue);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
logger.warn("[RefreshScope] refresh @Value field {}.{} error: {}",
|
||||
bean.getClass().getName(), field.getName(), e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holder for a {@code @ConfigurationProperties} bean.
|
||||
*/
|
||||
private static class ConfigPropsHolder {
|
||||
|
||||
private final Object bean;
|
||||
|
||||
private final String prefix;
|
||||
|
||||
ConfigPropsHolder(Object bean, String prefix) {
|
||||
this.bean = bean;
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
void refresh(Environment env, Set<String> changedKeys) {
|
||||
for (Field field : getAllDeclaredFields(ClassUtils.getUserClass(bean))) {
|
||||
if (Modifier.isStatic(field.getModifiers()) || Modifier.isFinal(field.getModifiers())) {
|
||||
continue;
|
||||
}
|
||||
String kebabKey = prefix + "." + camelToKebab(field.getName());
|
||||
String plainKey = prefix + "." + field.getName();
|
||||
String matchedKey = changedKeys.contains(kebabKey) ? kebabKey
|
||||
: changedKeys.contains(plainKey) ? plainKey : null;
|
||||
if (matchedKey == null) {
|
||||
continue;
|
||||
}
|
||||
String value = env.getProperty(matchedKey);
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
field.setAccessible(true);
|
||||
field.set(bean, convertType(field.getType(), value));
|
||||
logger.info("[RefreshScope] refreshed @ConfigurationProperties field: {}.{} = '{}'",
|
||||
bean.getClass().getName(), field.getName(), value);
|
||||
} catch (Exception e) {
|
||||
logger.warn("[RefreshScope] refresh @ConfigurationProperties field {}.{} error: {}",
|
||||
bean.getClass().getName(), field.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package cn.odboy.confcenter.spring;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Properties;
|
||||
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.DisposableBean;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
|
||||
|
||||
import cn.odboy.confcenter.ConfigcenterContext;
|
||||
|
||||
/**
|
||||
* Spring property placeholder configurer for plain Spring (non-Boot) applications.
|
||||
*
|
||||
* @author odboy
|
||||
* @version 3.0.0
|
||||
* @date 2026-10-21
|
||||
*/
|
||||
public class KenaitoPropertyPlaceholderConfigurer extends PropertySourcesPlaceholderConfigurer
|
||||
implements DisposableBean, ApplicationContextAware {
|
||||
|
||||
private boolean remoteEnabledSet = false;
|
||||
|
||||
private final ConfigcenterContext ccContext = ConfigcenterContext.getInstance();
|
||||
|
||||
public void setRemoteEnabled(boolean remoteEnabled) {
|
||||
ccContext.setRemoteEnabled(remoteEnabled);
|
||||
remoteEnabledSet = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Properties mergeProperties() throws IOException {
|
||||
Properties properties = super.mergeProperties();
|
||||
if (!remoteEnabledSet) {
|
||||
ccContext.setRemoteEnabled(Boolean.parseBoolean(
|
||||
properties.getProperty("kenaito.config.enabled",
|
||||
properties.getProperty("kenaito.configcenter.enabled", "true"))));
|
||||
}
|
||||
ccContext.init(properties, false);
|
||||
ccContext.mergeRemoteProperties(properties);
|
||||
return properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
ccContext.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
|
||||
ConfigcenterContext.setApplicationContext(applicationContext);
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package cn.odboy.confcenter.springboot;
|
||||
|
||||
import java.util.Properties;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.env.EnvironmentPostProcessor;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.EnumerablePropertySource;
|
||||
import org.springframework.core.env.PropertiesPropertySource;
|
||||
|
||||
import cn.odboy.confcenter.ConfigcenterContext;
|
||||
|
||||
/**
|
||||
* Spring Boot environment post processor.
|
||||
*
|
||||
* <p>Initializes the config center before the application context is created and
|
||||
* injects the remote configuration as a high-priority property source.</p>
|
||||
*
|
||||
* <p>Registered through {@code META-INF/spring.factories}, which Spring Boot 3
|
||||
* still supports for {@link EnvironmentPostProcessor} implementations.</p>
|
||||
*
|
||||
* @author odboy
|
||||
* @version 3.0.0
|
||||
* @date 2026-10-21
|
||||
*/
|
||||
public class KenaitoEnvironmentInitializer implements EnvironmentPostProcessor, Ordered {
|
||||
|
||||
private final ConfigcenterContext ccContext = ConfigcenterContext.getInstance();
|
||||
|
||||
@Override
|
||||
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
|
||||
if (!ccContext.isRemoteEnabled() || ccContext.isProcessed()) {
|
||||
return;
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
// Extract config center settings from the Spring Environment. The config data
|
||||
// post processor runs earlier, so application.properties is already loaded.
|
||||
environment.getPropertySources().forEach(propertySource -> {
|
||||
if (propertySource instanceof EnumerablePropertySource<?> enumerable) {
|
||||
for (String key : enumerable.getPropertyNames()) {
|
||||
if (key.startsWith("kenaito.config.")
|
||||
|| key.startsWith("kenaito.configcenter.")
|
||||
|| key.equals("spring.application.name")) {
|
||||
String value = environment.getProperty(key);
|
||||
if (value != null) {
|
||||
properties.setProperty(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ccContext.init(properties, true);
|
||||
ccContext.mergeRemoteProperties(properties);
|
||||
PropertiesPropertySource propertySource =
|
||||
new PropertiesPropertySource(ConfigcenterContext.MANAGER_PROPERTY_SOURCE, properties);
|
||||
environment.getPropertySources().addFirst(propertySource);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
// Run after ConfigDataEnvironmentPostProcessor (HIGHEST_PRECEDENCE + 10).
|
||||
return Ordered.LOWEST_PRECEDENCE;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package cn.odboy.confcenter.springboot;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
import cn.odboy.confcenter.ConfigcenterContext;
|
||||
import cn.odboy.confcenter.RefreshScopeRegistry;
|
||||
|
||||
/**
|
||||
* Spring Boot 3 auto configuration for config center refresh support.
|
||||
*
|
||||
* <p>Registers a {@link RefreshScopeRegistry} bean post processor and captures the
|
||||
* {@link ApplicationContext} so that remote config changes can update the Spring
|
||||
* environment and refresh {@code @Value} / {@code @ConfigurationProperties} fields
|
||||
* at runtime.</p>
|
||||
*
|
||||
* <p>Registered through
|
||||
* {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}.</p>
|
||||
*
|
||||
* @author odboy
|
||||
* @version 3.0.0
|
||||
* @date 2026-10-21
|
||||
*/
|
||||
@AutoConfiguration
|
||||
public class KenaitoRefreshAutoConfiguration implements ApplicationContextAware {
|
||||
|
||||
/**
|
||||
* Must be static so the bean post processor is registered before regular beans
|
||||
* are instantiated.
|
||||
*/
|
||||
@Bean
|
||||
public static RefreshScopeRegistry kenaitoRefreshScopeRegistry(Environment environment) {
|
||||
RefreshScopeRegistry registry = new RefreshScopeRegistry(environment);
|
||||
ConfigcenterContext.setRefreshScopeRegistry(registry);
|
||||
return registry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(ApplicationContext applicationContext) {
|
||||
ConfigcenterContext.setApplicationContext(applicationContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.springframework.boot.env.EnvironmentPostProcessor=\
|
||||
cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer
|
||||
+1
@@ -0,0 +1 @@
|
||||
cn.odboy.confcenter.springboot.KenaitoRefreshAutoConfiguration
|
||||
Reference in New Issue
Block a user