apollo-config-client 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apollo_config/__init__.py +44 -0
- apollo_config/_factory.py +90 -0
- apollo_config/client.py +248 -0
- apollo_config/contrib.py +145 -0
- apollo_config/manager.py +94 -0
- apollo_config_client-0.1.0.dist-info/METADATA +164 -0
- apollo_config_client-0.1.0.dist-info/RECORD +9 -0
- apollo_config_client-0.1.0.dist-info/WHEEL +4 -0
- apollo_config_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""apollo-config-client:一个轻量、同步、贴近 Apollo 官方 HTTP API 的 Python 配置客户端。
|
|
2
|
+
|
|
3
|
+
特性:
|
|
4
|
+
- 长轮询(long polling)热更新,秒级生效,带 key 级 diff 回调。
|
|
5
|
+
- 本地磁盘缓存,Apollo 不可用时应用仍可用上一次配置启动(容灾)。
|
|
6
|
+
- 访问密钥(Access Key)HMAC-SHA1 签名,兼容 Apollo 1.6+。
|
|
7
|
+
- 极简依赖:核心仅依赖 `requests`;pydantic 模型与 Oracle 助手为可选 extra。
|
|
8
|
+
|
|
9
|
+
典型用法::
|
|
10
|
+
|
|
11
|
+
from apollo_config import create_config_manager_from_env
|
|
12
|
+
|
|
13
|
+
cm = create_config_manager_from_env()
|
|
14
|
+
cm.get_configs("application") # 读取普通 namespace
|
|
15
|
+
cm.get_value("feature_flag", "off") # 读取单个 key
|
|
16
|
+
|
|
17
|
+
@cm.on_namespace_change("database")
|
|
18
|
+
def _on_db_change(ns, changes):
|
|
19
|
+
... # 配置变更时惰性重建连接池等可变资源
|
|
20
|
+
|
|
21
|
+
公开 API:
|
|
22
|
+
- :class:`ApolloClient`:底层 HTTP 客户端(长轮询 / 缓存 / 签名)。
|
|
23
|
+
- :class:`ConfigManager`:按 namespace 订阅变更的二次封装 + 通用读取。
|
|
24
|
+
- :func:`create_config_manager`:显式参数创建。
|
|
25
|
+
- :func:`create_config_manager_from_env`:读环境变量创建(可选 dotenv)。
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from apollo_config.client import ApolloClient
|
|
30
|
+
from apollo_config.manager import ConfigManager
|
|
31
|
+
from apollo_config._factory import (
|
|
32
|
+
create_config_manager,
|
|
33
|
+
create_config_manager_from_env,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"ApolloClient",
|
|
40
|
+
"ConfigManager",
|
|
41
|
+
"create_config_manager",
|
|
42
|
+
"create_config_manager_from_env",
|
|
43
|
+
"__version__",
|
|
44
|
+
]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""工厂函数:构建 ConfigManager。
|
|
2
|
+
|
|
3
|
+
库只提供工厂,不提供全局单例(避免与应用框架的生命周期冲突)。
|
|
4
|
+
应用侧(如 Flask / FastAPI 的 settings 模块)可自行决定是否缓存为单例。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from apollo_config.manager import ConfigManager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _env_namespaces() -> List[str]:
|
|
15
|
+
"""运行时读取 APOLLO_NAMESPACES,避免模块导入时缓存导致后续设置不生效。"""
|
|
16
|
+
return [
|
|
17
|
+
n.strip()
|
|
18
|
+
for n in os.getenv("APOLLO_NAMESPACES", "application").split(",")
|
|
19
|
+
if n.strip()
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_config_manager(
|
|
24
|
+
app_id: str,
|
|
25
|
+
config_url: str,
|
|
26
|
+
cluster: str = "default",
|
|
27
|
+
namespaces: Optional[List[str]] = None,
|
|
28
|
+
env: Optional[str] = None,
|
|
29
|
+
access_key_secret: Optional[str] = None,
|
|
30
|
+
cache_dir: str = ".apollo_cache",
|
|
31
|
+
auto_start: bool = True,
|
|
32
|
+
poll_timeout: int = 70,
|
|
33
|
+
request_timeout: int = 5,
|
|
34
|
+
poll_interval: float = 2.0,
|
|
35
|
+
max_retry_backoff: float = 30.0,
|
|
36
|
+
) -> ConfigManager:
|
|
37
|
+
"""以显式参数创建 ConfigManager。
|
|
38
|
+
|
|
39
|
+
``auto_start=True``(默认)时立即拉取一次全量配置并启动后台长轮询线程。
|
|
40
|
+
"""
|
|
41
|
+
mgr = ConfigManager(
|
|
42
|
+
app_id=app_id,
|
|
43
|
+
config_url=config_url,
|
|
44
|
+
cluster=cluster,
|
|
45
|
+
namespaces=namespaces,
|
|
46
|
+
env=env,
|
|
47
|
+
access_key_secret=access_key_secret,
|
|
48
|
+
cache_dir=cache_dir,
|
|
49
|
+
poll_timeout=poll_timeout,
|
|
50
|
+
request_timeout=request_timeout,
|
|
51
|
+
poll_interval=poll_interval,
|
|
52
|
+
max_retry_backoff=max_retry_backoff,
|
|
53
|
+
)
|
|
54
|
+
if auto_start:
|
|
55
|
+
mgr.start()
|
|
56
|
+
return mgr
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def create_config_manager_from_env(auto_start: bool = True) -> ConfigManager:
|
|
60
|
+
"""从环境变量构建 ConfigManager。
|
|
61
|
+
|
|
62
|
+
读取以下环境变量::
|
|
63
|
+
|
|
64
|
+
APOLLO_APP_ID 应用 AppId(默认 "apollo-app")
|
|
65
|
+
APOLLO_CONFIG_URL Config Service 地址(默认 http://localhost:8080)
|
|
66
|
+
APOLLO_CLUSTER 集群(默认 "default")
|
|
67
|
+
APOLLO_NAMESPACES 逗号分隔的 namespace 列表(默认 "application")
|
|
68
|
+
APOLLO_ENV 环境名(默认 None)
|
|
69
|
+
APOLLO_ACCESS_KEY_SECRET 访问密钥(默认 None,留空走匿名)
|
|
70
|
+
APOLLO_CACHE_DIR 本地缓存目录(默认 ".apollo_cache")
|
|
71
|
+
|
|
72
|
+
若已安装 ``python-dotenv``,则自动调用 ``load_dotenv()``(未安装则跳过,不报错)。
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
from dotenv import load_dotenv
|
|
76
|
+
|
|
77
|
+
load_dotenv()
|
|
78
|
+
except ImportError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
return create_config_manager(
|
|
82
|
+
app_id=os.getenv("APOLLO_APP_ID", "apollo-app"),
|
|
83
|
+
config_url=os.getenv("APOLLO_CONFIG_URL", "http://localhost:8080"),
|
|
84
|
+
cluster=os.getenv("APOLLO_CLUSTER", "default"),
|
|
85
|
+
namespaces=_env_namespaces(),
|
|
86
|
+
env=os.getenv("APOLLO_ENV"),
|
|
87
|
+
access_key_secret=os.getenv("APOLLO_ACCESS_KEY_SECRET"),
|
|
88
|
+
cache_dir=os.getenv("APOLLO_CACHE_DIR", ".apollo_cache"),
|
|
89
|
+
auto_start=auto_start,
|
|
90
|
+
)
|
apollo_config/client.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Apollo 配置中心 HTTP 客户端(兼容 Apollo 2.x,含长轮询热更新与本地容灾缓存)。
|
|
2
|
+
|
|
3
|
+
直接基于 Apollo 开放的 HTTP 接口实现,不依赖任何第三方 Apollo 专用客户端:
|
|
4
|
+
- 配置拉取: GET {config_url}/configs/{appId}/{cluster}/{namespace}
|
|
5
|
+
- 变更通知: GET {config_url}/notifications/v2 (HTTP 长轮询)
|
|
6
|
+
官方 Java / .NET 客户端底层使用的也是同一套接口,因此本实现可稳定对接 Apollo 2.4.1。
|
|
7
|
+
|
|
8
|
+
特性:
|
|
9
|
+
1. 长轮询(long polling)监听配置变更,秒级生效(热更新)。
|
|
10
|
+
2. 变更回调:拿到 namespace 的 key 级 diff,方便业务层做热重建。
|
|
11
|
+
3. 本地缓存:每次成功拉取都会落盘,Apollo 不可用时应用仍能用上次配置启动。
|
|
12
|
+
4. 线程安全的运行时配置快照,供业务直接读取。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import hmac
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
import requests
|
|
27
|
+
from requests import Request
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("apollo")
|
|
30
|
+
|
|
31
|
+
# 变更回调签名: (namespace: str, changes: Dict[key, {"old":..., "new":...}])
|
|
32
|
+
ChangeCallback = Callable[[str, Dict[str, Any]], None]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ApolloClient:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
app_id: str,
|
|
39
|
+
config_url: str,
|
|
40
|
+
cluster: str = "default",
|
|
41
|
+
namespaces: Optional[List[str]] = None,
|
|
42
|
+
env: Optional[str] = None,
|
|
43
|
+
client_ip: Optional[str] = None,
|
|
44
|
+
access_key_secret: Optional[str] = None,
|
|
45
|
+
cache_dir: str = ".apollo_cache",
|
|
46
|
+
poll_timeout: int = 70,
|
|
47
|
+
request_timeout: int = 5,
|
|
48
|
+
poll_interval: float = 2.0,
|
|
49
|
+
max_retry_backoff: float = 30.0,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.app_id = app_id
|
|
52
|
+
self.config_url = config_url.rstrip("/")
|
|
53
|
+
self.cluster = cluster
|
|
54
|
+
self.env = env
|
|
55
|
+
self.namespaces = namespaces or ["application"]
|
|
56
|
+
self.client_ip = client_ip
|
|
57
|
+
# 访问密钥(可选):配了则对所有请求做 HMAC-SHA1 签名,否则走匿名访问。
|
|
58
|
+
self.access_key_secret = access_key_secret
|
|
59
|
+
self.cache_dir = Path(cache_dir)
|
|
60
|
+
self.poll_timeout = poll_timeout # 略大于 Apollo 服务端长轮询超时(默认 60s)
|
|
61
|
+
self.request_timeout = request_timeout
|
|
62
|
+
self.poll_interval = poll_interval
|
|
63
|
+
self.max_retry_backoff = max_retry_backoff
|
|
64
|
+
|
|
65
|
+
# 运行时状态(加锁保证线程安全)
|
|
66
|
+
self._lock = threading.RLock()
|
|
67
|
+
self._configs: Dict[str, Dict[str, str]] = {}
|
|
68
|
+
self._release_keys: Dict[str, str] = {}
|
|
69
|
+
self._notification_ids: Dict[str, int] = {ns: -1 for ns in self.namespaces}
|
|
70
|
+
|
|
71
|
+
self._callbacks: List[ChangeCallback] = []
|
|
72
|
+
self._running = False
|
|
73
|
+
self._thread: Optional[threading.Thread] = None
|
|
74
|
+
|
|
75
|
+
# 启动前先装载本地缓存,保证 Apollo 不可用时应用仍可启动
|
|
76
|
+
for ns in self.namespaces:
|
|
77
|
+
cached = self._load_cache(ns)
|
|
78
|
+
if cached:
|
|
79
|
+
with self._lock:
|
|
80
|
+
self._configs[ns] = cached.get("configurations", {})
|
|
81
|
+
self._release_keys[ns] = cached.get("releaseKey", "")
|
|
82
|
+
logger.info("从本地缓存恢复 namespace=%s 的配置", ns)
|
|
83
|
+
|
|
84
|
+
# ------------------------- 对外 API -------------------------
|
|
85
|
+
def register_callback(self, cb: ChangeCallback) -> None:
|
|
86
|
+
"""注册全局配置变更回调。"""
|
|
87
|
+
self._callbacks.append(cb)
|
|
88
|
+
|
|
89
|
+
def get_configs(self, namespace: str = "application") -> Dict[str, str]:
|
|
90
|
+
with self._lock:
|
|
91
|
+
return dict(self._configs.get(namespace, {}))
|
|
92
|
+
|
|
93
|
+
def get_value(self, key: str, default: Optional[str] = None, namespace: str = "application") -> Optional[str]:
|
|
94
|
+
return self.get_configs(namespace).get(key, default)
|
|
95
|
+
|
|
96
|
+
def start(self) -> None:
|
|
97
|
+
"""拉取一次全量配置并启动后台长轮询线程。"""
|
|
98
|
+
if self._running:
|
|
99
|
+
return
|
|
100
|
+
for ns in self.namespaces:
|
|
101
|
+
try:
|
|
102
|
+
self._fetch_namespace(ns)
|
|
103
|
+
except Exception as exc: # noqa: BLE001
|
|
104
|
+
logger.warning("启动时拉取 namespace=%s 失败(将使用本地缓存): %s", ns, exc)
|
|
105
|
+
self._running = True
|
|
106
|
+
self._thread = threading.Thread(target=self._long_poll, name="apollo-long-poll", daemon=True)
|
|
107
|
+
self._thread.start()
|
|
108
|
+
logger.info("Apollo 长轮询已启动, appId=%s namespaces=%s", self.app_id, self.namespaces)
|
|
109
|
+
|
|
110
|
+
def stop(self) -> None:
|
|
111
|
+
self._running = False
|
|
112
|
+
if self._thread:
|
|
113
|
+
self._thread.join(timeout=5)
|
|
114
|
+
|
|
115
|
+
# ------------------------- 内部实现 -------------------------
|
|
116
|
+
def _auth_headers(self, path_with_query: str) -> Dict[str, str]:
|
|
117
|
+
"""Apollo 1.6+ 客户端访问密钥签名。
|
|
118
|
+
|
|
119
|
+
明文 = 毫秒时间戳 + "\\n" + pathWithQuery;HMAC-SHA1 + Base64。
|
|
120
|
+
通过 Timestamp / Authorization 两个头传递(无独立 Signature 头)。
|
|
121
|
+
未配置 access_key_secret 时返回空 dict(匿名访问)。
|
|
122
|
+
"""
|
|
123
|
+
if not self.access_key_secret:
|
|
124
|
+
return {}
|
|
125
|
+
timestamp = str(int(time.time() * 1000))
|
|
126
|
+
plain = f"{timestamp}\n{path_with_query}"
|
|
127
|
+
digest = hmac.new(
|
|
128
|
+
self.access_key_secret.encode("utf-8"), plain.encode("utf-8"), hashlib.sha1
|
|
129
|
+
).digest()
|
|
130
|
+
signature = base64.b64encode(digest).decode("utf-8")
|
|
131
|
+
return {
|
|
132
|
+
"Timestamp": timestamp,
|
|
133
|
+
"Authorization": f"Apollo {self.app_id}:{signature}",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
def _signed_get(self, url: str, params: Dict[str, Any], timeout: int) -> "requests.Response":
|
|
137
|
+
"""构造带签名的 GET 请求。
|
|
138
|
+
|
|
139
|
+
用 Request.prepare() 得到最终 pathWithQuery,确保签名用的 path 与
|
|
140
|
+
实际发出的 URL 完全一致(含查询串的编码)。
|
|
141
|
+
"""
|
|
142
|
+
prepared = Request("GET", url, params=params).prepare()
|
|
143
|
+
headers = self._auth_headers(prepared.path_url)
|
|
144
|
+
return requests.get(prepared.url, headers=headers, timeout=timeout)
|
|
145
|
+
|
|
146
|
+
def _fetch_namespace(self, namespace: str) -> Dict[str, Any]:
|
|
147
|
+
url = f"{self.config_url}/configs/{self.app_id}/{self.cluster}/{namespace}"
|
|
148
|
+
params: Dict[str, Any] = {}
|
|
149
|
+
release_key = self._release_keys.get(namespace)
|
|
150
|
+
if release_key:
|
|
151
|
+
params["releaseKey"] = release_key
|
|
152
|
+
if self.client_ip:
|
|
153
|
+
params["ip"] = self.client_ip
|
|
154
|
+
|
|
155
|
+
resp = self._signed_get(url, params, self.request_timeout)
|
|
156
|
+
resp.raise_for_status()
|
|
157
|
+
data = resp.json()
|
|
158
|
+
new_configs = data.get("configurations", {})
|
|
159
|
+
new_release = data.get("releaseKey", "")
|
|
160
|
+
|
|
161
|
+
with self._lock:
|
|
162
|
+
old_configs = self._configs.get(namespace, {})
|
|
163
|
+
changes = self._diff(old_configs, new_configs)
|
|
164
|
+
self._configs[namespace] = new_configs
|
|
165
|
+
self._release_keys[namespace] = new_release
|
|
166
|
+
|
|
167
|
+
self._save_cache(namespace, new_release, new_configs)
|
|
168
|
+
|
|
169
|
+
if changes:
|
|
170
|
+
logger.info("namespace=%s 配置变更: %s", namespace, list(changes.keys()))
|
|
171
|
+
for cb in self._callbacks:
|
|
172
|
+
try:
|
|
173
|
+
cb(namespace, changes)
|
|
174
|
+
except Exception as exc: # noqa: BLE001
|
|
175
|
+
logger.exception("配置变更回调执行失败: %s", exc)
|
|
176
|
+
return changes
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _diff(old: Dict[str, str], new: Dict[str, str]) -> Dict[str, Dict[str, Any]]:
|
|
180
|
+
changes: Dict[str, Dict[str, Any]] = {}
|
|
181
|
+
for k in set(old) | set(new):
|
|
182
|
+
ov, nv = old.get(k), new.get(k)
|
|
183
|
+
if ov != nv:
|
|
184
|
+
changes[k] = {"old": ov, "new": nv}
|
|
185
|
+
return changes
|
|
186
|
+
|
|
187
|
+
def _long_poll(self) -> None:
|
|
188
|
+
backoff = self.poll_interval
|
|
189
|
+
while self._running:
|
|
190
|
+
try:
|
|
191
|
+
notifications = [
|
|
192
|
+
{"namespaceName": ns, "notificationId": self._notification_ids.get(ns, -1)}
|
|
193
|
+
for ns in self.namespaces
|
|
194
|
+
]
|
|
195
|
+
url = f"{self.config_url}/notifications/v2"
|
|
196
|
+
params = {
|
|
197
|
+
"appId": self.app_id,
|
|
198
|
+
"cluster": self.cluster,
|
|
199
|
+
"notifications": json.dumps(notifications),
|
|
200
|
+
}
|
|
201
|
+
resp = self._signed_get(url, params, self.poll_timeout)
|
|
202
|
+
if resp.status_code == 304:
|
|
203
|
+
continue
|
|
204
|
+
resp.raise_for_status()
|
|
205
|
+
items = resp.json() or []
|
|
206
|
+
if not items:
|
|
207
|
+
backoff = self.poll_interval
|
|
208
|
+
continue
|
|
209
|
+
for item in items:
|
|
210
|
+
ns = item["namespaceName"]
|
|
211
|
+
nid = item["notificationId"]
|
|
212
|
+
if nid > self._notification_ids.get(ns, -1):
|
|
213
|
+
self._notification_ids[ns] = nid
|
|
214
|
+
try:
|
|
215
|
+
self._fetch_namespace(ns)
|
|
216
|
+
except Exception as exc: # noqa: BLE001
|
|
217
|
+
logger.warning("长轮询触发后拉取 namespace=%s 失败: %s", ns, exc)
|
|
218
|
+
backoff = self.poll_interval
|
|
219
|
+
except requests.exceptions.Timeout:
|
|
220
|
+
# 长轮询超时(服务端 60s 无变更)是正常现象,立即继续下一轮
|
|
221
|
+
continue
|
|
222
|
+
except Exception as exc: # noqa: BLE001
|
|
223
|
+
logger.warning("Apollo 长轮询异常, %ss 后重试: %s", backoff, exc)
|
|
224
|
+
time.sleep(backoff)
|
|
225
|
+
backoff = min(backoff * 2, self.max_retry_backoff)
|
|
226
|
+
|
|
227
|
+
# ------------------------- 本地缓存 -------------------------
|
|
228
|
+
def _cache_path(self, namespace: str) -> Path:
|
|
229
|
+
d = self.cache_dir / self.app_id / (self.env or "default")
|
|
230
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
231
|
+
safe = namespace.replace("/", "_")
|
|
232
|
+
return d / f"{safe}.json"
|
|
233
|
+
|
|
234
|
+
def _save_cache(self, namespace: str, release_key: str, configs: Dict[str, str]) -> None:
|
|
235
|
+
try:
|
|
236
|
+
payload = {"releaseKey": release_key, "configurations": configs}
|
|
237
|
+
self._cache_path(namespace).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
238
|
+
except OSError as exc: # noqa: BLE001
|
|
239
|
+
logger.warning("写入本地缓存失败 namespace=%s: %s", namespace, exc)
|
|
240
|
+
|
|
241
|
+
def _load_cache(self, namespace: str) -> Optional[Dict[str, Any]]:
|
|
242
|
+
try:
|
|
243
|
+
p = self._cache_path(namespace)
|
|
244
|
+
if p.exists():
|
|
245
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
246
|
+
except (OSError, json.JSONDecodeError) as exc: # noqa: BLE001
|
|
247
|
+
logger.warning("读取本地缓存失败 namespace=%s: %s", namespace, exc)
|
|
248
|
+
return None
|
apollo_config/contrib.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""可选领域辅助(需要对应的 extra 依赖)。
|
|
2
|
+
|
|
3
|
+
安装方式::
|
|
4
|
+
|
|
5
|
+
pip install apollo-config-client[models] # DatabaseConfig / LdapConfig(依赖 pydantic)
|
|
6
|
+
pip install apollo-config-client[oracle] # init_oracle_thick_mode(依赖 oracledb)
|
|
7
|
+
pip install apollo-config-client[all] # 以上全部
|
|
8
|
+
|
|
9
|
+
核心库(``apollo_config``)刻意不依赖这些领域模型;这里仅作为「拿来即用」的参考实现,
|
|
10
|
+
你完全可以用自己的项目模型调用 ``ConfigManager.get_typed(namespace, YourModel)``。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from typing import Any, Dict
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from pydantic import BaseModel, ConfigDict
|
|
20
|
+
except ImportError as exc: # pragma: no cover
|
|
21
|
+
raise ImportError(
|
|
22
|
+
"apollo_config.contrib 的模型需要 pydantic,请执行 "
|
|
23
|
+
"pip install apollo-config-client[models]"
|
|
24
|
+
) from exc
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("apollo.contrib")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DatabaseConfig(BaseModel):
|
|
30
|
+
model_config = ConfigDict(extra="ignore")
|
|
31
|
+
|
|
32
|
+
# 数据库类型 / 驱动:默认 Oracle 11g + python-oracledb
|
|
33
|
+
dialect: str = "oracle" # oracle / postgresql / mysql
|
|
34
|
+
driver: str = "oracledb" # Oracle: oracledb(推荐) / cx_oracle
|
|
35
|
+
host: str
|
|
36
|
+
port: int = 1521 # Oracle 默认监听端口 1521
|
|
37
|
+
username: str
|
|
38
|
+
password: str
|
|
39
|
+
# Oracle 连接标识:service_name 与 sid 二选一(11g 常用 service_name)
|
|
40
|
+
service_name: str | None = None
|
|
41
|
+
sid: str | None = None
|
|
42
|
+
pool_size: int = 10
|
|
43
|
+
echo: bool = False
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_namespace(cls, configs: Dict[str, Any]) -> "DatabaseConfig":
|
|
47
|
+
# Apollo 所有值均为字符串,pydantic 在 lax 模式下自动做类型转换
|
|
48
|
+
return cls.model_validate(dict(configs))
|
|
49
|
+
|
|
50
|
+
def connection_url(self, redact: bool = False) -> str:
|
|
51
|
+
"""生成 SQLAlchemy 连接串;redact=True 时隐藏密码(用于日志输出)。"""
|
|
52
|
+
pwd = "***" if redact else self.password
|
|
53
|
+
if self.dialect == "oracle":
|
|
54
|
+
base = f"oracle+{self.driver}://{self.username}:{pwd}@{self.host}:{self.port}"
|
|
55
|
+
if self.service_name:
|
|
56
|
+
return f"{base}/?service_name={self.service_name}"
|
|
57
|
+
if self.sid:
|
|
58
|
+
return f"{base}/{self.sid}"
|
|
59
|
+
return base
|
|
60
|
+
if self.dialect == "postgresql":
|
|
61
|
+
db = self.service_name or self.sid or ""
|
|
62
|
+
return f"postgresql+psycopg2://{self.username}:{pwd}@{self.host}:{self.port}/{db}"
|
|
63
|
+
if self.dialect == "mysql":
|
|
64
|
+
db = self.service_name or ""
|
|
65
|
+
return f"mysql+pymysql://{self.username}:{pwd}@{self.host}:{self.port}/{db}"
|
|
66
|
+
raise ValueError(f"unsupported dialect: {self.dialect}")
|
|
67
|
+
|
|
68
|
+
def public_dict(self) -> Dict[str, Any]:
|
|
69
|
+
"""对外暴露配置时脱敏(不返回密码)。"""
|
|
70
|
+
return {
|
|
71
|
+
"dialect": self.dialect,
|
|
72
|
+
"driver": self.driver,
|
|
73
|
+
"host": self.host,
|
|
74
|
+
"port": self.port,
|
|
75
|
+
"username": self.username,
|
|
76
|
+
"service_name": self.service_name,
|
|
77
|
+
"sid": self.sid,
|
|
78
|
+
"pool_size": self.pool_size,
|
|
79
|
+
"echo": self.echo,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class LdapConfig(BaseModel):
|
|
84
|
+
model_config = ConfigDict(extra="ignore")
|
|
85
|
+
|
|
86
|
+
server: str
|
|
87
|
+
port: int = 389
|
|
88
|
+
use_ssl: bool = False
|
|
89
|
+
bind_dn: str
|
|
90
|
+
bind_password: str
|
|
91
|
+
base_dn: str
|
|
92
|
+
user_search_base: str
|
|
93
|
+
user_search_filter: str = "(uid={user})"
|
|
94
|
+
timeout: int = 5
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_namespace(cls, configs: Dict[str, Any]) -> "LdapConfig":
|
|
98
|
+
return cls.model_validate(dict(configs))
|
|
99
|
+
|
|
100
|
+
def public_dict(self) -> Dict[str, Any]:
|
|
101
|
+
return {
|
|
102
|
+
"server": self.server,
|
|
103
|
+
"port": self.port,
|
|
104
|
+
"use_ssl": self.use_ssl,
|
|
105
|
+
"bind_dn": self.bind_dn,
|
|
106
|
+
"base_dn": self.base_dn,
|
|
107
|
+
"user_search_base": self.user_search_base,
|
|
108
|
+
"user_search_filter": self.user_search_filter,
|
|
109
|
+
"timeout": self.timeout,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def init_oracle_thick_mode(lib_dir: str | None = None) -> None:
|
|
114
|
+
"""启用 Oracle thick 模式;重复调用安全(第二次直接跳过)。
|
|
115
|
+
|
|
116
|
+
重要:Oracle 11g 无法使用 python-oracledb 的 thin 模式(thin 要求 Oracle DB ≥ 12.1),
|
|
117
|
+
必须在创建任何连接前用 Instant Client 启用 thick 模式。
|
|
118
|
+
|
|
119
|
+
Instant Client 路径通过环境变量 ``ORACLE_INSTANT_CLIENT`` 指定,例如::
|
|
120
|
+
|
|
121
|
+
/opt/lib/instantclient-basiclite-arm-23.26.1.0.0
|
|
122
|
+
"""
|
|
123
|
+
_INITIALIZED = getattr(init_oracle_thick_mode, "_initialized", False)
|
|
124
|
+
if _INITIALIZED:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
import oracledb
|
|
129
|
+
except ImportError:
|
|
130
|
+
logger.warning("未安装 oracledb,跳过 Oracle 驱动初始化(连接 11g 将失败)")
|
|
131
|
+
init_oracle_thick_mode._initialized = True
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
lib_dir = lib_dir or os.getenv("ORACLE_INSTANT_CLIENT")
|
|
135
|
+
if not lib_dir:
|
|
136
|
+
logger.info("未配置 ORACLE_INSTANT_CLIENT,使用 oracledb thin 模式(仅支持 Oracle >= 12.1,11g 无法连接)")
|
|
137
|
+
init_oracle_thick_mode._initialized = True
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
oracledb.init_oracle_client(lib_dir=lib_dir)
|
|
142
|
+
logger.info("Oracle thick 模式已启用, lib_dir=%s", lib_dir)
|
|
143
|
+
except Exception as exc: # 已初始化会抛 DPI-1049,忽略即可
|
|
144
|
+
logger.info("Oracle 驱动初始化跳过(可能已初始化): %s", exc)
|
|
145
|
+
init_oracle_thick_mode._initialized = True
|
apollo_config/manager.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""ConfigManager:在 ApolloClient 之上做二次封装。
|
|
2
|
+
|
|
3
|
+
职责:
|
|
4
|
+
- 聚合多个 namespace 的 ApolloClient;
|
|
5
|
+
- 提供「按 namespace 订阅变更」的回调(装饰器风格);
|
|
6
|
+
- 提供通用的配置读取;领域模型(如 DatabaseConfig)通过 get_typed() 接入,
|
|
7
|
+
库本身不耦合任何业务模型,保持核心仅依赖 requests。
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from typing import Any, Callable, Dict, List, Type, TypeVar
|
|
14
|
+
|
|
15
|
+
from apollo_config.client import ApolloClient
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("apollo.config")
|
|
18
|
+
|
|
19
|
+
# 回调签名: (namespace: str, changes: Dict[key, {"old":..., "new":...}])
|
|
20
|
+
NamespaceCallback = Callable[[str, Dict], None]
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigManager:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
app_id: str,
|
|
28
|
+
config_url: str,
|
|
29
|
+
cluster: str = "default",
|
|
30
|
+
namespaces: List[str] | None = None,
|
|
31
|
+
env: str | None = None,
|
|
32
|
+
access_key_secret: str | None = None,
|
|
33
|
+
cache_dir: str = ".apollo_cache",
|
|
34
|
+
poll_timeout: int = 70,
|
|
35
|
+
request_timeout: int = 5,
|
|
36
|
+
poll_interval: float = 2.0,
|
|
37
|
+
max_retry_backoff: float = 30.0,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._client = ApolloClient(
|
|
40
|
+
app_id=app_id,
|
|
41
|
+
config_url=config_url,
|
|
42
|
+
cluster=cluster,
|
|
43
|
+
namespaces=namespaces,
|
|
44
|
+
env=env,
|
|
45
|
+
access_key_secret=access_key_secret,
|
|
46
|
+
cache_dir=cache_dir,
|
|
47
|
+
poll_timeout=poll_timeout,
|
|
48
|
+
request_timeout=request_timeout,
|
|
49
|
+
poll_interval=poll_interval,
|
|
50
|
+
max_retry_backoff=max_retry_backoff,
|
|
51
|
+
)
|
|
52
|
+
self._ns_callbacks: Dict[str, List[NamespaceCallback]] = defaultdict(list)
|
|
53
|
+
self._client.register_callback(self._dispatch)
|
|
54
|
+
|
|
55
|
+
# ------------------------- 生命周期 -------------------------
|
|
56
|
+
def start(self) -> None:
|
|
57
|
+
self._client.start()
|
|
58
|
+
|
|
59
|
+
def stop(self) -> None:
|
|
60
|
+
self._client.stop()
|
|
61
|
+
|
|
62
|
+
# ------------------------- 变更订阅 -------------------------
|
|
63
|
+
def _dispatch(self, namespace: str, changes: dict) -> None:
|
|
64
|
+
for cb in self._ns_callbacks.get(namespace, []):
|
|
65
|
+
try:
|
|
66
|
+
cb(namespace, changes)
|
|
67
|
+
except Exception as exc: # noqa: BLE001
|
|
68
|
+
logger.exception("namespace=%s 变更回调失败: %s", namespace, exc)
|
|
69
|
+
|
|
70
|
+
def on_namespace_change(self, namespace: str):
|
|
71
|
+
"""装饰器 / 注册器:订阅某个 namespace 的变更。回调签名 (namespace, changes)。"""
|
|
72
|
+
def decorator(cb: NamespaceCallback) -> NamespaceCallback:
|
|
73
|
+
self._ns_callbacks[namespace].append(cb)
|
|
74
|
+
return cb
|
|
75
|
+
return decorator
|
|
76
|
+
|
|
77
|
+
# ------------------------- 通用读取 -------------------------
|
|
78
|
+
def get_value(self, key: str, default: str | None = None, namespace: str = "application") -> str | None:
|
|
79
|
+
return self._client.get_value(key, default, namespace)
|
|
80
|
+
|
|
81
|
+
def get_configs(self, namespace: str = "application") -> Dict[str, str]:
|
|
82
|
+
return self._client.get_configs(namespace)
|
|
83
|
+
|
|
84
|
+
def get_typed(self, namespace: str, model_cls: Type[T]) -> T:
|
|
85
|
+
"""用任意模型类(如 pydantic.BaseModel 子类)解析某个 namespace 的配置。
|
|
86
|
+
|
|
87
|
+
例::
|
|
88
|
+
|
|
89
|
+
cfg = cm.get_typed("database", DatabaseConfig)
|
|
90
|
+
|
|
91
|
+
库不绑定任何模型库:只要 ``model_cls`` 提供 ``model_validate(dict)`` 类方法即可
|
|
92
|
+
(pydantic v2 天然满足)。
|
|
93
|
+
"""
|
|
94
|
+
return model_cls.model_validate(dict(self.get_configs(namespace)))
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apollo-config-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight, synchronous Apollo config-center client: long-polling hot reload, local cache failover, and access-key HMAC signing. Only depends on requests.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Jiafan/apollo-config-client
|
|
6
|
+
Project-URL: Repository, https://github.com/Jiafan/apollo-config-client
|
|
7
|
+
Project-URL: Documentation, https://github.com/Jiafan/apollo-config-client#readme
|
|
8
|
+
Author: Jiafan
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: apollo,config,configuration,hot-reload,long-polling
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: requests>=2.31.0
|
|
22
|
+
Provides-Extra: all
|
|
23
|
+
Requires-Dist: oracledb>=2.0.0; extra == 'all'
|
|
24
|
+
Requires-Dist: pydantic>=2.5.0; extra == 'all'
|
|
25
|
+
Requires-Dist: python-dotenv>=1.0.0; extra == 'all'
|
|
26
|
+
Provides-Extra: dotenv
|
|
27
|
+
Requires-Dist: python-dotenv>=1.0.0; extra == 'dotenv'
|
|
28
|
+
Provides-Extra: models
|
|
29
|
+
Requires-Dist: pydantic>=2.5.0; extra == 'models'
|
|
30
|
+
Provides-Extra: oracle
|
|
31
|
+
Requires-Dist: oracledb>=2.0.0; extra == 'oracle'
|
|
32
|
+
Provides-Extra: test
|
|
33
|
+
Requires-Dist: pytest>=8.0.0; extra == 'test'
|
|
34
|
+
Requires-Dist: requests-mock>=1.11.0; extra == 'test'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# apollo-config-client
|
|
38
|
+
|
|
39
|
+
一个**轻量、同步、贴近 Apollo 官方 HTTP API** 的 Python 配置中心客户端。
|
|
40
|
+
|
|
41
|
+
> 直接基于 Apollo 开放的 HTTP 接口实现,不依赖任何第三方 Apollo 专用客户端,版本兼容性最好,且对热更新逻辑完全可控。官方 Java / .NET 客户端底层使用的也是同一套接口,可稳定对接 Apollo 1.6 – 2.4.1。
|
|
42
|
+
|
|
43
|
+
## 特性
|
|
44
|
+
|
|
45
|
+
| 能力 | 说明 |
|
|
46
|
+
|------|------|
|
|
47
|
+
| 长轮询热更新 | 后台守护线程长轮询 `notifications/v2`,配置发布后**秒级生效**,并给出 key 级 diff |
|
|
48
|
+
| 本地容灾缓存 | 每次成功拉取都落盘;Apollo 全部不可用时,应用仍可用上一次的配置启动 |
|
|
49
|
+
| 访问密钥签名 | 兼容 Apollo 1.6+ 的 `Access Key` HMAC-SHA1 签名(匿名访问同样支持) |
|
|
50
|
+
| 类型化读取 | `get_typed(namespace, Model)` 用任意模型类(如 pydantic)解析配置 |
|
|
51
|
+
| 极简依赖 | **核心仅依赖 `requests`**;pydantic 模型与 Oracle 助手都是可选 extra |
|
|
52
|
+
|
|
53
|
+
## 安装
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install apollo-config-client # 核心(仅 requests)
|
|
57
|
+
pip install apollo-config-client[models] # + pydantic 领域模型 DatabaseConfig / LdapConfig
|
|
58
|
+
pip install apollo-config-client[oracle] # + init_oracle_thick_mode 助手
|
|
59
|
+
pip install apollo-config-client[all] # 以上全部
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
> 发布前请先在 https://pypi.org 确认包名 `apollo-config-client` 可用;若被占用,备选 `apolloc` / `apollo-config`。
|
|
63
|
+
|
|
64
|
+
## 快速开始
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from apollo_config import create_config_manager_from_env
|
|
68
|
+
|
|
69
|
+
# 读环境变量 APOLLO_APP_ID / APOLLO_CONFIG_URL / APOLLO_NAMESPACES ...
|
|
70
|
+
cm = create_config_manager_from_env()
|
|
71
|
+
|
|
72
|
+
# 普通读取
|
|
73
|
+
cm.get_configs("application")
|
|
74
|
+
cm.get_value("feature_flag", "off")
|
|
75
|
+
|
|
76
|
+
# 订阅 namespace 变更,配置一发布即刻拿到 key 级 diff
|
|
77
|
+
@cm.on_namespace_change("database")
|
|
78
|
+
def _on_db_change(namespace, changes):
|
|
79
|
+
# 这里只做「置空 / 标记失效」等轻量操作,
|
|
80
|
+
# 真正的资源重建放到请求路径上惰性执行(避免回调里做重 IO)
|
|
81
|
+
rebuild_connection_pool_lazily()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
显式参数创建(不走环境变量):
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from apollo_config import create_config_manager
|
|
88
|
+
|
|
89
|
+
cm = create_config_manager(
|
|
90
|
+
app_id="my-app",
|
|
91
|
+
config_url="http://apollo-config-service:8080",
|
|
92
|
+
namespaces=["application", "database"],
|
|
93
|
+
access_key_secret="38fdae497a324263a5ad81aa387deee3", # 可选
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 用 pydantic 模型读取(可选 extra `[models]`)
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from apollo_config.contrib import DatabaseConfig
|
|
101
|
+
|
|
102
|
+
cfg = cm.get_typed("database", DatabaseConfig)
|
|
103
|
+
engine = create_engine(cfg.connection_url(), pool_size=cfg.pool_size)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## 设计要点
|
|
107
|
+
|
|
108
|
+
1. **直接调用 Apollo 开放 HTTP API**(官方客户端同款接口):
|
|
109
|
+
- 配置拉取:`GET {config_url}/configs/{appId}/{cluster}/{namespace}`
|
|
110
|
+
- 变更通知:`GET {config_url}/notifications/v2`(HTTP 长轮询,秒级生效)
|
|
111
|
+
2. **热更新机制**:后台守护线程长轮询 `notifications/v2`,一旦 Apollo 发布新配置,
|
|
112
|
+
客户端拉取最新值并计算 key 级 diff,触发业务注册的回调。
|
|
113
|
+
3. **本地容灾缓存**:每次成功拉取都会落盘到缓存目录;即使 Apollo 全部不可用,
|
|
114
|
+
应用也能用上一次的配置正常启动。
|
|
115
|
+
4. **访问密钥签名**:配置 `access_key_secret` 后,对 `configs` 与 `notifications/v2`
|
|
116
|
+
两类请求统一做 HMAC-SHA1 加签;留空则走匿名访问,行为完全一致。
|
|
117
|
+
|
|
118
|
+
## 环境变量(用于 `create_config_manager_from_env`)
|
|
119
|
+
|
|
120
|
+
| 变量 | 说明 | 默认 |
|
|
121
|
+
|------|------|------|
|
|
122
|
+
| `APOLLO_APP_ID` | 应用 AppId | `apollo-app` |
|
|
123
|
+
| `APOLLO_CONFIG_URL` | Config Service 地址(非 Portal 的 8070) | `http://localhost:8080` |
|
|
124
|
+
| `APOLLO_CLUSTER` | 集群 | `default` |
|
|
125
|
+
| `APOLLO_NAMESPACES` | 逗号分隔的 namespace | `application` |
|
|
126
|
+
| `APOLLO_ENV` | 环境名 | 空 |
|
|
127
|
+
| `APOLLO_ACCESS_KEY_SECRET` | 访问密钥(可选) | 空 |
|
|
128
|
+
| `APOLLO_CACHE_DIR` | 本地缓存目录 | `.apollo_cache` |
|
|
129
|
+
| `ORACLE_INSTANT_CLIENT` | Oracle Instant Client 路径(仅 11g 需要) | 空 |
|
|
130
|
+
|
|
131
|
+
## 示例应用
|
|
132
|
+
|
|
133
|
+
`examples/` 下提供 Flask / FastAPI 两个完整示例(DB 连接池热重建):
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
cd examples
|
|
137
|
+
pip install -r requirements.txt # 会以 editable 方式安装本库
|
|
138
|
+
cp ../.env.example .env # 按需修改 Apollo 地址 / AppId
|
|
139
|
+
python flask_app.py # http://localhost:5000/config/database
|
|
140
|
+
# 或 python fastapi_app.py
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## 与 flask_apollo_lab 的关系
|
|
144
|
+
|
|
145
|
+
本项目(库)原是从 `flask_apollo_lab` 中抽离出的通用部分(`apollo/` + `config/`)。
|
|
146
|
+
`flask_apollo_lab` 作为**消费方 / dogfooding 示例**保留,可改为 `pip install apollo-config-client`
|
|
147
|
+
并 `from apollo_config import ...`,从而把"拷贝两个包"变成一行导入。
|
|
148
|
+
|
|
149
|
+
## 生产建议
|
|
150
|
+
|
|
151
|
+
- **脱敏**:`password` / `bind_password` 等建议标记为 Apollo「私密配置」,对外接口务必脱敏(见 `public_dict()`)。
|
|
152
|
+
- **优雅关闭**:进程退出前调用 `config_manager.stop()` 停止长轮询线程(示例在 `settings.py` 用 `atexit` 注册)。
|
|
153
|
+
- **回调要快**:变更回调里只做「置空 / 标记失效」这类轻量操作,真正的重建放到请求路径上惰性执行。
|
|
154
|
+
- **本地缓存目录** `.apollo_cache/` 应加入 `.gitignore`,不要提交到代码库。
|
|
155
|
+
|
|
156
|
+
## Roadmap
|
|
157
|
+
|
|
158
|
+
- [ ] 多 Config Service 端点 / HA 故障转移(当前为单 `config_url`)
|
|
159
|
+
- [ ] 可选的 async wrapper(基于 `httpx` 或线程池),不进核心
|
|
160
|
+
- [ ] 配置变更事件的同步原语(如 `watch()` 返回最新值的上下文管理器)
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
apollo_config/__init__.py,sha256=bccTtk1XLPanjdTnuVtilQVNlzPNheRmaOeqeZfIiM8,1611
|
|
2
|
+
apollo_config/_factory.py,sha256=3pJxqwPtmrdU58CMcsook0wCbr6R2ZWwWMh3BSAkRi8,3052
|
|
3
|
+
apollo_config/client.py,sha256=d1XJ2TwpIfvhkppj-lfTaoeNgL1JFLw6Vj6uojvirXc,10751
|
|
4
|
+
apollo_config/contrib.py,sha256=95KBzv2dnSI2WRpF8iLSE30nXiXXPgp54SM0_VSXVCE,5472
|
|
5
|
+
apollo_config/manager.py,sha256=GXQoXnDI6AzNlTQJSFBCGsFYal81oiFOKVlmX4U40ck,3561
|
|
6
|
+
apollo_config_client-0.1.0.dist-info/METADATA,sha256=7lMbqexWRKcFMozOGCkzij1VDi982JEnJcZd5FQS6WQ,7246
|
|
7
|
+
apollo_config_client-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
apollo_config_client-0.1.0.dist-info/licenses/LICENSE,sha256=vZ7MRjFRVZPFAsEkrkxm7eG9ykCpHrWjHBggw1ubgeM,1063
|
|
9
|
+
apollo_config_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jiafan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|