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:
2026-03-05 21:14:42 +08:00
parent 2f5c343347
commit 4c3648cf7f
40 changed files with 2824 additions and 2 deletions
+236 -2
View File
@@ -6,7 +6,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 & 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/ # 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+)
└── 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(); 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 概览
### 管理端 API(需 JWT 认证) ### 管理端 API(需 JWT 认证)
+8
View File
@@ -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;
}
+56
View File
@@ -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` | 本地缓存目录 | `.` |
+206
View File
@@ -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;
}
+8
View File
@@ -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;
}
+50
View File
@@ -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` | 本地缓存目录 | `.` |
+153
View File
@@ -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...");
+267
View File
@@ -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>
+76
View File
@@ -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()
}
+66
View File
@@ -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
}
}
+49
View File
@@ -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
+73
View File
@@ -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
```
+120
View File
@@ -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"
}
}
+61
View File
@@ -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);
}
+91
View File
@@ -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
```
+6
View File
@@ -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';
+16
View File
@@ -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/"
}
}
}
+30
View File
@@ -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);
}
}
+24
View File
@@ -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;
}
}
+28
View File
@@ -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);
}
}
+51
View File
@@ -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
+70
View File
@@ -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
+8
View File
@@ -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"] }
+52
View File
@@ -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(())
}
+16
View File
@@ -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"
+70
View File
@@ -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
```
+191
View File
@@ -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(())
}
}