feat: 初始化配置中心项目

This commit is contained in:
2026-01-14 21:02:13 +08:00
commit 4436d3ee88
95 changed files with 8736 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
# kenaito-conf-springboot3-client
配置中心 Spring Boot 3 客户端(当前使用 Spring Boot 2.7.18 + JDK 11 编译)
## 重要说明
**当前状态:**
- 项目名称为 springboot3-client,但实际使用 Spring Boot 2.7.18 + JDK 11 编译
- 代码已重构,移除了 mendmix 依赖,使用标准 Java/Spring 工具类
- 为未来升级到 Spring Boot 3 做好准备
**升级到 Spring Boot 3 的步骤:**
1. 安装 JDK 17 或更高版本
2. 修改 `pom.xml`:
```xml
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<spring-boot.version>3.2.5</spring-boot.version>
```
3. 修改 Spring Framework 版本:
```xml
<version>6.1.6</version>
```
4. 重新编译:`mvn clean install`
## 与 springboot2-client 的区别
1. **移除 mendmix 依赖** - 使用标准 Java/Spring 工具类替代
2. **使用 Java HttpClient** - 替代 mendmix 的 HttpUtils
3. **使用 Jackson** - 替代 mendmix 的 JsonUtils
4. **内置加密工具** - 使用 Java 原生 javax.crypto 实现 AES/DES 加密
5. **更清晰的代码结构** - 便于维护和扩展
## 配置说明
在 `application.properties` 中配置:
```properties
# 启用配置中心
kenaito.config.enabled=true
# 配置中心服务地址(支持多个,逗号分隔)
kenaito.config.baseUrl=http://localhost:8080
# 应用名称
kenaito.config.appName=myapp
# 环境标识
kenaito.config.profile=dev
# 版本号
kenaito.config.version=latest
# 同步间隔(秒)
kenaito.config.sync-interval-seconds=60
# 认证 token(如果配置中心启用了认证)
kenaito.config.token=your-token-here
```
## 使用方式
### Spring Boot 应用
自动生效,无需额外配置。`KenaitoEnvironmentInitializer` 会在 Spring Boot 启动时自动加载远程配置。
### 传统 Spring 应用
在 Spring 配置文件中添加:
```xml
<bean class="cn.odboy.confcenter.spring.CCPropertyPlaceholderConfigurer">
<property name="remoteEnabled" value="true"/>
</bean>
```
## 安装到本地 Maven 仓库
```bash
mvn clean install -DskipTests
```
## 依赖说明
- Spring Boot 2.7.18(optional)
- Spring Context 5.3.31(optional)
- Jackson 2.17.0(JSON 处理)
- SLF4J 2.0.12(日志)
- Java HttpClient(HTTP 请求,JDK 11+ 内置)
## 升级路径
当你的项目升级到 JDK 17 后,可以修改 pom.xml 中的版本配置,切换到 Spring Boot 3:
```xml
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<spring-boot.version>3.2.5</spring-boot.version>
</properties>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>6.1.6</version>
</dependency>
```
+52
View File
@@ -0,0 +1,52 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cn.odboy</groupId>
<artifactId>kenaito-conf-springboot3-client</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<spring-boot.version>2.7.18</spring-boot.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
<version>${spring-boot.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.3.31</version>
<optional>true</optional>
</dependency>
<!-- HTTP Client -->
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.3.1</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.0</version>
</dependency>
<!-- Logging -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.12</version>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,7 @@
package cn.odboy.confcenter;
import java.util.Map;
public interface ConfigChangeHanlder {
public void onConfigChanged(Map<String, Object> changedConfigs);
}
@@ -0,0 +1,495 @@
package cn.odboy.confcenter;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.Date;
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 javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertiesPropertySource;
import org.springframework.core.env.StandardEnvironment;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Config Center Context for Spring Boot 3
*
* @author odboy
* @version 2.0.0
* @date 2026-10-11
*/
public class ConfigcenterContext {
private final static Logger logger = LoggerFactory.getLogger("cn.odboy");
private static ConfigcenterContext instance = new ConfigcenterContext();
private List<String> sensitiveKeys = new ArrayList<>(Arrays.asList("password","key","secret","token","credentials"));
public static final String MANAGER_PROPERTY_SOURCE = "kenaitoConfigPropertySource";
private static final String IGNORE_PLACEHOLER = "[Ignore]";
private static final String CRYPT_PREFIX = "{Cipher}";
private static final ObjectMapper objectMapper = new ObjectMapper();
private Boolean remoteEnabled;
private final String nodeId;
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 = false;
private boolean isSpringboot;
private int syncIntervalSeconds = 50;
private InternalConfigChangeListener configChangeListener;
private List<ConfigChangeHanlder> configChangeHanlders;
private boolean processed;
private Properties remoteProperties;
private Properties localProperties = new Properties();
private StandardEnvironment environment;
private ConfigcenterContext() {
String nodeName;
try {
nodeName = InetAddress.getLocalHost().getHostName();
} catch (Exception e) {
nodeName = "unknown";
}
this.nodeId = nodeName;
}
public boolean isProcessed() {
return processed;
}
public synchronized void init(Properties properties, boolean isSpringboot) {
if (processed || !isRemoteEnabled()) return;
if (properties != null) {
localProperties.putAll(properties);
}
System.setProperty("client.nodeId", nodeId);
System.setProperty("springboot", String.valueOf(isSpringboot));
this.isSpringboot = isSpringboot;
String defaultAppName = getProperty("spring.application.name", "unknown");
app = getProperty("kenaito.config.appName", defaultAppName);
if (remoteEnabled == null) {
remoteEnabled = Boolean.parseBoolean(getProperty("kenaito.config.enabled", "true"));
}
if (!isRemoteEnabled()) return;
env = getProperty("kenaito.config.profile", "dev");
if (env == null || env.isBlank()) {
throw new IllegalArgumentException("[kenaito.config.profile] is required");
}
setApiBaseUrl(getProperty("kenaito.config.baseUrl", getProperty("kenaito.config.base.url", "")));
version = getProperty("kenaito.config.version", "latest");
ignoreGlobal = Boolean.parseBoolean(getProperty("kenaito.config.global-ignore", "false"));
syncIntervalSeconds = Integer.parseInt(getProperty("kenaito.config.sync-interval-seconds", "60"));
token = getProperty("kenaito.config.token", "");
System.out.println(String.format("\n=====Configcenter config=====\nappName:%s\nenv:%s\nversion:%s\nremoteEnabled:%s\napiBaseUrls:%s\n=====Configcenter config=====",
app, env, version, isRemoteEnabled(), toJson(apiBaseUrls)));
}
public static ConfigcenterContext getInstance() {
return instance;
}
public boolean isRemoteEnabled() {
return remoteEnabled == null || remoteEnabled;
}
public void setRemoteEnabled(boolean remoteEnabled) {
this.remoteEnabled = remoteEnabled;
}
public String[] getApiBaseUrls() {
return apiBaseUrls;
}
public void setApiBaseUrl(String apiBaseUrl) {
if (apiBaseUrl == null || apiBaseUrl.isBlank()) {
throw new IllegalArgumentException("[kenaito.config.base.url] is required");
}
String[] urls = apiBaseUrl.split(",|;");
this.apiBaseUrls = new String[urls.length];
for (int i = 0; i < urls.length; i++) {
if (urls[i].endsWith("/")) {
this.apiBaseUrls[i] = urls[i].substring(0, urls[i].length() - 1);
} else {
this.apiBaseUrls[i] = urls[i];
}
}
}
public String getApp() {
return app;
}
public String getEnv() {
return env;
}
public String getVersion() {
return version;
}
public boolean isIgnoreGlobal() {
return ignoreGlobal;
}
public String getSecret() {
return secret;
}
public boolean isRemoteFirst() {
return remoteFirst;
}
public int getSyncIntervalSeconds() {
return syncIntervalSeconds;
}
public String getNodeId() {
return nodeId;
}
public boolean isSpringboot() {
return isSpringboot;
}
public void setEnvironment(StandardEnvironment environment) {
this.environment = environment;
}
public void mergeRemoteProperties(Properties properties) {
if (!remoteEnabled) return;
remoteProperties = getAllRemoteProperties();
if (remoteProperties != null) {
Set<Entry<Object, Object>> entrySet = remoteProperties.entrySet();
for (Entry<Object, Object> entry : entrySet) {
if (!isRemoteFirst() && properties.containsKey(entry.getKey())) {
continue;
}
String value = entry.getValue().toString();
if (IGNORE_PLACEHOLER.equals(value)) continue;
properties.setProperty(entry.getKey().toString(), value);
}
}
// Sync merged properties into localProperties for change tracking
localProperties.putAll(properties);
// Close existing listener before creating new one to prevent scheduler leak
if (configChangeListener != null) {
configChangeListener.close();
}
// Register listener
configChangeListener = new InternalConfigChangeListener();
printConfigs(properties);
}
private Properties getAllRemoteProperties() {
if (remoteProperties != null) return remoteProperties;
Properties properties = new Properties();
Map<String, Object> map = fetchConfigFromServer();
if (map == null) {
logger.warn("fetch remote config error and no local cache available, starting with empty remote config");
return properties;
}
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"));
Set<String> keys = map.keySet();
for (String key : keys) {
Object value = decodeEncryptIfRequire(map.get(key));
properties.put(key, value);
}
return properties;
}
@SuppressWarnings("unchecked")
private Map<String, Object> fetchConfigFromServer() {
Map<String, Object> result = null;
String errorMsg = null;
for (String apiBaseUrl : apiBaseUrls) {
String url = buildTokenParameter(String.format("%s/api/fetchAllConfigs?appName=%s&env=%s&version=%s&ignoreGlobal=%s",
apiBaseUrl, app, env, version, ignoreGlobal));
System.out.println("fetch configs url:" + url);
try {
String jsonString = HttpUtils.get(url);
if (jsonString != null) {
result = objectMapper.readValue(jsonString, Map.class);
if (result.containsKey("code")) {
errorMsg = result.get("msg").toString();
System.err.println("fetch error:" + errorMsg);
result = null;
} else {
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
if (result == null) {
System.out.println(">>>>>remote config fetch error, load from local Cache");
result = LocalCacheUtils.read();
} else {
LocalCacheUtils.write(result);
}
return result;
}
private void printConfigs(Properties properties) {
List<String> sortKeys = new ArrayList<>();
Set<Entry<Object, Object>> entrySet = properties.entrySet();
for (Entry<Object, Object> entry : entrySet) {
String key = entry.getKey().toString();
sortKeys.add(key);
}
Collections.sort(sortKeys);
System.out.println("==================final config list start==================");
String value;
for (String key : sortKeys) {
value = hideSensitive(key, properties.getProperty(key));
System.out.println(String.format("%s = %s", key, value));
}
System.out.println("==================final config list end====================");
}
public synchronized void updateConfig(Map<String, Object> updateConfig) {
if (!updateConfig.isEmpty()) {
Set<String> keySet = updateConfig.keySet();
for (String key : keySet) {
String oldValue = localProperties.getProperty(key);
localProperties.setProperty(key, decodeEncryptIfRequire(updateConfig.get(key)).toString());
if (environment != null) {
MutablePropertySources propertySources = environment.getPropertySources();
synchronized (propertySources) {
MapPropertySource source = null;
if (!propertySources.contains(MANAGER_PROPERTY_SOURCE)) {
source = new MapPropertySource(MANAGER_PROPERTY_SOURCE, new LinkedHashMap<>());
environment.getPropertySources().addFirst(source);
} else {
Object existingSource = propertySources.get(MANAGER_PROPERTY_SOURCE);
if (existingSource instanceof MapPropertySource) {
source = (MapPropertySource) existingSource;
}
}
if (source != null) {
Map<String, Object> map = source.getSource();
Properties properties = new Properties();
properties.putAll(map);
properties.putAll(updateConfig);
propertySources.replace(source.getName(), new PropertiesPropertySource(source.getName(), properties));
}
}
}
logger.info("Config [{}] Change, oldValue:{}, newValue:{}", key, oldValue, updateConfig.get(key));
}
if (configChangeHanlders == null) {
configChangeHanlders = new ArrayList<>();
}
for (ConfigChangeHanlder handler : configChangeHanlders) {
try {
handler.onConfigChanged(updateConfig);
logger.info("invoke {}.onConfigChanged successed!", handler.getClass().getName());
} catch (Exception e) {
e.printStackTrace();
logger.warn("invoke {}.onConfigChanged error, msg:{}", handler.getClass().getName(), e.getMessage());
}
}
}
}
public String buildTokenParameter(String url) {
if (token == null || token.length() < 8) {
return url;
}
String str = md5Short(token).concat(String.valueOf(new Date().getTime()));
String authtoken = desEncrypt(token.substring(0, 8), str).toLowerCase();
return url + (url.contains("?") ? "&" : "?") + "authtoken=" + authtoken;
}
public void close() {
if (configChangeListener != null) {
configChangeListener.close();
}
}
private Object decodeEncryptIfRequire(Object data) {
if (data == null) return "";
if (data.toString().startsWith(CRYPT_PREFIX)) {
if (secret == null || secret.isBlank()) {
throw new IllegalArgumentException("config[kenaito.config.encrypt-secret] is required");
}
data = data.toString().replace(CRYPT_PREFIX, "");
String decryptString;
try {
decryptString = decryptWithAES(secret, data.toString());
} catch (Exception e) {
decryptString = decryptWithAES(globalSecret, data.toString());
}
return decryptString;
}
return data;
}
private static String decryptWithAES(String key, String data) {
try {
String secretKey = md5(key).substring(16);
byte[] encrypted = Base64.getDecoder().decode(data.getBytes(StandardCharsets.UTF_8));
byte[] decrypted = aesDecrypt(encrypted, secretKey.getBytes(StandardCharsets.UTF_8));
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException(String.format("解密错误:%s", data));
}
}
private String hideSensitive(String key, String origin) {
if (key == null || key.isBlank() || origin == null || origin.isBlank()) return "";
boolean isSensitive = false;
for (String k : sensitiveKeys) {
if (key.toLowerCase().contains(k)) {
isSensitive = true;
break;
}
}
int length = origin.length();
if (isSensitive && length > 1) {
return origin.substring(0, length / 2).concat("****");
}
return origin;
}
// Helper methods
private String getProperty(String key, String defaultValue) {
String value = localProperties.getProperty(key);
if (value == null || value.isBlank()) {
value = System.getProperty(key);
}
if (value == null || value.isBlank()) {
value = System.getenv(key);
}
return value != null && !value.isBlank() ? value : defaultValue;
}
private String getProperty(String key) {
return getProperty(key, null);
}
private static String toJson(Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (Exception e) {
return "[]";
}
}
private static String md5(String input) {
try {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException("MD5 error", e);
}
}
private static String md5Short(String input) {
if (input == null || input.isBlank()) return "";
return md5(input).substring(0, 16);
}
private static String desEncrypt(String key, String data) {
try {
SecretKey secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "DES");
Cipher cipher = Cipher.getInstance("DES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new RuntimeException("DES encrypt error", e);
}
}
private static byte[] aesDecrypt(byte[] data, byte[] key) throws Exception {
SecretKey secretKey = new SecretKeySpec(key, "AES");
// Use CBC mode with zero IV to match Go server
IvParameterSpec iv = new IvParameterSpec(new byte[16]);
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, secretKey, iv);
byte[] decrypted = cipher.doFinal(data);
// Remove PKCS7 padding
int padding = decrypted[decrypted.length - 1] & 0xFF;
if (padding > 0 && padding <= 16) {
byte[] result = new byte[decrypted.length - padding];
System.arraycopy(decrypted, 0, result, 0, result.length);
return result;
}
return decrypted;
}
public void addConfigChangeHandler(ConfigChangeHanlder handler) {
if (configChangeHanlders == null) {
configChangeHanlders = new ArrayList<>();
}
configChangeHanlders.add(handler);
}
}
@@ -0,0 +1,21 @@
package cn.odboy.confcenter;
import java.util.Set;
import org.springframework.context.ApplicationEvent;
public class EnvironmentChangeEvent extends ApplicationEvent {
private static final long serialVersionUID = 1L;
private Set<String> keys;
public EnvironmentChangeEvent(Set<String> keys) {
super(keys);
this.keys = keys;
}
public Set<String> getKeys() {
return keys;
}
}
@@ -0,0 +1,53 @@
package cn.odboy.confcenter;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* HTTP Utilities for Spring Boot 3
* Uses Java's built-in HttpClient
*
* @author odboy
* @version 2.0.0
* @date 2026-10-11
*/
public class HttpUtils {
private static final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
public static String get(String url) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.timeout(Duration.ofSeconds(30))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
return response.body();
}
return null;
}
public static String postJson(String url, String json) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.timeout(Duration.ofSeconds(30))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
return response.body();
}
return null;
}
}
@@ -0,0 +1,82 @@
package cn.odboy.confcenter;
import java.util.HashMap;
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.fasterxml.jackson.databind.ObjectMapper;
/**
* Internal Config Change Listener
* Polls for config changes via HTTP
*
* @author odboy
* @version 2.0.0
* @date 2026-10-11
*/
public class InternalConfigChangeListener {
private final static Logger logger = LoggerFactory.getLogger("cn.odboy.confcenter");
private static final String LATEST_FETCH_TIMESTAMP = "latest.fetch.timestamp";
private static final ObjectMapper objectMapper = new ObjectMapper();
private ScheduledExecutorService hbScheduledExecutor;
public InternalConfigChangeListener() {
ConfigcenterContext context = ConfigcenterContext.getInstance();
registerHttpListener(context);
}
public void close() {
if (hbScheduledExecutor != null) {
hbScheduledExecutor.shutdown();
}
}
private void registerHttpListener(ConfigcenterContext context) {
hbScheduledExecutor = Executors.newScheduledThreadPool(1);
final String url = context.getApiBaseUrls()[0] + "/api/fetchChangedConfigs";
final Map<String, String> params = new HashMap<>();
params.put("version", context.getVersion());
params.put("appName", context.getApp());
params.put("env", context.getEnv());
if (context.isIgnoreGlobal()) {
params.put("ignoreGlobal", String.valueOf(context.isIgnoreGlobal()));
}
params.put("lastTime", String.valueOf(System.currentTimeMillis()));
hbScheduledExecutor.scheduleAtFixedRate(() -> {
try {
String safeUrl = context.buildTokenParameter(url);
String response = HttpUtils.postJson(safeUrl, objectMapper.writeValueAsString(params));
if (response != null) {
@SuppressWarnings("unchecked")
Map<String, Object> map = objectMapper.readValue(response, Map.class);
if (map.containsKey("data")) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) map.get("data");
if (data.containsKey(LATEST_FETCH_TIMESTAMP)) {
params.put("lastTime", data.remove(LATEST_FETCH_TIMESTAMP).toString());
}
if (!data.isEmpty()) {
context.updateConfig(data);
}
}
}
} catch (Exception e) {
logger.error("Error fetching config changes", e);
}
}, context.getSyncIntervalSeconds(), context.getSyncIntervalSeconds(), TimeUnit.SECONDS);
logger.info("registerHttpUpdaterListener OK intervalSeconds:{}", context.getSyncIntervalSeconds());
}
}
@@ -0,0 +1,59 @@
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.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Local Cache Utilities
*
* @author odboy
* @version 2.0.0
* @date 2026-10-11
*/
public class LocalCacheUtils {
private static final ObjectMapper objectMapper = new ObjectMapper();
private static String localStorageDir = System.getProperty("kenaito.configcenter.local-storage-dir",
System.getProperty("user.dir"));
public static void write(Map<String, Object> datas) {
File dir = new File(localStorageDir);
if (!dir.exists())
dir.mkdirs();
File file = new File(dir, "config-cache.log");
try (FileWriter fileWriter = new FileWriter(file, false)) {
fileWriter.write(objectMapper.writeValueAsString(datas));
} catch (Exception e) {
e.printStackTrace();
}
}
@SuppressWarnings("unchecked")
public static Map<String, Object> read() {
File dir = new File(localStorageDir);
if (!dir.exists())
dir.mkdirs();
File file = new File(dir, "config-cache.log");
if (!file.exists()) {
return null;
}
try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file)))) {
StringBuilder buffer = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
buffer.append(line);
}
return objectMapper.readValue(buffer.toString(), Map.class);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
@@ -0,0 +1,54 @@
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 org.springframework.core.env.StandardEnvironment;
import cn.odboy.confcenter.ConfigcenterContext;
/**
* Spring Property Placeholder Configurer for non-SpringBoot applications
*
* @author odboy
* @version 2.0.0
* @date 2026-10-11
*/
public class CCPropertyPlaceholderConfigurer extends PropertySourcesPlaceholderConfigurer
implements DisposableBean, ApplicationContextAware {
private boolean setRemoteEnabled = false;
private ConfigcenterContext ccContext = ConfigcenterContext.getInstance();
public void setRemoteEnabled(boolean remoteEnabled) {
ccContext.setRemoteEnabled(remoteEnabled);
setRemoteEnabled = true;
}
@Override
protected Properties mergeProperties() throws IOException {
Properties properties = super.mergeProperties();
if (!setRemoteEnabled) {
ccContext.setRemoteEnabled(Boolean.parseBoolean(properties.getProperty("kenaito.configcenter.enabled", "true")));
}
ccContext.init(properties, false);
ccContext.mergeRemoteProperties(properties);
return properties;
}
@Override
public void destroy() throws Exception {
ccContext.close();
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
StandardEnvironment environment = (StandardEnvironment) applicationContext.getEnvironment();
ccContext.setEnvironment(environment);
}
}
@@ -0,0 +1,36 @@
package cn.odboy.confcenter.springboot;
import java.util.Properties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.PropertiesPropertySource;
import org.springframework.core.env.StandardEnvironment;
import cn.odboy.confcenter.ConfigcenterContext;
/**
* Spring Boot 3 Environment Post Processor
* Initializes config center before application context is created
*
* @author odboy
* @version 2.0.0
* @date 2026-10-11
*/
public class KenaitoEnvironmentInitializer implements EnvironmentPostProcessor {
private ConfigcenterContext ccContext = ConfigcenterContext.getInstance();
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
if (ccContext.isRemoteEnabled() && !ccContext.isProcessed()) {
Properties properties = new Properties();
ccContext.setEnvironment((StandardEnvironment) environment);
ccContext.init(properties, true);
ccContext.mergeRemoteProperties(properties);
PropertiesPropertySource propertySource = new PropertiesPropertySource(ConfigcenterContext.MANAGER_PROPERTY_SOURCE, properties);
environment.getPropertySources().addFirst(propertySource);
}
}
}
@@ -0,0 +1,2 @@
org.springframework.boot.env.EnvironmentPostProcessor=\
cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer
@@ -0,0 +1,2 @@
org.springframework.boot.env.EnvironmentPostProcessor=\
cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer
@@ -0,0 +1,8 @@
cn\odboy\confcenter\ConfigChangeHanlder.class
cn\odboy\confcenter\springboot\KenaitoEnvironmentInitializer.class
cn\odboy\confcenter\HttpUtils.class
cn\odboy\confcenter\LocalCacheUtils.class
cn\odboy\confcenter\spring\CCPropertyPlaceholderConfigurer.class
cn\odboy\confcenter\ConfigcenterContext.class
cn\odboy\confcenter\EnvironmentChangeEvent.class
cn\odboy\confcenter\InternalConfigChangeListener.class
@@ -0,0 +1,8 @@
E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\ConfigcenterContext.java
E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\ConfigChangeHanlder.java
E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\EnvironmentChangeEvent.java
E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\HttpUtils.java
E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\InternalConfigChangeListener.java
E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\LocalCacheUtils.java
E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\spring\CCPropertyPlaceholderConfigurer.java
E:\SelfDev\Gitea2026\kenaito-conf\kenaito-conf-springboot3-client\src\main\java\cn\odboy\confcenter\springboot\KenaitoEnvironmentInitializer.java