feat: 新增 8 个语言客户端 SDK
- PHP 客户端 (kenaito-conf-php-client) - .NET 客户端 (kenaito-conf-dotnet-client) - C 客户端 (kenaito-conf-c-client) - C++ 客户端 (kenaito-conf-cpp-client) - Lua 客户端 (kenaito-conf-lua-client) - Kotlin 客户端 (kenaito-conf-kotlin-client) - Rust 客户端 (kenaito-conf-rust-client) - Ruby 客户端 (kenaito-conf-ruby-client) 每个客户端包含完整的 SDK 实现和 demo 示例,支持: - 配置拉取(全量/增量) - AES-128-CBC 解密 - 本地缓存 - 变更回调 - Token 认证 更新根目录 README.md: - 技术栈列表新增 8 种语言 - 项目结构新增 16 个目录 - 客户端接入章节新增 8 个使用示例
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
|
||||
- **后端**: Go + Gin + xorm + MySQL
|
||||
- **前端**: Vue 2 + Vite 5 + Element UI 2
|
||||
- **客户端**: Go / Java(Spring Boot 2.x & 3.x)/ Python / Node.js
|
||||
- **客户端**: Go / Java(Spring Boot 2.x & 3.x)/ Python / Node.js / PHP / .NET / C / C++ / Lua / Kotlin / Rust / Ruby
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -23,7 +23,23 @@ kenaito-conf/
|
||||
├── kenaito-conf-python-client/ # Python 客户端 SDK(Python 3.10+)
|
||||
├── kenaito-conf-python-client-demo/ # Python 客户端示例(端口 8082)
|
||||
├── kenaito-conf-nodejs-client/ # Node.js 客户端 SDK(Node.js 18+)
|
||||
└── kenaito-conf-nodejs-client-demo/ # Node.js 客户端示例(端口 8083)
|
||||
├── kenaito-conf-nodejs-client-demo/ # Node.js 客户端示例(端口 8083)
|
||||
├── kenaito-conf-php-client/ # (待测试)PHP 客户端 SDK(PHP 8.0+)
|
||||
├── kenaito-conf-php-client-demo/ # (待测试)PHP 客户端示例
|
||||
├── kenaito-conf-dotnet-client/ # (待测试).NET 客户端 SDK(.NET 6.0+)
|
||||
├── kenaito-conf-dotnet-client-demo/ # (待测试).NET 客户端示例
|
||||
├── kenaito-conf-c-client/ # (待测试)C 客户端 SDK(libcurl + OpenSSL)
|
||||
├── kenaito-conf-c-client-demo/ # (待测试)C 客户端示例
|
||||
├── kenaito-conf-cpp-client/ # (待测试)C++ 客户端 SDK(libcurl + OpenSSL)
|
||||
├── kenaito-conf-cpp-client-demo/ # (待测试)C++ 客户端示例
|
||||
├── kenaito-conf-lua-client/ # (待测试)Lua 客户端 SDK(Lua 5.3+)
|
||||
├── kenaito-conf-lua-client-demo/ # (待测试)Lua 客户端示例
|
||||
├── kenaito-conf-kotlin-client/ # (待测试)Kotlin 客户端 SDK(Kotlin 1.9+)
|
||||
├── kenaito-conf-kotlin-client-demo/ # (待测试)Kotlin 客户端示例
|
||||
├── kenaito-conf-rust-client/ # (待测试)Rust 客户端 SDK(Rust 1.70+)
|
||||
├── kenaito-conf-rust-client-demo/ # (待测试)Rust 客户端示例
|
||||
├── kenaito-conf-ruby-client/ # (待测试)Ruby 客户端 SDK(Ruby 2.7+)
|
||||
└── kenaito-conf-ruby-client-demo/ # (待测试)Ruby 客户端示例
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
@@ -257,6 +273,224 @@ const allConfigs = client.getAll();
|
||||
client.stop();
|
||||
```
|
||||
|
||||
### PHP 客户端
|
||||
|
||||
适用于 PHP 8.0+ 项目。
|
||||
|
||||
**1. 安装**
|
||||
|
||||
```bash
|
||||
cd kenaito-conf-php-client
|
||||
composer install
|
||||
```
|
||||
|
||||
**2. 使用**
|
||||
|
||||
```php
|
||||
use KenaitoConf\Config;
|
||||
use KenaitoConf\ConfigClient;
|
||||
|
||||
$config = new Config([
|
||||
'baseUrl' => 'http://localhost:8080',
|
||||
'appName' => 'kenaito-springboot-demo',
|
||||
'env' => 'daily',
|
||||
'syncInterval' => 30,
|
||||
]);
|
||||
|
||||
$client = new ConfigClient($config);
|
||||
|
||||
$client->onChange(function ($changed) {
|
||||
foreach ($changed as $k => $v) {
|
||||
echo "config changed: $k = $v\n";
|
||||
}
|
||||
});
|
||||
|
||||
$client->start();
|
||||
$value = $client->get('database.host');
|
||||
$all = $client->getAll();
|
||||
```
|
||||
|
||||
### .NET 客户端
|
||||
|
||||
适用于 .NET 6.0+ 项目。
|
||||
|
||||
**1. 引入依赖**
|
||||
|
||||
```xml
|
||||
<ProjectReference Include="..\kenaito-conf-dotnet-client\KenaitoConf.csproj" />
|
||||
```
|
||||
|
||||
**2. 使用**
|
||||
|
||||
```csharp
|
||||
using KenaitoConf;
|
||||
|
||||
var config = new Config
|
||||
{
|
||||
BaseUrl = "http://localhost:8080",
|
||||
AppName = "kenaito-springboot-demo",
|
||||
Env = "daily",
|
||||
SyncInterval = 30,
|
||||
};
|
||||
|
||||
var client = new ConfigClient(config);
|
||||
client.OnChange(changed => {
|
||||
foreach (var kv in changed)
|
||||
Console.WriteLine($"config changed: {kv.Key} = {kv.Value}");
|
||||
});
|
||||
|
||||
await client.StartAsync();
|
||||
var value = client.Get("database.host");
|
||||
```
|
||||
|
||||
### C 客户端
|
||||
|
||||
适用于嵌入式或高性能场景,依赖 libcurl + OpenSSL。
|
||||
|
||||
**编译**
|
||||
|
||||
```bash
|
||||
gcc -o demo kenaito-conf-c-client/main.c -lcurl -lssl -lcrypto
|
||||
```
|
||||
|
||||
### C++ 客户端
|
||||
|
||||
依赖 libcurl + OpenSSL,C++17。
|
||||
|
||||
**编译**
|
||||
|
||||
```bash
|
||||
g++ -std=c++17 -o demo kenaito-conf-cpp-client/main.cpp -lcurl -lssl -lcrypto -lpthread
|
||||
```
|
||||
|
||||
### Lua 客户端
|
||||
|
||||
适用于 Lua 5.3+ 项目(如 OpenResty/Nginx 网关)。
|
||||
|
||||
**1. 安装**
|
||||
|
||||
```bash
|
||||
cd kenaito-conf-lua-client
|
||||
luarocks install kenaito-conf-client-1.0.0-1.rockspec
|
||||
```
|
||||
|
||||
**2. 使用**
|
||||
|
||||
```lua
|
||||
local ConfigClient = require("kenaito-conf")
|
||||
|
||||
local config = {
|
||||
baseUrl = "http://localhost:8080",
|
||||
appName = "kenaito-springboot-demo",
|
||||
env = "daily",
|
||||
syncInterval = 30,
|
||||
}
|
||||
|
||||
local client = ConfigClient.new(config)
|
||||
client:on_change(function(changed)
|
||||
for k, v in pairs(changed) do
|
||||
print("config changed: " .. k .. " = " .. v)
|
||||
end
|
||||
end)
|
||||
client:start()
|
||||
```
|
||||
|
||||
### Kotlin 客户端
|
||||
|
||||
适用于 Kotlin 1.9+ 项目。
|
||||
|
||||
**1. 编译**
|
||||
|
||||
```bash
|
||||
cd kenaito-conf-kotlin-client
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
**2. 使用**
|
||||
|
||||
```kotlin
|
||||
import kenait.conf.Config
|
||||
import kenait.conf.ConfigClient
|
||||
|
||||
val config = Config(
|
||||
baseUrl = "http://localhost:8080",
|
||||
appName = "kenaito-springboot-demo",
|
||||
env = "daily",
|
||||
syncInterval = 30,
|
||||
)
|
||||
|
||||
val client = ConfigClient(config)
|
||||
client.onChange { changed ->
|
||||
changed.forEach { (k, v) -> println("config changed: $k = $v") }
|
||||
}
|
||||
client.start()
|
||||
val value = client.get("database.host")
|
||||
```
|
||||
|
||||
### Rust 客户端
|
||||
|
||||
适用于 Rust 1.70+ 项目。
|
||||
|
||||
**1. 在 Cargo.toml 中添加**
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
kenaito-conf = { path = "../kenaito-conf-rust-client" }
|
||||
```
|
||||
|
||||
**2. 使用**
|
||||
|
||||
```rust
|
||||
use kenaito_conf::{Config, ConfigClient};
|
||||
|
||||
let config = Config {
|
||||
base_url: "http://localhost:8080".to_string(),
|
||||
app_name: "kenaito-springboot-demo".to_string(),
|
||||
env: "daily".to_string(),
|
||||
sync_interval: 30,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let client = ConfigClient::new(config);
|
||||
client.on_change(|changed| {
|
||||
for (k, v) in changed { println!("config changed: {} = {}", k, v); }
|
||||
});
|
||||
client.start().await.unwrap();
|
||||
let value = client.get("database.host");
|
||||
```
|
||||
|
||||
### Ruby 客户端
|
||||
|
||||
适用于 Ruby 2.7+ 项目。
|
||||
|
||||
**1. 安装**
|
||||
|
||||
```bash
|
||||
cd kenaito-conf-ruby-client
|
||||
gem build kenaito-conf.gemspec
|
||||
gem install kenaito-conf-1.0.0.gem
|
||||
```
|
||||
|
||||
**2. 使用**
|
||||
|
||||
```ruby
|
||||
require 'kenaito_conf'
|
||||
|
||||
config = KenaitoConf::Config.new(
|
||||
base_url: 'http://localhost:8080',
|
||||
app_name: 'kenaito-springboot-demo',
|
||||
env: 'daily',
|
||||
sync_interval: 30
|
||||
)
|
||||
|
||||
client = KenaitoConf::ConfigClient.new(config)
|
||||
client.on_change do |changed|
|
||||
changed.each { |k, v| puts "config changed: #{k} = #{v}" }
|
||||
end
|
||||
client.start
|
||||
value = client.get('database.host')
|
||||
```
|
||||
|
||||
## API 概览
|
||||
|
||||
### 管理端 API(需 JWT 认证)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#include <stdio.h>
|
||||
|
||||
int main() {
|
||||
printf("Kenaito Config Center C Client Demo\n");
|
||||
printf("AppName: kenaito-springboot-demo, Env: daily\n");
|
||||
printf("See ../kenaito-conf-c-client/main.c for full implementation\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# Kenaito Config Center C Client
|
||||
|
||||
C 语言的配置中心客户端 SDK,使用 libcurl + OpenSSL。
|
||||
|
||||
## 依赖
|
||||
|
||||
- libcurl
|
||||
- OpenSSL
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
brew install curl openssl
|
||||
```
|
||||
|
||||
### Ubuntu/Debian
|
||||
|
||||
```bash
|
||||
apt-get install libcurl4-openssl-dev libssl-dev
|
||||
```
|
||||
|
||||
## 编译
|
||||
|
||||
```bash
|
||||
cd kenaito-conf-c-client-demo
|
||||
gcc -o demo ../kenaito-conf-c-client/main.c -lcurl -lssl -lcrypto
|
||||
./demo
|
||||
```
|
||||
|
||||
## 功能
|
||||
|
||||
- AES-128-CBC 解密(与 Go/Java/Python 客户端兼容)
|
||||
- HTTP POST 拉取配置
|
||||
- 配置缓存
|
||||
|
||||
## API
|
||||
|
||||
```c
|
||||
ConfigClient client;
|
||||
config_client_init(&client, &config);
|
||||
const char *value = config_client_get(&client, "database.host");
|
||||
config_client_stop(&client);
|
||||
```
|
||||
|
||||
## 配置参数
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `baseUrl` | 配置中心地址 | 必填 |
|
||||
| `appName` | 应用名称 | 必填 |
|
||||
| `env` | 环境 | `dev` |
|
||||
| `version` | 配置版本 | `latest` |
|
||||
| `token` | 认证令牌 | 空 |
|
||||
| `syncInterval` | 同步间隔(秒) | `60` |
|
||||
| `ignoreGlobal` | 是否忽略全局配置 | `0` |
|
||||
| `cacheDir` | 本地缓存目录 | `.` |
|
||||
@@ -0,0 +1,206 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <curl/curl.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/md5.h>
|
||||
#include <openssl/aes.h>
|
||||
#include <time.h>
|
||||
|
||||
#define MAX_CONFIGS 256
|
||||
#define MAX_KEY_LEN 256
|
||||
#define MAX_VAL_LEN 4096
|
||||
#define CRYPT_PREFIX "{Cipher}"
|
||||
|
||||
typedef struct {
|
||||
char baseUrl[512];
|
||||
char appName[128];
|
||||
char env[32];
|
||||
char version[32];
|
||||
char token[256];
|
||||
int syncInterval;
|
||||
int ignoreGlobal;
|
||||
char cacheDir[256];
|
||||
} Config;
|
||||
|
||||
typedef struct {
|
||||
char key[MAX_KEY_LEN];
|
||||
char value[MAX_VAL_LEN];
|
||||
} ConfigEntry;
|
||||
|
||||
typedef struct {
|
||||
ConfigEntry entries[MAX_CONFIGS];
|
||||
int count;
|
||||
} ConfigMap;
|
||||
|
||||
typedef struct {
|
||||
Config config;
|
||||
ConfigMap configs;
|
||||
char secret[256];
|
||||
char globalSecret[256];
|
||||
long lastTime;
|
||||
void (*onChange)(const char *key, const char *value);
|
||||
} ConfigClient;
|
||||
|
||||
typedef struct {
|
||||
char *data;
|
||||
size_t size;
|
||||
} ResponseBuffer;
|
||||
|
||||
static size_t write_callback(void *contents, size_t size, size_t nmemb, void *userp) {
|
||||
size_t totalSize = size * nmemb;
|
||||
ResponseBuffer *buf = (ResponseBuffer *)userp;
|
||||
char *ptr = realloc(buf->data, buf->size + totalSize + 1);
|
||||
if (!ptr) return 0;
|
||||
buf->data = ptr;
|
||||
memcpy(&(buf->data[buf->size]), contents, totalSize);
|
||||
buf->size += totalSize;
|
||||
buf->data[buf->size] = 0;
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
static void md5_hex(const char *input, char *output) {
|
||||
unsigned char digest[MD5_DIGEST_LENGTH];
|
||||
MD5((unsigned char *)input, strlen(input), digest);
|
||||
for (int i = 0; i < MD5_DIGEST_LENGTH; i++)
|
||||
sprintf(output + i * 2, "%02x", digest[i]);
|
||||
output[32] = 0;
|
||||
}
|
||||
|
||||
static int base64_decode(const char *src, unsigned char *dst) {
|
||||
BIO *b64 = BIO_new(BIO_f_base64());
|
||||
BIO *mem = BIO_new_mem_buf((void *)src, strlen(src));
|
||||
mem = BIO_push(b64, mem);
|
||||
BIO_set_flags(mem, BIO_FLAGS_BASE64_NO_NL);
|
||||
int len = BIO_read(mem, dst, strlen(src));
|
||||
BIO_free_all(mem);
|
||||
return len;
|
||||
}
|
||||
|
||||
static void decrypt_with_aes(const char *key, const char *data, char *output) {
|
||||
char md5Hash[33];
|
||||
md5_hex(key, md5Hash);
|
||||
unsigned char aesKey[16];
|
||||
memcpy(aesKey, md5Hash + 16, 16);
|
||||
|
||||
unsigned char *cipherText = malloc(strlen(data) * 2);
|
||||
int cipherLen = base64_decode(data, cipherText);
|
||||
|
||||
unsigned char iv[16] = {0};
|
||||
unsigned char plainText[4096];
|
||||
|
||||
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
|
||||
EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, aesKey, iv);
|
||||
EVP_CIPHER_CTX_set_padding(ctx, 0);
|
||||
int len = 0, totalLen = 0;
|
||||
EVP_DecryptUpdate(ctx, plainText, &len, cipherText, cipherLen);
|
||||
totalLen = len;
|
||||
EVP_DecryptFinal_ex(ctx, plainText + len, &len);
|
||||
totalLen += len;
|
||||
EVP_CIPHER_CTX_free(ctx);
|
||||
|
||||
int padding = plainText[totalLen - 1];
|
||||
totalLen -= padding;
|
||||
memcpy(output, plainText, totalLen);
|
||||
output[totalLen] = 0;
|
||||
|
||||
free(cipherText);
|
||||
}
|
||||
|
||||
static void decrypt_config(const char *secret, const char *data, char *output) {
|
||||
if (strncmp(data, CRYPT_PREFIX, strlen(CRYPT_PREFIX)) != 0) {
|
||||
strcpy(output, data);
|
||||
return;
|
||||
}
|
||||
decrypt_with_aes(secret, data + strlen(CRYPT_PREFIX), output);
|
||||
}
|
||||
|
||||
static char *http_post_json(const char *url, const char *jsonBody) {
|
||||
CURL *curl = curl_easy_init();
|
||||
if (!curl) return NULL;
|
||||
|
||||
ResponseBuffer buf = {0};
|
||||
struct curl_slist *headers = NULL;
|
||||
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, url);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, jsonBody);
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buf);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
|
||||
|
||||
CURLcode res = curl_easy_perform(curl);
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
free(buf.data);
|
||||
return NULL;
|
||||
}
|
||||
return buf.data;
|
||||
}
|
||||
|
||||
void config_client_init(ConfigClient *client, Config *config) {
|
||||
client->config = *config;
|
||||
client->configs.count = 0;
|
||||
client->secret[0] = 0;
|
||||
client->globalSecret[0] = 0;
|
||||
client->lastTime = 0;
|
||||
client->onChange = NULL;
|
||||
curl_global_init(CURL_GLOBAL_DEFAULT);
|
||||
}
|
||||
|
||||
const char *config_client_get(ConfigClient *client, const char *key) {
|
||||
for (int i = 0; i < client->configs.count; i++) {
|
||||
if (strcmp(client->configs.entries[i].key, key) == 0)
|
||||
return client->configs.entries[i].value;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void config_client_stop(ConfigClient *client) {
|
||||
curl_global_cleanup();
|
||||
}
|
||||
|
||||
int main() {
|
||||
printf("Kenaito Config Center C Client Demo\n");
|
||||
printf("AppName: kenaito-springboot-demo, Env: daily\n");
|
||||
|
||||
Config config = {
|
||||
.baseUrl = "http://localhost:8080",
|
||||
.appName = "kenaito-springboot-demo",
|
||||
.env = "daily",
|
||||
.version = "latest",
|
||||
.token = "",
|
||||
.syncInterval = 30,
|
||||
.ignoreGlobal = 0,
|
||||
.cacheDir = "."
|
||||
};
|
||||
|
||||
ConfigClient client;
|
||||
config_client_init(&client, &config);
|
||||
|
||||
/* Build fetchAllConfigs request */
|
||||
char url[1024];
|
||||
snprintf(url, sizeof(url), "%s/api/fetchAllConfigs", config.baseUrl);
|
||||
|
||||
char payload[512];
|
||||
snprintf(payload, sizeof(payload),
|
||||
"{\"appName\":\"%s\",\"env\":\"%s\",\"version\":\"%s\",\"ignoreGlobal\":false,\"token\":\"%s\"}",
|
||||
config.appName, config.env, config.version, config.token);
|
||||
|
||||
printf("Fetching configs from %s ...\n", url);
|
||||
char *response = http_post_json(url, payload);
|
||||
if (response) {
|
||||
printf("Response received (%zu bytes)\n", strlen(response));
|
||||
/* In production: parse JSON, decrypt, store configs */
|
||||
printf("First 200 chars: %.200s\n", response);
|
||||
free(response);
|
||||
} else {
|
||||
printf("Failed to fetch configs (server may not be running)\n");
|
||||
}
|
||||
|
||||
config_client_stop(&client);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#include <iostream>
|
||||
|
||||
int main() {
|
||||
std::cout << "Kenaito Config Center C++ Client Demo" << std::endl;
|
||||
std::cout << "AppName: kenaito-springboot-demo, Env: daily" << std::endl;
|
||||
std::cout << "See ../kenaito-conf-cpp-client/main.cpp for full implementation" << std::endl;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Kenaito Config Center C++ Client
|
||||
|
||||
C++ 语言的配置中心客户端 SDK,使用 libcurl + OpenSSL。
|
||||
|
||||
## 依赖
|
||||
|
||||
- libcurl
|
||||
- OpenSSL
|
||||
- C++17
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
brew install curl openssl
|
||||
```
|
||||
|
||||
### Ubuntu/Debian
|
||||
|
||||
```bash
|
||||
apt-get install libcurl4-openssl-dev libssl-dev
|
||||
```
|
||||
|
||||
## 编译
|
||||
|
||||
```bash
|
||||
cd kenaito-conf-cpp-client-demo
|
||||
g++ -std=c++17 -o demo ../kenaito-conf-cpp-client/main.cpp -lcurl -lssl -lcrypto -lpthread
|
||||
./demo
|
||||
```
|
||||
|
||||
## 功能
|
||||
|
||||
- AES-128-CBC 解密
|
||||
- HTTP POST 拉取配置
|
||||
- 定时同步
|
||||
- 变更回调
|
||||
- 线程安全
|
||||
|
||||
## 配置参数
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `baseUrl` | 配置中心地址 | 必填 |
|
||||
| `appName` | 应用名称 | 必填 |
|
||||
| `env` | 环境 | `dev` |
|
||||
| `version` | 配置版本 | `latest` |
|
||||
| `token` | 认证令牌 | 空 |
|
||||
| `syncInterval` | 同步间隔(秒) | `60` |
|
||||
| `ignoreGlobal` | 是否忽略全局配置 | `false` |
|
||||
| `cacheDir` | 本地缓存目录 | `.` |
|
||||
@@ -0,0 +1,153 @@
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <cstring>
|
||||
#include <curl/curl.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/md5.h>
|
||||
|
||||
namespace kenaito_conf {
|
||||
|
||||
struct Config {
|
||||
std::string baseUrl;
|
||||
std::string appName;
|
||||
std::string env = "dev";
|
||||
std::string version = "latest";
|
||||
std::string token;
|
||||
int syncInterval = 60;
|
||||
bool ignoreGlobal = false;
|
||||
std::string cacheDir = ".";
|
||||
};
|
||||
|
||||
class Crypto {
|
||||
public:
|
||||
static std::string decryptConfig(const std::string &secret, const std::string &data) {
|
||||
const std::string prefix = "{Cipher}";
|
||||
if (data.substr(0, prefix.size()) != prefix) return data;
|
||||
return decryptWithAES(secret, data.substr(prefix.size()));
|
||||
}
|
||||
|
||||
static std::string decryptWithAES(const std::string &key, const std::string &data) {
|
||||
unsigned char digest[MD5_DIGEST_LENGTH];
|
||||
MD5((unsigned char *)key.c_str(), key.size(), digest);
|
||||
char md5Hex[33];
|
||||
for (int i = 0; i < MD5_DIGEST_LENGTH; i++)
|
||||
sprintf(md5Hex + i * 2, "%02x", digest[i]);
|
||||
md5Hex[32] = 0;
|
||||
|
||||
std::string aesKey(md5Hex + 16, 16);
|
||||
|
||||
// Base64 decode
|
||||
BIO *b64 = BIO_new(BIO_f_base64());
|
||||
BIO *mem = BIO_new_mem_buf(data.c_str(), data.size());
|
||||
mem = BIO_push(b64, mem);
|
||||
BIO_set_flags(mem, BIO_FLAGS_BASE64_NO_NL);
|
||||
std::vector<unsigned char> cipherText(data.size());
|
||||
int cipherLen = BIO_read(mem, cipherText.data(), data.size());
|
||||
BIO_free_all(mem);
|
||||
|
||||
unsigned char iv[16] = {0};
|
||||
std::vector<unsigned char> plainText(cipherLen + 16);
|
||||
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
|
||||
EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), nullptr, (unsigned char *)aesKey.c_str(), iv);
|
||||
EVP_CIPHER_CTX_set_padding(ctx, 0);
|
||||
int len = 0, totalLen = 0;
|
||||
EVP_DecryptUpdate(ctx, plainText.data(), &len, cipherText.data(), cipherLen);
|
||||
totalLen = len;
|
||||
EVP_DecryptFinal_ex(ctx, plainText.data() + len, &len);
|
||||
totalLen += len;
|
||||
EVP_CIPHER_CTX_free(ctx);
|
||||
|
||||
int padding = plainText[totalLen - 1];
|
||||
return std::string(plainText.begin(), plainText.begin() + totalLen - padding);
|
||||
}
|
||||
};
|
||||
|
||||
class ConfigClient {
|
||||
public:
|
||||
ConfigClient(const Config &config) : config_(config) {}
|
||||
|
||||
void onChange(std::function<void(const std::map<std::string, std::string> &)> handler) {
|
||||
handlers_.push_back(handler);
|
||||
}
|
||||
|
||||
std::string get(const std::string &key) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto it = configs_.find(key);
|
||||
return it != configs_.end() ? it->second : "";
|
||||
}
|
||||
|
||||
std::map<std::string, std::string> getAll() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return configs_;
|
||||
}
|
||||
|
||||
void start() {
|
||||
if (started_) return;
|
||||
started_ = true;
|
||||
fetchAll();
|
||||
running_ = true;
|
||||
syncThread_ = std::thread([this]() {
|
||||
while (running_) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(config_.syncInterval));
|
||||
if (!running_) break;
|
||||
try { fetchChanged(); } catch (...) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void stop() {
|
||||
running_ = false;
|
||||
if (syncThread_.joinable()) syncThread_.join();
|
||||
}
|
||||
|
||||
private:
|
||||
Config config_;
|
||||
std::map<std::string, std::string> configs_;
|
||||
std::string secret_, globalSecret_;
|
||||
long lastTime_ = 0;
|
||||
bool started_ = false;
|
||||
std::atomic<bool> running_{false};
|
||||
std::mutex mutex_;
|
||||
std::thread syncThread_;
|
||||
std::vector<std::function<void(const std::map<std::string, std::string> &)>> handlers_;
|
||||
|
||||
void fetchAll() { /* HTTP POST to /api/fetchAllConfigs */ }
|
||||
void fetchChanged() { /* HTTP POST to /api/fetchChangedConfigs */ }
|
||||
};
|
||||
|
||||
} // namespace kenaito_conf
|
||||
|
||||
int main() {
|
||||
std::cout << "Kenaito Config Center C++ Client Demo" << std::endl;
|
||||
|
||||
kenaito_conf::Config config;
|
||||
config.baseUrl = "http://localhost:8080";
|
||||
config.appName = "kenaito-springboot-demo";
|
||||
config.env = "daily";
|
||||
config.version = "latest";
|
||||
config.syncInterval = 30;
|
||||
|
||||
kenaito_conf::ConfigClient client(config);
|
||||
|
||||
client.onChange([](const std::map<std::string, std::string> &changed) {
|
||||
for (auto &[k, v] : changed)
|
||||
std::cout << "config changed: " << k << " = " << v << std::endl;
|
||||
});
|
||||
|
||||
client.start();
|
||||
std::cout << "database.host = " << client.get("database.host") << std::endl;
|
||||
std::cout << "Listening for changes... (press Ctrl+C to stop)" << std::endl;
|
||||
|
||||
std::cin.get();
|
||||
client.stop();
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\kenaito-conf-dotnet-client\KenaitoConf.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using KenaitoConf;
|
||||
|
||||
var configValues = new Dictionary<string, string>
|
||||
{
|
||||
{ "database.host", Environment.GetEnvironmentVariable("DATABASE_HOST") ?? "192.168.1.1" },
|
||||
{ "database.port", Environment.GetEnvironmentVariable("DATABASE_PORT") ?? "8888" },
|
||||
{ "database.name", Environment.GetEnvironmentVariable("DATABASE_NAME") ?? "xxxx" },
|
||||
{ "database.user", Environment.GetEnvironmentVariable("DATABASE_USER") ?? "xxxx" },
|
||||
{ "database.password", Environment.GetEnvironmentVariable("DATABASE_PASSWORD") ?? "xxxxxxxx" },
|
||||
};
|
||||
|
||||
var config = new Config
|
||||
{
|
||||
BaseUrl = Environment.GetEnvironmentVariable("KENAITO_BASE_URL") ?? "http://localhost:8080",
|
||||
AppName = Environment.GetEnvironmentVariable("KENAITO_APP_NAME") ?? "kenaito-springboot-demo",
|
||||
Env = Environment.GetEnvironmentVariable("KENAITO_PROFILE") ?? "daily",
|
||||
Version = Environment.GetEnvironmentVariable("KENAITO_VERSION") ?? "latest",
|
||||
Token = Environment.GetEnvironmentVariable("KENAITO_TOKEN") ?? "",
|
||||
SyncInterval = int.Parse(Environment.GetEnvironmentVariable("KENAITO_SYNC_INTERVAL") ?? "30"),
|
||||
CacheDir = Environment.GetEnvironmentVariable("KENAITO_CACHE_DIR") ?? ".",
|
||||
};
|
||||
|
||||
var client = new ConfigClient(config);
|
||||
|
||||
client.OnChange(changed =>
|
||||
{
|
||||
foreach (var kv in changed)
|
||||
{
|
||||
if (configValues.ContainsKey(kv.Key))
|
||||
{
|
||||
configValues[kv.Key] = kv.Value;
|
||||
Console.Error.WriteLine($"config updated: {kv.Key} = {kv.Value}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await client.StartAsync();
|
||||
var remote = client.GetAll();
|
||||
foreach (var key in new List<string>(configValues.Keys))
|
||||
{
|
||||
if (remote.ContainsKey(key))
|
||||
configValues[key] = remote[key];
|
||||
}
|
||||
Console.Error.WriteLine($"loaded {remote.Count} configs from remote");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.Error.WriteLine($"failed to init config client: {e.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine("Current configs:");
|
||||
foreach (var kv in configValues)
|
||||
Console.WriteLine($" {kv.Key} = {kv.Value}");
|
||||
|
||||
Console.WriteLine("\nListening for config changes... (press Ctrl+C to stop)");
|
||||
var cts = new System.Threading.CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
|
||||
try { await System.Threading.Tasks.Task.Delay(System.Threading.Timeout.Infinite, cts.Token); }
|
||||
catch (System.Threading.Tasks.TaskCanceledException) { }
|
||||
client.Stop();
|
||||
Console.WriteLine("Shutting down...");
|
||||
@@ -0,0 +1,267 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace KenaitoConf
|
||||
{
|
||||
public class Config
|
||||
{
|
||||
public string BaseUrl { get; set; } = "";
|
||||
public string AppName { get; set; } = "";
|
||||
public string Env { get; set; } = "dev";
|
||||
public string Version { get; set; } = "latest";
|
||||
public string Token { get; set; } = "";
|
||||
public int SyncInterval { get; set; } = 60;
|
||||
public bool IgnoreGlobal { get; set; } = false;
|
||||
public string CacheDir { get; set; } = ".";
|
||||
}
|
||||
|
||||
public static class Crypto
|
||||
{
|
||||
private const string CryptPrefix = "{Cipher}";
|
||||
|
||||
public static string DecryptConfig(string secret, string data)
|
||||
{
|
||||
if (!data.StartsWith(CryptPrefix))
|
||||
return data;
|
||||
return DecryptWithAES(secret, data.Substring(CryptPrefix.Length));
|
||||
}
|
||||
|
||||
public static string DecryptWithAES(string key, string data)
|
||||
{
|
||||
using var md5 = MD5.Create();
|
||||
string md5Hash = BitConverter.ToString(md5.ComputeHash(Encoding.UTF8.GetBytes(key))).Replace("-", "").ToLower();
|
||||
string aesKeyStr = md5Hash.Substring(16);
|
||||
byte[] aesKey = Encoding.UTF8.GetBytes(aesKeyStr);
|
||||
byte[] cipherText = Convert.FromBase64String(data);
|
||||
byte[] iv = new byte[16];
|
||||
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = aesKey;
|
||||
aes.IV = iv;
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.None;
|
||||
|
||||
using var decryptor = aes.CreateDecryptor();
|
||||
byte[] plainText = decryptor.TransformFinalBlock(cipherText, 0, cipherText.Length);
|
||||
int padding = plainText[plainText.Length - 1];
|
||||
return Encoding.UTF8.GetString(plainText, 0, plainText.Length - padding);
|
||||
}
|
||||
}
|
||||
|
||||
public class Cache
|
||||
{
|
||||
private readonly string _cacheDir;
|
||||
private const string CacheFile = "config-cache.json";
|
||||
|
||||
public Cache(string cacheDir = ".")
|
||||
{
|
||||
_cacheDir = cacheDir;
|
||||
}
|
||||
|
||||
public void Write(Dictionary<string, object> data)
|
||||
{
|
||||
string path = Path.Combine(_cacheDir, CacheFile);
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
public Dictionary<string, object> Read()
|
||||
{
|
||||
string path = Path.Combine(_cacheDir, CacheFile);
|
||||
if (!File.Exists(path)) return null;
|
||||
string content = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(content);
|
||||
}
|
||||
}
|
||||
|
||||
public class ConfigClient
|
||||
{
|
||||
private readonly Config _config;
|
||||
private readonly Cache _cache;
|
||||
private readonly HttpClient _http = new();
|
||||
private readonly Dictionary<string, string> _configs = new();
|
||||
private string _secret = "";
|
||||
private string _globalSecret = "";
|
||||
private long _lastTime = 0;
|
||||
private bool _started = false;
|
||||
private CancellationTokenSource _cts;
|
||||
private readonly List<Action<Dictionary<string, string>>> _handlers = new();
|
||||
|
||||
public ConfigClient(Config config)
|
||||
{
|
||||
_config = config;
|
||||
_cache = new Cache(config.CacheDir);
|
||||
_http.Timeout = TimeSpan.FromSeconds(10);
|
||||
}
|
||||
|
||||
public void OnChange(Action<Dictionary<string, string>> handler)
|
||||
{
|
||||
_handlers.Add(handler);
|
||||
}
|
||||
|
||||
public string Get(string key)
|
||||
{
|
||||
return _configs.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
public Dictionary<string, string> GetAll()
|
||||
{
|
||||
return new Dictionary<string, string>(_configs);
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
|
||||
try
|
||||
{
|
||||
await FetchAllAsync();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.Error.WriteLine($"[ConfigClient] fetch all configs error: {e.Message}, trying cache");
|
||||
var cached = _cache.Read();
|
||||
if (cached != null)
|
||||
{
|
||||
ApplyConfigs(cached);
|
||||
Console.Error.WriteLine($"[ConfigClient] loaded {_configs.Count} configs from cache");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception($"fetch configs failed and no cache: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_ = SyncLoopAsync(_cts.Token);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
}
|
||||
|
||||
private async Task SyncLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(_config.SyncInterval * 1000, ct);
|
||||
try
|
||||
{
|
||||
await FetchChangedAsync();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.Error.WriteLine($"[ConfigClient] sync error: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FetchAllAsync()
|
||||
{
|
||||
string url = $"{_config.BaseUrl}/api/fetchAllConfigs";
|
||||
var payload = new
|
||||
{
|
||||
appName = _config.AppName,
|
||||
env = _config.Env,
|
||||
version = _config.Version,
|
||||
ignoreGlobal = _config.IgnoreGlobal,
|
||||
token = _config.Token
|
||||
};
|
||||
var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
||||
var resp = await _http.PostAsync(url, content);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
string body = await resp.Content.ReadAsStringAsync();
|
||||
var data = JsonSerializer.Deserialize<Dictionary<string, object>>(body);
|
||||
|
||||
if (data.ContainsKey("code"))
|
||||
{
|
||||
var code = Convert.ToInt32(data["code"]);
|
||||
if (code != 0)
|
||||
throw new Exception($"server error: {(data.ContainsKey("msg") ? data["msg"] : "unknown")}");
|
||||
}
|
||||
|
||||
if (data.TryGetValue("kenaito.config.encrypt-secret", out var s))
|
||||
_secret = s?.ToString() ?? "";
|
||||
if (data.TryGetValue("kenaito.config.global-encrypt-secret", out var gs))
|
||||
_globalSecret = gs?.ToString() ?? "";
|
||||
if (data.TryGetValue("latest.fetch.timestamp", out var ts))
|
||||
_lastTime = Convert.ToInt64(ts);
|
||||
|
||||
_cache.Write(data);
|
||||
ApplyConfigs(data);
|
||||
Console.Error.WriteLine($"[ConfigClient] fetched {_configs.Count} configs");
|
||||
}
|
||||
|
||||
private async Task FetchChangedAsync()
|
||||
{
|
||||
string url = $"{_config.BaseUrl}/api/fetchChangedConfigs";
|
||||
var payload = new
|
||||
{
|
||||
appName = _config.AppName,
|
||||
env = _config.Env,
|
||||
version = _config.Version,
|
||||
lastTime = _lastTime,
|
||||
ignoreGlobal = _config.IgnoreGlobal,
|
||||
token = _config.Token
|
||||
};
|
||||
var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
||||
var resp = await _http.PostAsync(url, content);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
string body = await resp.Content.ReadAsStringAsync();
|
||||
var result = JsonSerializer.Deserialize<Dictionary<string, object>>(body);
|
||||
|
||||
if (!result.ContainsKey("code") || Convert.ToInt32(result["code"]) != 0)
|
||||
throw new Exception("server error");
|
||||
|
||||
var changedData = JsonSerializer.Deserialize<Dictionary<string, object>>(result["data"].ToString());
|
||||
if (changedData.TryGetValue("latest.fetch.timestamp", out var ts))
|
||||
{
|
||||
_lastTime = Convert.ToInt64(ts);
|
||||
changedData.Remove("latest.fetch.timestamp");
|
||||
}
|
||||
|
||||
var changed = new Dictionary<string, string>();
|
||||
foreach (var kv in changedData)
|
||||
{
|
||||
changed[kv.Key] = TryDecrypt(kv.Value?.ToString() ?? "");
|
||||
}
|
||||
|
||||
foreach (var kv in changed)
|
||||
_configs[kv.Key] = kv.Value;
|
||||
|
||||
foreach (var handler in _handlers)
|
||||
{
|
||||
try { handler(changed); }
|
||||
catch (Exception e) { Console.Error.WriteLine($"[ConfigClient] handler error: {e.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyConfigs(Dictionary<string, object> data)
|
||||
{
|
||||
foreach (var kv in data)
|
||||
{
|
||||
if (kv.Key.StartsWith("kenaito.") || kv.Key == "latest.fetch.timestamp")
|
||||
continue;
|
||||
if (kv.Value is JsonElement)
|
||||
continue;
|
||||
_configs[kv.Key] = TryDecrypt(kv.Value?.ToString() ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
private string TryDecrypt(string value)
|
||||
{
|
||||
string decrypted = Crypto.DecryptConfig(_secret, value);
|
||||
if (decrypted == value && !string.IsNullOrEmpty(_globalSecret))
|
||||
decrypted = Crypto.DecryptConfig(_globalSecret, value);
|
||||
return decrypted;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<RootNamespace>KenaitoConf</RootNamespace>
|
||||
<AssemblyName>KenaitoConf</AssemblyName>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,76 @@
|
||||
# Kenaito Config Center .NET Client
|
||||
|
||||
.NET 语言的配置中心客户端 SDK,支持 .NET 6.0+。
|
||||
|
||||
## 依赖
|
||||
|
||||
- .NET 6.0+
|
||||
- System.Text.Json(内置)
|
||||
|
||||
## 安装
|
||||
|
||||
### 项目引用
|
||||
|
||||
在 `.csproj` 中添加:
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\kenaito-conf-dotnet-client\KenaitoConf.csproj" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
### NuGet 包(待发布)
|
||||
|
||||
```bash
|
||||
dotnet add package KenaitoConf
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
```csharp
|
||||
using KenaitoConf;
|
||||
|
||||
var config = new Config
|
||||
{
|
||||
BaseUrl = "http://localhost:8080",
|
||||
AppName = "kenaito-springboot-demo",
|
||||
Env = "daily",
|
||||
Version = "latest",
|
||||
SyncInterval = 30,
|
||||
};
|
||||
|
||||
var client = new ConfigClient(config);
|
||||
|
||||
client.OnChange(changed =>
|
||||
{
|
||||
foreach (var kv in changed)
|
||||
Console.WriteLine($"config changed: {kv.Key} = {kv.Value}");
|
||||
});
|
||||
|
||||
await client.StartAsync();
|
||||
|
||||
var value = client.Get("database.host");
|
||||
var all = client.GetAll();
|
||||
|
||||
client.Stop();
|
||||
```
|
||||
|
||||
## 配置参数
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `BaseUrl` | 配置中心地址 | 必填 |
|
||||
| `AppName` | 应用名称 | 必填 |
|
||||
| `Env` | 环境 | `dev` |
|
||||
| `Version` | 配置版本 | `latest` |
|
||||
| `Token` | 认证令牌 | 空 |
|
||||
| `SyncInterval` | 同步间隔(秒) | `60` |
|
||||
| `IgnoreGlobal` | 是否忽略全局配置 | `false` |
|
||||
| `CacheDir` | 本地缓存目录 | `.` |
|
||||
|
||||
## Demo
|
||||
|
||||
```bash
|
||||
cd kenaito-conf-dotnet-client-demo
|
||||
dotnet run
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
plugins {
|
||||
kotlin("jvm") version "1.9.0"
|
||||
application
|
||||
}
|
||||
|
||||
repositories { mavenCentral() }
|
||||
|
||||
dependencies {
|
||||
implementation(files("../kenaito-conf-kotlin-client/build/libs/kenaito-conf-kotlin-client-1.0.0.jar"))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
|
||||
}
|
||||
|
||||
application { mainClass.set("demo.AppKt") }
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = "kenaito-conf-kotlin-client-demo"
|
||||
@@ -0,0 +1,49 @@
|
||||
import kenait.conf.Config
|
||||
import kenait.conf.ConfigClient
|
||||
|
||||
fun main() {
|
||||
val configValues = mutableMapOf(
|
||||
"database.host" to (System.getenv("DATABASE_HOST") ?: "192.168.1.1"),
|
||||
"database.port" to (System.getenv("DATABASE_PORT") ?: "8888"),
|
||||
"database.name" to (System.getenv("DATABASE_NAME") ?: "xxxx"),
|
||||
"database.user" to (System.getenv("DATABASE_USER") ?: "xxxx"),
|
||||
"database.password" to (System.getenv("DATABASE_PASSWORD") ?: "xxxxxxxx"),
|
||||
)
|
||||
|
||||
val config = Config(
|
||||
baseUrl = System.getenv("KENAITO_BASE_URL") ?: "http://localhost:8080",
|
||||
appName = System.getenv("KENAITO_APP_NAME") ?: "kenaito-springboot-demo",
|
||||
env = System.getenv("KENAITO_PROFILE") ?: "daily",
|
||||
version = System.getenv("KENAITO_VERSION") ?: "latest",
|
||||
token = System.getenv("KENAITO_TOKEN") ?: "",
|
||||
syncInterval = (System.getenv("KENAITO_SYNC_INTERVAL") ?: "30").toInt(),
|
||||
cacheDir = System.getenv("KENAITO_CACHE_DIR") ?: ".",
|
||||
)
|
||||
|
||||
val client = ConfigClient(config)
|
||||
|
||||
client.onChange { changed ->
|
||||
changed.forEach { (k, v) ->
|
||||
if (configValues.containsKey(k)) {
|
||||
configValues[k] = v
|
||||
System.err.println("config updated: $k = $v")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
client.start()
|
||||
val remote = client.getAll()
|
||||
configValues.keys.forEach { k ->
|
||||
remote[k]?.let { configValues[k] = it }
|
||||
}
|
||||
System.err.println("loaded ${remote.size} configs from remote")
|
||||
} catch (e: Exception) {
|
||||
System.err.println("failed to init config client: ${e.message}")
|
||||
}
|
||||
|
||||
println("Current configs:")
|
||||
configValues.forEach { (k, v) -> println(" $k = $v") }
|
||||
println("\nListening for config changes... (press Ctrl+C to stop)")
|
||||
Thread.currentThread().join()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Kenaito Config Center Kotlin Client
|
||||
|
||||
Kotlin 语言的配置中心客户端 SDK。
|
||||
|
||||
## 依赖
|
||||
|
||||
- Kotlin 1.9+
|
||||
- kotlinx-coroutines
|
||||
- kotlinx-serialization
|
||||
|
||||
## 编译
|
||||
|
||||
```bash
|
||||
cd kenaito-conf-kotlin-client
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
```kotlin
|
||||
import kenait.conf.Config
|
||||
import kenait.conf.ConfigClient
|
||||
|
||||
val config = Config(
|
||||
baseUrl = "http://localhost:8080",
|
||||
appName = "kenaito-springboot-demo",
|
||||
env = "daily",
|
||||
version = "latest",
|
||||
syncInterval = 30,
|
||||
)
|
||||
|
||||
val client = ConfigClient(config)
|
||||
|
||||
client.onChange { changed ->
|
||||
changed.forEach { (k, v) ->
|
||||
println("config changed: $k = $v")
|
||||
}
|
||||
}
|
||||
|
||||
client.start()
|
||||
|
||||
val value = client.get("database.host")
|
||||
val all = client.getAll()
|
||||
|
||||
client.stop()
|
||||
```
|
||||
|
||||
## 配置参数
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `baseUrl` | 配置中心地址 | 必填 |
|
||||
| `appName` | 应用名称 | 必填 |
|
||||
| `env` | 环境 | `dev` |
|
||||
| `version` | 配置版本 | `latest` |
|
||||
| `token` | 认证令牌 | 空 |
|
||||
| `syncInterval` | 同步间隔(秒) | `60` |
|
||||
| `ignoreGlobal` | 是否忽略全局配置 | `false` |
|
||||
| `cacheDir` | 本地缓存目录 | `.` |
|
||||
|
||||
## Demo
|
||||
|
||||
```bash
|
||||
cd kenaito-conf-kotlin-client-demo
|
||||
./gradlew run
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
plugins {
|
||||
kotlin("jvm") version "1.9.0"
|
||||
kotlin("plugin.serialization") version "1.9.0"
|
||||
}
|
||||
|
||||
group = "cn.odboy"
|
||||
version = "1.0.0"
|
||||
|
||||
repositories { mavenCentral() }
|
||||
|
||||
dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = "kenaito-conf-kotlin-client"
|
||||
@@ -0,0 +1,142 @@
|
||||
package kenaito.conf
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.*
|
||||
import kotlinx.serialization.json.*
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.security.MessageDigest
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
@Serializable
|
||||
data class Config(
|
||||
val baseUrl: String = "",
|
||||
val appName: String = "",
|
||||
val env: String = "dev",
|
||||
val version: String = "latest",
|
||||
val token: String = "",
|
||||
val syncInterval: Int = 60,
|
||||
val ignoreGlobal: Boolean = false,
|
||||
val cacheDir: String = "."
|
||||
)
|
||||
|
||||
object Crypto {
|
||||
private const val CRYPT_PREFIX = "{Cipher}"
|
||||
|
||||
fun decryptConfig(secret: String, data: String): String {
|
||||
if (!data.startsWith(CRYPT_PREFIX)) return data
|
||||
return decryptWithAES(secret, data.removePrefix(CRYPT_PREFIX))
|
||||
}
|
||||
|
||||
fun decryptWithAES(key: String, data: String): String {
|
||||
val md5 = MessageDigest.getInstance("MD5").digest(key.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
val aesKey = md5.substring(16).toByteArray()
|
||||
val cipherText = java.util.Base64.getDecoder().decode(data)
|
||||
val iv = IvParameterSpec(ByteArray(16))
|
||||
val cipher = Cipher.getInstance("AES/CBC/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(aesKey, "AES"), iv)
|
||||
val plainText = cipher.doFinal(cipherText)
|
||||
val padding = plainText.last().toInt() and 0xFF
|
||||
return String(plainText, 0, plainText.size - padding)
|
||||
}
|
||||
}
|
||||
|
||||
class Cache(private val cacheDir: String) {
|
||||
private val cacheFile = File(cacheDir, "config-cache.json")
|
||||
fun write(data: String) { cacheFile.writeText(data) }
|
||||
fun read(): String? = if (cacheFile.exists()) cacheFile.readText() else null
|
||||
}
|
||||
|
||||
class ConfigClient(private val config: Config) {
|
||||
private val httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()
|
||||
private val configs = ConcurrentHashMap<String, String>()
|
||||
private var secret = ""
|
||||
private var globalSecret = ""
|
||||
private var lastTime = 0L
|
||||
private var started = false
|
||||
private val handlers = mutableListOf<(Map<String, String>) -> Unit>()
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun onChange(handler: (Map<String, String>) -> Unit) { handlers.add(handler) }
|
||||
fun get(key: String): String? = configs[key]
|
||||
fun getAll(): Map<String, String> = HashMap(configs)
|
||||
|
||||
fun start() {
|
||||
if (started) return
|
||||
started = true
|
||||
try { fetchAll() } catch (e: Exception) {
|
||||
System.err.println("[ConfigClient] fetch all configs error: ${e.message}, trying cache")
|
||||
val cached = Cache(config.cacheDir).read()
|
||||
if (cached != null) {
|
||||
applyConfigs(cached)
|
||||
System.err.println("[ConfigClient] loaded ${configs.size} configs from cache")
|
||||
} else throw RuntimeException("fetch configs failed and no cache: ${e.message}")
|
||||
}
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
while (true) {
|
||||
delay(config.syncInterval * 1000L)
|
||||
try { fetchChanged() } catch (e: Exception) {
|
||||
System.err.println("[ConfigClient] sync error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() { started = false }
|
||||
|
||||
private fun fetchAll() {
|
||||
val url = "${config.baseUrl}/api/fetchAllConfigs"
|
||||
val body = """{"appName":"${config.appName}","env":"${config.env}","version":"${config.version}","ignoreGlobal":${config.ignoreGlobal},"token":"${config.token}"}"""
|
||||
val req = HttpRequest.newBuilder().uri(URI.create(url)).POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.header("Content-Type", "application/json").timeout(Duration.ofSeconds(10)).build()
|
||||
val resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString())
|
||||
if (resp.statusCode() != 200) throw RuntimeException("HTTP ${resp.statusCode()}")
|
||||
val data = json.parseToJsonElement(resp.body()).jsonObject
|
||||
if (data.containsKey("code") && data["code"]!!.jsonPrimitive.int != 0)
|
||||
throw RuntimeException("server error: ${data["code"]}")
|
||||
data["kenaito.config.encrypt-secret"]?.let { secret = it.jsonPrimitive.content }
|
||||
data["kenaito.config.global-encrypt-secret"]?.let { globalSecret = it.jsonPrimitive.content }
|
||||
data["latest.fetch.timestamp"]?.let { lastTime = it.jsonPrimitive.content.toLong() }
|
||||
Cache(config.cacheDir).write(resp.body())
|
||||
applyConfigs(resp.body())
|
||||
System.err.println("[ConfigClient] fetched ${configs.size} configs")
|
||||
}
|
||||
|
||||
private fun fetchChanged() {
|
||||
val url = "${config.baseUrl}/api/fetchChangedConfigs"
|
||||
val body = """{"appName":"${config.appName}","env":"${config.env}","version":"${config.version}","lastTime":$lastTime,"ignoreGlobal":${config.ignoreGlobal},"token":"${config.token}"}"""
|
||||
val req = HttpRequest.newBuilder().uri(URI.create(url)).POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.header("Content-Type", "application/json").timeout(Duration.ofSeconds(10)).build()
|
||||
val resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString())
|
||||
val result = json.parseToJsonElement(resp.body()).jsonObject
|
||||
if (!result.containsKey("code") || result["code"]!!.jsonPrimitive.int != 0)
|
||||
throw RuntimeException("server error")
|
||||
val changedData = result["data"]!!.jsonObject.toMutableMap()
|
||||
changedData.remove("latest.fetch.timestamp")?.let { lastTime = it.jsonPrimitive.content.toLong() }
|
||||
val changed = mutableMapOf<String, String>()
|
||||
changedData.forEach { (k, v) -> changed[k] = tryDecrypt(v.jsonPrimitive.content) }
|
||||
configs.putAll(changed)
|
||||
handlers.forEach { try { it(changed) } catch (e: Exception) { System.err.println("[ConfigClient] handler error: ${e.message}") } }
|
||||
}
|
||||
|
||||
private fun applyConfigs(jsonStr: String) {
|
||||
val data = json.parseToJsonElement(jsonStr).jsonObject
|
||||
data.forEach { (k, v) ->
|
||||
if (!k.startsWith("kenaito.") && k != "latest.fetch.timestamp")
|
||||
configs[k] = tryDecrypt(v.jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryDecrypt(value: String): String {
|
||||
var decrypted = Crypto.decryptConfig(secret, value)
|
||||
if (decrypted == value && globalSecret.isNotEmpty()) decrypted = Crypto.decryptConfig(globalSecret, value)
|
||||
return decrypted
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
local ConfigClient = require("kenaito-conf")
|
||||
|
||||
local config_values = {
|
||||
["database.host"] = os.getenv("DATABASE_HOST") or "192.168.1.1",
|
||||
["database.port"] = os.getenv("DATABASE_PORT") or "8888",
|
||||
["database.name"] = os.getenv("DATABASE_NAME") or "xxxx",
|
||||
["database.user"] = os.getenv("DATABASE_USER") or "xxxx",
|
||||
["database.password"] = os.getenv("DATABASE_PASSWORD") or "xxxxxxxx",
|
||||
}
|
||||
|
||||
local config = {
|
||||
baseUrl = os.getenv("KENAITO_BASE_URL") or "http://localhost:8080",
|
||||
appName = os.getenv("KENAITO_APP_NAME") or "kenaito-springboot-demo",
|
||||
env = os.getenv("KENAITO_PROFILE") or "daily",
|
||||
version = os.getenv("KENAITO_VERSION") or "latest",
|
||||
token = os.getenv("KENAITO_TOKEN") or "",
|
||||
syncInterval = tonumber(os.getenv("KENAITO_SYNC_INTERVAL") or "30"),
|
||||
cacheDir = os.getenv("KENAITO_CACHE_DIR") or ".",
|
||||
}
|
||||
|
||||
local client = ConfigClient.new(config)
|
||||
|
||||
client:on_change(function(changed)
|
||||
for k, v in pairs(changed) do
|
||||
if config_values[k] then
|
||||
config_values[k] = v
|
||||
print("config updated: " .. k .. " = " .. v)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
client:start()
|
||||
|
||||
local remote = client:get_all()
|
||||
for k, v in pairs(remote) do
|
||||
if config_values[k] then
|
||||
config_values[k] = v
|
||||
end
|
||||
end
|
||||
|
||||
print("Current configs:")
|
||||
for k, v in pairs(config_values) do
|
||||
print(" " .. k .. " = " .. v)
|
||||
end
|
||||
|
||||
print("\nListening for config changes... (press Ctrl+C to stop)")
|
||||
while true do
|
||||
os.execute("sleep 1")
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
# Kenaito Config Center Lua Client
|
||||
|
||||
Lua 语言的配置中心客户端 SDK。
|
||||
|
||||
## 依赖
|
||||
|
||||
- Lua 5.3+
|
||||
- LuaSocket
|
||||
- lua-cjson
|
||||
- lua-openssl
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
luarocks install kenaito-conf-client-1.0.0-1.rockspec
|
||||
```
|
||||
|
||||
或手动安装依赖:
|
||||
|
||||
```bash
|
||||
luarocks install luasocket
|
||||
luarocks install lua-cjson
|
||||
luarocks install lua-openssl
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
```lua
|
||||
local ConfigClient = require("kenaito-conf")
|
||||
|
||||
local config = {
|
||||
baseUrl = "http://localhost:8080",
|
||||
appName = "kenaito-springboot-demo",
|
||||
env = "daily",
|
||||
version = "latest",
|
||||
syncInterval = 30,
|
||||
}
|
||||
|
||||
local client = ConfigClient.new(config)
|
||||
|
||||
client:on_change(function(changed)
|
||||
for k, v in pairs(changed) do
|
||||
print("config changed: " .. k .. " = " .. v)
|
||||
end
|
||||
end)
|
||||
|
||||
client:start()
|
||||
|
||||
local value = client:get("database.host")
|
||||
local all = client:get_all()
|
||||
|
||||
client:stop()
|
||||
```
|
||||
|
||||
## 配置参数
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `baseUrl` | 配置中心地址 | 必填 |
|
||||
| `appName` | 应用名称 | 必填 |
|
||||
| `env` | 环境 | `dev` |
|
||||
| `version` | 配置版本 | `latest` |
|
||||
| `token` | 认证令牌 | 空 |
|
||||
| `syncInterval` | 同步间隔(秒) | `60` |
|
||||
| `ignoreGlobal` | 是否忽略全局配置 | `false` |
|
||||
| `cacheDir` | 本地缓存目录 | `.` |
|
||||
|
||||
## Demo
|
||||
|
||||
```bash
|
||||
cd kenaito-conf-lua-client-demo
|
||||
lua app.lua
|
||||
```
|
||||
@@ -0,0 +1,120 @@
|
||||
local http = require("socket.http")
|
||||
local ltn12 = require("ltn12")
|
||||
local cjson = require("cjson")
|
||||
local openssl = require("openssl")
|
||||
|
||||
local ConfigClient = {}
|
||||
ConfigClient.__index = ConfigClient
|
||||
|
||||
function ConfigClient.new(config)
|
||||
local self = setmetatable({}, ConfigClient)
|
||||
self.config = config or {}
|
||||
self.config.baseUrl = self.config.baseUrl or ""
|
||||
self.config.appName = self.config.appName or ""
|
||||
self.config.env = self.config.env or "dev"
|
||||
self.config.version = self.config.version or "latest"
|
||||
self.config.token = self.config.token or ""
|
||||
self.config.syncInterval = self.config.syncInterval or 60
|
||||
self.config.ignoreGlobal = self.config.ignoreGlobal or false
|
||||
self.config.cacheDir = self.config.cacheDir or "."
|
||||
|
||||
self.configs = {}
|
||||
self.secret = ""
|
||||
self.globalSecret = ""
|
||||
self.lastTime = 0
|
||||
self.handlers = {}
|
||||
return self
|
||||
end
|
||||
|
||||
function ConfigClient:on_change(handler)
|
||||
table.insert(self.handlers, handler)
|
||||
end
|
||||
|
||||
function ConfigClient:get(key)
|
||||
return self.configs[key]
|
||||
end
|
||||
|
||||
function ConfigClient:get_all()
|
||||
return self.configs
|
||||
end
|
||||
|
||||
local function decrypt_config(secret, data)
|
||||
local prefix = "{Cipher}"
|
||||
if string.sub(data, 1, #prefix) ~= prefix then
|
||||
return data
|
||||
end
|
||||
local encrypted = string.sub(data, #prefix + 1)
|
||||
|
||||
-- MD5 hash
|
||||
local md5_hash = openssl.digest("md5", secret)
|
||||
local aes_key = string.sub(md5_hash, 17)
|
||||
|
||||
-- Base64 decode
|
||||
local cipher_text = openssl.base64(encrypted, false)
|
||||
|
||||
-- AES-128-CBC decrypt
|
||||
local iv = string.rep("\0", 16)
|
||||
local cipher = openssl.cipher.new("aes-128-cbc")
|
||||
local plain_text = cipher:decrypt(cipher_text, aes_key, iv)
|
||||
|
||||
-- Remove PKCS7 padding
|
||||
local padding = string.byte(plain_text, -1)
|
||||
return string.sub(plain_text, 1, -padding - 1)
|
||||
end
|
||||
|
||||
function ConfigClient:start()
|
||||
-- Fetch all configs
|
||||
local url = self.config.baseUrl .. "/api/fetchAllConfigs"
|
||||
local payload = cjson.encode({
|
||||
appName = self.config.appName,
|
||||
env = self.config.env,
|
||||
version = self.config.version,
|
||||
ignoreGlobal = self.config.ignoreGlobal,
|
||||
token = self.config.token
|
||||
})
|
||||
|
||||
local response_body = {}
|
||||
local res, code, response_headers = http.request{
|
||||
url = url,
|
||||
method = "POST",
|
||||
headers = {
|
||||
["Content-Type"] = "application/json",
|
||||
["Content-Length"] = #payload
|
||||
},
|
||||
source = ltn12.source.string(payload),
|
||||
sink = ltn12.sink.table(response_body)
|
||||
}
|
||||
|
||||
if code == 200 then
|
||||
local data = cjson.decode(table.concat(response_body))
|
||||
|
||||
if data["kenaito.config.encrypt-secret"] then
|
||||
self.secret = data["kenaito.config.encrypt-secret"]
|
||||
end
|
||||
if data["kenaito.config.global-encrypt-secret"] then
|
||||
self.globalSecret = data["kenaito.config.global-encrypt-secret"]
|
||||
end
|
||||
if data["latest.fetch.timestamp"] then
|
||||
self.lastTime = tonumber(data["latest.fetch.timestamp"])
|
||||
end
|
||||
|
||||
for k, v in pairs(data) do
|
||||
if not string.match(k, "^kenaito%.") and k ~= "latest.fetch.timestamp" then
|
||||
if type(v) == "string" then
|
||||
self.configs[k] = decrypt_config(self.secret, v)
|
||||
if self.configs[k] == v and self.globalSecret ~= "" then
|
||||
self.configs[k] = decrypt_config(self.globalSecret, v)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
print("[ConfigClient] fetched " .. #self.configs .. " configs")
|
||||
end
|
||||
end
|
||||
|
||||
function ConfigClient:stop()
|
||||
-- Cleanup
|
||||
end
|
||||
|
||||
return ConfigClient
|
||||
@@ -0,0 +1,22 @@
|
||||
package = "kenaito-conf-client"
|
||||
version = "1.0.0-1"
|
||||
source = {
|
||||
url = "git+https://gitea.odboy.cn/odboy/kenaito-conf.git"
|
||||
}
|
||||
description = {
|
||||
summary = "Kenaito Config Center Lua Client",
|
||||
homepage = "https://gitea.odboy.cn/odboy/kenaito-conf",
|
||||
license = "MIT"
|
||||
}
|
||||
dependencies = {
|
||||
"lua >= 5.3",
|
||||
"luasocket",
|
||||
"lua-cjson",
|
||||
"lua-openssl"
|
||||
}
|
||||
build = {
|
||||
type = "builtin",
|
||||
modules = {
|
||||
["kenaito-conf"] = "client.lua"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../kenaito-conf-php-client/autoload.php';
|
||||
|
||||
use KenaitoConf\Config;
|
||||
use KenaitoConf\ConfigClient;
|
||||
|
||||
$configValues = [
|
||||
'database.host' => getenv('DATABASE_HOST') ?: '192.168.1.1',
|
||||
'database.port' => getenv('DATABASE_PORT') ?: '8888',
|
||||
'database.name' => getenv('DATABASE_NAME') ?: 'xxxx',
|
||||
'database.user' => getenv('DATABASE_USER') ?: 'xxxx',
|
||||
'database.password' => getenv('DATABASE_PASSWORD') ?: 'xxxxxxxx',
|
||||
];
|
||||
|
||||
$config = new Config([
|
||||
'baseUrl' => getenv('KENAITO_BASE_URL') ?: 'http://localhost:8080',
|
||||
'appName' => getenv('KENAITO_APP_NAME') ?: 'kenaito-springboot-demo',
|
||||
'env' => getenv('KENAITO_PROFILE') ?: 'daily',
|
||||
'version' => getenv('KENAITO_VERSION') ?: 'latest',
|
||||
'token' => getenv('KENAITO_TOKEN') ?: '',
|
||||
'syncInterval' => (int)(getenv('KENAITO_SYNC_INTERVAL') ?: 30),
|
||||
'cacheDir' => getenv('KENAITO_CACHE_DIR') ?: '.',
|
||||
]);
|
||||
|
||||
$client = new ConfigClient($config);
|
||||
|
||||
$client->onChange(function ($changed) use (&$configValues) {
|
||||
foreach ($changed as $k => $v) {
|
||||
if (isset($configValues[$k])) {
|
||||
$configValues[$k] = $v;
|
||||
error_log("config updated: {$k} = {$v}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
$client->start();
|
||||
$remote = $client->getAll();
|
||||
foreach ($configValues as $k => &$v) {
|
||||
if (isset($remote[$k])) {
|
||||
$v = $remote[$k];
|
||||
}
|
||||
}
|
||||
error_log("loaded " . count($remote) . " configs from remote");
|
||||
} catch (Exception $e) {
|
||||
error_log("failed to init config client: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$port = (int)(getenv('SERVER_PORT') ?: 8084);
|
||||
|
||||
echo "Server started on port {$port}\n";
|
||||
echo "Current configs:\n";
|
||||
foreach ($configValues as $k => $v) {
|
||||
echo " {$k} = {$v}\n";
|
||||
}
|
||||
|
||||
while (true) {
|
||||
pcntl_signal_dispatch();
|
||||
usleep(100000);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# Kenaito Config Center PHP Client
|
||||
|
||||
PHP 语言的配置中心客户端 SDK。
|
||||
|
||||
## 依赖
|
||||
|
||||
- PHP 8.0+
|
||||
- ext-curl
|
||||
- ext-openssl
|
||||
- ext-pcntl
|
||||
|
||||
## 安装
|
||||
|
||||
### Composer
|
||||
|
||||
```bash
|
||||
cd kenaito-conf-php-client
|
||||
composer install
|
||||
```
|
||||
|
||||
或在项目中引用:
|
||||
|
||||
```json
|
||||
{
|
||||
"require": {
|
||||
"kenaito/conf-client": "*"
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "path",
|
||||
"url": "../kenaito-conf-php-client"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 手动引入
|
||||
|
||||
```php
|
||||
require_once 'kenaito-conf-php-client/autoload.php';
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
```php
|
||||
use KenaitoConf\Config;
|
||||
use KenaitoConf\ConfigClient;
|
||||
|
||||
$config = new Config([
|
||||
'baseUrl' => 'http://localhost:8080',
|
||||
'appName' => 'kenaito-springboot-demo',
|
||||
'env' => 'daily',
|
||||
'version' => 'latest',
|
||||
'syncInterval' => 30,
|
||||
]);
|
||||
|
||||
$client = new ConfigClient($config);
|
||||
|
||||
$client->onChange(function ($changed) {
|
||||
foreach ($changed as $k => $v) {
|
||||
echo "config changed: $k = $v\n";
|
||||
}
|
||||
});
|
||||
|
||||
$client->start();
|
||||
|
||||
$value = $client->get('database.host');
|
||||
$all = $client->getAll();
|
||||
|
||||
$client->stop();
|
||||
```
|
||||
|
||||
## 配置参数
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `baseUrl` | 配置中心地址 | 必填 |
|
||||
| `appName` | 应用名称 | 必填 |
|
||||
| `env` | 环境 | `dev` |
|
||||
| `version` | 配置版本 | `latest` |
|
||||
| `token` | 认证令牌 | 空 |
|
||||
| `syncInterval` | 同步间隔(秒) | `60` |
|
||||
| `ignoreGlobal` | 是否忽略全局配置 | `false` |
|
||||
| `cacheDir` | 本地缓存目录 | `.` |
|
||||
|
||||
## Demo
|
||||
|
||||
```bash
|
||||
cd kenaito-conf-php-client-demo
|
||||
php app.php
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/src/Config.php';
|
||||
require_once __DIR__ . '/src/Crypto.php';
|
||||
require_once __DIR__ . '/src/Cache.php';
|
||||
require_once __DIR__ . '/src/ConfigClient.php';
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "kenaito/conf-client",
|
||||
"description": "Kenaito Config Center PHP Client",
|
||||
"type": "library",
|
||||
"require": {
|
||||
"php": ">=8.0",
|
||||
"ext-curl": "*",
|
||||
"ext-openssl": "*",
|
||||
"ext-pcntl": "*"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"KenaitoConf\\": "src/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace KenaitoConf;
|
||||
|
||||
class Cache
|
||||
{
|
||||
private string $cacheDir;
|
||||
private const CACHE_FILE = 'config-cache.json';
|
||||
|
||||
public function __construct(string $cacheDir = '.')
|
||||
{
|
||||
$this->cacheDir = $cacheDir;
|
||||
}
|
||||
|
||||
public function write(array $data): void
|
||||
{
|
||||
$path = $this->cacheDir . DIRECTORY_SEPARATOR . self::CACHE_FILE;
|
||||
file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
public function read(): ?array
|
||||
{
|
||||
$path = $this->cacheDir . DIRECTORY_SEPARATOR . self::CACHE_FILE;
|
||||
if (!file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
$content = file_get_contents($path);
|
||||
return json_decode($content, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace KenaitoConf;
|
||||
|
||||
class Config
|
||||
{
|
||||
public string $baseUrl = '';
|
||||
public string $appName = '';
|
||||
public string $env = 'dev';
|
||||
public string $version = 'latest';
|
||||
public string $token = '';
|
||||
public int $syncInterval = 60;
|
||||
public bool $ignoreGlobal = false;
|
||||
public string $cacheDir = '.';
|
||||
|
||||
public function __construct(array $options = [])
|
||||
{
|
||||
foreach ($options as $key => $value) {
|
||||
if (property_exists($this, $key)) {
|
||||
$this->$key = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace KenaitoConf;
|
||||
|
||||
class ConfigClient
|
||||
{
|
||||
private Config $config;
|
||||
private Cache $cache;
|
||||
private array $configs = [];
|
||||
private string $secret = '';
|
||||
private string $globalSecret = '';
|
||||
private int $lastTime = 0;
|
||||
private bool $started = false;
|
||||
private array $handlers = [];
|
||||
private $timer = null;
|
||||
|
||||
public function __construct(Config $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->cache = new Cache($config->cacheDir);
|
||||
}
|
||||
|
||||
public function onChange(callable $handler): void
|
||||
{
|
||||
$this->handlers[] = $handler;
|
||||
}
|
||||
|
||||
public function get(string $key): ?string
|
||||
{
|
||||
return $this->configs[$key] ?? null;
|
||||
}
|
||||
|
||||
public function getAll(): array
|
||||
{
|
||||
return $this->configs;
|
||||
}
|
||||
|
||||
public function start(): void
|
||||
{
|
||||
if ($this->started) {
|
||||
return;
|
||||
}
|
||||
$this->started = true;
|
||||
|
||||
try {
|
||||
$this->fetchAll();
|
||||
} catch (\Exception $e) {
|
||||
error_log("[ConfigClient] fetch all configs error: {$e->getMessage()}, trying cache");
|
||||
$cached = $this->cache->read();
|
||||
if ($cached !== null) {
|
||||
$this->applyConfigs($cached);
|
||||
error_log("[ConfigClient] loaded " . count($this->configs) . " configs from cache");
|
||||
} else {
|
||||
throw new \RuntimeException("fetch configs failed and no cache: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->timer = pcntl_alarm(0);
|
||||
pcntl_signal(SIGALRM, [$this, 'syncTick']);
|
||||
pcntl_alarm($this->config->syncInterval);
|
||||
}
|
||||
|
||||
public function syncTick(): void
|
||||
{
|
||||
try {
|
||||
$this->fetchChanged();
|
||||
} catch (\Exception $e) {
|
||||
error_log("[ConfigClient] sync error: {$e->getMessage()}");
|
||||
}
|
||||
pcntl_alarm($this->config->syncInterval);
|
||||
}
|
||||
|
||||
public function stop(): void
|
||||
{
|
||||
if ($this->timer !== null) {
|
||||
pcntl_alarm(0);
|
||||
}
|
||||
}
|
||||
|
||||
private function fetchAll(): void
|
||||
{
|
||||
$url = $this->config->baseUrl . '/api/fetchAllConfigs';
|
||||
$payload = [
|
||||
'appName' => $this->config->appName,
|
||||
'env' => $this->config->env,
|
||||
'version' => $this->config->version,
|
||||
'ignoreGlobal' => $this->config->ignoreGlobal,
|
||||
'token' => $this->config->token,
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
throw new \RuntimeException("HTTP {$httpCode}");
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (isset($data['code']) && $data['code'] !== 0) {
|
||||
throw new \RuntimeException("server error: " . ($data['msg'] ?? 'unknown'));
|
||||
}
|
||||
|
||||
if (isset($data['kenaito.config.encrypt-secret'])) {
|
||||
$this->secret = $data['kenaito.config.encrypt-secret'];
|
||||
}
|
||||
if (isset($data['kenaito.config.global-encrypt-secret'])) {
|
||||
$this->globalSecret = $data['kenaito.config.global-encrypt-secret'];
|
||||
}
|
||||
if (isset($data['latest.fetch.timestamp'])) {
|
||||
$this->lastTime = (int)$data['latest.fetch.timestamp'];
|
||||
}
|
||||
|
||||
$this->cache->write($data);
|
||||
$this->applyConfigs($data);
|
||||
error_log("[ConfigClient] fetched " . count($this->configs) . " configs");
|
||||
}
|
||||
|
||||
private function fetchChanged(): void
|
||||
{
|
||||
$url = $this->config->baseUrl . '/api/fetchChangedConfigs';
|
||||
$payload = [
|
||||
'appName' => $this->config->appName,
|
||||
'env' => $this->config->env,
|
||||
'version' => $this->config->version,
|
||||
'lastTime' => $this->lastTime,
|
||||
'ignoreGlobal' => $this->config->ignoreGlobal,
|
||||
'token' => $this->config->token,
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
$result = json_decode($response, true);
|
||||
if (!isset($result['code']) || $result['code'] !== 0) {
|
||||
throw new \RuntimeException("server error code: " . ($result['code'] ?? 'unknown'));
|
||||
}
|
||||
|
||||
$changedData = $result['data'] ?? [];
|
||||
if (isset($changedData['latest.fetch.timestamp'])) {
|
||||
$this->lastTime = (int)$changedData['latest.fetch.timestamp'];
|
||||
unset($changedData['latest.fetch.timestamp']);
|
||||
}
|
||||
|
||||
$changed = [];
|
||||
foreach ($changedData as $k => $v) {
|
||||
$changed[$k] = $this->tryDecrypt($v);
|
||||
}
|
||||
|
||||
$this->configs = array_merge($this->configs, $changed);
|
||||
|
||||
foreach ($this->handlers as $handler) {
|
||||
try {
|
||||
$handler($changed);
|
||||
} catch (\Exception $e) {
|
||||
error_log("[ConfigClient] change handler error: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
error_log("[ConfigClient] " . count($changed) . " configs changed");
|
||||
}
|
||||
|
||||
private function applyConfigs(array $data): void
|
||||
{
|
||||
foreach ($data as $k => $v) {
|
||||
if (str_starts_with($k, 'kenaito.') || $k === 'latest.fetch.timestamp') {
|
||||
continue;
|
||||
}
|
||||
if (!is_string($v)) {
|
||||
continue;
|
||||
}
|
||||
$this->configs[$k] = $this->tryDecrypt($v);
|
||||
}
|
||||
}
|
||||
|
||||
private function tryDecrypt(string $value): string
|
||||
{
|
||||
$decrypted = Crypto::decryptConfig($this->secret, $value);
|
||||
if ($decrypted === $value && $this->globalSecret) {
|
||||
$decrypted = Crypto::decryptConfig($this->globalSecret, $value);
|
||||
}
|
||||
return $decrypted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace KenaitoConf;
|
||||
|
||||
class Crypto
|
||||
{
|
||||
private const CRYPT_PREFIX = '{Cipher}';
|
||||
|
||||
public static function decryptConfig(string $secret, string $data): string
|
||||
{
|
||||
if (!str_starts_with($data, self::CRYPT_PREFIX)) {
|
||||
return $data;
|
||||
}
|
||||
$encrypted = substr($data, strlen(self::CRYPT_PREFIX));
|
||||
return self::decryptWithAES($secret, $encrypted);
|
||||
}
|
||||
|
||||
public static function decryptWithAES(string $key, string $data): string
|
||||
{
|
||||
$md5Hash = md5($key);
|
||||
$aesKey = substr($md5Hash, 16);
|
||||
$cipherText = base64_decode($data);
|
||||
$iv = str_repeat("\0", 16);
|
||||
$plainText = openssl_decrypt($cipherText, 'AES-128-CBC', $aesKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $iv);
|
||||
$padding = ord($plainText[strlen($plainText) - 1]);
|
||||
return substr($plainText, 0, -$padding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative '../kenaito-conf-ruby-client/lib/kenaito_conf'
|
||||
|
||||
config_values = {
|
||||
'database.host' => ENV['DATABASE_HOST'] || '192.168.1.1',
|
||||
'database.port' => ENV['DATABASE_PORT'] || '8888',
|
||||
'database.name' => ENV['DATABASE_NAME'] || 'xxxx',
|
||||
'database.user' => ENV['DATABASE_USER'] || 'xxxx',
|
||||
'database.password' => ENV['DATABASE_PASSWORD'] || 'xxxxxxxx'
|
||||
}
|
||||
|
||||
config = KenaitoConf::Config.new(
|
||||
base_url: ENV['KENAITO_BASE_URL'] || 'http://localhost:8080',
|
||||
app_name: ENV['KENAITO_APP_NAME'] || 'kenaito-springboot-demo',
|
||||
env: ENV['KENAITO_PROFILE'] || 'daily',
|
||||
version: ENV['KENAITO_VERSION'] || 'latest',
|
||||
token: ENV['KENAITO_TOKEN'] || '',
|
||||
sync_interval: (ENV['KENAITO_SYNC_INTERVAL'] || '30').to_i,
|
||||
cache_dir: ENV['KENAITO_CACHE_DIR'] || '.'
|
||||
)
|
||||
|
||||
client = KenaitoConf::ConfigClient.new(config)
|
||||
|
||||
client.on_change do |changed|
|
||||
changed.each do |k, v|
|
||||
if config_values.key?(k)
|
||||
config_values[k] = v
|
||||
warn "config updated: #{k} = #{v}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
client.start
|
||||
remote = client.get_all
|
||||
config_values.each_key do |k|
|
||||
config_values[k] = remote[k] if remote[k]
|
||||
end
|
||||
warn "loaded #{remote.size} configs from remote"
|
||||
rescue StandardError => e
|
||||
warn "failed to init config client: #{e.message}"
|
||||
end
|
||||
|
||||
puts 'Current configs:'
|
||||
config_values.each do |k, v|
|
||||
puts " #{k} = #{v}"
|
||||
end
|
||||
|
||||
puts "\nListening for config changes... (press Ctrl+C to stop)"
|
||||
sleep
|
||||
@@ -0,0 +1,70 @@
|
||||
# Kenaito Config Center Ruby Client
|
||||
|
||||
Ruby 语言的配置中心客户端 SDK。
|
||||
|
||||
## 依赖
|
||||
|
||||
- Ruby 2.7+
|
||||
- 标准库(net/http, json, openssl, base64)
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
gem build kenaito-conf.gemspec
|
||||
gem install kenaito-conf-1.0.0.gem
|
||||
```
|
||||
|
||||
或在 Gemfile 中添加:
|
||||
|
||||
```ruby
|
||||
gem 'kenaito-conf', path: '../kenaito-conf-ruby-client'
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
```ruby
|
||||
require 'kenaito_conf'
|
||||
|
||||
config = KenaitoConf::Config.new(
|
||||
base_url: 'http://localhost:8080',
|
||||
app_name: 'kenaito-springboot-demo',
|
||||
env: 'daily',
|
||||
version: 'latest',
|
||||
sync_interval: 30
|
||||
)
|
||||
|
||||
client = KenaitoConf::ConfigClient.new(config)
|
||||
|
||||
client.on_change do |changed|
|
||||
changed.each do |k, v|
|
||||
puts "config changed: #{k} = #{v}"
|
||||
end
|
||||
end
|
||||
|
||||
client.start
|
||||
|
||||
value = client.get('database.host')
|
||||
all = client.get_all
|
||||
|
||||
client.stop
|
||||
```
|
||||
|
||||
## 配置参数
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `base_url` | 配置中心地址 | 必填 |
|
||||
| `app_name` | 应用名称 | 必填 |
|
||||
| `env` | 环境 | `dev` |
|
||||
| `version` | 配置版本 | `latest` |
|
||||
| `token` | 认证令牌 | 空 |
|
||||
| `sync_interval` | 同步间隔(秒) | `60` |
|
||||
| `ignore_global` | 是否忽略全局配置 | `false` |
|
||||
| `cache_dir` | 本地缓存目录 | `.` |
|
||||
|
||||
## Demo
|
||||
|
||||
```bash
|
||||
cd kenaito-conf-ruby-client-demo
|
||||
ruby app.rb
|
||||
```
|
||||
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Gem::Specification.new do |spec|
|
||||
spec.name = 'kenaito-conf'
|
||||
spec.version = '1.0.0'
|
||||
spec.authors = ['odboy']
|
||||
spec.email = ['tianjun@odboy.cn']
|
||||
|
||||
spec.summary = 'Kenaito Config Center Ruby Client'
|
||||
spec.description = 'Ruby client for Kenaito Config Center'
|
||||
spec.homepage = 'https://gitea.odboy.cn/odboy/kenaito-conf'
|
||||
spec.license = 'MIT'
|
||||
|
||||
spec.required_ruby_version = '>= 2.7.0'
|
||||
|
||||
spec.files = Dir['lib/**/*']
|
||||
spec.require_paths = ['lib']
|
||||
end
|
||||
@@ -0,0 +1,204 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'net/http'
|
||||
require 'json'
|
||||
require 'openssl'
|
||||
require 'base64'
|
||||
|
||||
module KenaitoConf
|
||||
class Config
|
||||
attr_accessor :base_url, :app_name, :env, :version, :token, :sync_interval, :ignore_global, :cache_dir
|
||||
|
||||
def initialize(options = {})
|
||||
@base_url = options[:base_url] || ''
|
||||
@app_name = options[:app_name] || ''
|
||||
@env = options[:env] || 'dev'
|
||||
@version = options[:version] || 'latest'
|
||||
@token = options[:token] || ''
|
||||
@sync_interval = options[:sync_interval] || 60
|
||||
@ignore_global = options[:ignore_global] || false
|
||||
@cache_dir = options[:cache_dir] || '.'
|
||||
end
|
||||
end
|
||||
|
||||
module Crypto
|
||||
CRYPT_PREFIX = '{Cipher}'
|
||||
|
||||
def self.decrypt_config(secret, data)
|
||||
return data unless data.start_with?(CRYPT_PREFIX)
|
||||
encrypted = data[CRYPT_PREFIX.length..]
|
||||
decrypt_with_aes(secret, encrypted)
|
||||
end
|
||||
|
||||
def self.decrypt_with_aes(key, data)
|
||||
md5_hash = OpenSSL::Digest::MD5.hexdigest(key)
|
||||
aes_key = md5_hash[16..]
|
||||
cipher_text = Base64.decode64(data)
|
||||
iv = "\0" * 16
|
||||
|
||||
cipher = OpenSSL::Cipher::AES128.new(:CBC)
|
||||
cipher.decrypt
|
||||
cipher.key = aes_key
|
||||
cipher.iv = iv
|
||||
cipher.padding = 0
|
||||
|
||||
plain_text = cipher.update(cipher_text) + cipher.final
|
||||
padding = plain_text[-1].ord
|
||||
plain_text[0...-padding]
|
||||
end
|
||||
end
|
||||
|
||||
class Cache
|
||||
CACHE_FILE = 'config-cache.json'
|
||||
|
||||
def initialize(cache_dir = '.')
|
||||
@cache_dir = cache_dir
|
||||
end
|
||||
|
||||
def write(data)
|
||||
File.write(File.join(@cache_dir, CACHE_FILE), JSON.pretty_generate(data))
|
||||
end
|
||||
|
||||
def read
|
||||
path = File.join(@cache_dir, CACHE_FILE)
|
||||
return nil unless File.exist?(path)
|
||||
JSON.parse(File.read(path))
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
class ConfigClient
|
||||
def initialize(config)
|
||||
@config = config
|
||||
@cache = Cache.new(config.cache_dir)
|
||||
@configs = {}
|
||||
@secret = ''
|
||||
@global_secret = ''
|
||||
@last_time = 0
|
||||
@handlers = []
|
||||
@started = false
|
||||
end
|
||||
|
||||
def on_change(&handler)
|
||||
@handlers << handler
|
||||
end
|
||||
|
||||
def get(key)
|
||||
@configs[key]
|
||||
end
|
||||
|
||||
def get_all
|
||||
@configs.dup
|
||||
end
|
||||
|
||||
def start
|
||||
return if @started
|
||||
@started = true
|
||||
|
||||
begin
|
||||
fetch_all
|
||||
rescue StandardError => e
|
||||
warn "[ConfigClient] fetch all configs error: #{e.message}, trying cache"
|
||||
cached = @cache.read
|
||||
if cached
|
||||
apply_configs(cached)
|
||||
warn "[ConfigClient] loaded #{@configs.size} configs from cache"
|
||||
else
|
||||
raise "fetch configs failed and no cache: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
Thread.new do
|
||||
loop do
|
||||
sleep @config.sync_interval
|
||||
begin
|
||||
fetch_changed
|
||||
rescue StandardError => e
|
||||
warn "[ConfigClient] sync error: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def stop
|
||||
@started = false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_all
|
||||
uri = URI("#{@config.base_url}/api/fetchAllConfigs")
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.read_timeout = 10
|
||||
|
||||
request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
|
||||
request.body = JSON.generate({
|
||||
appName: @config.app_name,
|
||||
env: @config.env,
|
||||
version: @config.version,
|
||||
ignoreGlobal: @config.ignore_global,
|
||||
token: @config.token
|
||||
})
|
||||
|
||||
response = http.request(request)
|
||||
raise "HTTP #{response.code}" unless response.code == '200'
|
||||
|
||||
data = JSON.parse(response.body)
|
||||
raise "server error: #{data['msg']}" if data['code'] && data['code'] != 0
|
||||
|
||||
@secret = data['kenaito.config.encrypt-secret'] if data['kenaito.config.encrypt-secret']
|
||||
@global_secret = data['kenaito.config.global-encrypt-secret'] if data['kenaito.config.global-encrypt-secret']
|
||||
@last_time = data['latest.fetch.timestamp'].to_i if data['latest.fetch.timestamp']
|
||||
|
||||
@cache.write(data)
|
||||
apply_configs(data)
|
||||
warn "[ConfigClient] fetched #{@configs.size} configs"
|
||||
end
|
||||
|
||||
def fetch_changed
|
||||
uri = URI("#{@config.base_url}/api/fetchChangedConfigs")
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.read_timeout = 10
|
||||
|
||||
request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
|
||||
request.body = JSON.generate({
|
||||
appName: @config.app_name,
|
||||
env: @config.env,
|
||||
version: @config.version,
|
||||
lastTime: @last_time,
|
||||
ignoreGlobal: @config.ignore_global,
|
||||
token: @config.token
|
||||
})
|
||||
|
||||
response = http.request(request)
|
||||
result = JSON.parse(response.body)
|
||||
raise "server error" if !result['code'] || result['code'] != 0
|
||||
|
||||
changed_data = result['data'] || {}
|
||||
@last_time = changed_data.delete('latest.fetch.timestamp').to_i if changed_data['latest.fetch.timestamp']
|
||||
|
||||
changed = {}
|
||||
changed_data.each do |k, v|
|
||||
changed[k] = try_decrypt(v)
|
||||
end
|
||||
|
||||
@configs.merge!(changed)
|
||||
@handlers.each { |h| h.call(changed) rescue warn "[ConfigClient] handler error: #{$!.message}" }
|
||||
end
|
||||
|
||||
def apply_configs(data)
|
||||
data.each do |k, v|
|
||||
next if k.start_with?('kenaito.') || k == 'latest.fetch.timestamp'
|
||||
next unless v.is_a?(String)
|
||||
@configs[k] = try_decrypt(v)
|
||||
end
|
||||
end
|
||||
|
||||
def try_decrypt(value)
|
||||
decrypted = Crypto.decrypt_config(@secret, value)
|
||||
decrypted = Crypto.decrypt_config(@global_secret, value) if decrypted == value && !@global_secret.empty?
|
||||
decrypted
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "kenaito-conf-demo"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
kenaito-conf = { path = "../kenaito-conf-rust-client" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -0,0 +1,52 @@
|
||||
use kenaito_conf::{Config, ConfigClient};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut config_values: HashMap<String, String> = HashMap::new();
|
||||
config_values.insert("database.host".to_string(), std::env::var("DATABASE_HOST").unwrap_or_else(|_| "192.168.1.1".to_string()));
|
||||
config_values.insert("database.port".to_string(), std::env::var("DATABASE_PORT").unwrap_or_else(|_| "8888".to_string()));
|
||||
config_values.insert("database.name".to_string(), std::env::var("DATABASE_NAME").unwrap_or_else(|_| "xxxx".to_string()));
|
||||
config_values.insert("database.user".to_string(), std::env::var("DATABASE_USER").unwrap_or_else(|_| "xxxx".to_string()));
|
||||
config_values.insert("database.password".to_string(), std::env::var("DATABASE_PASSWORD").unwrap_or_else(|_| "xxxxxxxx".to_string()));
|
||||
|
||||
let config = Config {
|
||||
base_url: std::env::var("KENAITO_BASE_URL").unwrap_or_else(|_| "http://localhost:8080".to_string()),
|
||||
app_name: std::env::var("KENAITO_APP_NAME").unwrap_or_else(|_| "kenaito-springboot-demo".to_string()),
|
||||
env: std::env::var("KENAITO_PROFILE").unwrap_or_else(|_| "daily".to_string()),
|
||||
version: std::env::var("KENAITO_VERSION").unwrap_or_else(|_| "latest".to_string()),
|
||||
token: std::env::var("KENAITO_TOKEN").unwrap_or_else(|_| String::new()),
|
||||
sync_interval: std::env::var("KENAITO_SYNC_INTERVAL").unwrap_or_else(|_| "30".to_string()).parse().unwrap_or(30),
|
||||
cache_dir: std::env::var("KENAITO_CACHE_DIR").unwrap_or_else(|_| ".".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let client = ConfigClient::new(config);
|
||||
|
||||
client.on_change(move |changed| {
|
||||
for (k, v) in changed.iter() {
|
||||
eprintln!("config changed: {} = {}", k, v);
|
||||
}
|
||||
});
|
||||
|
||||
match client.start().await {
|
||||
Ok(_) => {
|
||||
let remote = client.get_all();
|
||||
eprintln!("loaded {} configs from remote", remote.len());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("failed to init config client: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
println!("Current configs:");
|
||||
for (k, v) in &config_values {
|
||||
println!(" {} = {}", k, v);
|
||||
}
|
||||
|
||||
println!("\nListening for config changes... (press Ctrl+C to stop)");
|
||||
tokio::signal::ctrl_c().await?;
|
||||
println!("Shutting down...");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "kenaito-conf"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
authors = ["odboy <tianjun@odboy.cn>"]
|
||||
description = "Kenaito Config Center Rust Client"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
md-5 = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
base64 = "0.21"
|
||||
@@ -0,0 +1,70 @@
|
||||
# Kenaito Config Center Rust Client
|
||||
|
||||
Rust 语言的配置中心客户端 SDK。
|
||||
|
||||
## 依赖
|
||||
|
||||
- Rust 1.70+
|
||||
- reqwest (HTTP 客户端)
|
||||
- tokio (异步运行时)
|
||||
- aes + cbc (AES 解密)
|
||||
|
||||
## 安装
|
||||
|
||||
在 `Cargo.toml` 中添加:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
kenaito-conf = { path = "../kenaito-conf-rust-client" }
|
||||
```
|
||||
|
||||
## 使用
|
||||
|
||||
```rust
|
||||
use kenaito_conf::{Config, ConfigClient};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let config = Config {
|
||||
base_url: "http://localhost:8080".to_string(),
|
||||
app_name: "kenaito-springboot-demo".to_string(),
|
||||
env: "daily".to_string(),
|
||||
version: "latest".to_string(),
|
||||
sync_interval: 30,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let client = ConfigClient::new(config);
|
||||
|
||||
client.on_change(|changed| {
|
||||
for (k, v) in changed.iter() {
|
||||
println!("config changed: {} = {}", k, v);
|
||||
}
|
||||
});
|
||||
|
||||
client.start().await.unwrap();
|
||||
|
||||
let value = client.get("database.host");
|
||||
let all = client.get_all();
|
||||
}
|
||||
```
|
||||
|
||||
## 配置参数
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `base_url` | 配置中心地址 | 必填 |
|
||||
| `app_name` | 应用名称 | 必填 |
|
||||
| `env` | 环境 | `dev` |
|
||||
| `version` | 配置版本 | `latest` |
|
||||
| `token` | 认证令牌 | 空 |
|
||||
| `sync_interval` | 同步间隔(秒) | `60` |
|
||||
| `ignore_global` | 是否忽略全局配置 | `false` |
|
||||
| `cache_dir` | 本地缓存目录 | `.` |
|
||||
|
||||
## Demo
|
||||
|
||||
```bash
|
||||
cd kenaito-conf-rust-client-demo
|
||||
cargo run
|
||||
```
|
||||
@@ -0,0 +1,191 @@
|
||||
use aes::cipher::{block_padding::NoPadding, BlockDecryptMut, KeyIvInit};
|
||||
use md5::{Digest, Md5};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub base_url: String,
|
||||
pub app_name: String,
|
||||
pub env: String,
|
||||
pub version: String,
|
||||
pub token: String,
|
||||
pub sync_interval: u64,
|
||||
pub ignore_global: bool,
|
||||
pub cache_dir: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: String::new(),
|
||||
app_name: String::new(),
|
||||
env: "dev".to_string(),
|
||||
version: "latest".to_string(),
|
||||
token: String::new(),
|
||||
sync_interval: 60,
|
||||
ignore_global: false,
|
||||
cache_dir: ".".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct FetchAllRequest {
|
||||
app_name: String,
|
||||
env: String,
|
||||
version: String,
|
||||
ignore_global: bool,
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct FetchChangedRequest {
|
||||
app_name: String,
|
||||
env: String,
|
||||
version: String,
|
||||
last_time: i64,
|
||||
ignore_global: bool,
|
||||
token: String,
|
||||
}
|
||||
|
||||
fn decrypt_config(secret: &str, data: &str) -> String {
|
||||
let prefix = "{Cipher}";
|
||||
if !data.starts_with(prefix) {
|
||||
return data.to_string();
|
||||
}
|
||||
let encrypted = &data[prefix.len()..];
|
||||
decrypt_with_aes(secret, encrypted)
|
||||
}
|
||||
|
||||
fn decrypt_with_aes(key: &str, data: &str) -> String {
|
||||
let mut hasher = Md5::new();
|
||||
hasher.update(key.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
let md5_hex = format!("{:x}", result);
|
||||
let aes_key = &md5_hex[16..];
|
||||
|
||||
let cipher_text = base64::decode(data).unwrap_or_default();
|
||||
let iv = [0u8; 16];
|
||||
|
||||
let mut buf = cipher_text.clone();
|
||||
let decrypted = Aes128CbcDec::new_from_slices(aes_key.as_bytes(), &iv)
|
||||
.unwrap()
|
||||
.decrypt_padded_mut::<NoPadding>(&mut buf)
|
||||
.unwrap();
|
||||
|
||||
let padding = decrypted[decrypted.len() - 1] as usize;
|
||||
String::from_utf8_lossy(&decrypted[..decrypted.len() - padding]).to_string()
|
||||
}
|
||||
|
||||
pub struct ConfigClient {
|
||||
config: Config,
|
||||
configs: Arc<Mutex<HashMap<String, String>>>,
|
||||
secret: Arc<Mutex<String>>,
|
||||
global_secret: Arc<Mutex<String>>,
|
||||
last_time: Arc<Mutex<i64>>,
|
||||
handlers: Arc<Mutex<Vec<Box<dyn Fn(&HashMap<String, String>) + Send + Sync>>>>,
|
||||
}
|
||||
|
||||
impl ConfigClient {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self {
|
||||
config,
|
||||
configs: Arc::new(Mutex::new(HashMap::new())),
|
||||
secret: Arc::new(Mutex::new(String::new())),
|
||||
global_secret: Arc::new(Mutex::new(String::new())),
|
||||
last_time: Arc::new(Mutex::new(0)),
|
||||
handlers: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_change<F>(&self, handler: F)
|
||||
where
|
||||
F: Fn(&HashMap<String, String>) + Send + Sync + 'static,
|
||||
{
|
||||
self.handlers.lock().unwrap().push(Box::new(handler));
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<String> {
|
||||
self.configs.lock().unwrap().get(key).cloned()
|
||||
}
|
||||
|
||||
pub fn get_all(&self) -> HashMap<String, String> {
|
||||
self.configs.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.fetch_all().await?;
|
||||
|
||||
let configs = self.configs.clone();
|
||||
let secret = self.secret.clone();
|
||||
let global_secret = self.global_secret.clone();
|
||||
let last_time = self.last_time.clone();
|
||||
let handlers = self.handlers.clone();
|
||||
let config = self.config.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(config.sync_interval)).await;
|
||||
// fetch_changed logic here
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_all(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/api/fetchAllConfigs", self.config.base_url);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"appName": self.config.app_name,
|
||||
"env": self.config.env,
|
||||
"version": self.config.version,
|
||||
"ignoreGlobal": self.config.ignore_global,
|
||||
"token": self.config.token,
|
||||
});
|
||||
|
||||
let resp = client.post(&url).json(&payload).send().await?;
|
||||
let data: HashMap<String, serde_json::Value> = resp.json().await?;
|
||||
|
||||
if let Some(code) = data.get("code") {
|
||||
if code.as_i64() != Some(0) {
|
||||
return Err("server error".into());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(s) = data.get("kenaito.config.encrypt-secret") {
|
||||
*self.secret.lock().unwrap() = s.as_str().unwrap_or("").to_string();
|
||||
}
|
||||
if let Some(gs) = data.get("kenaito.config.global-encrypt-secret") {
|
||||
*self.global_secret.lock().unwrap() = gs.as_str().unwrap_or("").to_string();
|
||||
}
|
||||
if let Some(ts) = data.get("latest.fetch.timestamp") {
|
||||
*self.last_time.lock().unwrap() = ts.as_i64().unwrap_or(0);
|
||||
}
|
||||
|
||||
let mut configs = self.configs.lock().unwrap();
|
||||
let secret = self.secret.lock().unwrap().clone();
|
||||
let global_secret = self.global_secret.lock().unwrap().clone();
|
||||
|
||||
for (k, v) in data.iter() {
|
||||
if k.starts_with("kenaito.") || k == "latest.fetch.timestamp" {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = v.as_str() {
|
||||
let mut decrypted = decrypt_config(&secret, s);
|
||||
if decrypted == s && !global_secret.is_empty() {
|
||||
decrypted = decrypt_config(&global_secret, s);
|
||||
}
|
||||
configs.insert(k.clone(), decrypted);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user