feat: 新增 Spring Boot 3 客户端 SDK 及 demo
- 新增 kenaito-conf-springboot3-client,支持配置拉取、增量同步、@Value/@ConfigurationProperties 动态刷新和本地缓存 - 新增 kenaito-conf-springboot3-client-demo(端口 8084)及 Dockerfile.springboot3 - 完善中英文 README、Docker 部署文档,修正 Spring Boot 2 客户端配置键名与 demo 端口说明 - 修复增量同步将服务端 code=0 成功响应误判为错误的问题
This commit is contained in:
@@ -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"]
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
- **后端**: Go + Gin + xorm + MySQL
|
- **后端**: Go + Gin + xorm + MySQL
|
||||||
- **前端**: Vue 2 + Vite 5 + Element UI 2
|
- **前端**: 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-go-client-demo/ # Go 客户端示例
|
||||||
├── kenaito-conf-springboot2-client/ # Java Spring Boot 2.x 客户端 SDK(JDK 8+)
|
├── kenaito-conf-springboot2-client/ # Java Spring Boot 2.x 客户端 SDK(JDK 8+)
|
||||||
├── kenaito-conf-springboot2-client-demo/# Spring Boot 2.x 客户端示例(端口 8081)
|
├── 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/ # Python 客户端 SDK(Python 3.10+)
|
||||||
├── kenaito-conf-python-client-demo/ # Python 客户端示例(端口 8082)
|
├── kenaito-conf-python-client-demo/ # Python 客户端示例(端口 8082)
|
||||||
├── kenaito-conf-nodejs-client/ # Node.js 客户端 SDK(Node.js 18+)
|
├── kenaito-conf-nodejs-client/ # Node.js 客户端 SDK(Node.js 18+)
|
||||||
@@ -148,17 +150,17 @@ dbHost := c.Get("db.host")
|
|||||||
|
|
||||||
```properties
|
```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)
|
# 环境(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. 使用配置**
|
**3. 使用配置**
|
||||||
@@ -174,6 +176,49 @@ private Integer databasePort;
|
|||||||
配置变更时会自动刷新 `@Value`
|
配置变更时会自动刷新 `@Value`
|
||||||
字段,无需重启应用。详见 [Spring Boot 2 客户端文档](kenaito-conf-springboot2-client/README.md)。
|
字段,无需重启应用。详见 [Spring Boot 2 客户端文档](kenaito-conf-springboot2-client/README.md)。
|
||||||
|
|
||||||
|
### Java Spring Boot 3.x 客户端
|
||||||
|
|
||||||
|
适用于 JDK 17+、Spring Boot 3.x 项目,支持配置自动刷新、`@Value` / `@ConfigurationProperties` 动态更新和本地缓存容灾。
|
||||||
|
|
||||||
|
**1. 引入依赖**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.odboy</groupId>
|
||||||
|
<artifactId>kenaito-conf-springboot3-client</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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 客户端
|
||||||
|
|
||||||
适用于 Python 3.10+ 项目。
|
适用于 Python 3.10+ 项目。
|
||||||
|
|||||||
+32
-1
@@ -165,6 +165,37 @@ curl http://localhost:8081/health
|
|||||||
docker stop springboot2-demo
|
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++ 客户端
|
## C++ 客户端
|
||||||
|
|
||||||
```bash
|
```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 ...
|
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. **日志查看**:
|
4. **日志查看**:
|
||||||
- Go/.NET:直接输出到 stdout
|
- Go/.NET:直接输出到 stdout
|
||||||
|
|||||||
+52
-7
@@ -6,7 +6,7 @@ A centralized configuration management platform rebuilt with Go, supporting mult
|
|||||||
|
|
||||||
- **Backend**: Go + Gin + xorm + MySQL
|
- **Backend**: Go + Gin + xorm + MySQL
|
||||||
- **Frontend**: Vue 2 + Vite 5 + Element UI 2
|
- **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
|
## Project Structure
|
||||||
|
|
||||||
@@ -18,6 +18,8 @@ kenaito-conf/
|
|||||||
├── kenaito-conf-go-client-demo/ # Go client demo
|
├── 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/ # Java Spring Boot 2.x client SDK (JDK 8+)
|
||||||
├── kenaito-conf-springboot2-client-demo/# Spring Boot 2.x client demo (port 8081)
|
├── 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/ # Python client SDK (Python 3.10+)
|
||||||
├── kenaito-conf-python-client-demo/ # Python client demo (port 8082)
|
├── kenaito-conf-python-client-demo/ # Python client demo (port 8082)
|
||||||
├── kenaito-conf-nodejs-client/ # Node.js client SDK (Node.js 18+)
|
├── 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
|
```properties
|
||||||
# Config center settings
|
# Config center settings
|
||||||
kenaito.configcenter.base-url=http://localhost:8080
|
kenaito.config.baseUrl=http://localhost:8080
|
||||||
# Application name (required)
|
# Application name (required)
|
||||||
kenaito.configcenter.app-name=myapp
|
kenaito.config.appName=myapp
|
||||||
# Environment (dev/daily/prod)
|
# Environment (dev/daily/prod)
|
||||||
kenaito.configcenter.env=daily
|
kenaito.config.profile=daily
|
||||||
# Config version
|
# Config version
|
||||||
kenaito.configcenter.version=latest
|
kenaito.config.version=latest
|
||||||
# Auth token (optional)
|
# Auth token (optional)
|
||||||
kenaito.configcenter.token=app-token
|
kenaito.config.token=app-token
|
||||||
# Sync interval (seconds)
|
# Sync interval (seconds)
|
||||||
kenaito.configcenter.sync-interval=30
|
kenaito.config.sync-interval-seconds=30
|
||||||
```
|
```
|
||||||
|
|
||||||
**3. Use Configuration**
|
**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.
|
`@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
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.odboy</groupId>
|
||||||
|
<artifactId>kenaito-conf-springboot3-client</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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
|
### Python Client
|
||||||
|
|
||||||
Suitable for Python 3.10+ projects.
|
Suitable for Python 3.10+ projects.
|
||||||
|
|||||||
@@ -38,28 +38,28 @@ mvn clean install
|
|||||||
|
|
||||||
```properties
|
```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)
|
# 环境(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)
|
# 是否启用远程配置(默认 true)
|
||||||
kenaito.configcenter.enabled=true
|
kenaito.config.enabled=true
|
||||||
|
|
||||||
# 同步间隔(秒,默认 10)
|
# 同步间隔(秒,默认 60)
|
||||||
kenaito.configcenter.sync-interval=10
|
kenaito.config.sync-interval-seconds=10
|
||||||
|
|
||||||
# 敏感配置键(用于日志脱敏,可选)
|
# 是否忽略业务组全局配置(默认 false)
|
||||||
kenaito.configcenter.sensitive-keys=password,secret,key,token
|
kenaito.config.global-ignore=false
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 使用 @Value 注入配置
|
### 2. 使用 @Value 注入配置
|
||||||
@@ -124,14 +124,17 @@ public class DatabaseProperties {
|
|||||||
|
|
||||||
| 配置项 | 说明 | 默认值 | 必填 |
|
| 配置项 | 说明 | 默认值 | 必填 |
|
||||||
|--------|------|--------|------|
|
|--------|------|--------|------|
|
||||||
| `kenaito.configcenter.base-url` | 配置中心地址 | - | 是 |
|
| `kenaito.config.baseUrl` | 配置中心地址 | - | 是 |
|
||||||
| `kenaito.configcenter.app-name` | 应用名称 | - | 是 |
|
| `kenaito.config.appName` | 应用名称 | `spring.application.name` | 是 |
|
||||||
| `kenaito.configcenter.env` | 环境标识 | `dev` | 否 |
|
| `kenaito.config.profile` | 环境标识 | `dev` | 否 |
|
||||||
| `kenaito.configcenter.version` | 配置版本 | `latest` | 否 |
|
| `kenaito.config.version` | 配置版本 | `latest` | 否 |
|
||||||
| `kenaito.configcenter.token` | 认证令牌 | 空 | 否 |
|
| `kenaito.config.token` | 认证令牌 | 空 | 否 |
|
||||||
| `kenaito.configcenter.enabled` | 是否启用 | `true` | 否 |
|
| `kenaito.config.enabled` | 是否启用 | `true` | 否 |
|
||||||
| `kenaito.configcenter.sync-interval` | 同步间隔(秒) | `10` | 否 |
|
| `kenaito.config.sync-interval-seconds` | 同步间隔(秒) | `60` | 否 |
|
||||||
| `kenaito.configcenter.sensitive-keys` | 敏感键(日志脱敏) | 空 | 否 |
|
| `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 默认配置:
|
Demo 默认配置:
|
||||||
- 端口:8083
|
- 端口:8081
|
||||||
- 应用名称:`kenaito-springboot2-demo`
|
- 应用名称:`kenaito-springboot-demo`
|
||||||
- 环境:`daily`
|
- 环境:`daily`
|
||||||
- 配置中心地址:`http://localhost:8080`
|
- 配置中心地址:`http://localhost:8080`
|
||||||
|
|
||||||
访问 `http://localhost:8083/config` 查看当前配置。
|
访问 `http://localhost:8081/` 或 `http://localhost:8081/configprops` 查看当前配置。
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
|
|||||||
@@ -36,28 +36,28 @@ Add config center connection parameters in `application.properties`:
|
|||||||
|
|
||||||
```properties
|
```properties
|
||||||
# Config center URL
|
# Config center URL
|
||||||
kenaito.configcenter.base-url=http://localhost:8080
|
kenaito.config.baseUrl=http://localhost:8080
|
||||||
|
|
||||||
# Application name (required)
|
# Application name (required)
|
||||||
kenaito.configcenter.app-name=my-app
|
kenaito.config.appName=my-app
|
||||||
|
|
||||||
# Environment (dev/daily/prod)
|
# Environment (dev/daily/prod)
|
||||||
kenaito.configcenter.env=daily
|
kenaito.config.profile=daily
|
||||||
|
|
||||||
# Config version
|
# Config version
|
||||||
kenaito.configcenter.version=latest
|
kenaito.config.version=latest
|
||||||
|
|
||||||
# Auth token (optional)
|
# Auth token (optional)
|
||||||
kenaito.configcenter.token=your-token
|
kenaito.config.token=your-token
|
||||||
|
|
||||||
# Whether to enable remote config (default true)
|
# Whether to enable remote config (default true)
|
||||||
kenaito.configcenter.enabled=true
|
kenaito.config.enabled=true
|
||||||
|
|
||||||
# Sync interval in seconds (default 10)
|
# Sync interval in seconds (default 60)
|
||||||
kenaito.configcenter.sync-interval=10
|
kenaito.config.sync-interval-seconds=10
|
||||||
|
|
||||||
# Sensitive config keys (for log masking, optional)
|
# Ignore business group global configs (default false)
|
||||||
kenaito.configcenter.sensitive-keys=password,secret,key,token
|
kenaito.config.global-ignore=false
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Inject Configs with @Value
|
### 2. Inject Configs with @Value
|
||||||
@@ -122,14 +122,17 @@ public class DatabaseProperties {
|
|||||||
|
|
||||||
| Property | Description | Default | Required |
|
| Property | Description | Default | Required |
|
||||||
|----------|-------------|---------|----------|
|
|----------|-------------|---------|----------|
|
||||||
| `kenaito.configcenter.base-url` | Config center URL | - | Yes |
|
| `kenaito.config.baseUrl` | Config center URL | - | Yes |
|
||||||
| `kenaito.configcenter.app-name` | Application name | - | Yes |
|
| `kenaito.config.appName` | Application name | `spring.application.name` | Yes |
|
||||||
| `kenaito.configcenter.env` | Environment | `dev` | No |
|
| `kenaito.config.profile` | Environment | `dev` | No |
|
||||||
| `kenaito.configcenter.version` | Config version | `latest` | No |
|
| `kenaito.config.version` | Config version | `latest` | No |
|
||||||
| `kenaito.configcenter.token` | Auth token | Empty | No |
|
| `kenaito.config.token` | Auth token | Empty | No |
|
||||||
| `kenaito.configcenter.enabled` | Whether to enable | `true` | No |
|
| `kenaito.config.enabled` | Whether to enable | `true` | No |
|
||||||
| `kenaito.configcenter.sync-interval` | Sync interval (seconds) | `10` | No |
|
| `kenaito.config.sync-interval-seconds` | Sync interval (seconds) | `60` | No |
|
||||||
| `kenaito.configcenter.sensitive-keys` | Sensitive keys (log masking) | Empty | 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
|
## Auto Configuration
|
||||||
|
|
||||||
@@ -150,12 +153,12 @@ mvn spring-boot:run
|
|||||||
```
|
```
|
||||||
|
|
||||||
Demo defaults:
|
Demo defaults:
|
||||||
- Port: 8083
|
- Port: 8081
|
||||||
- App name: `kenaito-springboot2-demo`
|
- App name: `kenaito-springboot-demo`
|
||||||
- Environment: `daily`
|
- Environment: `daily`
|
||||||
- Config center URL: `http://localhost:8080`
|
- 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
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/target/
|
||||||
|
.DS_Store
|
||||||
|
/.settings/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>cn.odboy</groupId>
|
||||||
|
<artifactId>kenaito-conf-springboot3-client-demo</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>kenaito-conf-springboot3-client-demo</name>
|
||||||
|
<description>Demo project for kenaito-conf config center (Spring Boot 3)</description>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.5.16</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- kenaito-conf Spring Boot 3 client -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.odboy</groupId>
|
||||||
|
<artifactId>kenaito-conf-springboot3-client</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
+44
@@ -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<Map<String, Object>> history = new ArrayDeque<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void onConfigChanged(Map<String, Object> changedConfigs) {
|
||||||
|
logger.info("[Demo] config changed: {}", changedConfigs);
|
||||||
|
history.addFirst(new LinkedHashMap<>(changedConfigs));
|
||||||
|
while (history.size() > MAX_HISTORY) {
|
||||||
|
history.removeLast();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized List<Map<String, Object>> getHistory() {
|
||||||
|
return new ArrayList<>(history);
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -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<String, Object> 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<String, Object> config() {
|
||||||
|
Map<String, Object> 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<String, Object> configprops() {
|
||||||
|
Map<String, Object> 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<Map<String, Object>> changes() {
|
||||||
|
return configChangeLogger.getHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/health")
|
||||||
|
public Map<String, String> health() {
|
||||||
|
return Map.of("status", "UP");
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/target/
|
||||||
|
.DS_Store
|
||||||
|
/.settings/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
@@ -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
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.odboy</groupId>
|
||||||
|
<artifactId>kenaito-conf-springboot3-client</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 本地安装(开发调试)
|
||||||
|
|
||||||
|
```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<String, Object> 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
|
||||||
@@ -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
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.odboy</groupId>
|
||||||
|
<artifactId>kenaito-conf-springboot3-client</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<String, Object> 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
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<groupId>cn.odboy</groupId>
|
||||||
|
<artifactId>kenaito-conf-springboot3-client</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>kenaito-conf-springboot3-client</name>
|
||||||
|
<description>Kenaito Config Center client for Spring Boot 3.x</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<spring-boot.version>3.5.16</spring-boot.version>
|
||||||
|
<fastjson2.version>2.0.60</fastjson2.version>
|
||||||
|
<httpclient.version>4.5.14</httpclient.version>
|
||||||
|
<commons-lang3.version>3.17.0</commons-lang3.version>
|
||||||
|
<slf4j.version>2.0.16</slf4j.version>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.alibaba.fastjson2</groupId>
|
||||||
|
<artifactId>fastjson2</artifactId>
|
||||||
|
<version>${fastjson2.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.httpcomponents</groupId>
|
||||||
|
<artifactId>httpclient</artifactId>
|
||||||
|
<version>${httpclient.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-lang3</artifactId>
|
||||||
|
<version>${commons-lang3.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
<version>${slf4j.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot is provided by the host application -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot</artifactId>
|
||||||
|
<version>${spring-boot.version}</version>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||||
|
<version>${spring-boot.version}</version>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.13.0</version>
|
||||||
|
<configuration>
|
||||||
|
<release>${java.version}</release>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package cn.odboy.confcenter;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for remote config changes.
|
||||||
|
*
|
||||||
|
* <p>Implement this interface as a Spring bean to be notified whenever the config
|
||||||
|
* center pushes a set of changed keys.</p>
|
||||||
|
*
|
||||||
|
* @author odboy
|
||||||
|
* @version 3.0.0
|
||||||
|
* @date 2026-10-21
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ConfigChangeHanlder {
|
||||||
|
|
||||||
|
void onConfigChanged(Map<String, Object> changedConfigs);
|
||||||
|
}
|
||||||
+679
@@ -0,0 +1,679 @@
|
|||||||
|
package cn.odboy.confcenter;
|
||||||
|
|
||||||
|
import java.io.FileReader;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Map.Entry;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.commons.lang3.Validate;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
|
import org.springframework.core.env.ConfigurableEnvironment;
|
||||||
|
import org.springframework.core.env.MapPropertySource;
|
||||||
|
import org.springframework.core.env.MutablePropertySources;
|
||||||
|
import org.springframework.core.env.PropertiesPropertySource;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config center context.
|
||||||
|
*
|
||||||
|
* <p>Holds the client configuration, the remote configuration snapshot and the
|
||||||
|
* Spring environment wiring used to refresh {@code @Value} and
|
||||||
|
* {@code @ConfigurationProperties} fields at runtime.</p>
|
||||||
|
*
|
||||||
|
* @author odboy
|
||||||
|
* @version 3.0.0
|
||||||
|
* @date 2026-10-21
|
||||||
|
*/
|
||||||
|
public class ConfigcenterContext {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger("cn.odboy");
|
||||||
|
|
||||||
|
private static final ConfigcenterContext INSTANCE = new ConfigcenterContext();
|
||||||
|
|
||||||
|
public static final String MANAGER_PROPERTY_SOURCE = "kenaitoConfigPropertySource";
|
||||||
|
|
||||||
|
private static final String IGNORE_PLACEHOLDER = "[Ignore]";
|
||||||
|
|
||||||
|
private static final String LATEST_FETCH_TIMESTAMP = "latest.fetch.timestamp";
|
||||||
|
|
||||||
|
private static final List<String> DEFAULT_SENSITIVE_KEYS = Arrays.asList(
|
||||||
|
"password", "passwd", "key", "secret", "token", "credentials");
|
||||||
|
|
||||||
|
/** Properties visible to the client (application config + remote config). */
|
||||||
|
private static final Map<String, String> localProps = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private static volatile ApplicationContext applicationContext;
|
||||||
|
|
||||||
|
private static volatile RefreshScopeRegistry refreshScopeRegistry;
|
||||||
|
|
||||||
|
private final String nodeId;
|
||||||
|
|
||||||
|
private Boolean remoteEnabled;
|
||||||
|
|
||||||
|
private String[] apiBaseUrls;
|
||||||
|
|
||||||
|
private String app;
|
||||||
|
|
||||||
|
private String env;
|
||||||
|
|
||||||
|
private String version;
|
||||||
|
|
||||||
|
private boolean ignoreGlobal;
|
||||||
|
|
||||||
|
private String secret;
|
||||||
|
|
||||||
|
private String globalSecret;
|
||||||
|
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
private boolean remoteFirst;
|
||||||
|
|
||||||
|
private boolean isSpringboot;
|
||||||
|
|
||||||
|
private int syncIntervalSeconds = 60;
|
||||||
|
|
||||||
|
private List<String> sensitiveKeys = DEFAULT_SENSITIVE_KEYS;
|
||||||
|
|
||||||
|
private InternalConfigChangeListener configChangeListener;
|
||||||
|
|
||||||
|
private List<ConfigChangeHanlder> configChangeHanlders;
|
||||||
|
|
||||||
|
private boolean processed;
|
||||||
|
|
||||||
|
private volatile Properties remoteProperties;
|
||||||
|
|
||||||
|
private volatile long lastFetchTimestamp;
|
||||||
|
|
||||||
|
private ConfigcenterContext() {
|
||||||
|
String name;
|
||||||
|
try {
|
||||||
|
name = InetAddress.getLocalHost().getHostName();
|
||||||
|
} catch (Exception e) {
|
||||||
|
name = "unknown";
|
||||||
|
}
|
||||||
|
this.nodeId = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ConfigcenterContext getInstance() {
|
||||||
|
return INSTANCE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setApplicationContext(ApplicationContext ctx) {
|
||||||
|
applicationContext = ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApplicationContext getApplicationContext() {
|
||||||
|
return applicationContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void setRefreshScopeRegistry(RefreshScopeRegistry registry) {
|
||||||
|
refreshScopeRegistry = registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RefreshScopeRegistry getRefreshScopeRegistry() {
|
||||||
|
return refreshScopeRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Property Utilities ====================
|
||||||
|
|
||||||
|
public static void mergeProperties(Properties props) {
|
||||||
|
if (props != null) {
|
||||||
|
for (Entry<Object, Object> entry : props.entrySet()) {
|
||||||
|
localProps.put(entry.getKey().toString(), entry.getValue().toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void addProperty(String key, String value) {
|
||||||
|
localProps.put(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getProperty(String key) {
|
||||||
|
String val = localProps.get(key);
|
||||||
|
if (val == null) {
|
||||||
|
val = System.getProperty(key);
|
||||||
|
}
|
||||||
|
if (val == null) {
|
||||||
|
val = System.getenv(key);
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getProperty(String key, String defaultValue) {
|
||||||
|
String val = getProperty(key);
|
||||||
|
return val != null && !val.isEmpty() ? val : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean getBoolean(String key, boolean defaultValue) {
|
||||||
|
String val = getProperty(key);
|
||||||
|
if (val == null || val.isEmpty()) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return Boolean.parseBoolean(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int getInt(String key, int defaultValue) {
|
||||||
|
String val = getProperty(key);
|
||||||
|
if (val == null || val.isEmpty()) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(val.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getAnyProperty(String... keys) {
|
||||||
|
for (String key : keys) {
|
||||||
|
String val = getProperty(key);
|
||||||
|
if (val != null && !val.isEmpty()) {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String firstNonBlank(String... values) {
|
||||||
|
for (String value : values) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean getBooleanAny(boolean defaultValue, String... keys) {
|
||||||
|
String val = getAnyProperty(keys);
|
||||||
|
return val == null ? defaultValue : Boolean.parseBoolean(val.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
static int getIntAny(int defaultValue, String... keys) {
|
||||||
|
String val = getAnyProperty(keys);
|
||||||
|
if (val == null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(val.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== HTTP Utilities ====================
|
||||||
|
|
||||||
|
public static String httpGet(String url) throws Exception {
|
||||||
|
return HttpClientUtils.get(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String httpPostJson(String url, String json) throws Exception {
|
||||||
|
return HttpClientUtils.postJson(url, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Core Methods ====================
|
||||||
|
|
||||||
|
public boolean isProcessed() {
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void init(Properties properties, boolean isSpringboot) {
|
||||||
|
if (processed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
processed = true;
|
||||||
|
if (properties != null) {
|
||||||
|
mergeProperties(properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isSpringboot = isSpringboot;
|
||||||
|
System.setProperty("client.nodeId", nodeId);
|
||||||
|
System.setProperty("springboot", String.valueOf(isSpringboot));
|
||||||
|
|
||||||
|
if (remoteEnabled == null) {
|
||||||
|
remoteEnabled = getBooleanAny(true,
|
||||||
|
"kenaito.config.enabled", "kenaito.configcenter.enabled");
|
||||||
|
}
|
||||||
|
if (!isRemoteEnabled()) {
|
||||||
|
logger.info("[ConfigClient] remote config is disabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app = firstNonBlank(
|
||||||
|
getProperty("kenaito.config.app-name"),
|
||||||
|
getProperty("kenaito.config.appName"),
|
||||||
|
getProperty("kenaito.configcenter.app-name"),
|
||||||
|
getProperty("kenaito.configcenter.appName"),
|
||||||
|
getProperty("spring.application.name"));
|
||||||
|
Validate.notBlank(app, "[kenaito.config.app-name] is required");
|
||||||
|
|
||||||
|
env = firstNonBlank(
|
||||||
|
getProperty("kenaito.config.profile"),
|
||||||
|
getProperty("kenaito.config.env"),
|
||||||
|
getProperty("kenaito.configcenter.profile"),
|
||||||
|
getProperty("kenaito.configcenter.env"),
|
||||||
|
"dev");
|
||||||
|
Validate.notBlank(env, "[kenaito.config.profile] is required");
|
||||||
|
|
||||||
|
String baseUrl = firstNonBlank(
|
||||||
|
getProperty("kenaito.config.base-url"),
|
||||||
|
getProperty("kenaito.config.baseUrl"),
|
||||||
|
getProperty("kenaito.configcenter.base-url"),
|
||||||
|
getProperty("kenaito.configcenter.baseUrl"));
|
||||||
|
setApiBaseUrl(baseUrl);
|
||||||
|
|
||||||
|
version = firstNonBlank(
|
||||||
|
getProperty("kenaito.config.version"),
|
||||||
|
getProperty("kenaito.configcenter.version"),
|
||||||
|
"latest");
|
||||||
|
ignoreGlobal = getBooleanAny(false,
|
||||||
|
"kenaito.config.global-ignore", "kenaito.config.ignore-global",
|
||||||
|
"kenaito.configcenter.global-ignore");
|
||||||
|
syncIntervalSeconds = getIntAny(60,
|
||||||
|
"kenaito.config.sync-interval-seconds", "kenaito.config.sync-interval",
|
||||||
|
"kenaito.configcenter.sync-interval-seconds", "kenaito.configcenter.sync-interval");
|
||||||
|
token = firstNonBlank(
|
||||||
|
getProperty("kenaito.config.token"),
|
||||||
|
getProperty("kenaito.configcenter.token"));
|
||||||
|
String configuredSensitiveKeys = getAnyProperty(
|
||||||
|
"kenaito.config.sensitive-keys", "kenaito.configcenter.sensitive-keys");
|
||||||
|
if (StringUtils.isNotBlank(configuredSensitiveKeys)) {
|
||||||
|
List<String> keys = new ArrayList<>();
|
||||||
|
for (String key : configuredSensitiveKeys.split(",")) {
|
||||||
|
if (StringUtils.isNotBlank(key)) {
|
||||||
|
keys.add(key.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!keys.isEmpty()) {
|
||||||
|
sensitiveKeys = Collections.unmodifiableList(keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("\n=====Configcenter config=====\nappName:{}\nenv:{}\nversion:{}\nremoteEnabled:{}\napiBaseUrls:{}\nsyncIntervalSeconds:{}\n=====Configcenter config=====",
|
||||||
|
app, env, version, isRemoteEnabled(), JSON.toJSONString(apiBaseUrls), syncIntervalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isRemoteEnabled() {
|
||||||
|
return remoteEnabled == null || remoteEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRemoteEnabled(boolean remoteEnabled) {
|
||||||
|
this.remoteEnabled = remoteEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String[] getApiBaseUrls() {
|
||||||
|
return apiBaseUrls == null ? new String[0] : apiBaseUrls.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setApiBaseUrl(String apiBaseUrl) {
|
||||||
|
Validate.notBlank(apiBaseUrl, "[kenaito.config.base-url] is required");
|
||||||
|
String[] urls = apiBaseUrl.split("[,;]");
|
||||||
|
this.apiBaseUrls = new String[urls.length];
|
||||||
|
for (int i = 0; i < urls.length; i++) {
|
||||||
|
String url = urls[i].trim();
|
||||||
|
if (url.endsWith("/")) {
|
||||||
|
url = url.substring(0, url.length() - 1);
|
||||||
|
}
|
||||||
|
this.apiBaseUrls[i] = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getApp() {
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEnv() {
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getVersion() {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isIgnoreGlobal() {
|
||||||
|
return ignoreGlobal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isRemoteFirst() {
|
||||||
|
return remoteFirst;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSpringboot() {
|
||||||
|
return isSpringboot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getSyncIntervalSeconds() {
|
||||||
|
return syncIntervalSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getLastFetchTimestamp() {
|
||||||
|
return lastFetchTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastFetchTimestamp(long lastFetchTimestamp) {
|
||||||
|
this.lastFetchTimestamp = lastFetchTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNodeId() {
|
||||||
|
return nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the remote configuration and injects it into the supplied properties.
|
||||||
|
* Remote fetch failures are logged and never prevent the application from starting.
|
||||||
|
*/
|
||||||
|
public void mergeRemoteProperties(Properties properties) {
|
||||||
|
if (!isRemoteEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Properties fetched = getAllRemoteProperties();
|
||||||
|
if (fetched != null) {
|
||||||
|
for (Entry<Object, Object> entry : fetched.entrySet()) {
|
||||||
|
String key = entry.getKey().toString();
|
||||||
|
if (!isRemoteFirst() && properties.containsKey(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String value = Objects.toString(entry.getValue(), "");
|
||||||
|
if (IGNORE_PLACEHOLDER.equals(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
properties.setProperty(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Entry<Object, Object> entry : properties.entrySet()) {
|
||||||
|
addProperty(entry.getKey().toString(), entry.getValue().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional local override file, useful for local debugging.
|
||||||
|
String path = System.getProperty("localtest.config.location");
|
||||||
|
if (StringUtils.isNotBlank(path)) {
|
||||||
|
try (FileReader fileReader = new FileReader(path)) {
|
||||||
|
Properties localProperties = new Properties();
|
||||||
|
localProperties.load(fileReader);
|
||||||
|
if (!localProperties.isEmpty()) {
|
||||||
|
properties.putAll(localProperties);
|
||||||
|
mergeProperties(localProperties);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("[ConfigClient] failed to load local test config from {}: {}", path, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configChangeListener != null) {
|
||||||
|
configChangeListener.close();
|
||||||
|
}
|
||||||
|
configChangeListener = new InternalConfigChangeListener();
|
||||||
|
printConfigs(properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Properties getAllRemoteProperties() {
|
||||||
|
if (remoteProperties != null) {
|
||||||
|
return remoteProperties;
|
||||||
|
}
|
||||||
|
Map<String, Object> map = fetchConfigFromServer();
|
||||||
|
if (map == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
secret = Objects.toString(map.remove("kenaito.config.encrypt-secret"), null);
|
||||||
|
globalSecret = Objects.toString(map.remove("kenaito.config.global-encrypt-secret"), null);
|
||||||
|
remoteFirst = Boolean.parseBoolean(Objects.toString(map.remove("kenaito.config.remote-config-first"), "false"));
|
||||||
|
Object timestamp = map.remove(LATEST_FETCH_TIMESTAMP);
|
||||||
|
if (timestamp != null) {
|
||||||
|
try {
|
||||||
|
lastFetchTimestamp = Long.parseLong(timestamp.toString());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
logger.warn("[ConfigClient] invalid {}: {}", LATEST_FETCH_TIMESTAMP, timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Properties properties = new Properties();
|
||||||
|
for (Entry<String, Object> entry : map.entrySet()) {
|
||||||
|
if (entry.getValue() != null) {
|
||||||
|
properties.setProperty(entry.getKey(), entry.getValue().toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remoteProperties = properties;
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> fetchConfigFromServer() {
|
||||||
|
if (apiBaseUrls == null || apiBaseUrls.length == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Object> result = null;
|
||||||
|
for (String apiBaseUrl : apiBaseUrls) {
|
||||||
|
String url = apiBaseUrl + "/api/fetchAllConfigs";
|
||||||
|
Map<String, Object> params = new java.util.HashMap<>();
|
||||||
|
params.put("appName", app);
|
||||||
|
params.put("env", env);
|
||||||
|
params.put("version", version);
|
||||||
|
params.put("ignoreGlobal", ignoreGlobal);
|
||||||
|
if (StringUtils.isNotBlank(token)) {
|
||||||
|
params.put("token", token);
|
||||||
|
}
|
||||||
|
logger.info("[ConfigClient] fetching configs from {}", url);
|
||||||
|
try {
|
||||||
|
String jsonString = httpPostJson(url, JSON.toJSONString(params));
|
||||||
|
if (jsonString == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Map<String, Object> parsed = JSON.parseObject(jsonString, Map.class);
|
||||||
|
// Error responses use {"code": <non-zero number>, "msg": "..."} while
|
||||||
|
// successful responses are a plain key/value map (which may legitimately
|
||||||
|
// contain a string "code" entry).
|
||||||
|
Object code = parsed.get("code");
|
||||||
|
if (code instanceof Number codeNumber && codeNumber.intValue() != 0
|
||||||
|
&& parsed.get("msg") instanceof String) {
|
||||||
|
logger.error("[ConfigClient] fetch config error from {}: {} ({})",
|
||||||
|
apiBaseUrl, parsed.get("msg"), code);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result = parsed;
|
||||||
|
break;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("[ConfigClient] fetch config error from {}: {}", apiBaseUrl, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
logger.warn("[ConfigClient] remote config fetch failed, loading from local cache");
|
||||||
|
result = LocalCacheUtils.read();
|
||||||
|
} else {
|
||||||
|
LocalCacheUtils.write(result);
|
||||||
|
logger.info("[ConfigClient] fetched {} configs", result.size());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void printConfigs(Properties properties) {
|
||||||
|
List<String> sortKeys = new ArrayList<>(properties.stringPropertyNames());
|
||||||
|
Collections.sort(sortKeys);
|
||||||
|
StringBuilder output = new StringBuilder("\n==================final config list start==================\n");
|
||||||
|
for (String key : sortKeys) {
|
||||||
|
output.append(key).append(" = ").append(hideSensitive(key, properties.getProperty(key))).append('\n');
|
||||||
|
}
|
||||||
|
output.append("==================final config list end====================");
|
||||||
|
logger.info(output.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void updateConfig(Map<String, Object> updateConfig) {
|
||||||
|
if (updateConfig == null || updateConfig.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (Entry<String, Object> entry : updateConfig.entrySet()) {
|
||||||
|
String key = entry.getKey();
|
||||||
|
String newValue = Objects.toString(entry.getValue(), "");
|
||||||
|
String oldValue = getProperty(key);
|
||||||
|
addProperty(key, newValue);
|
||||||
|
if (remoteProperties != null) {
|
||||||
|
remoteProperties.setProperty(key, newValue);
|
||||||
|
}
|
||||||
|
logger.info("[ConfigClient] config [{}] changed, oldValue:{}, newValue:{}",
|
||||||
|
key, oldValue, entry.getValue());
|
||||||
|
}
|
||||||
|
persistRemoteCache();
|
||||||
|
|
||||||
|
updateSpringPropertySource(updateConfig, Collections.emptySet());
|
||||||
|
|
||||||
|
if (configChangeHanlders == null) {
|
||||||
|
configChangeHanlders = new ArrayList<>();
|
||||||
|
if (applicationContext != null) {
|
||||||
|
Map<String, ConfigChangeHanlder> handlers = applicationContext.getBeansOfType(ConfigChangeHanlder.class);
|
||||||
|
configChangeHanlders.addAll(handlers.values());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (ConfigChangeHanlder handler : configChangeHanlders) {
|
||||||
|
try {
|
||||||
|
handler.onConfigChanged(updateConfig);
|
||||||
|
logger.info("[ConfigClient] invoked {}.onConfigChanged", handler.getClass().getName());
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("[ConfigClient] invoke {}.onConfigChanged error: {}", handler.getClass().getName(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshDynamicConfigFields(new HashSet<>(updateConfig.keySet()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void removeConfigs(List<String> keys) {
|
||||||
|
if (keys == null || keys.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (String key : keys) {
|
||||||
|
localProps.remove(key);
|
||||||
|
if (remoteProperties != null) {
|
||||||
|
remoteProperties.remove(key);
|
||||||
|
}
|
||||||
|
logger.info("[ConfigClient] config [{}] removed", key);
|
||||||
|
}
|
||||||
|
persistRemoteCache();
|
||||||
|
updateSpringPropertySource(Collections.emptyMap(), new HashSet<>(keys));
|
||||||
|
refreshDynamicConfigFields(new HashSet<>(keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persistRemoteCache() {
|
||||||
|
Properties snapshotSource = remoteProperties;
|
||||||
|
if (snapshotSource == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Object> snapshot = new LinkedHashMap<>();
|
||||||
|
for (String name : snapshotSource.stringPropertyNames()) {
|
||||||
|
snapshot.put(name, snapshotSource.getProperty(name));
|
||||||
|
}
|
||||||
|
if (lastFetchTimestamp > 0) {
|
||||||
|
snapshot.put(LATEST_FETCH_TIMESTAMP, String.valueOf(lastFetchTimestamp));
|
||||||
|
}
|
||||||
|
LocalCacheUtils.write(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateSpringPropertySource(Map<String, Object> updates, Set<String> removals) {
|
||||||
|
if (applicationContext == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(applicationContext.getEnvironment() instanceof ConfigurableEnvironment environment)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MutablePropertySources propertySources = environment.getPropertySources();
|
||||||
|
synchronized (propertySources) {
|
||||||
|
Properties properties = new Properties();
|
||||||
|
if (propertySources.contains(MANAGER_PROPERTY_SOURCE)) {
|
||||||
|
Object source = propertySources.get(MANAGER_PROPERTY_SOURCE);
|
||||||
|
if (source instanceof MapPropertySource mapPropertySource) {
|
||||||
|
mapPropertySource.getSource().forEach((k, v) -> properties.put(k, Objects.toString(v, "")));
|
||||||
|
} else if (source instanceof PropertiesPropertySource propertiesPropertySource) {
|
||||||
|
properties.putAll(propertiesPropertySource.getSource());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String key : removals) {
|
||||||
|
properties.remove(key);
|
||||||
|
}
|
||||||
|
updates.forEach((k, v) -> properties.put(k, Objects.toString(v, "")));
|
||||||
|
PropertiesPropertySource propertySource =
|
||||||
|
new PropertiesPropertySource(MANAGER_PROPERTY_SOURCE, properties);
|
||||||
|
if (propertySources.contains(MANAGER_PROPERTY_SOURCE)) {
|
||||||
|
propertySources.replace(MANAGER_PROPERTY_SOURCE, propertySource);
|
||||||
|
} else {
|
||||||
|
propertySources.addFirst(propertySource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshDynamicConfigFields(Set<String> changedKeys) {
|
||||||
|
if (applicationContext == null) {
|
||||||
|
logger.warn("[RefreshScope] ApplicationContext is null, cannot refresh dynamic config fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
RefreshScopeRegistry registry = refreshScopeRegistry;
|
||||||
|
if (registry == null) {
|
||||||
|
// Fallback for environments where the auto-configuration is not active
|
||||||
|
// (for example plain Spring applications).
|
||||||
|
registry = new RefreshScopeRegistry(applicationContext.getEnvironment());
|
||||||
|
for (String beanName : applicationContext.getBeanDefinitionNames()) {
|
||||||
|
try {
|
||||||
|
registry.postProcessAfterInitialization(applicationContext.getBean(beanName), beanName);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// ignore beans that cannot be resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshScopeRegistry = registry;
|
||||||
|
}
|
||||||
|
int refreshed = registry.refresh(changedKeys);
|
||||||
|
if (refreshed > 0) {
|
||||||
|
logger.info("[RefreshScope] refreshed {} @Value field(s) for keys: {}", refreshed, changedKeys);
|
||||||
|
} else {
|
||||||
|
logger.info("[RefreshScope] no registered @Value field references keys {}, "
|
||||||
|
+ "their values are already updated in the Spring Environment", changedKeys);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("[RefreshScope] refresh dynamic config fields error: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String buildTokenParameter(String url) {
|
||||||
|
if (StringUtils.isBlank(token)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return url + (url.contains("?") ? "&" : "?") + "token=" + token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void close() {
|
||||||
|
if (configChangeListener != null) {
|
||||||
|
configChangeListener.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String hideSensitive(String key, String origin) {
|
||||||
|
if (StringUtils.isAnyBlank(key, origin)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String lowerKey = key.toLowerCase();
|
||||||
|
boolean sensitive = false;
|
||||||
|
for (String k : sensitiveKeys) {
|
||||||
|
if (lowerKey.contains(k)) {
|
||||||
|
sensitive = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int length = origin.length();
|
||||||
|
if (sensitive && length > 1) {
|
||||||
|
return origin.substring(0, length / 2) + "****";
|
||||||
|
}
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
package cn.odboy.confcenter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import org.apache.http.HttpResponse;
|
||||||
|
import org.apache.http.client.config.RequestConfig;
|
||||||
|
import org.apache.http.client.methods.HttpGet;
|
||||||
|
import org.apache.http.client.methods.HttpPost;
|
||||||
|
import org.apache.http.entity.StringEntity;
|
||||||
|
import org.apache.http.impl.client.CloseableHttpClient;
|
||||||
|
import org.apache.http.impl.client.HttpClients;
|
||||||
|
import org.apache.http.util.EntityUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP client utilities based on Apache HttpClient 4.x.
|
||||||
|
*
|
||||||
|
* @author odboy
|
||||||
|
* @version 3.0.0
|
||||||
|
* @date 2026-10-21
|
||||||
|
*/
|
||||||
|
public class HttpClientUtils {
|
||||||
|
|
||||||
|
private static final CloseableHttpClient HTTP_CLIENT;
|
||||||
|
|
||||||
|
static {
|
||||||
|
RequestConfig config = RequestConfig.custom()
|
||||||
|
.setConnectTimeout(10_000)
|
||||||
|
.setSocketTimeout(30_000)
|
||||||
|
.setConnectionRequestTimeout(5_000)
|
||||||
|
.build();
|
||||||
|
HTTP_CLIENT = HttpClients.custom()
|
||||||
|
.setDefaultRequestConfig(config)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClientUtils() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String get(String url) throws IOException {
|
||||||
|
HttpGet request = new HttpGet(url);
|
||||||
|
return execute(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String postJson(String url, String json) throws IOException {
|
||||||
|
HttpPost request = new HttpPost(url);
|
||||||
|
request.setHeader("Content-Type", "application/json;charset=UTF-8");
|
||||||
|
request.setEntity(new StringEntity(json, "UTF-8"));
|
||||||
|
return execute(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String execute(org.apache.http.client.methods.HttpRequestBase request) throws IOException {
|
||||||
|
HttpResponse response = HTTP_CLIENT.execute(request);
|
||||||
|
int statusCode = response.getStatusLine().getStatusCode();
|
||||||
|
String body = response.getEntity() == null ? null : EntityUtils.toString(response.getEntity(), "UTF-8");
|
||||||
|
if (statusCode >= 200 && statusCode < 300) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
package cn.odboy.confcenter;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal config change listener.
|
||||||
|
*
|
||||||
|
* <p>Polls {@code /api/fetchChangedConfigs} at a fixed interval and applies
|
||||||
|
* incremental changes (including deleted keys) to the config center context.</p>
|
||||||
|
*
|
||||||
|
* @author odboy
|
||||||
|
* @version 3.0.0
|
||||||
|
* @date 2026-10-21
|
||||||
|
*/
|
||||||
|
public class InternalConfigChangeListener {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger("cn.odboy.confcenter");
|
||||||
|
|
||||||
|
private static final String LATEST_FETCH_TIMESTAMP = "latest.fetch.timestamp";
|
||||||
|
|
||||||
|
private final ScheduledExecutorService scheduler;
|
||||||
|
|
||||||
|
private volatile boolean closed;
|
||||||
|
|
||||||
|
private volatile long lastTime;
|
||||||
|
|
||||||
|
public InternalConfigChangeListener() {
|
||||||
|
ConfigcenterContext context = ConfigcenterContext.getInstance();
|
||||||
|
this.scheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||||
|
Thread thread = new Thread(runnable, "kenaito-config-listener");
|
||||||
|
thread.setDaemon(true);
|
||||||
|
return thread;
|
||||||
|
});
|
||||||
|
registerHttpListener(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void close() {
|
||||||
|
closed = true;
|
||||||
|
scheduler.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerHttpListener(ConfigcenterContext context) {
|
||||||
|
final String[] baseUrls = context.getApiBaseUrls();
|
||||||
|
if (baseUrls.length == 0) {
|
||||||
|
logger.warn("[ConfigClient] no config center base url configured, skip change listener");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long initialLastTime = context.getLastFetchTimestamp();
|
||||||
|
if (initialLastTime <= 0) {
|
||||||
|
initialLastTime = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
this.lastTime = initialLastTime;
|
||||||
|
final int interval = Math.max(1, context.getSyncIntervalSeconds());
|
||||||
|
|
||||||
|
scheduler.scheduleWithFixedDelay(() -> {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
poll(context, baseUrls);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("[ConfigClient] error fetching config changes: {}", e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}, interval, interval, TimeUnit.SECONDS);
|
||||||
|
|
||||||
|
logger.info("[ConfigClient] change listener registered, intervalSeconds:{}, lastFetchTime:{}",
|
||||||
|
interval, initialLastTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void poll(ConfigcenterContext context, String[] baseUrls) {
|
||||||
|
for (String baseUrl : baseUrls) {
|
||||||
|
String url = context.buildTokenParameter(baseUrl + "/api/fetchChangedConfigs");
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("version", context.getVersion());
|
||||||
|
params.put("appName", context.getApp());
|
||||||
|
params.put("env", context.getEnv());
|
||||||
|
params.put("ignoreGlobal", context.isIgnoreGlobal());
|
||||||
|
params.put("lastTime", lastTime);
|
||||||
|
|
||||||
|
String response;
|
||||||
|
try {
|
||||||
|
response = ConfigcenterContext.httpPostJson(url, JSON.toJSONString(params));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("[ConfigClient] fetch changed configs error from {}: {}", baseUrl, e.getMessage());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (response == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> map = JSON.parseObject(response, Map.class);
|
||||||
|
if (map == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Object code = map.get("code");
|
||||||
|
// The server returns {"code": 0, "data": {...}} on success and a
|
||||||
|
// non-zero code on error. Only non-zero codes are failures.
|
||||||
|
if (code instanceof Number codeNumber && codeNumber.intValue() != 0) {
|
||||||
|
logger.error("[ConfigClient] fetch changed configs error from {}: {} ({})",
|
||||||
|
baseUrl, map.get("msg"), code);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object dataObject = map.get("data");
|
||||||
|
if (!(dataObject instanceof Map)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new HashMap<>((Map<String, Object>) dataObject);
|
||||||
|
|
||||||
|
Object timestamp = data.remove(LATEST_FETCH_TIMESTAMP);
|
||||||
|
if (timestamp != null) {
|
||||||
|
try {
|
||||||
|
long parsedTimestamp = Long.parseLong(timestamp.toString());
|
||||||
|
context.setLastFetchTimestamp(parsedTimestamp);
|
||||||
|
this.lastTime = parsedTimestamp;
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
logger.warn("[ConfigClient] invalid {}: {}", LATEST_FETCH_TIMESTAMP, timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object deleted = data.remove("deletedKeys");
|
||||||
|
if (deleted != null) {
|
||||||
|
String deletedKeys = deleted.toString();
|
||||||
|
if (!deletedKeys.isEmpty()) {
|
||||||
|
List<String> keys = new ArrayList<>();
|
||||||
|
for (String key : deletedKeys.split(",")) {
|
||||||
|
if (!key.isBlank()) {
|
||||||
|
keys.add(key.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!keys.isEmpty()) {
|
||||||
|
context.removeConfigs(keys);
|
||||||
|
logger.info("[ConfigClient] {} configs deleted: {}", keys.size(), keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.isEmpty()) {
|
||||||
|
context.updateConfig(data);
|
||||||
|
logger.info("[ConfigClient] {} configs changed: {}", data.size(), data.keySet());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
package cn.odboy.confcenter;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileWriter;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local disaster-recovery cache.
|
||||||
|
*
|
||||||
|
* <p>The last successfully fetched remote configuration is written to
|
||||||
|
* {@code config-cache.log} so that the application can still start when the
|
||||||
|
* config center is temporarily unavailable.</p>
|
||||||
|
*
|
||||||
|
* @author odboy
|
||||||
|
* @version 3.0.0
|
||||||
|
* @date 2026-10-21
|
||||||
|
*/
|
||||||
|
public class LocalCacheUtils {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(LocalCacheUtils.class);
|
||||||
|
|
||||||
|
private static final String CACHE_FILE_NAME = "config-cache.log";
|
||||||
|
|
||||||
|
private LocalCacheUtils() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getStorageDir() {
|
||||||
|
String dir = ConfigcenterContext.getAnyProperty(
|
||||||
|
"kenaito.config.local-storage-dir",
|
||||||
|
"kenaito.configcenter.local-storage-dir");
|
||||||
|
return dir != null ? dir : System.getProperty("user.dir");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void write(Map<String, Object> datas) {
|
||||||
|
File dir = new File(getStorageDir());
|
||||||
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
|
logger.warn("[ConfigClient] cannot create local cache directory: {}", dir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
File file = new File(dir, CACHE_FILE_NAME);
|
||||||
|
try (FileWriter fileWriter = new FileWriter(file, StandardCharsets.UTF_8, false)) {
|
||||||
|
fileWriter.write(JSON.toJSONString(datas));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("[ConfigClient] write local cache error: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public static Map<String, Object> read() {
|
||||||
|
File dir = new File(getStorageDir());
|
||||||
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
File file = new File(dir, CACHE_FILE_NAME);
|
||||||
|
if (!file.exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try (BufferedReader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
|
||||||
|
StringBuilder buffer = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
buffer.append(line);
|
||||||
|
}
|
||||||
|
return JSON.parseObject(buffer.toString(), Map.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("[ConfigClient] read local cache error: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+282
@@ -0,0 +1,282 @@
|
|||||||
|
package cn.odboy.confcenter;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Modifier;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.BeansException;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.core.annotation.AnnotatedElementUtils;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
import org.springframework.util.ClassUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh scope registry.
|
||||||
|
*
|
||||||
|
* <p>Collects {@code @Value} fields and {@code @ConfigurationProperties} beans while
|
||||||
|
* the application context starts, then refreshes only the fields affected by a
|
||||||
|
* config change. This is registered as a static bean by
|
||||||
|
* {@link cn.odboy.confcenter.springboot.KenaitoRefreshAutoConfiguration}.</p>
|
||||||
|
*
|
||||||
|
* @author odboy
|
||||||
|
* @version 3.0.0
|
||||||
|
* @date 2026-10-21
|
||||||
|
*/
|
||||||
|
public class RefreshScopeRegistry implements BeanPostProcessor {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(RefreshScopeRegistry.class);
|
||||||
|
|
||||||
|
private final Environment environment;
|
||||||
|
|
||||||
|
private final Map<String, ValueFieldHolder> valueFields = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
private final Map<String, ConfigPropsHolder> configPropsBeans = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
public RefreshScopeRegistry(Environment environment) {
|
||||||
|
this.environment = environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getValueFieldCount() {
|
||||||
|
return valueFields.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getConfigPropsCount() {
|
||||||
|
return configPropsBeans.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
|
||||||
|
if (bean == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Class<?> clazz = ClassUtils.getUserClass(bean);
|
||||||
|
|
||||||
|
for (Field field : getAllDeclaredFields(clazz)) {
|
||||||
|
if (Modifier.isStatic(field.getModifiers())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Value valueAnnotation = field.getAnnotation(Value.class);
|
||||||
|
if (valueAnnotation == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = parseKey(valueAnnotation.value());
|
||||||
|
if (key == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
valueFields.putIfAbsent(beanName + "#" + field.getName(), new ValueFieldHolder(bean, field, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigurationProperties annotation =
|
||||||
|
AnnotatedElementUtils.findMergedAnnotation(clazz, ConfigurationProperties.class);
|
||||||
|
if (annotation != null) {
|
||||||
|
String prefix = firstNonBlank(annotation.prefix(), annotation.value());
|
||||||
|
if (prefix != null) {
|
||||||
|
configPropsBeans.putIfAbsent(beanName, new ConfigPropsHolder(bean, prefix));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the fields affected by the supplied keys.
|
||||||
|
*
|
||||||
|
* @return number of refreshed {@code @Value} fields
|
||||||
|
*/
|
||||||
|
public synchronized int refresh(Set<String> changedKeys) {
|
||||||
|
int count = 0;
|
||||||
|
for (ValueFieldHolder holder : valueFields.values()) {
|
||||||
|
if (changedKeys.contains(holder.key)) {
|
||||||
|
if (holder.refresh(environment)) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (ConfigPropsHolder holder : configPropsBeans.values()) {
|
||||||
|
try {
|
||||||
|
holder.refresh(environment, changedKeys);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("[RefreshScope] refresh @ConfigurationProperties bean {} error: {}",
|
||||||
|
holder.bean.getClass().getName(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String parseKey(String expression) {
|
||||||
|
if (expression == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int start = expression.indexOf("${");
|
||||||
|
if (start < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
int end = expression.indexOf('}', start);
|
||||||
|
if (end < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String key = expression.substring(start + 2, end);
|
||||||
|
int colon = key.indexOf(':');
|
||||||
|
if (colon >= 0) {
|
||||||
|
key = key.substring(0, colon);
|
||||||
|
}
|
||||||
|
key = key.trim();
|
||||||
|
return key.isEmpty() ? null : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstNonBlank(String... values) {
|
||||||
|
for (String value : values) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Field> getAllDeclaredFields(Class<?> clazz) {
|
||||||
|
List<Field> fields = new ArrayList<>();
|
||||||
|
Class<?> current = clazz;
|
||||||
|
while (current != null && current != Object.class) {
|
||||||
|
for (Field field : current.getDeclaredFields()) {
|
||||||
|
fields.add(field);
|
||||||
|
}
|
||||||
|
current = current.getSuperclass();
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String camelToKebab(String camelCase) {
|
||||||
|
StringBuilder result = new StringBuilder();
|
||||||
|
for (int i = 0; i < camelCase.length(); i++) {
|
||||||
|
char c = camelCase.charAt(i);
|
||||||
|
if (Character.isUpperCase(c)) {
|
||||||
|
if (i > 0) {
|
||||||
|
result.append('-');
|
||||||
|
}
|
||||||
|
result.append(Character.toLowerCase(c));
|
||||||
|
} else {
|
||||||
|
result.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object convertType(Class<?> type, String value) {
|
||||||
|
if (type == String.class || type == Object.class) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (type == int.class || type == Integer.class) {
|
||||||
|
return Integer.parseInt(value.trim());
|
||||||
|
}
|
||||||
|
if (type == long.class || type == Long.class) {
|
||||||
|
return Long.parseLong(value.trim());
|
||||||
|
}
|
||||||
|
if (type == boolean.class || type == Boolean.class) {
|
||||||
|
return Boolean.parseBoolean(value.trim());
|
||||||
|
}
|
||||||
|
if (type == double.class || type == Double.class) {
|
||||||
|
return Double.parseDouble(value.trim());
|
||||||
|
}
|
||||||
|
if (type == float.class || type == Float.class) {
|
||||||
|
return Float.parseFloat(value.trim());
|
||||||
|
}
|
||||||
|
if (type == short.class || type == Short.class) {
|
||||||
|
return Short.parseShort(value.trim());
|
||||||
|
}
|
||||||
|
if (type == char.class || type == Character.class) {
|
||||||
|
return value.isEmpty() ? null : value.charAt(0);
|
||||||
|
}
|
||||||
|
if (type == byte.class || type == Byte.class) {
|
||||||
|
return Byte.parseByte(value.trim());
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holder for a {@code @Value} annotated field.
|
||||||
|
*/
|
||||||
|
private static class ValueFieldHolder {
|
||||||
|
|
||||||
|
private final Object bean;
|
||||||
|
|
||||||
|
private final Field field;
|
||||||
|
|
||||||
|
private final String key;
|
||||||
|
|
||||||
|
ValueFieldHolder(Object bean, Field field, String key) {
|
||||||
|
this.bean = bean;
|
||||||
|
this.field = field;
|
||||||
|
this.key = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean refresh(Environment env) {
|
||||||
|
String newValue = env.getProperty(key);
|
||||||
|
if (newValue == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
field.setAccessible(true);
|
||||||
|
Object oldValue = field.get(bean);
|
||||||
|
Object convertedValue = convertType(field.getType(), newValue);
|
||||||
|
field.set(bean, convertedValue);
|
||||||
|
logger.info("[RefreshScope] refreshed @Value field: {}.{} | old='{}' | new='{}'",
|
||||||
|
bean.getClass().getName(), field.getName(), oldValue, newValue);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("[RefreshScope] refresh @Value field {}.{} error: {}",
|
||||||
|
bean.getClass().getName(), field.getName(), e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holder for a {@code @ConfigurationProperties} bean.
|
||||||
|
*/
|
||||||
|
private static class ConfigPropsHolder {
|
||||||
|
|
||||||
|
private final Object bean;
|
||||||
|
|
||||||
|
private final String prefix;
|
||||||
|
|
||||||
|
ConfigPropsHolder(Object bean, String prefix) {
|
||||||
|
this.bean = bean;
|
||||||
|
this.prefix = prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
void refresh(Environment env, Set<String> changedKeys) {
|
||||||
|
for (Field field : getAllDeclaredFields(ClassUtils.getUserClass(bean))) {
|
||||||
|
if (Modifier.isStatic(field.getModifiers()) || Modifier.isFinal(field.getModifiers())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String kebabKey = prefix + "." + camelToKebab(field.getName());
|
||||||
|
String plainKey = prefix + "." + field.getName();
|
||||||
|
String matchedKey = changedKeys.contains(kebabKey) ? kebabKey
|
||||||
|
: changedKeys.contains(plainKey) ? plainKey : null;
|
||||||
|
if (matchedKey == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String value = env.getProperty(matchedKey);
|
||||||
|
if (value == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(bean, convertType(field.getType(), value));
|
||||||
|
logger.info("[RefreshScope] refreshed @ConfigurationProperties field: {}.{} = '{}'",
|
||||||
|
bean.getClass().getName(), field.getName(), value);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("[RefreshScope] refresh @ConfigurationProperties field {}.{} error: {}",
|
||||||
|
bean.getClass().getName(), field.getName(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
package cn.odboy.confcenter.spring;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import org.springframework.beans.BeansException;
|
||||||
|
import org.springframework.beans.factory.DisposableBean;
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
|
import org.springframework.context.ApplicationContextAware;
|
||||||
|
import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
|
||||||
|
|
||||||
|
import cn.odboy.confcenter.ConfigcenterContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring property placeholder configurer for plain Spring (non-Boot) applications.
|
||||||
|
*
|
||||||
|
* @author odboy
|
||||||
|
* @version 3.0.0
|
||||||
|
* @date 2026-10-21
|
||||||
|
*/
|
||||||
|
public class KenaitoPropertyPlaceholderConfigurer extends PropertySourcesPlaceholderConfigurer
|
||||||
|
implements DisposableBean, ApplicationContextAware {
|
||||||
|
|
||||||
|
private boolean remoteEnabledSet = false;
|
||||||
|
|
||||||
|
private final ConfigcenterContext ccContext = ConfigcenterContext.getInstance();
|
||||||
|
|
||||||
|
public void setRemoteEnabled(boolean remoteEnabled) {
|
||||||
|
ccContext.setRemoteEnabled(remoteEnabled);
|
||||||
|
remoteEnabledSet = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Properties mergeProperties() throws IOException {
|
||||||
|
Properties properties = super.mergeProperties();
|
||||||
|
if (!remoteEnabledSet) {
|
||||||
|
ccContext.setRemoteEnabled(Boolean.parseBoolean(
|
||||||
|
properties.getProperty("kenaito.config.enabled",
|
||||||
|
properties.getProperty("kenaito.configcenter.enabled", "true"))));
|
||||||
|
}
|
||||||
|
ccContext.init(properties, false);
|
||||||
|
ccContext.mergeRemoteProperties(properties);
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void destroy() {
|
||||||
|
ccContext.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
|
||||||
|
ConfigcenterContext.setApplicationContext(applicationContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package cn.odboy.confcenter.springboot;
|
||||||
|
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.env.EnvironmentPostProcessor;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.env.ConfigurableEnvironment;
|
||||||
|
import org.springframework.core.env.EnumerablePropertySource;
|
||||||
|
import org.springframework.core.env.PropertiesPropertySource;
|
||||||
|
|
||||||
|
import cn.odboy.confcenter.ConfigcenterContext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Boot environment post processor.
|
||||||
|
*
|
||||||
|
* <p>Initializes the config center before the application context is created and
|
||||||
|
* injects the remote configuration as a high-priority property source.</p>
|
||||||
|
*
|
||||||
|
* <p>Registered through {@code META-INF/spring.factories}, which Spring Boot 3
|
||||||
|
* still supports for {@link EnvironmentPostProcessor} implementations.</p>
|
||||||
|
*
|
||||||
|
* @author odboy
|
||||||
|
* @version 3.0.0
|
||||||
|
* @date 2026-10-21
|
||||||
|
*/
|
||||||
|
public class KenaitoEnvironmentInitializer implements EnvironmentPostProcessor, Ordered {
|
||||||
|
|
||||||
|
private final ConfigcenterContext ccContext = ConfigcenterContext.getInstance();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
|
||||||
|
if (!ccContext.isRemoteEnabled() || ccContext.isProcessed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Properties properties = new Properties();
|
||||||
|
// Extract config center settings from the Spring Environment. The config data
|
||||||
|
// post processor runs earlier, so application.properties is already loaded.
|
||||||
|
environment.getPropertySources().forEach(propertySource -> {
|
||||||
|
if (propertySource instanceof EnumerablePropertySource<?> enumerable) {
|
||||||
|
for (String key : enumerable.getPropertyNames()) {
|
||||||
|
if (key.startsWith("kenaito.config.")
|
||||||
|
|| key.startsWith("kenaito.configcenter.")
|
||||||
|
|| key.equals("spring.application.name")) {
|
||||||
|
String value = environment.getProperty(key);
|
||||||
|
if (value != null) {
|
||||||
|
properties.setProperty(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ccContext.init(properties, true);
|
||||||
|
ccContext.mergeRemoteProperties(properties);
|
||||||
|
PropertiesPropertySource propertySource =
|
||||||
|
new PropertiesPropertySource(ConfigcenterContext.MANAGER_PROPERTY_SOURCE, properties);
|
||||||
|
environment.getPropertySources().addFirst(propertySource);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getOrder() {
|
||||||
|
// Run after ConfigDataEnvironmentPostProcessor (HIGHEST_PRECEDENCE + 10).
|
||||||
|
return Ordered.LOWEST_PRECEDENCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
package cn.odboy.confcenter.springboot;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
|
import org.springframework.context.ApplicationContextAware;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.core.env.Environment;
|
||||||
|
|
||||||
|
import cn.odboy.confcenter.ConfigcenterContext;
|
||||||
|
import cn.odboy.confcenter.RefreshScopeRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Boot 3 auto configuration for config center refresh support.
|
||||||
|
*
|
||||||
|
* <p>Registers a {@link RefreshScopeRegistry} bean post processor and captures the
|
||||||
|
* {@link ApplicationContext} so that remote config changes can update the Spring
|
||||||
|
* environment and refresh {@code @Value} / {@code @ConfigurationProperties} fields
|
||||||
|
* at runtime.</p>
|
||||||
|
*
|
||||||
|
* <p>Registered through
|
||||||
|
* {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}.</p>
|
||||||
|
*
|
||||||
|
* @author odboy
|
||||||
|
* @version 3.0.0
|
||||||
|
* @date 2026-10-21
|
||||||
|
*/
|
||||||
|
@AutoConfiguration
|
||||||
|
public class KenaitoRefreshAutoConfiguration implements ApplicationContextAware {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Must be static so the bean post processor is registered before regular beans
|
||||||
|
* are instantiated.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public static RefreshScopeRegistry kenaitoRefreshScopeRegistry(Environment environment) {
|
||||||
|
RefreshScopeRegistry registry = new RefreshScopeRegistry(environment);
|
||||||
|
ConfigcenterContext.setRefreshScopeRegistry(registry);
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setApplicationContext(ApplicationContext applicationContext) {
|
||||||
|
ConfigcenterContext.setApplicationContext(applicationContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
org.springframework.boot.env.EnvironmentPostProcessor=\
|
||||||
|
cn.odboy.confcenter.springboot.KenaitoEnvironmentInitializer
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
cn.odboy.confcenter.springboot.KenaitoRefreshAutoConfiguration
|
||||||
Reference in New Issue
Block a user