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
+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; }
}
}