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