feat(client): 添加 Python 客户端及示例应用
- 新增 python-client 模块,提供 ConfigClient 配置拉取、定时同步、变更监听 - 使用 pyaes 替代 cryptography 实现 AES 解密,避免编译问题 - 新增 python-client-demo Flask 示例应用 - .gitignore 添加 Python 相关忽略规则
This commit is contained in:
+11
@@ -34,6 +34,17 @@ bin/
|
|||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
.eggs/
|
||||||
|
*.whl
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
from kenaito_conf import Config, ConfigClient
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
|
||||||
|
logger = logging.getLogger("demo")
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Config defaults (overridden by remote config center)
|
||||||
|
config_values = {
|
||||||
|
"database.host": os.getenv("DATABASE_HOST", "192.168.1.1"),
|
||||||
|
"database.port": os.getenv("DATABASE_PORT", "8888"),
|
||||||
|
"database.name": os.getenv("DATABASE_NAME", "xxxx"),
|
||||||
|
"database.user": os.getenv("DATABASE_USER", "xxxx"),
|
||||||
|
"database.password": os.getenv("DATABASE_PASSWORD", "xxxxxxxx"),
|
||||||
|
}
|
||||||
|
|
||||||
|
client: ConfigClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def on_config_changed(changed: dict[str, str]) -> None:
|
||||||
|
for k, v in changed.items():
|
||||||
|
if k in config_values:
|
||||||
|
config_values[k] = v
|
||||||
|
logger.info("config updated: %s = %s", k, v)
|
||||||
|
|
||||||
|
|
||||||
|
def create_client() -> ConfigClient:
|
||||||
|
cfg = Config(
|
||||||
|
base_url=os.getenv("KENAITO_BASE_URL", "http://localhost:8080"),
|
||||||
|
app_name=os.getenv("KENAITO_APP_NAME", "kenaito-springboot-demo"),
|
||||||
|
env=os.getenv("KENAITO_PROFILE", "daily"),
|
||||||
|
version=os.getenv("KENAITO_VERSION", "latest"),
|
||||||
|
token=os.getenv("KENAITO_TOKEN", ""),
|
||||||
|
sync_interval=int(os.getenv("KENAITO_SYNC_INTERVAL", "30")),
|
||||||
|
cache_dir=os.getenv("KENAITO_CACHE_DIR", "."),
|
||||||
|
)
|
||||||
|
c = ConfigClient(cfg)
|
||||||
|
c.on_change(on_config_changed)
|
||||||
|
c.start()
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return json.dumps(config_values, ensure_ascii=False, indent=2), 200, {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/health")
|
||||||
|
def health():
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
client = create_client()
|
||||||
|
remote = client.get_all()
|
||||||
|
for k in config_values:
|
||||||
|
if k in remote:
|
||||||
|
config_values[k] = remote[k]
|
||||||
|
logger.info("loaded %d configs from remote", len(remote))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("failed to init config client, using local defaults")
|
||||||
|
|
||||||
|
port = int(os.getenv("SERVER_PORT", "8082"))
|
||||||
|
app.run(host="0.0.0.0", port=port)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
flask>=3.0
|
||||||
|
kenaito-conf>=1.0.0
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Kenaito Config Center Python Client
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd python-client
|
||||||
|
pip3 install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```python
|
||||||
|
from kenaito_conf import Config, ConfigClient
|
||||||
|
|
||||||
|
config = Config(
|
||||||
|
base_url="http://localhost:8080",
|
||||||
|
app_name="my-app",
|
||||||
|
env="daily",
|
||||||
|
sync_interval=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
client = ConfigClient(config)
|
||||||
|
|
||||||
|
# 监听配置变更
|
||||||
|
def on_change(changed):
|
||||||
|
for k, v in changed.items():
|
||||||
|
print(f"config changed: {k} = {v}")
|
||||||
|
|
||||||
|
client.on_change(on_change)
|
||||||
|
|
||||||
|
# 启动客户端(拉取配置 + 开启定时同步)
|
||||||
|
client.start()
|
||||||
|
|
||||||
|
# 获取配置
|
||||||
|
value = client.get("database.host")
|
||||||
|
all_configs = client.get_all()
|
||||||
|
|
||||||
|
# 停止
|
||||||
|
client.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 参数 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `base_url` | 配置中心地址 | 必填 |
|
||||||
|
| `app_name` | 应用名称 | 必填 |
|
||||||
|
| `env` | 环境(dev/daily/prod) | `dev` |
|
||||||
|
| `version` | 配置版本 | `latest` |
|
||||||
|
| `token` | 认证令牌 | 空 |
|
||||||
|
| `sync_interval` | 同步间隔(秒) | `60` |
|
||||||
|
| `ignore_global` | 是否忽略全局配置 | `False` |
|
||||||
|
| `cache_dir` | 本地缓存目录 | `.` |
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd python-client-demo
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
python3 app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Demo 默认监听 `8082` 端口,访问 `http://localhost:8082/` 查看配置。
|
||||||
|
|
||||||
|
环境变量覆盖:
|
||||||
|
|
||||||
|
| 环境变量 | 说明 | 默认值 |
|
||||||
|
|----------|------|--------|
|
||||||
|
| `SERVER_PORT` | 服务端口 | `8082` |
|
||||||
|
| `KENAITO_BASE_URL` | 配置中心地址 | `http://localhost:8080` |
|
||||||
|
| `KENAITO_APP_NAME` | 应用名称 | `kenaito-springboot-demo` |
|
||||||
|
| `KENAITO_PROFILE` | 环境 | `daily` |
|
||||||
|
| `KENAITO_VERSION` | 配置版本 | `latest` |
|
||||||
|
| `KENAITO_TOKEN` | 认证令牌 | 空 |
|
||||||
|
| `KENAITO_SYNC_INTERVAL` | 同步间隔(秒) | `30` |
|
||||||
|
| `KENAITO_CACHE_DIR` | 缓存目录 | `.` |
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from kenaito_conf.client import ConfigClient
|
||||||
|
from kenaito_conf.config import Config
|
||||||
|
|
||||||
|
__all__ = ["ConfigClient", "Config"]
|
||||||
|
__version__ = "1.0.0"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
CACHE_FILE = "config-cache.json"
|
||||||
|
|
||||||
|
|
||||||
|
class Cache:
|
||||||
|
def __init__(self, cache_dir: str = "."):
|
||||||
|
self.cache_dir = cache_dir
|
||||||
|
|
||||||
|
def write(self, data: dict) -> None:
|
||||||
|
path = os.path.join(self.cache_dir, CACHE_FILE)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False)
|
||||||
|
|
||||||
|
def read(self) -> dict | None:
|
||||||
|
path = os.path.join(self.cache_dir, CACHE_FILE)
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from kenaito_conf.cache import Cache
|
||||||
|
from kenaito_conf.config import Config
|
||||||
|
from kenaito_conf.crypto import decrypt_config
|
||||||
|
from kenaito_conf.listener import Listener
|
||||||
|
|
||||||
|
logger = logging.getLogger("kenaito_conf")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigClient:
|
||||||
|
def __init__(self, config: Config):
|
||||||
|
self._config = config
|
||||||
|
self._cache = Cache(config.cache_dir)
|
||||||
|
self._listener = Listener()
|
||||||
|
self._configs: dict[str, str] = {}
|
||||||
|
self._secret: str = ""
|
||||||
|
self._global_secret: str = ""
|
||||||
|
self._last_time: int = 0
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
def on_change(self, handler) -> None:
|
||||||
|
self._listener.on_change(handler)
|
||||||
|
|
||||||
|
def get(self, key: str) -> str | None:
|
||||||
|
with self._lock:
|
||||||
|
return self._configs.get(key)
|
||||||
|
|
||||||
|
def get_all(self) -> dict[str, str]:
|
||||||
|
with self._lock:
|
||||||
|
return dict(self._configs)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._started:
|
||||||
|
return
|
||||||
|
self._started = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._fetch_all()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[ConfigClient] fetch all configs error: %s, trying cache", e)
|
||||||
|
cached = self._cache.read()
|
||||||
|
if cached is not None:
|
||||||
|
self._apply_configs(cached)
|
||||||
|
logger.info("[ConfigClient] loaded %d configs from cache", len(self._configs))
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"fetch configs failed and no cache: {e}") from e
|
||||||
|
|
||||||
|
t = threading.Thread(target=self._sync_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
def _fetch_all(self) -> None:
|
||||||
|
url = (
|
||||||
|
f"{self._config.base_url}/api/fetchAllConfigs"
|
||||||
|
f"?appName={self._config.app_name}"
|
||||||
|
f"&env={self._config.env}"
|
||||||
|
f"&version={self._config.version}"
|
||||||
|
f"&ignoreGlobal={str(self._config.ignore_global).lower()}"
|
||||||
|
)
|
||||||
|
resp = requests.get(url, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if data.get("code", 0) != 0:
|
||||||
|
raise RuntimeError(f"server error: {data.get('msg', 'unknown')}")
|
||||||
|
|
||||||
|
secret = data.get("kenaito.config.encrypt-secret")
|
||||||
|
if secret:
|
||||||
|
self._secret = secret
|
||||||
|
|
||||||
|
global_secret = data.get("kenaito.config.global-encrypt-secret")
|
||||||
|
if global_secret:
|
||||||
|
self._global_secret = global_secret
|
||||||
|
|
||||||
|
ts = data.get("latest.fetch.timestamp")
|
||||||
|
if ts:
|
||||||
|
self._last_time = int(ts)
|
||||||
|
|
||||||
|
self._cache.write(data)
|
||||||
|
self._apply_configs(data)
|
||||||
|
logger.info("[ConfigClient] fetched %d configs", len(self._configs))
|
||||||
|
|
||||||
|
def _fetch_changed(self) -> None:
|
||||||
|
url = f"{self._config.base_url}/api/fetchChangedConfigs"
|
||||||
|
payload = {
|
||||||
|
"appName": self._config.app_name,
|
||||||
|
"env": self._config.env,
|
||||||
|
"version": self._config.version,
|
||||||
|
"lastTime": self._last_time,
|
||||||
|
"ignoreGlobal": self._config.ignore_global,
|
||||||
|
}
|
||||||
|
resp = requests.post(url, json=payload, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
result = resp.json()
|
||||||
|
|
||||||
|
if result.get("code", -1) != 0:
|
||||||
|
raise RuntimeError(f"server error code: {result.get('code')}")
|
||||||
|
|
||||||
|
changed_data = result.get("data", {})
|
||||||
|
|
||||||
|
ts = changed_data.pop("latest.fetch.timestamp", None)
|
||||||
|
if ts:
|
||||||
|
self._last_time = int(ts)
|
||||||
|
|
||||||
|
if changed_data:
|
||||||
|
changed = {}
|
||||||
|
for k, v in changed_data.items():
|
||||||
|
decrypted = self._try_decrypt(v)
|
||||||
|
changed[k] = decrypted
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._configs.update(changed)
|
||||||
|
|
||||||
|
self._listener.notify(changed)
|
||||||
|
logger.info("[ConfigClient] %d configs changed", len(changed))
|
||||||
|
|
||||||
|
def _apply_configs(self, data: dict) -> None:
|
||||||
|
with self._lock:
|
||||||
|
for k, v in data.items():
|
||||||
|
if k.startswith("kenaito.") or k == "latest.fetch.timestamp":
|
||||||
|
continue
|
||||||
|
if not isinstance(v, str):
|
||||||
|
continue
|
||||||
|
self._configs[k] = self._try_decrypt(v)
|
||||||
|
|
||||||
|
def _try_decrypt(self, value: str) -> str:
|
||||||
|
decrypted = decrypt_config(self._secret, value)
|
||||||
|
if decrypted == value and self._global_secret:
|
||||||
|
decrypted = decrypt_config(self._global_secret, value)
|
||||||
|
return decrypted
|
||||||
|
|
||||||
|
def _sync_loop(self) -> None:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
self._stop_event.wait(self._config.sync_interval)
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
self._fetch_changed()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[ConfigClient] sync error")
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
class Config:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str = "",
|
||||||
|
app_name: str = "",
|
||||||
|
env: str = "dev",
|
||||||
|
version: str = "latest",
|
||||||
|
token: str = "",
|
||||||
|
sync_interval: int = 60,
|
||||||
|
ignore_global: bool = False,
|
||||||
|
cache_dir: str = ".",
|
||||||
|
):
|
||||||
|
self.base_url = base_url
|
||||||
|
self.app_name = app_name
|
||||||
|
self.env = env
|
||||||
|
self.version = version
|
||||||
|
self.token = token
|
||||||
|
self.sync_interval = sync_interval
|
||||||
|
self.ignore_global = ignore_global
|
||||||
|
self.cache_dir = cache_dir
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import pyaes
|
||||||
|
|
||||||
|
CRYPT_PREFIX = "{Cipher}"
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_config(secret: str, data: str) -> str:
|
||||||
|
if not data.startswith(CRYPT_PREFIX):
|
||||||
|
return data
|
||||||
|
encrypted = data[len(CRYPT_PREFIX):]
|
||||||
|
return decrypt_with_aes(secret, encrypted)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_with_aes(key: str, data: str) -> str:
|
||||||
|
md5_hash = hashlib.md5(key.encode()).hexdigest()
|
||||||
|
aes_key = md5_hash[16:].encode()
|
||||||
|
|
||||||
|
cipher_text = base64.b64decode(data)
|
||||||
|
|
||||||
|
iv = b"\0" * 16
|
||||||
|
aes_cbc = pyaes.AESModeOfOperationCBC(aes_key, iv=iv)
|
||||||
|
plain_text = aes_cbc.decrypt(cipher_text)
|
||||||
|
|
||||||
|
padding = plain_text[-1]
|
||||||
|
plain_text = plain_text[:-padding]
|
||||||
|
|
||||||
|
return plain_text.decode()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
ChangeHandler = Callable[[dict[str, str]], None]
|
||||||
|
|
||||||
|
logger = logging.getLogger("kenaito_conf")
|
||||||
|
|
||||||
|
|
||||||
|
class Listener:
|
||||||
|
def __init__(self):
|
||||||
|
self._handlers: list[ChangeHandler] = []
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def on_change(self, handler: ChangeHandler) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._handlers.append(handler)
|
||||||
|
|
||||||
|
def notify(self, changed: dict[str, str]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
handlers = list(self._handlers)
|
||||||
|
for h in handlers:
|
||||||
|
try:
|
||||||
|
h(changed)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[ConfigClient] change handler error")
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "kenaito-conf"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Kenaito Config Center Python Client"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"requests>=2.28",
|
||||||
|
"pyaes>=1.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0",
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user