From 182765e4a0989807b01b0ba3f958370d27613050 Mon Sep 17 00:00:00 2001 From: Odboy Date: Sat, 22 Aug 2026 21:02:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Spring=20Boot=203?= =?UTF-8?q?=20=E5=AE=A2=E6=88=B7=E7=AB=AF=20SDK=20=E5=8F=8A=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 kenaito-conf-springboot3-client,支持配置拉取、增量同步、@Value/@ConfigurationProperties 动态刷新和本地缓存 - 新增 kenaito-conf-springboot3-client-demo(端口 8084)及 Dockerfile.springboot3 - 完善中英文 README、Docker 部署文档,修正 Spring Boot 2 客户端配置键名与 demo 端口说明 - 修复增量同步将服务端 code=0 成功响应误判为错误的问题 --- Dockerfile.springboot3 | 25 + README.md | 59 +- README_DOCKERFILE.md | 33 +- README_EN.md | 59 +- kenaito-conf-springboot2-client/README.md | 45 +- kenaito-conf-springboot2-client/README_EN.md | 45 +- .../.gitignore | 5 + kenaito-conf-springboot3-client-demo/pom.xml | 49 ++ .../cn/odboy/demo/ConfigChangeLogger.java | 44 ++ .../java/cn/odboy/demo/ConfigController.java | 96 +++ .../cn/odboy/demo/DatabaseProperties.java | 65 ++ .../java/cn/odboy/demo/DemoApplication.java | 18 + .../src/main/resources/application.properties | 23 + kenaito-conf-springboot3-client/.gitignore | 5 + kenaito-conf-springboot3-client/README.md | 265 +++++++ kenaito-conf-springboot3-client/README_EN.md | 265 +++++++ kenaito-conf-springboot3-client/pom.xml | 76 ++ .../odboy/confcenter/ConfigChangeHanlder.java | 19 + .../odboy/confcenter/ConfigcenterContext.java | 679 ++++++++++++++++++ .../cn/odboy/confcenter/HttpClientUtils.java | 60 ++ .../InternalConfigChangeListener.java | 158 ++++ .../cn/odboy/confcenter/LocalCacheUtils.java | 80 +++ .../confcenter/RefreshScopeRegistry.java | 282 ++++++++ .../KenaitoPropertyPlaceholderConfigurer.java | 55 ++ .../KenaitoEnvironmentInitializer.java | 65 ++ .../KenaitoRefreshAutoConfiguration.java | 45 ++ .../main/resources/META-INF/spring.factories | 2 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + 28 files changed, 2566 insertions(+), 57 deletions(-) create mode 100644 Dockerfile.springboot3 create mode 100644 kenaito-conf-springboot3-client-demo/.gitignore create mode 100644 kenaito-conf-springboot3-client-demo/pom.xml create mode 100644 kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/ConfigChangeLogger.java create mode 100644 kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/ConfigController.java create mode 100644 kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/DatabaseProperties.java create mode 100644 kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/DemoApplication.java create mode 100644 kenaito-conf-springboot3-client-demo/src/main/resources/application.properties create mode 100644 kenaito-conf-springboot3-client/.gitignore create mode 100644 kenaito-conf-springboot3-client/README.md create mode 100644 kenaito-conf-springboot3-client/README_EN.md create mode 100644 kenaito-conf-springboot3-client/pom.xml create mode 100644 kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/ConfigChangeHanlder.java create mode 100644 kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/ConfigcenterContext.java create mode 100644 kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/HttpClientUtils.java create mode 100644 kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/InternalConfigChangeListener.java create mode 100644 kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/LocalCacheUtils.java create mode 100644 kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/RefreshScopeRegistry.java create mode 100644 kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/spring/KenaitoPropertyPlaceholderConfigurer.java create mode 100644 kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/springboot/KenaitoEnvironmentInitializer.java create mode 100644 kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/springboot/KenaitoRefreshAutoConfiguration.java create mode 100644 kenaito-conf-springboot3-client/src/main/resources/META-INF/spring.factories create mode 100644 kenaito-conf-springboot3-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/Dockerfile.springboot3 b/Dockerfile.springboot3 new file mode 100644 index 0000000..5b3e4aa --- /dev/null +++ b/Dockerfile.springboot3 @@ -0,0 +1,25 @@ +# Build stage (Maven 3 + JDK 21, compiles the Java 17 client and demo) +FROM registry.cn-shanghai.aliyuncs.com/odboy/kenaito-cicd:runtime-maven3-jdk21 AS build + +WORKDIR /src + +# Copy client library and install it to the local Maven repository +COPY kenaito-conf-springboot3-client/ ./kenaito-conf-springboot3-client/ +WORKDIR /src/kenaito-conf-springboot3-client +RUN mvn clean install -DskipTests + +# Copy demo project and build +WORKDIR /src +COPY kenaito-conf-springboot3-client-demo/ ./kenaito-conf-springboot3-client-demo/ +WORKDIR /src/kenaito-conf-springboot3-client-demo +RUN mvn clean package -DskipTests + +# Runtime stage (Spring Boot 3 requires Java 17+) +FROM eclipse-temurin:17-jre + +WORKDIR /app +COPY --from=build /src/kenaito-conf-springboot3-client-demo/target/*.jar app.jar + +EXPOSE 8084 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/README.md b/README.md index 764bd02..f889d4b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ - **后端**: Go + Gin + xorm + MySQL - **前端**: Vue 2 + Vite 5 + Element UI 2 -- **客户端**: Go / Java(Spring Boot 2.x)/ Python / Node.js / Perl / Lua / PHP / .NET / C++ / Rust / Ruby / Bash / Dart +- **客户端**: Go / Java(Spring Boot 2.x / 3.x)/ Python / Node.js / Perl / Lua / PHP / .NET / C++ / Rust / Ruby / Bash / Dart ## 项目结构 @@ -20,6 +20,8 @@ kenaito-conf/ ├── kenaito-conf-go-client-demo/ # Go 客户端示例 ├── kenaito-conf-springboot2-client/ # Java Spring Boot 2.x 客户端 SDK(JDK 8+) ├── kenaito-conf-springboot2-client-demo/# Spring Boot 2.x 客户端示例(端口 8081) +├── kenaito-conf-springboot3-client/ # Java Spring Boot 3.x 客户端 SDK(JDK 17+) +├── kenaito-conf-springboot3-client-demo/# Spring Boot 3.x 客户端示例(端口 8084) ├── kenaito-conf-python-client/ # Python 客户端 SDK(Python 3.10+) ├── kenaito-conf-python-client-demo/ # Python 客户端示例(端口 8082) ├── kenaito-conf-nodejs-client/ # Node.js 客户端 SDK(Node.js 18+) @@ -148,17 +150,17 @@ dbHost := c.Get("db.host") ```properties # 配置中心地址 -kenaito.configcenter.base-url=http://localhost:8080 +kenaito.config.baseUrl=http://localhost:8080 # 应用名称(必填) -kenaito.configcenter.app-name=myapp +kenaito.config.appName=myapp # 环境(dev/daily/prod) -kenaito.configcenter.env=daily +kenaito.config.profile=daily # 配置版本 -kenaito.configcenter.version=latest +kenaito.config.version=latest # 认证令牌(可选) -kenaito.configcenter.token=app-token +kenaito.config.token=app-token # 同步间隔(秒) -kenaito.configcenter.sync-interval=30 +kenaito.config.sync-interval-seconds=30 ``` **3. 使用配置** @@ -174,6 +176,49 @@ private Integer databasePort; 配置变更时会自动刷新 `@Value` 字段,无需重启应用。详见 [Spring Boot 2 客户端文档](kenaito-conf-springboot2-client/README.md)。 +### Java Spring Boot 3.x 客户端 + +适用于 JDK 17+、Spring Boot 3.x 项目,支持配置自动刷新、`@Value` / `@ConfigurationProperties` 动态更新和本地缓存容灾。 + +**1. 引入依赖** + +```xml + + cn.odboy + kenaito-conf-springboot3-client + 1.0.0 + +``` + +**2. 添加配置**(`application.properties`) + +```properties +# 配置中心地址(多个地址用逗号分隔) +kenaito.config.base-url=http://localhost:8080 +# 应用名称(必填) +kenaito.config.app-name=myapp +# 环境(dev/daily/prod) +kenaito.config.profile=daily +# 配置版本 +kenaito.config.version=latest +# 认证令牌(可选) +kenaito.config.token=app-token +# 同步间隔(秒) +kenaito.config.sync-interval-seconds=30 +``` + +**3. 使用配置** + +```java +@Value("${database.host:localhost}") +private String databaseHost; + +@Value("${database.port:3306}") +private Integer databasePort; +``` + +配置变更时会自动刷新 `@Value` 字段和 `@ConfigurationProperties` Bean,无需重启应用。详见 [Spring Boot 3 客户端文档](kenaito-conf-springboot3-client/README.md)。 + ### Python 客户端 适用于 Python 3.10+ 项目。 diff --git a/README_DOCKERFILE.md b/README_DOCKERFILE.md index 4b4cc33..b33e3be 100644 --- a/README_DOCKERFILE.md +++ b/README_DOCKERFILE.md @@ -165,6 +165,37 @@ curl http://localhost:8081/health docker stop springboot2-demo ``` +## Spring Boot 3 客户端 + +Spring Boot 3 客户端要求 JDK 17+,Demo 默认监听 `8084`。构建阶段使用 Maven 3 + JDK 21 镜像编译,运行阶段使用 `eclipse-temurin:17-jre`。 + +```bash +docker build -f Dockerfile.springboot3 -t kenaito-conf-springboot3-demo . + +# linux or mac +docker run -d --rm --name springboot3-demo \ + -p 8084:8084 \ + -e KENAITO_BASE_URL=http://host.docker.internal:8080 \ + -e KENAITO_APP_NAME=kenaito-springboot-demo \ + -e KENAITO_ENV=daily \ + kenaito-conf-springboot3-demo + +# windows +docker run -it --rm -e KENAITO_BASE_URL=http://host.docker.internal:8080 -e KENAITO_APP_NAME=kenaito-springboot-demo -e KENAITO_ENV=daily kenaito-conf-springboot3-demo + +# 查看日志 +docker logs -f springboot3-demo + +# 测试接口 +curl http://localhost:8084/config +curl http://localhost:8084/configprops +curl http://localhost:8084/changes +curl http://localhost:8084/health + +# 停止 +docker stop springboot3-demo +``` + ## C++ 客户端 ```bash @@ -269,7 +300,7 @@ docker run -it --rm -e KENAITO_BASE_URL=http://host.docker.internal:8080 -e KENA docker run --network kenaito-net -e KENAITO_BASE_URL=http://kenaito-server:8080 ... ``` -3. **配置缓存**:各客户端会在运行目录生成 `config-cache.json` 文件,容器重启后可从缓存加载配置 +3. **配置缓存**:各客户端会在运行目录生成 `config-cache.*` 缓存文件(Spring Boot 客户端为 `config-cache.log`),容器重启后可从缓存加载配置 4. **日志查看**: - Go/.NET:直接输出到 stdout diff --git a/README_EN.md b/README_EN.md index 623d701..4e8b327 100644 --- a/README_EN.md +++ b/README_EN.md @@ -6,7 +6,7 @@ A centralized configuration management platform rebuilt with Go, supporting mult - **Backend**: Go + Gin + xorm + MySQL - **Frontend**: Vue 2 + Vite 5 + Element UI 2 -- **Clients**: Go / Java (Spring Boot 2.x) / Python / Node.js / Perl / Lua / PHP / .NET / C++ / Rust / Ruby / Bash / Dart +- **Clients**: Go / Java (Spring Boot 2.x / 3.x) / Python / Node.js / Perl / Lua / PHP / .NET / C++ / Rust / Ruby / Bash / Dart ## Project Structure @@ -18,6 +18,8 @@ kenaito-conf/ ├── kenaito-conf-go-client-demo/ # Go client demo ├── kenaito-conf-springboot2-client/ # Java Spring Boot 2.x client SDK (JDK 8+) ├── kenaito-conf-springboot2-client-demo/# Spring Boot 2.x client demo (port 8081) +├── kenaito-conf-springboot3-client/ # Java Spring Boot 3.x client SDK (JDK 17+) +├── kenaito-conf-springboot3-client-demo/# Spring Boot 3.x client demo (port 8084) ├── kenaito-conf-python-client/ # Python client SDK (Python 3.10+) ├── kenaito-conf-python-client-demo/ # Python client demo (port 8082) ├── kenaito-conf-nodejs-client/ # Node.js client SDK (Node.js 18+) @@ -145,17 +147,17 @@ Suitable for JDK 8+ and Spring Boot 2.x projects, supports automatic config refr ```properties # Config center settings -kenaito.configcenter.base-url=http://localhost:8080 +kenaito.config.baseUrl=http://localhost:8080 # Application name (required) -kenaito.configcenter.app-name=myapp +kenaito.config.appName=myapp # Environment (dev/daily/prod) -kenaito.configcenter.env=daily +kenaito.config.profile=daily # Config version -kenaito.configcenter.version=latest +kenaito.config.version=latest # Auth token (optional) -kenaito.configcenter.token=app-token +kenaito.config.token=app-token # Sync interval (seconds) -kenaito.configcenter.sync-interval=30 +kenaito.config.sync-interval-seconds=30 ``` **3. Use Configuration** @@ -170,6 +172,49 @@ private Integer databasePort; `@Value` fields are automatically refreshed when configuration changes, no application restart required. See [Spring Boot 2 Client Documentation](kenaito-conf-springboot2-client/README.md) for details. +### Java Spring Boot 3.x Client + +Suitable for JDK 17+ and Spring Boot 3.x projects, supports automatic config refresh, dynamic `@Value` / `@ConfigurationProperties` updates, and local cache fallback. + +**1. Add Dependency** + +```xml + + cn.odboy + kenaito-conf-springboot3-client + 1.0.0 + +``` + +**2. Add Configuration** (`application.properties`) + +```properties +# Config center settings (multiple addresses separated by commas) +kenaito.config.base-url=http://localhost:8080 +# Application name (required) +kenaito.config.app-name=myapp +# Environment (dev/daily/prod) +kenaito.config.profile=daily +# Config version +kenaito.config.version=latest +# Auth token (optional) +kenaito.config.token=app-token +# Sync interval (seconds) +kenaito.config.sync-interval-seconds=30 +``` + +**3. Use Configuration** + +```java +@Value("${database.host:localhost}") +private String databaseHost; + +@Value("${database.port:3306}") +private Integer databasePort; +``` + +`@Value` fields and `@ConfigurationProperties` beans are automatically refreshed when configuration changes, no application restart required. See [Spring Boot 3 Client Documentation](kenaito-conf-springboot3-client/README_EN.md) for details. + ### Python Client Suitable for Python 3.10+ projects. diff --git a/kenaito-conf-springboot2-client/README.md b/kenaito-conf-springboot2-client/README.md index 1bc1684..d42ecaa 100644 --- a/kenaito-conf-springboot2-client/README.md +++ b/kenaito-conf-springboot2-client/README.md @@ -38,28 +38,28 @@ mvn clean install ```properties # 配置中心地址 -kenaito.configcenter.base-url=http://localhost:8080 +kenaito.config.baseUrl=http://localhost:8080 # 应用名称(必填) -kenaito.configcenter.app-name=my-app +kenaito.config.appName=my-app # 环境(dev/daily/prod) -kenaito.configcenter.env=daily +kenaito.config.profile=daily # 配置版本 -kenaito.configcenter.version=latest +kenaito.config.version=latest # 认证令牌(可选) -kenaito.configcenter.token=your-token +kenaito.config.token=your-token # 是否启用远程配置(默认 true) -kenaito.configcenter.enabled=true +kenaito.config.enabled=true -# 同步间隔(秒,默认 10) -kenaito.configcenter.sync-interval=10 +# 同步间隔(秒,默认 60) +kenaito.config.sync-interval-seconds=10 -# 敏感配置键(用于日志脱敏,可选) -kenaito.configcenter.sensitive-keys=password,secret,key,token +# 是否忽略业务组全局配置(默认 false) +kenaito.config.global-ignore=false ``` ### 2. 使用 @Value 注入配置 @@ -124,14 +124,17 @@ public class DatabaseProperties { | 配置项 | 说明 | 默认值 | 必填 | |--------|------|--------|------| -| `kenaito.configcenter.base-url` | 配置中心地址 | - | 是 | -| `kenaito.configcenter.app-name` | 应用名称 | - | 是 | -| `kenaito.configcenter.env` | 环境标识 | `dev` | 否 | -| `kenaito.configcenter.version` | 配置版本 | `latest` | 否 | -| `kenaito.configcenter.token` | 认证令牌 | 空 | 否 | -| `kenaito.configcenter.enabled` | 是否启用 | `true` | 否 | -| `kenaito.configcenter.sync-interval` | 同步间隔(秒) | `10` | 否 | -| `kenaito.configcenter.sensitive-keys` | 敏感键(日志脱敏) | 空 | 否 | +| `kenaito.config.baseUrl` | 配置中心地址 | - | 是 | +| `kenaito.config.appName` | 应用名称 | `spring.application.name` | 是 | +| `kenaito.config.profile` | 环境标识 | `dev` | 否 | +| `kenaito.config.version` | 配置版本 | `latest` | 否 | +| `kenaito.config.token` | 认证令牌 | 空 | 否 | +| `kenaito.config.enabled` | 是否启用 | `true` | 否 | +| `kenaito.config.sync-interval-seconds` | 同步间隔(秒) | `60` | 否 | +| `kenaito.config.global-ignore` | 忽略业务组全局配置 | `false` | 否 | +| `kenaito.configcenter.local-storage-dir` | 本地缓存目录 | 应用工作目录 | 否 | + +> Spring Boot 2 客户端读取的是驼峰形式的 `baseUrl` / `appName`;敏感键固定为 `password`、`passwd`、`key`、`secret`、`token`、`credentials`,暂不支持自定义。 ## 自动配置说明 @@ -152,12 +155,12 @@ mvn spring-boot:run ``` Demo 默认配置: -- 端口:8083 -- 应用名称:`kenaito-springboot2-demo` +- 端口:8081 +- 应用名称:`kenaito-springboot-demo` - 环境:`daily` - 配置中心地址:`http://localhost:8080` -访问 `http://localhost:8083/config` 查看当前配置。 +访问 `http://localhost:8081/` 或 `http://localhost:8081/configprops` 查看当前配置。 ## 注意事项 diff --git a/kenaito-conf-springboot2-client/README_EN.md b/kenaito-conf-springboot2-client/README_EN.md index 4ff3c70..1f8f852 100644 --- a/kenaito-conf-springboot2-client/README_EN.md +++ b/kenaito-conf-springboot2-client/README_EN.md @@ -36,28 +36,28 @@ Add config center connection parameters in `application.properties`: ```properties # Config center URL -kenaito.configcenter.base-url=http://localhost:8080 +kenaito.config.baseUrl=http://localhost:8080 # Application name (required) -kenaito.configcenter.app-name=my-app +kenaito.config.appName=my-app # Environment (dev/daily/prod) -kenaito.configcenter.env=daily +kenaito.config.profile=daily # Config version -kenaito.configcenter.version=latest +kenaito.config.version=latest # Auth token (optional) -kenaito.configcenter.token=your-token +kenaito.config.token=your-token # Whether to enable remote config (default true) -kenaito.configcenter.enabled=true +kenaito.config.enabled=true -# Sync interval in seconds (default 10) -kenaito.configcenter.sync-interval=10 +# Sync interval in seconds (default 60) +kenaito.config.sync-interval-seconds=10 -# Sensitive config keys (for log masking, optional) -kenaito.configcenter.sensitive-keys=password,secret,key,token +# Ignore business group global configs (default false) +kenaito.config.global-ignore=false ``` ### 2. Inject Configs with @Value @@ -122,14 +122,17 @@ public class DatabaseProperties { | Property | Description | Default | Required | |----------|-------------|---------|----------| -| `kenaito.configcenter.base-url` | Config center URL | - | Yes | -| `kenaito.configcenter.app-name` | Application name | - | Yes | -| `kenaito.configcenter.env` | Environment | `dev` | No | -| `kenaito.configcenter.version` | Config version | `latest` | No | -| `kenaito.configcenter.token` | Auth token | Empty | No | -| `kenaito.configcenter.enabled` | Whether to enable | `true` | No | -| `kenaito.configcenter.sync-interval` | Sync interval (seconds) | `10` | No | -| `kenaito.configcenter.sensitive-keys` | Sensitive keys (log masking) | Empty | No | +| `kenaito.config.baseUrl` | Config center URL | - | Yes | +| `kenaito.config.appName` | Application name | `spring.application.name` | Yes | +| `kenaito.config.profile` | Environment | `dev` | No | +| `kenaito.config.version` | Config version | `latest` | No | +| `kenaito.config.token` | Auth token | Empty | No | +| `kenaito.config.enabled` | Whether to enable | `true` | No | +| `kenaito.config.sync-interval-seconds` | Sync interval (seconds) | `60` | No | +| `kenaito.config.global-ignore` | Ignore business group global configs | `false` | No | +| `kenaito.configcenter.local-storage-dir` | Local cache directory | Application working directory | No | + +> The Spring Boot 2 client reads the camel-case `baseUrl` / `appName` keys. Sensitive keys are fixed to `password`, `passwd`, `key`, `secret`, `token`, `credentials` and cannot be customized. ## Auto Configuration @@ -150,12 +153,12 @@ mvn spring-boot:run ``` Demo defaults: -- Port: 8083 -- App name: `kenaito-springboot2-demo` +- Port: 8081 +- App name: `kenaito-springboot-demo` - Environment: `daily` - Config center URL: `http://localhost:8080` -Visit `http://localhost:8083/config` to view current configs. +Visit `http://localhost:8081/` or `http://localhost:8081/configprops` to view current configs. ## Notes diff --git a/kenaito-conf-springboot3-client-demo/.gitignore b/kenaito-conf-springboot3-client-demo/.gitignore new file mode 100644 index 0000000..39c133d --- /dev/null +++ b/kenaito-conf-springboot3-client-demo/.gitignore @@ -0,0 +1,5 @@ +/target/ +.DS_Store +/.settings/ +.project +.classpath diff --git a/kenaito-conf-springboot3-client-demo/pom.xml b/kenaito-conf-springboot3-client-demo/pom.xml new file mode 100644 index 0000000..ab2ca03 --- /dev/null +++ b/kenaito-conf-springboot3-client-demo/pom.xml @@ -0,0 +1,49 @@ + + + 4.0.0 + + cn.odboy + kenaito-conf-springboot3-client-demo + 1.0.0 + jar + + kenaito-conf-springboot3-client-demo + Demo project for kenaito-conf config center (Spring Boot 3) + + + org.springframework.boot + spring-boot-starter-parent + 3.5.16 + + + + + 17 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + + + cn.odboy + kenaito-conf-springboot3-client + 1.0.0 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/ConfigChangeLogger.java b/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/ConfigChangeLogger.java new file mode 100644 index 0000000..687ea0e --- /dev/null +++ b/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/ConfigChangeLogger.java @@ -0,0 +1,44 @@ +package cn.odboy.demo; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import cn.odboy.confcenter.ConfigChangeHanlder; + +/** + * Example {@link ConfigChangeHanlder} that logs changes and keeps the 20 most + * recent ones for the {@code /changes} endpoint. + * + * @author odboy + * @date 2026-10-21 + */ +@Component +public class ConfigChangeLogger implements ConfigChangeHanlder { + + private static final Logger logger = LoggerFactory.getLogger(ConfigChangeLogger.class); + + private static final int MAX_HISTORY = 20; + + private final Deque> history = new ArrayDeque<>(); + + @Override + public synchronized void onConfigChanged(Map changedConfigs) { + logger.info("[Demo] config changed: {}", changedConfigs); + history.addFirst(new LinkedHashMap<>(changedConfigs)); + while (history.size() > MAX_HISTORY) { + history.removeLast(); + } + } + + public synchronized List> getHistory() { + return new ArrayList<>(history); + } +} diff --git a/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/ConfigController.java b/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/ConfigController.java new file mode 100644 index 0000000..30ff9b5 --- /dev/null +++ b/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/ConfigController.java @@ -0,0 +1,96 @@ +package cn.odboy.demo; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Demo endpoints showing {@code @Value} and {@code @ConfigurationProperties} + * values refresh when configs change in the config center. + * + * @author odboy + * @date 2026-10-21 + */ +@RestController +public class ConfigController { + + @Value("${database.host:unknown}") + private String host; + + @Value("${database.port:unknown}") + private String port; + + @Value("${database.name:unknown}") + private String name; + + @Value("${database.user:unknown}") + private String user; + + @Value("${database.password:unknown}") + private String password; + + @Value("${app.name:unknown}") + private String appName; + + private final DatabaseProperties databaseProperties; + + private final ConfigChangeLogger configChangeLogger; + + private final Environment environment; + + @Autowired + public ConfigController(DatabaseProperties databaseProperties, + ConfigChangeLogger configChangeLogger, + Environment environment) { + this.databaseProperties = databaseProperties; + this.configChangeLogger = configChangeLogger; + this.environment = environment; + } + + @GetMapping("/") + public Map index() { + return Map.of( + "app", appName, + "profiles", String.join(",", environment.getActiveProfiles()), + "configCenter", environment.getProperty("kenaito.config.base-url", ""), + "database.host", host); + } + + @GetMapping("/config") + public Map config() { + Map result = new HashMap<>(); + result.put("host", host); + result.put("port", port); + result.put("name", name); + result.put("user", user); + result.put("password", password); + return result; + } + + @GetMapping("/configprops") + public Map configprops() { + Map result = new HashMap<>(); + result.put("host", databaseProperties.getHost()); + result.put("port", databaseProperties.getPort()); + result.put("name", databaseProperties.getName()); + result.put("user", databaseProperties.getUser()); + result.put("password", databaseProperties.getPassword()); + return result; + } + + @GetMapping("/changes") + public List> changes() { + return configChangeLogger.getHistory(); + } + + @GetMapping("/health") + public Map health() { + return Map.of("status", "UP"); + } +} diff --git a/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/DatabaseProperties.java b/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/DatabaseProperties.java new file mode 100644 index 0000000..29e4dfb --- /dev/null +++ b/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/DatabaseProperties.java @@ -0,0 +1,65 @@ +package cn.odboy.demo; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * Database configuration bound with {@code @ConfigurationProperties}. + * + * @author odboy + * @date 2026-10-21 + */ +@Component +@ConfigurationProperties(prefix = "database") +public class DatabaseProperties { + + private String host; + + private Integer port; + + private String name; + + private String user; + + private String password; + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public Integer getPort() { + return port; + } + + public void setPort(Integer port) { + this.port = port; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getUser() { + return user; + } + + public void setUser(String user) { + this.user = user; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/DemoApplication.java b/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/DemoApplication.java new file mode 100644 index 0000000..fa5dbe8 --- /dev/null +++ b/kenaito-conf-springboot3-client-demo/src/main/java/cn/odboy/demo/DemoApplication.java @@ -0,0 +1,18 @@ +package cn.odboy.demo; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Kenaito Config Center Spring Boot 3 demo application. + * + * @author odboy + * @date 2026-10-21 + */ +@SpringBootApplication +public class DemoApplication { + + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } +} diff --git a/kenaito-conf-springboot3-client-demo/src/main/resources/application.properties b/kenaito-conf-springboot3-client-demo/src/main/resources/application.properties new file mode 100644 index 0000000..e89b3ce --- /dev/null +++ b/kenaito-conf-springboot3-client-demo/src/main/resources/application.properties @@ -0,0 +1,23 @@ +# Server +server.port=8084 + +# Application +spring.application.name=kenaito-conf-springboot3-client-demo +app.name=kenaito-conf-springboot3-client-demo +app.version=1.0.0 + +# Kenaito Config Center +kenaito.config.base-url=${KENAITO_BASE_URL:http://localhost:8080} +kenaito.config.app-name=${KENAITO_APP_NAME:kenaito-springboot-demo} +kenaito.config.profile=${KENAITO_ENV:daily} +kenaito.config.version=${KENAITO_VERSION:latest} +kenaito.config.token=${KENAITO_TOKEN:} +kenaito.config.sync-interval-seconds=${KENAITO_SYNC_INTERVAL:10} +kenaito.config.sensitive-keys=password,secret,key,token + +# Local configs (overridden by remote configs when they exist) +database.host=192.168.1.1 +database.port=8888 +database.name=xxxx +database.user=xxxx +database.password=xxxxxxxx diff --git a/kenaito-conf-springboot3-client/.gitignore b/kenaito-conf-springboot3-client/.gitignore new file mode 100644 index 0000000..39c133d --- /dev/null +++ b/kenaito-conf-springboot3-client/.gitignore @@ -0,0 +1,5 @@ +/target/ +.DS_Store +/.settings/ +.project +.classpath diff --git a/kenaito-conf-springboot3-client/README.md b/kenaito-conf-springboot3-client/README.md new file mode 100644 index 0000000..8790574 --- /dev/null +++ b/kenaito-conf-springboot3-client/README.md @@ -0,0 +1,265 @@ +# Kenaito Config Center Spring Boot 3 Client + +[English](README_EN.md) | 中文 + +适用于 Spring Boot 3.x / JDK 17+ 应用的配置中心客户端,支持启动时拉取远程配置、定时同步、`@Value` 与 `@ConfigurationProperties` 字段动态刷新,以及配置中心不可用时的本地缓存降级。 + +## 功能特性 + +- 应用启动前从配置中心拉取全量配置,注入 Spring `Environment` +- 定时轮询增量变更(新增、修改、删除),无需重启应用 +- 自动刷新 `@Value` 字段和 `@ConfigurationProperties` Bean +- 支持通过 `ConfigChangeHanlder` 监听配置变更 +- 本地文件缓存(`config-cache.log`),配置中心不可用时自动降级 +- 敏感配置日志脱敏 +- 支持配置中心多地址(`base-url` 使用 `,` 或 `;` 分隔,自动故障转移) +- Spring Boot 3 自动配置(`AutoConfiguration.imports`) + +## 环境要求 + +| 项目 | 版本 | +|------|------| +| JDK | 17+ | +| Spring Boot | 3.x | +| Maven | 3.6+ | + +> Spring Boot 2.x / JDK 8 项目请使用 [`kenaito-conf-springboot2-client`](../kenaito-conf-springboot2-client/README.md)。 + +## 安装 + +### Maven 依赖 + +```xml + + cn.odboy + kenaito-conf-springboot3-client + 1.0.0 + +``` + +### 本地安装(开发调试) + +```bash +cd kenaito-conf-springboot3-client +mvn clean install +``` + +## 快速开始 + +### 1. 添加配置 + +在 `application.properties` 中添加配置中心连接参数: + +```properties +# 配置中心地址(多个地址用逗号分隔) +kenaito.config.base-url=http://localhost:8080 + +# 应用名称(必填) +kenaito.config.app-name=my-app + +# 环境(dev/daily/prod) +kenaito.config.profile=daily + +# 配置版本(默认 latest) +kenaito.config.version=latest + +# 认证令牌(应用开启 Token 校验时必填) +kenaito.config.token=your-token + +# 是否启用远程配置(默认 true) +kenaito.config.enabled=true + +# 同步间隔(秒,默认 60) +kenaito.config.sync-interval-seconds=10 + +# 敏感配置键(用于日志脱敏,逗号分隔) +kenaito.config.sensitive-keys=password,secret,key,token +``` + +也可以使用 YAML: + +```yaml +kenaito: + config: + base-url: http://localhost:8080 + app-name: my-app + profile: daily + version: latest + sync-interval-seconds: 10 +``` + +### 2. 使用 @Value 注入配置 + +```java +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ConfigController { + + @Value("${database.host:localhost}") + private String databaseHost; + + @Value("${database.port:3306}") + private Integer databasePort; + + @Value("${feature.enabled:false}") + private Boolean featureEnabled; + + @GetMapping("/config") + public String config() { + return databaseHost + ":" + databasePort + ", feature=" + featureEnabled; + } +} +``` + +配置中心的值会覆盖 `application.properties` 中的同名配置。 + +### 3. 使用 @ConfigurationProperties + +```java +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "database") +public class DatabaseProperties { + + private String host; + + private Integer port; + + private String username; + + private String password; + + // getters and setters +} +``` + +### 4. 监听配置变更 + +实现 `ConfigChangeHanlder` 并注册为 Spring Bean: + +```java +import cn.odboy.confcenter.ConfigChangeHanlder; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class MyConfigChangeListener implements ConfigChangeHanlder { + + @Override + public void onConfigChanged(Map changedConfigs) { + changedConfigs.forEach((key, value) -> + System.out.printf("config changed: %s = %s%n", key, value)); + } +} +``` + +### 5. 自动刷新 + +配置中心的配置发生变更后,客户端会在下一个同步周期内: + +1. 更新 `ConfigcenterContext` 中的配置值; +2. 更新 Spring `Environment` 中的 `kenaitoConfigPropertySource`; +3. 回调所有 `ConfigChangeHanlder` Bean; +4. 刷新受影响的 `@Value` 字段和 `@ConfigurationProperties` 字段。 + +刷新日志示例: + +``` +[RefreshScope] refreshed @Value field: cn.odboy.demo.ConfigController.host | old='10.0.0.1' | new='10.0.0.2' +[RefreshScope] refreshed @ConfigurationProperties field: cn.odboy.demo.DatabaseProperties.port = '13306' +``` + +> 与 Spring Cloud 的 `@RefreshScope` 不同,本客户端直接通过反射刷新字段,不需要 `spring-cloud-context` 依赖。 + +## 配置项说明 + +| 配置项 | 说明 | 默认值 | 必填 | +|--------|------|--------|------| +| `kenaito.config.base-url` | 配置中心地址,多个地址用 `,` 或 `;` 分隔 | - | 是 | +| `kenaito.config.app-name` | 应用名称(配置中心中的应用编码) | `spring.application.name` | 是 | +| `kenaito.config.profile` | 环境标识 | `dev` | 否 | +| `kenaito.config.version` | 配置版本 | `latest` | 否 | +| `kenaito.config.token` | 认证令牌 | 空 | 否 | +| `kenaito.config.enabled` | 是否启用远程配置 | `true` | 否 | +| `kenaito.config.sync-interval-seconds` | 增量同步间隔(秒) | `60` | 否 | +| `kenaito.config.global-ignore` | 是否忽略业务组全局配置 | `false` | 否 | +| `kenaito.config.sensitive-keys` | 日志中需要脱敏的键(逗号分隔) | `password,passwd,key,secret,token,credentials` | 否 | +| `kenaito.config.local-storage-dir` | 本地缓存目录 | 应用工作目录 | 否 | + +> `baseUrl`/`appName`/`base-url`/`app-name` 等驼峰写法和旧的 `kenaito.configcenter.*` 前缀同样兼容,推荐使用表中列出的中划线写法。 + +## 自动配置说明 + +客户端通过 Spring Boot 3 的自动配置机制工作,引入依赖并配置参数即可使用,无需手动注册 Bean: + +- `KenaitoEnvironmentInitializer` + 通过 `META-INF/spring.factories` 注册的 `EnvironmentPostProcessor`,在 Spring 上下文创建前拉取远程配置,并将其作为最高优先级的 PropertySource 加入 `Environment`。 +- `KenaitoRefreshAutoConfiguration` + 通过 `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` 注册的自动配置,负责: + - 将 `ApplicationContext` 交给客户端; + - 注册 `RefreshScopeRegistry`(`BeanPostProcessor`),收集 `@Value` 字段与 `@ConfigurationProperties` Bean; + - 在配置变更时刷新受影响的字段。 + +### 与 Spring Boot 2 客户端的差异 + +| 项目 | Spring Boot 2 客户端 | Spring Boot 3 客户端 | +|------|----------------------|----------------------| +| JDK | 8+ | 17+ | +| Spring Boot | 2.x | 3.x | +| 自动配置注册 | `META-INF/spring.factories` | `META-INF/spring/...AutoConfiguration.imports` | +| 公共 API | `ConfigChangeHanlder` 等 | 相同 | + +## Demo + +Demo 项目:[`kenaito-conf-springboot3-client-demo`](../kenaito-conf-springboot3-client-demo/) + +```bash +# 先安装客户端到本地仓库 +cd kenaito-conf-springboot3-client +mvn clean install + +# 启动 Demo +cd ../kenaito-conf-springboot3-client-demo +mvn spring-boot:run +``` + +Demo 默认配置: + +- 端口:`8084` +- 应用名称:`kenaito-springboot-demo`(可通过 `KENAITO_APP_NAME` 覆盖) +- 环境:`daily`(可通过 `KENAITO_ENV` 覆盖) +- 配置中心地址:`http://localhost:8080`(可通过 `KENAITO_BASE_URL` 覆盖) + +可用接口: + +| 接口 | 说明 | +|------|------| +| `GET /` | 应用信息与部分配置 | +| `GET /config` | `@Value` 注入的配置 | +| `GET /configprops` | `@ConfigurationProperties` 绑定的配置 | +| `GET /changes` | 最近 20 次配置变更记录 | +| `GET /health` | 健康检查 | + +## 本地缓存与容灾 + +客户端在成功拉取远程配置后,会将配置写入本地缓存文件 `config-cache.log`(默认位于应用工作目录,可通过 `kenaito.config.local-storage-dir` 指定)。 + +当配置中心不可用且本地存在缓存时,客户端会从缓存加载配置,保证应用仍可启动;同时定时轮询会持续重试,配置中心恢复后自动继续同步。 + +## 注意事项 + +1. **刷新类型限制**:`@Value` 刷新支持 `String`、`int/Integer`、`long/Long`、`boolean/Boolean`、`double/Double`、`float/Float`、`short/Short`、`byte/Byte`、`char/Character`,其他类型按字符串尝试赋值。 +2. **配置优先级**:远程配置优先于本地同名配置;配置中心连接参数(`kenaito.config.*`)以本地配置为准。 +3. **启动失败**:未配置 `base-url` 或 `app-name` 时应用启动失败;配置中心暂时不可用不会阻止启动(若本地无缓存,则仅使用本地配置)。 +4. **日志脱敏**:键名包含敏感词的配置在启动日志中只显示前一半字符 + `****`。 +5. **多实例**:每个应用实例独立轮询配置中心,变更检测基于 `lastTime` 时间戳。 + +## 版本历史 + +- **1.0.0** - 初始版本,适配 Spring Boot 3 / JDK 17 diff --git a/kenaito-conf-springboot3-client/README_EN.md b/kenaito-conf-springboot3-client/README_EN.md new file mode 100644 index 0000000..ae8295a --- /dev/null +++ b/kenaito-conf-springboot3-client/README_EN.md @@ -0,0 +1,265 @@ +# Kenaito Config Center Spring Boot 3 Client + +English | [中文](README.md) + +A config center client for Spring Boot 3.x / JDK 17+ applications. It fetches remote configuration at startup, polls for incremental changes, refreshes `@Value` and `@ConfigurationProperties` fields at runtime, and falls back to a local cache when the config center is unavailable. + +## Features + +- Fetches all configs before the Spring context starts and injects them into the `Environment` +- Polls for incremental changes (add, update, delete) without restarting the application +- Refreshes `@Value` fields and `@ConfigurationProperties` beans automatically +- Notifies `ConfigChangeHanlder` beans on every change +- Local file cache (`config-cache.log`) used as a fallback when the config center is down +- Sensitive values masked in logs +- Multiple config center addresses (`base-url` separated by `,` or `;`) with automatic failover +- Spring Boot 3 auto-configuration (`AutoConfiguration.imports`) + +## Requirements + +| Item | Version | +|------|---------| +| JDK | 17+ | +| Spring Boot | 3.x | +| Maven | 3.6+ | + +> For Spring Boot 2.x / JDK 8 applications use [`kenaito-conf-springboot2-client`](../kenaito-conf-springboot2-client/README_EN.md). + +## Installation + +### Maven + +```xml + + cn.odboy + kenaito-conf-springboot3-client + 1.0.0 + +``` + +### Local install (for development) + +```bash +cd kenaito-conf-springboot3-client +mvn clean install +``` + +## Quick Start + +### 1. Add configuration + +Add the config center settings to `application.properties`: + +```properties +# Config center address (multiple addresses can be separated by commas) +kenaito.config.base-url=http://localhost:8080 + +# Application name (required) +kenaito.config.app-name=my-app + +# Environment (dev/daily/prod) +kenaito.config.profile=daily + +# Config version (default: latest) +kenaito.config.version=latest + +# Auth token (required when the application enables token validation) +kenaito.config.token=your-token + +# Enable remote config (default: true) +kenaito.config.enabled=true + +# Sync interval in seconds (default: 60) +kenaito.config.sync-interval-seconds=10 + +# Keys masked in logs (comma separated) +kenaito.config.sensitive-keys=password,secret,key,token +``` + +YAML is supported as well: + +```yaml +kenaito: + config: + base-url: http://localhost:8080 + app-name: my-app + profile: daily + version: latest + sync-interval-seconds: 10 +``` + +### 2. Inject configs with @Value + +```java +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class ConfigController { + + @Value("${database.host:localhost}") + private String databaseHost; + + @Value("${database.port:3306}") + private Integer databasePort; + + @Value("${feature.enabled:false}") + private Boolean featureEnabled; + + @GetMapping("/config") + public String config() { + return databaseHost + ":" + databasePort + ", feature=" + featureEnabled; + } +} +``` + +Values from the config center override local properties with the same key. + +### 3. Use @ConfigurationProperties + +```java +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "database") +public class DatabaseProperties { + + private String host; + + private Integer port; + + private String username; + + private String password; + + // getters and setters +} +``` + +### 4. Listen for changes + +Implement `ConfigChangeHanlder` and register it as a Spring bean: + +```java +import cn.odboy.confcenter.ConfigChangeHanlder; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class MyConfigChangeListener implements ConfigChangeHanlder { + + @Override + public void onConfigChanged(Map changedConfigs) { + changedConfigs.forEach((key, value) -> + System.out.printf("config changed: %s = %s%n", key, value)); + } +} +``` + +### 5. Automatic refresh + +When configuration changes in the config center, the client will, within the next sync interval: + +1. Update the values in `ConfigcenterContext`; +2. Update the `kenaitoConfigPropertySource` in the Spring `Environment`; +3. Invoke every `ConfigChangeHanlder` bean; +4. Refresh the affected `@Value` fields and `@ConfigurationProperties` fields. + +Example refresh log: + +``` +[RefreshScope] refreshed @Value field: cn.odboy.demo.ConfigController.host | old='10.0.0.1' | new='10.0.0.2' +[RefreshScope] refreshed @ConfigurationProperties field: cn.odboy.demo.DatabaseProperties.port = '13306' +``` + +> Unlike Spring Cloud's `@RefreshScope`, this client refreshes fields directly via reflection and does not require `spring-cloud-context`. + +## Configuration Reference + +| Property | Description | Default | Required | +|----------|-------------|---------|----------| +| `kenaito.config.base-url` | Config center address, multiple addresses separated by `,` or `;` | - | Yes | +| `kenaito.config.app-name` | Application name (application code in the config center) | `spring.application.name` | Yes | +| `kenaito.config.profile` | Environment identifier | `dev` | No | +| `kenaito.config.version` | Config version | `latest` | No | +| `kenaito.config.token` | Auth token | empty | No | +| `kenaito.config.enabled` | Enable remote config | `true` | No | +| `kenaito.config.sync-interval-seconds` | Incremental sync interval (seconds) | `60` | No | +| `kenaito.config.global-ignore` | Ignore business group global configs | `false` | No | +| `kenaito.config.sensitive-keys` | Keys masked in logs (comma separated) | `password,passwd,key,secret,token,credentials` | No | +| `kenaito.config.local-storage-dir` | Local cache directory | Application working directory | No | + +> Camel case variants (`baseUrl`, `appName`) and the legacy `kenaito.configcenter.*` prefix are also accepted. The kebab-case keys above are recommended. + +## Auto-configuration + +The client relies on Spring Boot 3 auto-configuration. No manual bean registration is needed: + +- `KenaitoEnvironmentInitializer` + An `EnvironmentPostProcessor` registered via `META-INF/spring.factories`. It fetches remote configs before the Spring context starts and adds them as the highest-priority property source. +- `KenaitoRefreshAutoConfiguration` + Auto-configuration registered via `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`. It: + - gives the `ApplicationContext` to the client; + - registers `RefreshScopeRegistry` (`BeanPostProcessor`) to collect `@Value` fields and `@ConfigurationProperties` beans; + - refreshes affected fields when configs change. + +### Differences from the Spring Boot 2 client + +| Item | Spring Boot 2 client | Spring Boot 3 client | +|------|----------------------|----------------------| +| JDK | 8+ | 17+ | +| Spring Boot | 2.x | 3.x | +| Auto-configuration registration | `META-INF/spring.factories` | `META-INF/spring/...AutoConfiguration.imports` | +| Public API | `ConfigChangeHanlder`, etc. | Same | + +## Demo + +Demo project: [`kenaito-conf-springboot3-client-demo`](../kenaito-conf-springboot3-client-demo/) + +```bash +# Install the client into the local repository first +cd kenaito-conf-springboot3-client +mvn clean install + +# Start the demo +cd ../kenaito-conf-springboot3-client-demo +mvn spring-boot:run +``` + +Demo defaults: + +- Port: `8084` +- Application name: `kenaito-springboot-demo` (override with `KENAITO_APP_NAME`) +- Environment: `daily` (override with `KENAITO_ENV`) +- Config center: `http://localhost:8080` (override with `KENAITO_BASE_URL`) + +Endpoints: + +| Endpoint | Description | +|----------|-------------| +| `GET /` | Application info and some configs | +| `GET /config` | Configs injected via `@Value` | +| `GET /configprops` | Configs bound via `@ConfigurationProperties` | +| `GET /changes` | The 20 most recent config changes | +| `GET /health` | Health check | + +## Local Cache and Disaster Recovery + +After a successful fetch, the client writes the remote configs to `config-cache.log` (in the application working directory by default, configurable via `kenaito.config.local-storage-dir`). + +If the config center is unavailable and a cache exists, the client loads configs from the cache so the application can still start. Polling keeps retrying and resumes syncing once the config center is back. + +## Notes + +1. **Refresh type limits**: `@Value` refresh supports `String`, `int/Integer`, `long/Long`, `boolean/Boolean`, `double/Double`, `float/Float`, `short/Short`, `byte/Byte` and `char/Character`. Other types are assigned as strings. +2. **Priority**: remote configs override local properties with the same key; config center connection settings (`kenaito.config.*`) come from local configuration. +3. **Startup**: the application fails to start when `base-url` or `app-name` is missing. A temporarily unavailable config center does not block startup (without a cache only local configs are used). +4. **Masking**: values whose keys contain a sensitive word are logged as the first half of the value followed by `****`. +5. **Multiple instances**: every instance polls independently; change detection is based on the `lastTime` timestamp. + +## Changelog + +- **1.0.0** - Initial release for Spring Boot 3 / JDK 17 diff --git a/kenaito-conf-springboot3-client/pom.xml b/kenaito-conf-springboot3-client/pom.xml new file mode 100644 index 0000000..484e0c5 --- /dev/null +++ b/kenaito-conf-springboot3-client/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + cn.odboy + kenaito-conf-springboot3-client + 1.0.0 + jar + + kenaito-conf-springboot3-client + Kenaito Config Center client for Spring Boot 3.x + + + 17 + 3.5.16 + 2.0.60 + 4.5.14 + 3.17.0 + 2.0.16 + UTF-8 + + + + + com.alibaba.fastjson2 + fastjson2 + ${fastjson2.version} + + + + org.apache.httpcomponents + httpclient + ${httpclient.version} + + + + org.apache.commons + commons-lang3 + ${commons-lang3.version} + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + + org.springframework.boot + spring-boot + ${spring-boot.version} + true + + + + org.springframework.boot + spring-boot-autoconfigure + ${spring-boot.version} + true + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${java.version} + + + + + diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/ConfigChangeHanlder.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/ConfigChangeHanlder.java new file mode 100644 index 0000000..c41e1b5 --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/ConfigChangeHanlder.java @@ -0,0 +1,19 @@ +package cn.odboy.confcenter; + +import java.util.Map; + +/** + * Callback for remote config changes. + * + *

Implement this interface as a Spring bean to be notified whenever the config + * center pushes a set of changed keys.

+ * + * @author odboy + * @version 3.0.0 + * @date 2026-10-21 + */ +@FunctionalInterface +public interface ConfigChangeHanlder { + + void onConfigChanged(Map changedConfigs); +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/ConfigcenterContext.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/ConfigcenterContext.java new file mode 100644 index 0000000..cc4496f --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/ConfigcenterContext.java @@ -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. + * + *

Holds the client configuration, the remote configuration snapshot and the + * Spring environment wiring used to refresh {@code @Value} and + * {@code @ConfigurationProperties} fields at runtime.

+ * + * @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 DEFAULT_SENSITIVE_KEYS = Arrays.asList( + "password", "passwd", "key", "secret", "token", "credentials"); + + /** Properties visible to the client (application config + remote config). */ + private static final Map 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 sensitiveKeys = DEFAULT_SENSITIVE_KEYS; + + private InternalConfigChangeListener configChangeListener; + + private List 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 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 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 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 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 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 entry : map.entrySet()) { + if (entry.getValue() != null) { + properties.setProperty(entry.getKey(), entry.getValue().toString()); + } + } + remoteProperties = properties; + return properties; + } + + @SuppressWarnings("unchecked") + private Map fetchConfigFromServer() { + if (apiBaseUrls == null || apiBaseUrls.length == 0) { + return null; + } + Map result = null; + for (String apiBaseUrl : apiBaseUrls) { + String url = apiBaseUrl + "/api/fetchAllConfigs"; + Map 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 parsed = JSON.parseObject(jsonString, Map.class); + // Error responses use {"code": , "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 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 updateConfig) { + if (updateConfig == null || updateConfig.isEmpty()) { + return; + } + for (Entry 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 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 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 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 updates, Set 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 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; + } +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/HttpClientUtils.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/HttpClientUtils.java new file mode 100644 index 0000000..5dd3be1 --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/HttpClientUtils.java @@ -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; + } +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/InternalConfigChangeListener.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/InternalConfigChangeListener.java new file mode 100644 index 0000000..70c42e5 --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/InternalConfigChangeListener.java @@ -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. + * + *

Polls {@code /api/fetchChangedConfigs} at a fixed interval and applies + * incremental changes (including deleted keys) to the config center context.

+ * + * @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 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 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 data = new HashMap<>((Map) 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 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; + } + } +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/LocalCacheUtils.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/LocalCacheUtils.java new file mode 100644 index 0000000..72152f1 --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/LocalCacheUtils.java @@ -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. + * + *

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.

+ * + * @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 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 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; + } +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/RefreshScopeRegistry.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/RefreshScopeRegistry.java new file mode 100644 index 0000000..6429dc6 --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/RefreshScopeRegistry.java @@ -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. + * + *

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

+ * + * @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 valueFields = new LinkedHashMap<>(); + + private final Map 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 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 getAllDeclaredFields(Class clazz) { + List 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 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()); + } + } + } + } +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/spring/KenaitoPropertyPlaceholderConfigurer.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/spring/KenaitoPropertyPlaceholderConfigurer.java new file mode 100644 index 0000000..548eb74 --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/spring/KenaitoPropertyPlaceholderConfigurer.java @@ -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); + } +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/springboot/KenaitoEnvironmentInitializer.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/springboot/KenaitoEnvironmentInitializer.java new file mode 100644 index 0000000..6ea323f --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/springboot/KenaitoEnvironmentInitializer.java @@ -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. + * + *

Initializes the config center before the application context is created and + * injects the remote configuration as a high-priority property source.

+ * + *

Registered through {@code META-INF/spring.factories}, which Spring Boot 3 + * still supports for {@link EnvironmentPostProcessor} implementations.

+ * + * @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; + } +} diff --git a/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/springboot/KenaitoRefreshAutoConfiguration.java b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/springboot/KenaitoRefreshAutoConfiguration.java new file mode 100644 index 0000000..a469835 --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/java/cn/odboy/confcenter/springboot/KenaitoRefreshAutoConfiguration.java @@ -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. + * + *

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.

+ * + *

Registered through + * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}.

+ * + * @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); + } +} diff --git a/kenaito-conf-springboot3-client/src/main/resources/META-INF/spring.factories b/kenaito-conf-springboot3-client/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..a58f71e --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer diff --git a/kenaito-conf-springboot3-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/kenaito-conf-springboot3-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..78491be --- /dev/null +++ b/kenaito-conf-springboot3-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.odboy.confcenter.springboot.KenaitoRefreshAutoConfiguration