feat: 添加多语言客户端 Docker 支持

- 新增 .NET 客户端 SDK 和 demo 项目
- 添加 Go/Node.js/Python/.NET/SpringBoot2 的 Dockerfile
- Go demo 支持从环境变量读取配置
- 创建 README_DOCKERFILE.md 文档说明 Docker 使用方法
- 更新根目录 README.md 标记 .NET 客户端为已支持
This commit is contained in:
2026-06-14 21:07:35 +08:00
parent 18b8c4c18b
commit 82523b9236
19 changed files with 820 additions and 11 deletions
+41
View File
@@ -0,0 +1,41 @@
using Newtonsoft.Json;
namespace Kenaito.Conf.Client;
internal class Cache
{
private readonly string _cacheFile;
public Cache(string cacheDir)
{
_cacheFile = Path.Combine(cacheDir, "config-cache.json");
}
public Dictionary<string, object>? Read()
{
try
{
if (!File.Exists(_cacheFile))
return null;
var json = File.ReadAllText(_cacheFile);
return JsonConvert.DeserializeObject<Dictionary<string, object>>(json);
}
catch
{
return null;
}
}
public void Write(Dictionary<string, object> data)
{
try
{
var json = JsonConvert.SerializeObject(data, Formatting.Indented);
File.WriteAllText(_cacheFile, json);
}
catch
{
// ignore cache write errors
}
}
}
+222
View File
@@ -0,0 +1,222 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Kenaito.Conf.Client;
public class Client
{
private readonly Config _config;
private readonly Cache _cache;
private readonly Listener _listener;
private readonly HttpClient _httpClient;
private readonly Dictionary<string, string> _configs;
private readonly object _lock = new();
private long _lastTime;
private bool _started;
private CancellationTokenSource? _cts;
public Client(Config config)
{
_config = config;
if (string.IsNullOrEmpty(_config.Version))
_config.Version = "latest";
if (_config.SyncInterval <= 0)
_config.SyncInterval = 10;
if (string.IsNullOrEmpty(_config.CacheDir))
_config.CacheDir = ".";
_cache = new Cache(_config.CacheDir);
_listener = new Listener();
_configs = new Dictionary<string, string>();
_httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
}
public void OnChange(ChangeHandler handler)
{
_listener.OnChange(handler);
}
public string? Get(string key)
{
lock (_lock)
{
return _configs.TryGetValue(key, out var value) ? value : null;
}
}
public Dictionary<string, string> GetAll()
{
lock (_lock)
{
return new Dictionary<string, string>(_configs);
}
}
public async Task StartAsync()
{
if (_started)
return;
_started = true;
try
{
await FetchAllAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[ConfigClient] fetch all configs error: {ex.Message}, trying cache");
var cached = _cache.Read();
if (cached != null)
{
ApplyConfigs(cached);
Console.WriteLine($"[ConfigClient] loaded {CountConfigs(cached)} configs from cache");
}
else
{
throw new Exception("fetch configs failed and no cache", ex);
}
}
_cts = new CancellationTokenSource();
_ = SyncLoopAsync(_cts.Token);
}
public void Stop()
{
_cts?.Cancel();
}
private async Task FetchAllAsync()
{
var url = $"{_config.BaseUrl.TrimEnd('/')}/api/fetchAllConfigs";
var payload = new
{
appName = _config.AppName,
env = _config.Env,
version = _config.Version,
ignoreGlobal = _config.IgnoreGlobal,
token = _config.Token
};
var content = new StringContent(JsonConvert.SerializeObject(payload), System.Text.Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(url, content);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<Dictionary<string, object>>(body);
if (result == null)
throw new Exception("empty response");
if (result.TryGetValue("code", out var codeObj))
{
var code = Convert.ToInt64(codeObj);
if (code != 0)
{
var msg = result.TryGetValue("msg", out var msgObj) ? msgObj?.ToString() : "unknown error";
throw new Exception($"server error: {msg}");
}
}
if (result.TryGetValue("latest.fetch.timestamp", out var tsObj))
{
long.TryParse(tsObj?.ToString(), out _lastTime);
}
_cache.Write(result);
ApplyConfigs(result);
Console.WriteLine($"[ConfigClient] fetched {CountConfigs(result)} configs");
}
private async Task FetchChangedAsync()
{
var url = $"{_config.BaseUrl.TrimEnd('/')}/api/fetchChangedConfigs";
var payload = new
{
appName = _config.AppName,
env = _config.Env,
version = _config.Version,
lastTime = _lastTime,
ignoreGlobal = _config.IgnoreGlobal
};
var content = new StringContent(JsonConvert.SerializeObject(payload), System.Text.Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync(url, content);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
var wrapper = JsonConvert.DeserializeObject<FetchChangedResponse>(body);
if (wrapper == null)
return;
if (wrapper.Code != 0)
throw new Exception($"server error code: {wrapper.Code}");
if (wrapper.Data == null)
return;
if (wrapper.Data.TryGetValue("latest.fetch.timestamp", out var ts))
{
long.TryParse(ts, out _lastTime);
wrapper.Data.Remove("latest.fetch.timestamp");
}
if (wrapper.Data.Count > 0)
{
lock (_lock)
{
foreach (var kv in wrapper.Data)
{
_configs[kv.Key] = kv.Value;
}
}
_listener.Notify(wrapper.Data);
Console.WriteLine($"[ConfigClient] {wrapper.Data.Count} configs changed");
}
}
private void ApplyConfigs(Dictionary<string, object> data)
{
lock (_lock)
{
foreach (var kv in data)
{
if (kv.Key.StartsWith("kenaito.") || kv.Key == "latest.fetch.timestamp")
continue;
if (kv.Value is string strVal)
{
_configs[kv.Key] = strVal;
}
}
}
}
private int CountConfigs(Dictionary<string, object> data)
{
return data.Count(kv => !kv.Key.StartsWith("kenaito.") && kv.Key != "latest.fetch.timestamp" && kv.Value is string);
}
private async Task SyncLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(_config.SyncInterval * 1000, ct);
await FetchChangedAsync();
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Console.WriteLine($"[ConfigClient] sync error: {ex.Message}");
}
}
}
private class FetchChangedResponse
{
public int Code { get; set; }
public Dictionary<string, string>? Data { get; set; }
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace Kenaito.Conf.Client;
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; } = 10;
public string CacheDir { get; set; } = ".";
public bool IgnoreGlobal { get; set; } = false;
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackageId>Kenaito.Conf.Client</PackageId>
<Version>1.0.0</Version>
<Authors>odboy</Authors>
<Company>www.odboy.cn</Company>
<Description>Kenaito Config Center .NET Client</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>
+28
View File
@@ -0,0 +1,28 @@
namespace Kenaito.Conf.Client;
public delegate void ChangeHandler(Dictionary<string, string> changed);
internal class Listener
{
private readonly List<ChangeHandler> _handlers = new();
public void OnChange(ChangeHandler handler)
{
_handlers.Add(handler);
}
public void Notify(Dictionary<string, string> changed)
{
foreach (var handler in _handlers)
{
try
{
handler(changed);
}
catch
{
// ignore handler errors
}
}
}
}
+75
View File
@@ -0,0 +1,75 @@
# Kenaito Config Center .NET Client
kenaito-conf 配置中心的 .NET 客户端 SDK。
## 安装
### 本地编译
```bash
cd kenaito-conf-dotnet-client
dotnet build
```
### 作为本地依赖
在 demo 项目的 csproj 中引用:
```xml
<ItemGroup>
<ProjectReference Include="../kenaito-conf-dotnet-client/KenaitoConfClient.csproj" />
</ItemGroup>
```
## 快速开始
```csharp
using Kenaito.Conf.Client;
var config = new Config
{
BaseUrl = "http://localhost:8080",
AppName = "kenaito-dotnet-demo",
Env = "daily",
SyncInterval = 10
};
var client = new Client(config);
client.OnChange(changed =>
{
Console.WriteLine("Config changed:");
foreach (var kv in changed)
{
Console.WriteLine($" {kv.Key} = {kv.Value}");
}
});
await client.StartAsync();
var dbHost = client.Get("database.host");
Console.WriteLine($"database.host = {dbHost}");
Console.ReadLine();
client.Stop();
```
## 配置项
| 配置项 | 说明 | 默认值 | 必填 |
|--------|------|--------|------|
| BaseUrl | 配置中心地址 | - | 是 |
| AppName | 应用名称 | - | 是 |
| Env | 环境标识 | dev | 否 |
| Version | 配置版本 | latest | 否 |
| Token | 认证令牌 | null | 否 |
| SyncInterval | 同步间隔(秒) | 10 | 否 |
| CacheDir | 缓存目录 | . | 否 |
| IgnoreGlobal | 是否忽略全局配置 | false | 否 |
## 功能特性
- 启动时拉取全量配置
- 定时轮询增量配置变更
- 本地文件缓存,服务不可用时从缓存加载
- 配置变更回调通知