refactor: 迁移 gateway.ini 至 core/configs/,删除已废弃的 confs 和 confs2 模块
- gateway.ini 从 confs/configs/ 迁入 core/configs/(真实目录替代符号链接) - core/_set.sh 移除创建符号链接逻辑 - core/.gitignore 移除 configs 条目 - 删除 confs(Python)和 confs2(C++20 归档),配置分发已内嵌于 core - 更新 gateway/README.md 配置文件路径
This commit is contained in:
1 parent
cca0cdff5f
commit
bcd63e4fde
18 files changed
+1
-1470
No files matched your search
@@ -41,7 +41,6 @@ inst_module "db2"
|
|||||||
inst_module "smtp"
|
inst_module "smtp"
|
||||||
inst_module "smtp3"
|
inst_module "smtp3"
|
||||||
inst_module "captcha2"
|
inst_module "captcha2"
|
||||||
inst_module "confs"
|
|
||||||
inst_module "ai_review" # 需联网拉 chromadb onnx 模型(_fetch_onnx_model.sh 走 ModelScope 镜像)
|
inst_module "ai_review" # 需联网拉 chromadb onnx 模型(_fetch_onnx_model.sh 走 ModelScope 镜像)
|
||||||
inst_module "aibox" # 需 ~/.cert/deepseek.key(见 _setenv.sh)
|
inst_module "aibox" # 需 ~/.cert/deepseek.key(见 _setenv.sh)
|
||||||
inst_module "redirect80" # 80 端口重定向,依赖 ../pymsg
|
inst_module "redirect80" # 80 端口重定向,依赖 ../pymsg
|
||||||
|
|||||||
-291
@@ -1,291 +0,0 @@
|
|||||||
# confs 统一配置服务(pymsg RpcServer/TCP)
|
|
||||||
|
|
||||||
> **已废弃**:配置分发已内嵌进 [core](../core/)(ConfsTask,--confs 8010),仅 `confs/configs/gateway.ini` 仍由其分发;本 README 为历史说明。
|
|
||||||
|
|
||||||
集中存放各微服务的配置文件,通过 pymsg RPC 协议分发给内部服务,替代各服务本地散落的 ini/json/yaml 配置,实现配置的统一管理与下发。
|
|
||||||
|
|
||||||
服务由 `core`(PMC)的 `init.txt` 拉起,监听 `127.0.0.1:8010`(TCP),仅允许本机调用。
|
|
||||||
|
|
||||||
## 功能特性
|
|
||||||
|
|
||||||
- **集中托管**:各微服务的配置文件统一存放于 `configs/` 目录,按配置名分发
|
|
||||||
- **多格式支持**:`.ini` / `.conf` / `.cfg` / `.json` / `.yaml` / `.yml` / `.toml` / `.txt`
|
|
||||||
- **内存缓存 + 自动重载**:文件 mtime 变化时自动重新读取,无需重启服务
|
|
||||||
- **按键取值**:ini/conf/cfg 支持 `Section.option` 取单键,json 支持点号路径(含数字下标)
|
|
||||||
- **路径遍历防护**:basename 校验 + realpath 前缀校验 + 扩展名白名单
|
|
||||||
- **TOCTOU 防护**:所有文件系统操作(realpath → stat → open)在锁内原子完成,防止符号链接替换竞态
|
|
||||||
- **缓存防污染**:`get()` 返回缓存条目的浅拷贝,调用方修改返回值不会影响内部缓存
|
|
||||||
- **list 符号链接防护**:列出配置时同样解析 realpath 校验前缀,逃逸符号链接不会泄露外部文件元信息
|
|
||||||
- **严格参数校验**:`name`/`key` 参数必须为字符串,空字符串/空白字符串/非字符串类型明确返回错误,不会产生误行为
|
|
||||||
- **INI 安全解析**:禁用 ConfigParser 的 `%(key)s` 插值功能,配置值中包含裸 `%` 字符不会触发解析错误
|
|
||||||
- **文件大小限制**:单配置文件上限 1 MiB,防止超大文件导致 OOM
|
|
||||||
- **线程安全**:所有公开方法持锁,保证 `workers>0` 并发回调下的缓存一致性
|
|
||||||
- **仅监听本机**:默认 `127.0.0.1:8010`,不对外暴露
|
|
||||||
- **Slowloris 防护**:pymsg MsgServer 默认设置 10 秒接收超时,慢速半开连接自动断开
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- Python 3
|
|
||||||
- [pymsg](../pymsg/) —— TCP 长度前缀帧协议(`[4字节大端长度][UTF-8 JSON]`),上层提供 `RpcServer`/`RpcClient` 高层 JSON RPC 封装
|
|
||||||
|
|
||||||
## 安装
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./_set.sh # 建 venv 并安装 requirements.txt(本地 ../pymsg + nuitka)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 运行
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 方式一:通过启动脚本(推荐,由 core 守护时即使用此方式)
|
|
||||||
cd confs && ./_run.sh
|
|
||||||
|
|
||||||
# 方式二:直接运行
|
|
||||||
python3 confs.py # 默认 127.0.0.1:8010,配置目录 configs/
|
|
||||||
python3 confs.py --addr 127.0.0.1 --port 8010 --workers 4 --dir configs/
|
|
||||||
```
|
|
||||||
|
|
||||||
服务监听 `tcp://127.0.0.1:8010`,配置目录默认为同级的 `configs/`。生产环境由 core(Nidus)按 init.txt 统一拉起,守护靠 systemd。
|
|
||||||
|
|
||||||
## 协议与路由
|
|
||||||
|
|
||||||
服务基于 pymsg RPC 协议:客户端使用 `RpcClient.call(route, payload)` 传 dict,无需手动拼 `_route` 或手动 JSON 序列化/反序列化。响应格式与 db / smtp / captcha 一致:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"success": true, "data": {...}}
|
|
||||||
{"success": false, "error": "..."}
|
|
||||||
```
|
|
||||||
|
|
||||||
`RpcClient.call()` 成功时直接返回 `data` 字段的值(dict/str/None 等),失败时抛出 `RpcError`。
|
|
||||||
|
|
||||||
| 路由 | 说明 |
|
|
||||||
| ------ | ------------------------------------------ |
|
|
||||||
| `get` | 取配置文件全文;带 `key` 时返回单键值(仅 ini/json) |
|
|
||||||
| `list` | 列出配置目录下全部配置文件及其元信息 |
|
|
||||||
| `reload` | 重载指定配置;省略 `name`(或传 `null`)时清空全部缓存 |
|
|
||||||
| `health` | 健康检查 |
|
|
||||||
|
|
||||||
### 1. get — 取配置
|
|
||||||
|
|
||||||
**请求**(取全文):
|
|
||||||
|
|
||||||
```python
|
|
||||||
c.call("get", {"name": "example.ini"})
|
|
||||||
```
|
|
||||||
|
|
||||||
**返回**(data 字段):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "example.ini",
|
|
||||||
"content": "[server]\nhost = 127.0.0.1\nport = 8010\nratio = 80%\n",
|
|
||||||
"size": 60,
|
|
||||||
"mtime": "2026-08-01T10:00:00+00:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求**(按键取值,仅 ini/conf/cfg 与 json 支持):
|
|
||||||
|
|
||||||
```python
|
|
||||||
c.call("get", {"name": "example.ini", "key": "server.port"})
|
|
||||||
```
|
|
||||||
|
|
||||||
**返回**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"name": "example.ini", "key": "server.port", "value": "8010"}
|
|
||||||
```
|
|
||||||
|
|
||||||
参数校验:
|
|
||||||
- `name` 必填,必须为非空字符串;非字符串/空字符串返回 `ConfigError`
|
|
||||||
- `key` 可选;若提供必须为非空字符串,非字符串/空字符串返回 `ConfigError`
|
|
||||||
|
|
||||||
> - ini/conf/cfg:键格式为 `Section.option`,返回字符串值;插值已禁用,`%` 字符原样返回
|
|
||||||
> - json:键格式为点号路径(如 `server.port`),列表可用数字下标(如 `servers.0.host`),返回原始 JSON 值
|
|
||||||
> - 其他类型(yaml/yml/toml/txt)不支持按键查询,需取全文后自行解析
|
|
||||||
|
|
||||||
### 2. list — 列出配置
|
|
||||||
|
|
||||||
**请求**:
|
|
||||||
|
|
||||||
```python
|
|
||||||
c.call("list", {})
|
|
||||||
```
|
|
||||||
|
|
||||||
**返回**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"config_dir": "/opt/micro_servers/confs/configs",
|
|
||||||
"configs": [
|
|
||||||
{"name": "example.ini", "size": 60, "mtime": "2026-08-01T10:00:00+00:00", "cached": true}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> 逃逸到 `configs/` 目录外的符号链接不会出现在列表中。
|
|
||||||
|
|
||||||
### 3. reload — 重载配置
|
|
||||||
|
|
||||||
**重载指定配置**(立即重新读取文件):
|
|
||||||
|
|
||||||
```python
|
|
||||||
c.call("reload", {"name": "example.ini"})
|
|
||||||
```
|
|
||||||
|
|
||||||
**清空全部缓存**(下次访问惰性重载):
|
|
||||||
|
|
||||||
```python
|
|
||||||
c.call("reload", {}) # 省略 name 字段
|
|
||||||
# 或
|
|
||||||
c.call("reload", {"name": None})
|
|
||||||
```
|
|
||||||
|
|
||||||
**返回**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"reloaded": ["example.ini"]}
|
|
||||||
```
|
|
||||||
|
|
||||||
参数校验:
|
|
||||||
- 省略 `name` 或 `name` 为 `null`:清空全部缓存,返回被清除的配置名列表
|
|
||||||
- `name` 为非字符串(数字、布尔、列表等):返回 `ConfigError("name 字段必须是字符串")`
|
|
||||||
- `name` 为空字符串或纯空白:返回 `ConfigError("name 字段不能为空(要清空全部缓存请省略 name 字段)")`
|
|
||||||
- 指定合法 `name`:立即重新读取该配置并返回受影响列表
|
|
||||||
|
|
||||||
### 4. health — 健康检查
|
|
||||||
|
|
||||||
**请求**:
|
|
||||||
|
|
||||||
```python
|
|
||||||
c.call("health", {})
|
|
||||||
```
|
|
||||||
|
|
||||||
**返回**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"service": "confs",
|
|
||||||
"config_dir": "/opt/micro_servers/confs/configs",
|
|
||||||
"cached": 1,
|
|
||||||
"uptime": 3600
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 调用示例(Python)
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pymsg import RpcClient, RpcError
|
|
||||||
|
|
||||||
c = RpcClient("127.0.0.1", 8010, "my-service")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 取配置全文(c.call() 直接返回 data 字段,无需 resp["data"])
|
|
||||||
entry = c.call("get", {"name": "static.ini"}, timeout=5)
|
|
||||||
content = entry["content"]
|
|
||||||
print(f"static.ini size={entry['size']}")
|
|
||||||
|
|
||||||
# 按键取值
|
|
||||||
port = c.call("get", {"name": "static.ini", "key": "server.port"}, timeout=5)
|
|
||||||
print(f"static server.port = {port}")
|
|
||||||
|
|
||||||
# 列出所有配置
|
|
||||||
lst = c.call("list", {}, timeout=5)
|
|
||||||
for cfg in lst["configs"]:
|
|
||||||
print(cfg["name"], cfg["size"])
|
|
||||||
|
|
||||||
# 重载指定配置
|
|
||||||
c.call("reload", {"name": "static.ini"})
|
|
||||||
|
|
||||||
# 清空全部缓存
|
|
||||||
c.call("reload", {})
|
|
||||||
|
|
||||||
except RpcError as e:
|
|
||||||
print(f"confs 调用失败: {e}")
|
|
||||||
```
|
|
||||||
|
|
||||||
## 缓存机制
|
|
||||||
|
|
||||||
- **内存缓存**:每个配置首次读取后缓存于 `ConfigStore._cache`,结构为 `{name: {content, mtime, size}}`
|
|
||||||
- **自动重载**:每次 `get` 时在锁内比对文件 mtime(通过 `os.stat()`),变化则重新读取并更新缓存
|
|
||||||
- **手动重载**:`reload` 路由可指定单个配置立即重读,或清空全部缓存(下次访问惰性重载)
|
|
||||||
- **返回值隔离**:`get()` 返回缓存条目的**浅拷贝**,调用方对返回 dict 的修改不会污染内部缓存
|
|
||||||
- **线程安全**:所有缓存操作持 `threading.Lock`,并发安全
|
|
||||||
|
|
||||||
## 安全机制
|
|
||||||
|
|
||||||
- **扩展名白名单**:仅允许 `.ini/.conf/.cfg/.json/.yaml/.yml/.toml/.txt`,其余一律拒绝
|
|
||||||
- **路径遍历防护**:配置名必须等于 `os.path.basename(name)`,且 realpath 必须位于 `configs/` 目录前缀之下;符号链接会被 `os.path.realpath()` 解析后校验
|
|
||||||
- **TOCTOU 防护**:所有文件系统操作(`realpath` → `stat` → `open`)均在持锁状态下原子完成,不存在"先校验后打开"的时间窗口,防止符号链接替换攻击
|
|
||||||
- **文件大小限制**:单配置文件上限 1 MiB(`MAX_CONFIG_SIZE`),超过则拒绝读取并返回错误,防止超大文件导致内存溢出
|
|
||||||
- **Slowloris 防护**:pymsg `MsgServer` 默认设置 10 秒接收超时,慢速发送的半开连接会被自动断开
|
|
||||||
- **缓存防污染**:`get()` 返回浅拷贝,调用方无法通过修改返回值污染内部缓存
|
|
||||||
- **list 符号链接防护**:列出配置时对每个文件做 realpath 前缀校验,逃逸到目录外的符号链接被跳过,不泄露外部文件元信息(大小/mtime)
|
|
||||||
- **严格参数校验**:`name`/`key` 参数必须为非空字符串,类型错误或空值明确返回 `ConfigError`,不产生误行为(如空字符串误清空全部缓存)
|
|
||||||
- **INI 安全解析**:使用 `ConfigParser(interpolation=None)` 禁用 `%(key)s` 插值,配置值中裸 `%` 字符不会触发 `InterpolationError`
|
|
||||||
- **仅监听本机**:默认 `127.0.0.1`,不对外暴露,仅供内部服务调用
|
|
||||||
- **无鉴权**:当前无身份校验,依赖"仅本机 + 内部网络"的信任边界;如需承载敏感配置(如 API 密钥),应额外评估鉴权方案
|
|
||||||
|
|
||||||
## 集成关系
|
|
||||||
|
|
||||||
- 由 `core`(`PMC`)通过 `init.txt` 中的 `bash /opt/micro_servers/confs/_run.sh` 拉起并守护(confs 最先启动即可;各业务服务通过 pymsg `retry_on_refused=True` 在拉取配置时自动阻塞等待 confs 就绪,不严格要求启动顺序;confs 日志推送 db 为异步 fail-safe,db 不可用时静默丢弃,不阻塞 confs 启动)
|
|
||||||
- 各内部服务通过 pymsg `RpcClient` 调 `get` 路由按需获取配置,替代本地散落的 ini/json/yaml
|
|
||||||
- 自身日志通过 `log_client.py` 异步推送至 `db:8005` 的 Logs 表(与其他服务一致)
|
|
||||||
|
|
||||||
### 已接入的服务
|
|
||||||
|
|
||||||
| 服务 | 配置文件 | 接入方式 |
|
|
||||||
|------|---------|---------|
|
|
||||||
| [static](../static/) | `static.ini` | confs 强依赖(pymsg 自动阻塞等待 confs 就绪)→ 环境变量覆盖(最高优先级,Docker 兼容) |
|
|
||||||
| [db](../db/) | `db.ini` | confs 强依赖(pymsg 自动阻塞等待 confs 就绪)→ 环境变量覆盖(最高优先级,Docker 兼容) |
|
|
||||||
|
|
||||||
static 的接入实现见 [static/fsys.py](../static/fsys.py):
|
|
||||||
- 两层加载顺序:confs(static.ini,强依赖)→ 环境变量覆盖(优先级最高)
|
|
||||||
- pymsg `retry_on_refused=True` 自动阻塞等待 confs 就绪(每秒重试,每 5 秒打印等待提示),不会因启动顺序导致进程退出
|
|
||||||
- static.ini 缺失必需配置项时仍然报错退出(配置错误,非启动时序问题)
|
|
||||||
- 配置项涵盖:`[server]` host/port、`[directory]` root、`[ssl]` enabled/cert_file/key_file(ssl 有默认值)、`[cors]` allowed_origins
|
|
||||||
- confs 连接地址为引导配置:环境变量 `CONFS_HOST`/`CONFS_PORT` 优先,默认 `127.0.0.1:8010`
|
|
||||||
- 支持的环境变量:`LISTEN_ADDR`/`LISTEN_PORT`/`STATIC_ROOT`/`CONFS_HOST`/`CONFS_PORT`
|
|
||||||
- 已删除 `--config` 命令行参数和本地 `config.ini` 回退逻辑
|
|
||||||
|
|
||||||
db 的接入实现见 [db/dbv2.py](../db/dbv2.py):
|
|
||||||
- 两层加载顺序:confs(db.ini,强依赖)→ 环境变量覆盖(优先级最高)
|
|
||||||
- pymsg `retry_on_refused=True` 自动阻塞等待 confs 就绪,不严格要求启动顺序
|
|
||||||
- 配置项涵盖:`[server]` host/port/workers、`[database]` url、`[logs]` retention_days/max_records/cleanup_interval
|
|
||||||
- confs 连接地址为引导配置:环境变量 `CONFS_HOST`/`CONFS_PORT` 优先,默认 `127.0.0.1:8010`
|
|
||||||
- 支持的环境变量:`LISTEN_ADDR`/`LISTEN_PORT`/`WORKERS`/`DATABASE_URL`/`CONFS_HOST`/`CONFS_PORT`
|
|
||||||
- 独立运维工具 `db_tools.py`(backup/export/import 等)仍直接读 `DATABASE_URL` 环境变量,不依赖 confs,避免运维脚本依赖服务就绪
|
|
||||||
|
|
||||||
其他服务(gateway / waf / ai_review / smtp)尚未接入,仍读各自本地配置。
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
confs/
|
|
||||||
├── confs.py # 服务主程序(ConfigStore + ConfsServer)
|
|
||||||
├── log_client.py # 统一日志客户端 SDK(与其他服务一致的副本)
|
|
||||||
├── configs/ # 配置文件存放目录(被分发对象)
|
|
||||||
│ ├── example.ini # 示例配置
|
|
||||||
│ ├── static.ini # static 服务配置(已接入分发)
|
|
||||||
│ └── db.ini # db 服务配置(已接入分发)
|
|
||||||
├── docs/
|
|
||||||
│ └── SECURITY_AUDIT_20260801.md # 安全审计报告
|
|
||||||
├── _set.sh # 初始化脚本(建 venv + 安装依赖)
|
|
||||||
├── _run.sh # 启动脚本(venv 中 exec python3 confs.py)
|
|
||||||
├── requirements.txt # Python 依赖(本地 ../pymsg + nuitka)
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
- 配置文件需手动放入 `configs/` 目录(或用 `--dir` 指定其他目录),服务不会自动生成业务配置
|
|
||||||
- 文件以 UTF-8 读取,非 UTF-8 文件会返回 `配置读取失败` 错误
|
|
||||||
- 配置名区分大小写(与文件系统一致)
|
|
||||||
- 单配置文件大小上限为 1 MiB(`MAX_CONFIG_SIZE`),超过此限制会返回 `配置文件过大` 错误,防止超大文件读入内存导致 OOM
|
|
||||||
- 缓存基于文件 mtime 判断变化,若同秒内修改且 mtime 精度不足(如某些 FAT 文件系统),可能无法及时重载——可手动调 `reload` 路由强制刷新
|
|
||||||
- `reload` 清空全部缓存的正确方式是**省略 `name` 字段**或传 `{"name": null}`;传空字符串 `""` 会返回错误而非清空缓存(防误操作)
|
|
||||||
- INI 配置文件中 `%(xxx)s` 语法**不会**被当作插值引用,而是作为字面字符串返回;如需插值请在业务层自行处理
|
|
||||||
- 调用方拿到 `get()` 返回的 dict 可以自由修改(如 `entry["content"] = "x"`),不会影响 confs 内部缓存
|
|
||||||
- 当前接入情况见上文"集成关系 → 已接入的服务";新服务接入可参考 static 的三层 fail-safe 模式(内置默认 → confs 优先 → 本地回退)
|
|
||||||
- 安全审计报告详见 [confs_SECURITY_AUDIT_20260801.md](../docs/security/confs_SECURITY_AUDIT_20260801.md)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
cd "$(dirname "$0")" || exit
|
|
||||||
source ./venv/bin/activate
|
|
||||||
#exec ./smtp.bin
|
|
||||||
exec python3 confs.py
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
cd "$(dirname "$0")" || exit
|
|
||||||
python3 -m venv venv
|
|
||||||
source venv/bin/activate
|
|
||||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
|
||||||
#nuitka --standalone confs.py
|
|
||||||
-360
@@ -1,360 +0,0 @@
|
|||||||
"""
|
|
||||||
confs — 统一配置服务(pymsg RpcServer/TCP,默认 127.0.0.1:8010)
|
|
||||||
|
|
||||||
集中存放各微服务的配置文件,通过 pymsg 高层 RPC 协议分发给内部服务,
|
|
||||||
替代各服务本地散落的 ini/json/yaml 配置文件,实现配置的统一管理与下发。
|
|
||||||
|
|
||||||
客户端使用 RpcClient 直接传 dict,无需手动拼 _route / json.dumps / json.loads::
|
|
||||||
|
|
||||||
c = RpcClient("127.0.0.1", 8010, "my-service")
|
|
||||||
c.call("get", {"name": "example.ini"})
|
|
||||||
c.call("get", {"name": "example.ini", "key": "server.port"})
|
|
||||||
c.call("list", {})
|
|
||||||
c.call("reload", {"name": "example.ini"})
|
|
||||||
c.call("health", {})
|
|
||||||
|
|
||||||
响应格式(与 db / smtp / captcha 一致)::
|
|
||||||
|
|
||||||
{"success": true, "data": {...}}
|
|
||||||
{"success": false, "error": "..."}
|
|
||||||
|
|
||||||
配置文件存放于 confs/configs/ 目录(可用 --dir 覆盖),内存缓存 +
|
|
||||||
文件 mtime 变化自动重载;配置名校验防路径遍历,所有文件系统操作在
|
|
||||||
持锁状态下原子完成以防 TOCTOU 竞态。仅监听 127.0.0.1,不对外暴露。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import configparser
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# pymsg / log_client 导入:生产环境 pymsg 由 venv(pip install ../pymsg)提供;
|
|
||||||
# 附加 ../pymsg 源码路径作为无 venv 时的开发兜底
|
|
||||||
_here = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
sys.path.insert(0, os.path.join(_here, ".."))
|
|
||||||
sys.path.insert(0, os.path.join(_here, "..", "pymsg"))
|
|
||||||
from pymsg import RpcServer # noqa: E402
|
|
||||||
from log_client import logs # noqa: E402
|
|
||||||
|
|
||||||
# ── 配置 ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
CONFIG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "configs")
|
|
||||||
# 允许分发的配置文件类型(白名单)
|
|
||||||
ALLOWED_EXT = {".ini", ".conf", ".cfg", ".json", ".yaml", ".yml", ".toml", ".txt"}
|
|
||||||
# 单配置文件大小上限(1 MiB),防止超大文件读入内存导致 OOM
|
|
||||||
MAX_CONFIG_SIZE = 1 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
# ── 异常 ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class ConfigError(Exception):
|
|
||||||
"""配置服务业务错误(由 RpcServer 包装为 success=false 返回)。"""
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigNotFound(ConfigError):
|
|
||||||
"""配置文件不存在。"""
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigNameInvalid(ConfigError):
|
|
||||||
"""配置名非法(路径遍历或类型不允许)。"""
|
|
||||||
|
|
||||||
|
|
||||||
class KeyNotFound(ConfigError):
|
|
||||||
"""配置键不存在或键格式错误。"""
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(ts: float) -> str:
|
|
||||||
"""时间戳转 ISO 8601(系统时区)。"""
|
|
||||||
return datetime.fromtimestamp(ts).astimezone().isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# ConfigStore — 配置文件仓库
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
class ConfigStore:
|
|
||||||
"""配置文件仓库:磁盘 configs/ 目录 + 内存缓存,mtime 变化自动重载。
|
|
||||||
|
|
||||||
所有公开方法持锁操作(配置文件小、内部调用频率低,简单持锁即可),
|
|
||||||
保证 workers>0 并发回调下的缓存一致性。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, root: str):
|
|
||||||
self.root = os.path.realpath(root)
|
|
||||||
os.makedirs(self.root, exist_ok=True)
|
|
||||||
self._cache = {} # name -> {"content": str, "mtime": float, "size": int}
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _validate_name(name: str) -> None:
|
|
||||||
"""纯字符串校验配置名(basename + 扩展名白名单),不访问文件系统。
|
|
||||||
|
|
||||||
用于锁外快速拒绝明显非法的请求;真实路径解析和文件存在性检查
|
|
||||||
必须在持锁后通过 _resolve_locked() 完成,防止 TOCTOU 竞态。
|
|
||||||
"""
|
|
||||||
if not name or name != os.path.basename(name):
|
|
||||||
raise ConfigNameInvalid(f"非法配置名: {name!r}")
|
|
||||||
ext = os.path.splitext(name)[1].lower()
|
|
||||||
if ext not in ALLOWED_EXT:
|
|
||||||
raise ConfigNameInvalid(f"不支持的配置类型: {ext or '(无扩展名)'}")
|
|
||||||
|
|
||||||
def _resolve_locked(self, name: str) -> str:
|
|
||||||
"""持锁状态下解析配置名到绝对路径,并校验路径安全性与文件存在性。
|
|
||||||
|
|
||||||
必须在 self._lock 持有期间调用;通过 os.path.realpath() 即时解析符号链接,
|
|
||||||
确保从校验到打开文件之间不存在替换窗口,防御 TOCTOU 攻击。
|
|
||||||
"""
|
|
||||||
path = os.path.realpath(os.path.join(self.root, name))
|
|
||||||
if not path.startswith(self.root + os.sep):
|
|
||||||
raise ConfigNameInvalid(f"非法配置名: {name!r}")
|
|
||||||
if not os.path.isfile(path):
|
|
||||||
raise ConfigNotFound(f"配置不存在: {name}")
|
|
||||||
return path
|
|
||||||
|
|
||||||
def get(self, name: str) -> dict:
|
|
||||||
"""取配置内容(带缓存;文件 mtime 变化时自动重新读取)。
|
|
||||||
|
|
||||||
所有文件系统操作(realpath/stat/open)均在锁内完成,
|
|
||||||
防止路径校验与文件读取之间的 TOCTOU 竞态。
|
|
||||||
返回缓存条目的浅拷贝,防止调用方意外修改污染缓存。
|
|
||||||
"""
|
|
||||||
self._validate_name(name)
|
|
||||||
with self._lock:
|
|
||||||
path = self._resolve_locked(name)
|
|
||||||
try:
|
|
||||||
st = os.stat(path)
|
|
||||||
mtime = st.st_mtime
|
|
||||||
size = st.st_size
|
|
||||||
except OSError:
|
|
||||||
self._cache.pop(name, None)
|
|
||||||
raise ConfigNotFound(f"配置不存在或不可读: {name}") from None
|
|
||||||
entry = self._cache.get(name)
|
|
||||||
if entry is not None and entry["mtime"] == mtime:
|
|
||||||
return dict(entry)
|
|
||||||
if size > MAX_CONFIG_SIZE:
|
|
||||||
raise ConfigError(
|
|
||||||
f"配置文件过大: {name}({size} 字节,上限 {MAX_CONFIG_SIZE} 字节)"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
|
||||||
content = f.read()
|
|
||||||
except (OSError, UnicodeDecodeError) as e:
|
|
||||||
raise ConfigError(f"配置读取失败: {name}({e})") from None
|
|
||||||
entry = {"content": content, "mtime": mtime,
|
|
||||||
"size": len(content.encode("utf-8"))}
|
|
||||||
self._cache[name] = entry
|
|
||||||
logs.info("confs", f"配置已加载 {name}({entry['size']}B)")
|
|
||||||
return dict(entry)
|
|
||||||
|
|
||||||
def list(self) -> list:
|
|
||||||
"""列出配置目录下全部允许类型的配置文件(持锁遍历,保证 _cache 访问一致)。
|
|
||||||
|
|
||||||
通过 os.path.realpath() 解析符号链接后校验前缀,防止逃逸到 configs/
|
|
||||||
目录外的符号链接泄露外部文件元信息(大小/mtime)。
|
|
||||||
"""
|
|
||||||
out = []
|
|
||||||
with self._lock:
|
|
||||||
root_prefix = self.root + os.sep
|
|
||||||
for name in sorted(os.listdir(self.root)):
|
|
||||||
if os.path.splitext(name)[1].lower() not in ALLOWED_EXT:
|
|
||||||
continue
|
|
||||||
raw_path = os.path.join(self.root, name)
|
|
||||||
# realpath 解析符号链接后校验前缀,逃逸的符号链接跳过
|
|
||||||
try:
|
|
||||||
path = os.path.realpath(raw_path)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
if not path.startswith(root_prefix):
|
|
||||||
continue
|
|
||||||
if not os.path.isfile(path):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
st = os.stat(path)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
out.append({"name": name, "size": st.st_size,
|
|
||||||
"mtime": _iso(st.st_mtime),
|
|
||||||
"cached": name in self._cache})
|
|
||||||
return out
|
|
||||||
|
|
||||||
def reload(self, name: "str | None" = None) -> list:
|
|
||||||
"""重载配置:指定 name 立即重新读取;缺省清空全部缓存(下次访问惰性重载)。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
受影响(重载/移出缓存)的配置名列表。
|
|
||||||
"""
|
|
||||||
if name is not None:
|
|
||||||
self._validate_name(name)
|
|
||||||
with self._lock:
|
|
||||||
# 在锁内重新解析路径,防止 TOCTOU
|
|
||||||
self._resolve_locked(name)
|
|
||||||
self._cache.pop(name, None)
|
|
||||||
self.get(name)
|
|
||||||
return [name]
|
|
||||||
with self._lock:
|
|
||||||
dropped = list(self._cache.keys())
|
|
||||||
self._cache.clear()
|
|
||||||
return dropped
|
|
||||||
|
|
||||||
def cached_count(self) -> int:
|
|
||||||
with self._lock:
|
|
||||||
return len(self._cache)
|
|
||||||
|
|
||||||
|
|
||||||
# ── 按键提取(可选功能) ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _extract_key(name: str, content: str, key: str):
|
|
||||||
"""从配置内容中提取单个键。
|
|
||||||
|
|
||||||
- ini/conf/cfg:键格式 ``Section.option``,返回字符串值;
|
|
||||||
- json:键格式为点号路径(如 ``server.port``,列表可用数字下标),
|
|
||||||
返回原始 JSON 值;
|
|
||||||
- 其他类型不支持按键查询,需取全文后自行解析。
|
|
||||||
"""
|
|
||||||
ext = os.path.splitext(name)[1].lower()
|
|
||||||
|
|
||||||
if ext in (".ini", ".conf", ".cfg"):
|
|
||||||
# interpolation=None:禁用 %(key)s 插值,避免配置值中裸 % 触发 InterpolationError
|
|
||||||
parser = configparser.ConfigParser(interpolation=None)
|
|
||||||
try:
|
|
||||||
parser.read_string(content)
|
|
||||||
except configparser.Error as e:
|
|
||||||
raise ConfigError(f"INI 解析失败: {name}({e})") from None
|
|
||||||
section, _, option = key.partition(".")
|
|
||||||
if not section or not option:
|
|
||||||
raise KeyNotFound(f"INI 键格式应为 Section.option: {key!r}")
|
|
||||||
if parser.has_option(section, option):
|
|
||||||
return parser.get(section, option)
|
|
||||||
raise KeyNotFound(f"键不存在: {name} -> {key}")
|
|
||||||
|
|
||||||
if ext == ".json":
|
|
||||||
try:
|
|
||||||
node = json.loads(content)
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
raise ConfigError(f"JSON 解析失败: {name}({e})") from None
|
|
||||||
for part in key.split("."):
|
|
||||||
if isinstance(node, dict) and part in node:
|
|
||||||
node = node[part]
|
|
||||||
elif isinstance(node, list) and part.isdigit() and int(part) < len(node):
|
|
||||||
node = node[int(part)]
|
|
||||||
else:
|
|
||||||
raise KeyNotFound(f"键不存在: {name} -> {key}")
|
|
||||||
return node
|
|
||||||
|
|
||||||
raise ConfigError(f"{ext} 类型不支持按键查询,请取全文后自行解析")
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# ConfsServer — 主服务类
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
class ConfsServer:
|
|
||||||
"""统一配置服务:集中管理并向内部微服务分发配置文件。"""
|
|
||||||
|
|
||||||
def __init__(self, addr="127.0.0.1", port=8010, workers=4, config_dir=CONFIG_DIR):
|
|
||||||
self.addr = addr
|
|
||||||
self.port = port
|
|
||||||
self._started_at = time.time()
|
|
||||||
self.store = ConfigStore(config_dir)
|
|
||||||
self._server = RpcServer(
|
|
||||||
addr, port, workers=workers,
|
|
||||||
on_error=lambda route, e: logs.error(
|
|
||||||
"confs", f"路由 {route} 处理异常: {e}"),
|
|
||||||
)
|
|
||||||
self._server.add_route("get", self._handle_get)
|
|
||||||
self._server.add_route("list", self._handle_list)
|
|
||||||
self._server.add_route("reload", self._handle_reload)
|
|
||||||
self._server.add_route("health", self._handle_health)
|
|
||||||
|
|
||||||
# ── 启动 / 停止 ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def start(self) -> None:
|
|
||||||
"""启动服务(阻塞当前线程)。"""
|
|
||||||
print(f"[confs] 监听 tcp://{self.addr}:{self.port},配置目录 {self.store.root}")
|
|
||||||
logs.info("confs", f"开始监听 tcp://{self.addr}:{self.port}"
|
|
||||||
f"(配置目录 {self.store.root})")
|
|
||||||
self._server.start()
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
|
||||||
"""从另一线程停止服务。"""
|
|
||||||
self._server.stop()
|
|
||||||
|
|
||||||
# ── 路由处理 ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _handle_get(self, req):
|
|
||||||
"""取配置文件全文;带 key 时返回单个键的值(仅 ini/json)。"""
|
|
||||||
name = str(req.get("name", "")).strip()
|
|
||||||
if not name:
|
|
||||||
raise ConfigError("缺少 name 字段")
|
|
||||||
entry = self.store.get(name)
|
|
||||||
|
|
||||||
key = req.get("key")
|
|
||||||
if key is None:
|
|
||||||
logs.info("confs", f"分发配置 {name}({entry['size']}B)")
|
|
||||||
return {"name": name, "content": entry["content"],
|
|
||||||
"size": entry["size"], "mtime": _iso(entry["mtime"])}
|
|
||||||
|
|
||||||
if not isinstance(key, str):
|
|
||||||
raise ConfigError("key 字段必须是字符串")
|
|
||||||
key = key.strip()
|
|
||||||
if not key:
|
|
||||||
raise ConfigError("key 字段不能为空")
|
|
||||||
value = _extract_key(name, entry["content"], key)
|
|
||||||
logs.info("confs", f"分发配置键 {name} -> {key}")
|
|
||||||
return {"name": name, "key": key, "value": value}
|
|
||||||
|
|
||||||
def _handle_list(self, _req):
|
|
||||||
"""列出配置目录下全部配置文件及其元信息。"""
|
|
||||||
return {"config_dir": self.store.root, "configs": self.store.list()}
|
|
||||||
|
|
||||||
def _handle_reload(self, req):
|
|
||||||
"""重载指定配置;缺省 name(或 name 为 null)时清空全部缓存(下次访问惰性重载)。
|
|
||||||
|
|
||||||
注意:name 为空字符串、空白字符串或非字符串类型时返回错误,
|
|
||||||
不再像旧版本那样将 falsy 值(""、0、False)误判为"清空全部缓存"。
|
|
||||||
"""
|
|
||||||
name = req.get("name")
|
|
||||||
if name is None:
|
|
||||||
names = self.store.reload(None)
|
|
||||||
logs.info("confs", f"清空配置缓存({len(names)} 项)")
|
|
||||||
return {"reloaded": names}
|
|
||||||
if not isinstance(name, str):
|
|
||||||
raise ConfigError("name 字段必须是字符串")
|
|
||||||
name = name.strip()
|
|
||||||
if not name:
|
|
||||||
raise ConfigError("name 字段不能为空(要清空全部缓存请省略 name 字段)")
|
|
||||||
names = self.store.reload(name)
|
|
||||||
logs.info("confs", f"重载配置 {names[0]}")
|
|
||||||
return {"reloaded": names}
|
|
||||||
|
|
||||||
def _handle_health(self, _req):
|
|
||||||
return {"status": "ok", "service": "confs",
|
|
||||||
"config_dir": self.store.root,
|
|
||||||
"cached": self.store.cached_count(),
|
|
||||||
"uptime": int(time.time() - self._started_at)}
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
# 入口
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="confs — 统一配置服务(pymsg RpcServer/TCP)")
|
|
||||||
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
|
|
||||||
parser.add_argument("--port", type=int, default=8010, help="监听端口")
|
|
||||||
parser.add_argument("--workers", type=int, default=4,
|
|
||||||
help="回调并发线程数(0=同步串行模式)")
|
|
||||||
parser.add_argument("--dir", default=CONFIG_DIR, help="配置文件目录")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
server = ConfsServer(args.addr, args.port,
|
|
||||||
workers=args.workers, config_dir=args.dir)
|
|
||||||
try:
|
|
||||||
server.start()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
server.stop()
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
"""日志客户端 — 委托到 pymsg.logc 统一库。"""
|
|
||||||
from pymsg.logc import LogClient, set_log_level # noqa: F401
|
|
||||||
|
|
||||||
logs = LogClient(service_name="confs")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
../pymsg
|
|
||||||
nuitka
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# configs 符号链接(由 _set.sh 创建,指向 ../confs/configs/)
|
|
||||||
configs
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
CXX := g++
|
|
||||||
CXXFLAGS := -std=c++20 -Wall -Wextra -O2
|
|
||||||
INCS := -I./inc
|
|
||||||
LIBS := -lus_msg -lus_logc -lboost_json -lboost_program_options -lpthread
|
|
||||||
|
|
||||||
TARGET := confs2.bin
|
|
||||||
SRC := src/main.cpp
|
|
||||||
|
|
||||||
.PHONY: all clean install
|
|
||||||
|
|
||||||
all: $(TARGET)
|
|
||||||
|
|
||||||
$(TARGET): $(SRC)
|
|
||||||
$(CXX) $(CXXFLAGS) $(INCS) $^ $(LIBS) -o $@
|
|
||||||
|
|
||||||
install: $(TARGET)
|
|
||||||
cp $(TARGET) /usr/local/bin/
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -f $(TARGET)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# confs2 — 统一配置服务(C++20 重写,已归档)
|
|
||||||
|
|
||||||
confs 的 C++20 重写实现,基于 libmsg `RpcServer`,协议与 Python 版 confs 完全兼容(`get` / `list` / `reload` / `health`),消费方零改动。
|
|
||||||
|
|
||||||
> **状态:已归档**。配置分发逻辑已合并进 `core` 的 `ConfsTask`(`--confs <port>` 启用,如 `./nidus.out -i . --serve 5200 --confs 8010`),不再独立部署。本目录保留为开发存档,开发文档见 [docs/reports/confs2_cpp_rewrite_dev_doc.md](../docs/reports/confs2_cpp_rewrite_dev_doc.md)。
|
|
||||||
|
|
||||||
配套黑盒测试:`python3 test_confs2.py`。
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
cd "$(dirname "$0")" || exit
|
|
||||||
exec ./confs2.bin
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$(dirname "$0")" || exit
|
|
||||||
|
|
||||||
make clean 2>/dev/null || true
|
|
||||||
make
|
|
||||||
echo "[confs2] 编译完成 → ./confs2.bin"
|
|
||||||
|
|
||||||
# 创建 configs 符号链接(指向 ../confs/configs/,配置文件零迁移)
|
|
||||||
if [ ! -e configs ]; then
|
|
||||||
ln -s ../confs/configs configs
|
|
||||||
echo "[confs2] 已创建 configs → ../confs/configs 符号链接"
|
|
||||||
fi
|
|
||||||
@@ -1,590 +0,0 @@
|
|||||||
/*
|
|
||||||
* confs2 — C++20 统一配置服务(pymsg RPC)
|
|
||||||
*
|
|
||||||
* 基于 libmsg RpcServer,协议与 Python confs 完全兼容,消费方零改动。
|
|
||||||
* ConfigStore 磁盘缓存 + mtime 自动重载,安全机制逐项 C++ 实现。
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include <us/Msg.hpp>
|
|
||||||
#include <us/RpcServer.hpp>
|
|
||||||
#include <us/RpcClient.hpp>
|
|
||||||
|
|
||||||
#include <boost/json.hpp>
|
|
||||||
#include <boost/program_options.hpp>
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <chrono>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstring>
|
|
||||||
#include <ctime>
|
|
||||||
#include <filesystem>
|
|
||||||
#include <fstream>
|
|
||||||
#include <iostream>
|
|
||||||
#include <mutex>
|
|
||||||
#include <optional>
|
|
||||||
#include <regex>
|
|
||||||
#include <sstream>
|
|
||||||
#include <stdexcept>
|
|
||||||
#include <string>
|
|
||||||
#include <unordered_map>
|
|
||||||
#include <unordered_set>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
#include <us/LogC.hpp>
|
|
||||||
|
|
||||||
namespace json = boost::json;
|
|
||||||
namespace po = boost::program_options;
|
|
||||||
namespace fs = std::filesystem;
|
|
||||||
|
|
||||||
using qing::LogC;
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// 1. 常量与异常
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
static const std::unordered_set<std::string> kAllowedExt = {
|
|
||||||
".ini", ".conf", ".cfg", ".json", ".yaml", ".yml", ".toml", ".txt"
|
|
||||||
};
|
|
||||||
|
|
||||||
static constexpr std::size_t kMaxConfigSize = 1 * 1024 * 1024; // 1 MiB
|
|
||||||
|
|
||||||
class ConfigError : public std::runtime_error {
|
|
||||||
public:
|
|
||||||
using std::runtime_error::runtime_error;
|
|
||||||
};
|
|
||||||
class ConfigNotFound : public ConfigError {
|
|
||||||
public:
|
|
||||||
using ConfigError::ConfigError;
|
|
||||||
};
|
|
||||||
class ConfigNameInvalid : public ConfigError {
|
|
||||||
public:
|
|
||||||
using ConfigError::ConfigError;
|
|
||||||
};
|
|
||||||
class KeyNotFound : public ConfigError {
|
|
||||||
public:
|
|
||||||
using ConfigError::ConfigError;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// 2. 工具函数
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
static std::string get_extension(const std::string& name) {
|
|
||||||
auto dot = name.rfind('.');
|
|
||||||
if (dot == std::string::npos) return "";
|
|
||||||
std::string ext = name.substr(dot);
|
|
||||||
std::transform(ext.begin(), ext.end(), ext.begin(),
|
|
||||||
[](unsigned char c) { return std::tolower(c); });
|
|
||||||
return ext;
|
|
||||||
}
|
|
||||||
|
|
||||||
static std::string iso_from_mtime(fs::file_time_type mtime) {
|
|
||||||
auto now_sys = std::chrono::system_clock::now();
|
|
||||||
auto now_file = fs::file_time_type::clock::now();
|
|
||||||
auto sys_tp = std::chrono::time_point_cast<std::chrono::system_clock::duration>(
|
|
||||||
mtime - now_file + now_sys);
|
|
||||||
std::time_t t = std::chrono::system_clock::to_time_t(sys_tp);
|
|
||||||
|
|
||||||
// 提取微秒部分(与 Python datetime.isoformat() 一致)
|
|
||||||
auto us = std::chrono::duration_cast<std::chrono::microseconds>(
|
|
||||||
sys_tp.time_since_epoch()).count() % 1000000;
|
|
||||||
|
|
||||||
// 系统本地时间 + 时区偏移
|
|
||||||
std::tm* lt = std::localtime(&t);
|
|
||||||
char buf[48];
|
|
||||||
std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", lt);
|
|
||||||
char tz_raw[8];
|
|
||||||
std::strftime(tz_raw, sizeof(tz_raw), "%z", lt); // "+0000" or "+0800"
|
|
||||||
std::string tz = tz_raw;
|
|
||||||
if (tz.length() >= 5)
|
|
||||||
tz = tz.substr(0, 3) + ":" + tz.substr(3); // "+00:00"
|
|
||||||
|
|
||||||
if (us == 0)
|
|
||||||
return std::string(buf) + tz; // Python: 微秒为0时省略
|
|
||||||
char out[64];
|
|
||||||
std::snprintf(out, sizeof(out), "%s.%06lld%s",
|
|
||||||
buf, static_cast<long long>(us), tz.c_str());
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
static int parse_int(const std::string& key, const std::string& val) {
|
|
||||||
try {
|
|
||||||
return std::stoi(val);
|
|
||||||
} catch (const std::exception& ex) {
|
|
||||||
std::cerr << "[confs2] 配置解析失败: " << key << " = '" << val
|
|
||||||
<< "' (" << ex.what() << ")" << std::endl;
|
|
||||||
std::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// 3. ConfigStore — 配置文件仓库(线程安全)
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
struct CacheEntry {
|
|
||||||
std::string content;
|
|
||||||
fs::file_time_type mtime;
|
|
||||||
std::size_t size;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct ConfigMeta {
|
|
||||||
std::string name;
|
|
||||||
std::size_t size;
|
|
||||||
std::string mtime_iso;
|
|
||||||
bool cached;
|
|
||||||
};
|
|
||||||
|
|
||||||
class ConfigStore {
|
|
||||||
public:
|
|
||||||
explicit ConfigStore(const std::string& root) {
|
|
||||||
root_ = fs::weakly_canonical(fs::absolute(root));
|
|
||||||
fs::create_directories(root_);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string root_string() const { return root_.string(); }
|
|
||||||
|
|
||||||
// 取配置全文(带缓存 + mtime 自动重载)
|
|
||||||
// 返回 CacheEntry 的拷贝(防缓存污染,与 Python 版 dict(entry) 语义一致)
|
|
||||||
CacheEntry get(const std::string& name) {
|
|
||||||
validate_name(name); // 锁外快速拒绝非法名
|
|
||||||
std::lock_guard<std::mutex> lock(mtx_);
|
|
||||||
auto path = resolve_locked(name); // 锁内: canonical + 前缀校验 + 存在性检查
|
|
||||||
|
|
||||||
// 锁内: stat → mtime 比对 → size 校验 → open → read,无 TOCTOU 窗口
|
|
||||||
auto mtime = fs::last_write_time(path);
|
|
||||||
auto size = fs::file_size(path);
|
|
||||||
|
|
||||||
auto it = cache_.find(name);
|
|
||||||
if (it != cache_.end() && it->second.mtime == mtime)
|
|
||||||
return it->second; // 缓存命中,返回拷贝
|
|
||||||
|
|
||||||
if (size > kMaxConfigSize)
|
|
||||||
throw ConfigError("配置文件过大: " + name);
|
|
||||||
|
|
||||||
std::ifstream f(path);
|
|
||||||
if (!f.is_open())
|
|
||||||
throw ConfigError("配置读取失败: " + name);
|
|
||||||
std::string content((std::istreambuf_iterator<char>(f)),
|
|
||||||
std::istreambuf_iterator<char>());
|
|
||||||
|
|
||||||
CacheEntry entry{content, mtime, content.size()};
|
|
||||||
cache_[name] = entry;
|
|
||||||
return entry; // 返回拷贝
|
|
||||||
}
|
|
||||||
|
|
||||||
// 列出配置目录全部文件元信息
|
|
||||||
std::vector<ConfigMeta> list() {
|
|
||||||
std::vector<ConfigMeta> out;
|
|
||||||
std::lock_guard<std::mutex> lock(mtx_);
|
|
||||||
std::string root_str = root_.string();
|
|
||||||
|
|
||||||
std::vector<std::string> names;
|
|
||||||
for (const auto& entry : fs::directory_iterator(root_)) {
|
|
||||||
auto name = entry.path().filename().string();
|
|
||||||
if (kAllowedExt.find(get_extension(name)) == kAllowedExt.end())
|
|
||||||
continue;
|
|
||||||
// 解析符号链接,逃逸的跳过
|
|
||||||
auto path = fs::weakly_canonical(entry.path());
|
|
||||||
std::string path_str = path.string();
|
|
||||||
if (path_str.find(root_str) != 0 || path_str == root_str)
|
|
||||||
continue;
|
|
||||||
if (path_str.size() > root_str.size() && path_str[root_str.size()] != '/')
|
|
||||||
continue;
|
|
||||||
if (!fs::is_regular_file(path))
|
|
||||||
continue;
|
|
||||||
names.push_back(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::sort(names.begin(), names.end());
|
|
||||||
|
|
||||||
for (const auto& name : names) {
|
|
||||||
auto path = fs::weakly_canonical(root_ / name);
|
|
||||||
try {
|
|
||||||
auto mtime = fs::last_write_time(path);
|
|
||||||
auto size = fs::file_size(path);
|
|
||||||
out.push_back({name, size, iso_from_mtime(mtime),
|
|
||||||
cache_.count(name) > 0});
|
|
||||||
} catch (const std::exception&) {
|
|
||||||
continue; // 文件可能在遍历时被删除,跳过
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重载指定配置 / 清空全部缓存
|
|
||||||
std::vector<std::string> reload(const std::string* name) {
|
|
||||||
if (name != nullptr) {
|
|
||||||
validate_name(*name);
|
|
||||||
{
|
|
||||||
std::lock_guard<std::mutex> lock(mtx_);
|
|
||||||
resolve_locked(*name); // 锁内检查存在性,防止 TOCTOU
|
|
||||||
cache_.erase(*name);
|
|
||||||
}
|
|
||||||
get(*name); // 重新读取并缓存(get 内部自己加锁)
|
|
||||||
return {*name};
|
|
||||||
}
|
|
||||||
// 清空全部缓存
|
|
||||||
std::vector<std::string> dropped;
|
|
||||||
std::lock_guard<std::mutex> lock(mtx_);
|
|
||||||
for (const auto& [k, v] : cache_)
|
|
||||||
dropped.push_back(k);
|
|
||||||
cache_.clear();
|
|
||||||
return dropped;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::size_t cached_count() const {
|
|
||||||
std::lock_guard<std::mutex> lock(mtx_);
|
|
||||||
return cache_.size();
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
// 扩展名白名单校验(纯字符串操作,不访问文件系统)
|
|
||||||
static void validate_name(const std::string& name) {
|
|
||||||
if (name.empty())
|
|
||||||
throw ConfigNameInvalid("非法配置名: (空)");
|
|
||||||
if (name.find('/') != std::string::npos || name.find('\\') != std::string::npos)
|
|
||||||
throw ConfigNameInvalid("非法配置名");
|
|
||||||
auto ext = get_extension(name);
|
|
||||||
if (kAllowedExt.find(ext) == kAllowedExt.end())
|
|
||||||
throw ConfigNameInvalid("不支持的配置类型: " + (ext.empty() ? "(无扩展名)" : ext));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 持锁解析路径(canonical 解析符号链接 + 前缀校验 + 文件存在性)
|
|
||||||
fs::path resolve_locked(const std::string& name) {
|
|
||||||
auto path = fs::weakly_canonical(root_ / name);
|
|
||||||
std::string path_str = path.string();
|
|
||||||
std::string root_str = root_.string();
|
|
||||||
|
|
||||||
// 校验路径在 root_ 之下
|
|
||||||
if (path_str == root_str ||
|
|
||||||
path_str.size() <= root_str.size() ||
|
|
||||||
path_str[root_str.size()] != '/' ||
|
|
||||||
path_str.find(root_str) != 0)
|
|
||||||
throw ConfigNameInvalid("非法配置名");
|
|
||||||
|
|
||||||
if (!fs::is_regular_file(path))
|
|
||||||
throw ConfigNotFound("配置不存在: " + name);
|
|
||||||
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
fs::path root_;
|
|
||||||
std::unordered_map<std::string, CacheEntry> cache_;
|
|
||||||
mutable std::mutex mtx_;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// 4. INI 按键取值 — 与 Python ConfigParser(interpolation=None) 行为对等
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
//
|
|
||||||
// 大小写策略(与 Python configparser 一致):
|
|
||||||
// - Section 名:大小写敏感([Server] ≠ [server])
|
|
||||||
// - Option 名:大小写不敏感(port = Port = PORT)
|
|
||||||
// - 值:原样返回,不做任何转换
|
|
||||||
// - 插值:禁用,%(key)s 原样返回
|
|
||||||
|
|
||||||
static std::optional<std::string> extract_ini_key(
|
|
||||||
const std::string& content, const std::string& key)
|
|
||||||
{
|
|
||||||
auto dot = key.find('.');
|
|
||||||
if (dot == std::string::npos || dot == 0 || dot == key.size() - 1)
|
|
||||||
return std::nullopt;
|
|
||||||
|
|
||||||
std::string section = key.substr(0, dot);
|
|
||||||
std::string option = key.substr(dot + 1);
|
|
||||||
// option 大小写不敏感 → 转小写比较
|
|
||||||
std::string option_lower = option;
|
|
||||||
std::transform(option_lower.begin(), option_lower.end(), option_lower.begin(),
|
|
||||||
[](unsigned char c) { return std::tolower(c); });
|
|
||||||
|
|
||||||
std::istringstream stream(content);
|
|
||||||
std::string line;
|
|
||||||
std::string current_section;
|
|
||||||
|
|
||||||
while (std::getline(stream, line)) {
|
|
||||||
// 去 \r
|
|
||||||
if (!line.empty() && line.back() == '\r')
|
|
||||||
line.pop_back();
|
|
||||||
|
|
||||||
auto start = line.find_first_not_of(" \t");
|
|
||||||
if (start == std::string::npos) continue; // 空行
|
|
||||||
if (line[start] == ';' || line[start] == '#') continue; // 注释
|
|
||||||
|
|
||||||
if (line[start] == '[') {
|
|
||||||
auto end = line.find(']', start);
|
|
||||||
if (end != std::string::npos)
|
|
||||||
current_section = line.substr(start + 1, end - start - 1);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto eq = line.find('=', start);
|
|
||||||
if (eq == std::string::npos) continue;
|
|
||||||
|
|
||||||
std::string key_name = line.substr(start, eq - start);
|
|
||||||
auto ke = key_name.find_last_not_of(" \t");
|
|
||||||
if (ke != std::string::npos) key_name = key_name.substr(0, ke + 1);
|
|
||||||
|
|
||||||
std::string key_lower = key_name;
|
|
||||||
std::transform(key_lower.begin(), key_lower.end(), key_lower.begin(),
|
|
||||||
[](unsigned char c) { return std::tolower(c); });
|
|
||||||
|
|
||||||
std::string value = line.substr(eq + 1);
|
|
||||||
auto vs = value.find_first_not_of(" \t");
|
|
||||||
if (vs != std::string::npos) value = value.substr(vs);
|
|
||||||
auto ve = value.find_last_not_of(" \t\r");
|
|
||||||
if (ve != std::string::npos) value = value.substr(0, ve + 1);
|
|
||||||
|
|
||||||
if (current_section == section && key_lower == option_lower)
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return std::nullopt;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// 5. JSON 点路径取值
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
static json::value extract_json_key(
|
|
||||||
const std::string& content, const std::string& key)
|
|
||||||
{
|
|
||||||
json::value node = json::parse(content); // 解析失败会抛异常
|
|
||||||
|
|
||||||
// 按点分割
|
|
||||||
std::vector<std::string> parts;
|
|
||||||
std::istringstream iss(key);
|
|
||||||
std::string part;
|
|
||||||
while (std::getline(iss, part, '.')) {
|
|
||||||
if (!part.empty()) parts.push_back(part);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const auto& p : parts) {
|
|
||||||
if (node.is_object()) {
|
|
||||||
auto it = node.as_object().find(p);
|
|
||||||
if (it == node.as_object().end())
|
|
||||||
throw KeyNotFound("键不存在: -> " + key);
|
|
||||||
node = it->value();
|
|
||||||
} else if (node.is_array()) {
|
|
||||||
if (p.empty() || !std::all_of(p.begin(), p.end(), [](char c) { return std::isdigit(c); }))
|
|
||||||
throw KeyNotFound("数组下标必须是数字: " + p);
|
|
||||||
std::size_t idx = std::stoul(p);
|
|
||||||
if (idx >= node.as_array().size())
|
|
||||||
throw KeyNotFound("数组下标越界: " + p);
|
|
||||||
node = node.as_array()[idx];
|
|
||||||
} else {
|
|
||||||
throw KeyNotFound("无法继续遍历: " + p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// 6. 配置结构
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
struct ConfsConfig {
|
|
||||||
std::string host = "127.0.0.1";
|
|
||||||
int port = 8010;
|
|
||||||
int workers = 4;
|
|
||||||
std::string config_dir; // 默认同级 configs/
|
|
||||||
};
|
|
||||||
|
|
||||||
static void apply_env_overrides(ConfsConfig& cfg) {
|
|
||||||
if (auto* v = std::getenv("LISTEN_ADDR")) cfg.host = v;
|
|
||||||
if (auto* v = std::getenv("LISTEN_PORT")) cfg.port = parse_int("LISTEN_PORT", v);
|
|
||||||
if (auto* v = std::getenv("WORKERS")) cfg.workers = parse_int("WORKERS", v);
|
|
||||||
if (auto* v = std::getenv("CONFS_DIR")) cfg.config_dir = v;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void validate_config(const ConfsConfig& cfg) {
|
|
||||||
if (cfg.host.empty()) { std::cerr << "[confs2] 配置缺失: host" << std::endl; std::exit(1); }
|
|
||||||
if (cfg.port <= 0 || cfg.port > 65535) { std::cerr << "[confs2] 配置无效: port" << std::endl; std::exit(1); }
|
|
||||||
if (cfg.workers < 0) { std::cerr << "[confs2] workers 不能为负数" << std::endl; std::exit(1); }
|
|
||||||
if (cfg.config_dir.empty()) { std::cerr << "[confs2] 配置缺失: config_dir" << std::endl; std::exit(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// 7. 全局实例 + 路由 + main()
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
static ConfsConfig g_config;
|
|
||||||
static ConfigStore* g_store = nullptr;
|
|
||||||
static auto& g_log = LogC::instance();
|
|
||||||
static const auto _log_init = (LogC::instance().set_service_name("confs2-log"), true);
|
|
||||||
static const auto g_start_time = std::chrono::steady_clock::now();
|
|
||||||
|
|
||||||
static std::int64_t uptime_seconds() {
|
|
||||||
auto now = std::chrono::steady_clock::now();
|
|
||||||
return std::chrono::duration_cast<std::chrono::seconds>(now - g_start_time).count();
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(int argc, char* argv[]) {
|
|
||||||
try {
|
|
||||||
// ── 默认配置目录:同级 configs/ ──
|
|
||||||
{
|
|
||||||
// 可执行文件同目录下的 configs/
|
|
||||||
// 编译时不知道运行时路径,用 __FILE__ 作为开发期 fallback
|
|
||||||
g_config.config_dir = "./configs";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 命令行参数 ──
|
|
||||||
po::options_description desc("confs2 — C++20 统一配置服务(pymsg RPC)");
|
|
||||||
desc.add_options()
|
|
||||||
("help,h", "显示帮助")
|
|
||||||
("addr,a", po::value<std::string>(), "监听地址")
|
|
||||||
("port,p", po::value<int>(), "监听端口")
|
|
||||||
("workers,w", po::value<int>(), "工作线程数")
|
|
||||||
("dir,d", po::value<std::string>(), "配置文件目录");
|
|
||||||
po::variables_map vm;
|
|
||||||
po::store(po::parse_command_line(argc, argv, desc), vm);
|
|
||||||
po::notify(vm);
|
|
||||||
|
|
||||||
if (vm.count("help")) {
|
|
||||||
std::cout << desc << std::endl;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 配置加载:硬编码默认 → 环境变量 → 命令行 ──
|
|
||||||
apply_env_overrides(g_config);
|
|
||||||
if (vm.count("addr")) g_config.host = vm["addr"].as<std::string>();
|
|
||||||
if (vm.count("port")) g_config.port = vm["port"].as<int>();
|
|
||||||
if (vm.count("workers")) g_config.workers = vm["workers"].as<int>();
|
|
||||||
if (vm.count("dir")) g_config.config_dir = vm["dir"].as<std::string>();
|
|
||||||
|
|
||||||
validate_config(g_config);
|
|
||||||
|
|
||||||
// ── 初始化 ConfigStore ──
|
|
||||||
ConfigStore store(g_config.config_dir);
|
|
||||||
g_store = &store;
|
|
||||||
|
|
||||||
std::cerr << "[confs2] 配置目录: " << store.root_string() << std::endl;
|
|
||||||
std::cerr << "[confs2] 监听 tcp://" << g_config.host << ":" << g_config.port
|
|
||||||
<< " workers=" << g_config.workers << std::endl;
|
|
||||||
|
|
||||||
g_log.info("confs", "启动中... 监听 tcp://" + g_config.host + ":"
|
|
||||||
+ std::to_string(g_config.port) + " 配置目录 " + store.root_string());
|
|
||||||
|
|
||||||
// ── 启动 RpcServer ──
|
|
||||||
pmc::net::RpcServer rpc(g_config.host, g_config.port,
|
|
||||||
pmc::net::MsgServerConfig{.workers = g_config.workers},
|
|
||||||
[](const std::string& route, const std::exception& e) {
|
|
||||||
g_log.error("confs", "路由 " + route + " 处理异常: " + e.what());
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 路由: get ──
|
|
||||||
rpc.add_route("get", [](const json::object& req) -> json::value {
|
|
||||||
auto name_it = req.find("name");
|
|
||||||
if (name_it == req.end() || !name_it->value().is_string())
|
|
||||||
throw ConfigError("缺少 name 字段");
|
|
||||||
std::string name = json::value_to<std::string>(name_it->value());
|
|
||||||
|
|
||||||
auto entry = g_store->get(name);
|
|
||||||
|
|
||||||
// 无 key:返回全文
|
|
||||||
auto key_it = req.find("key");
|
|
||||||
if (key_it == req.end())
|
|
||||||
return json::object{
|
|
||||||
{"name", name},
|
|
||||||
{"content", entry.content},
|
|
||||||
{"size", (std::int64_t)entry.size},
|
|
||||||
{"mtime", iso_from_mtime(entry.mtime)}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 有 key:校验
|
|
||||||
if (!key_it->value().is_string())
|
|
||||||
throw ConfigError("key 字段必须是字符串");
|
|
||||||
std::string key = json::value_to<std::string>(key_it->value());
|
|
||||||
if (key.empty())
|
|
||||||
throw ConfigError("key 字段不能为空");
|
|
||||||
|
|
||||||
auto ext = get_extension(name);
|
|
||||||
|
|
||||||
if (ext == ".ini" || ext == ".conf" || ext == ".cfg") {
|
|
||||||
auto val = extract_ini_key(entry.content, key);
|
|
||||||
if (!val)
|
|
||||||
throw KeyNotFound("键不存在: " + name + " -> " + key);
|
|
||||||
g_log.info("confs", "分发配置键 " + name + " -> " + key);
|
|
||||||
return json::object{{"name", name}, {"key", key}, {"value", *val}};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ext == ".json") {
|
|
||||||
auto val = extract_json_key(entry.content, key);
|
|
||||||
g_log.info("confs", "分发配置键 " + name + " -> " + key);
|
|
||||||
return json::object{{"name", name}, {"key", key}, {"value", val}};
|
|
||||||
}
|
|
||||||
|
|
||||||
throw ConfigError(ext + " 类型不支持按键查询,请取全文后自行解析");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 路由: list ──
|
|
||||||
rpc.add_route("list", [](const json::object&) -> json::value {
|
|
||||||
auto configs = g_store->list();
|
|
||||||
json::array arr;
|
|
||||||
for (const auto& c : configs)
|
|
||||||
arr.push_back(json::object{
|
|
||||||
{"name", c.name},
|
|
||||||
{"size", (std::int64_t)c.size},
|
|
||||||
{"mtime", c.mtime_iso},
|
|
||||||
{"cached", c.cached}
|
|
||||||
});
|
|
||||||
return json::object{
|
|
||||||
{"config_dir", g_store->root_string()},
|
|
||||||
{"configs", arr}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 路由: reload ──
|
|
||||||
rpc.add_route("reload", [](const json::object& req) -> json::value {
|
|
||||||
auto it = req.find("name");
|
|
||||||
|
|
||||||
// 省略 name 或 null → 清空全部缓存
|
|
||||||
if (it == req.end() || it->value().is_null()) {
|
|
||||||
auto names = g_store->reload(nullptr);
|
|
||||||
g_log.info("confs", "清空配置缓存(" + std::to_string(names.size()) + " 项)");
|
|
||||||
json::array arr;
|
|
||||||
for (const auto& n : names) arr.push_back(json::value(n));
|
|
||||||
return json::object{{"reloaded", arr}};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 非字符串 → 报错
|
|
||||||
if (!it->value().is_string())
|
|
||||||
throw ConfigError("name 字段必须是字符串");
|
|
||||||
|
|
||||||
std::string name = json::value_to<std::string>(it->value());
|
|
||||||
if (name.empty())
|
|
||||||
throw ConfigError("name 字段不能为空(要清空全部缓存请省略 name 字段)");
|
|
||||||
|
|
||||||
auto names = g_store->reload(&name);
|
|
||||||
g_log.info("confs", "重载配置 " + names[0]);
|
|
||||||
json::array arr;
|
|
||||||
for (const auto& n : names) arr.push_back(json::value(n));
|
|
||||||
return json::object{{"reloaded", arr}};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── 路由: health ──
|
|
||||||
rpc.add_route("health", [](const json::object&) -> json::value {
|
|
||||||
return json::object{
|
|
||||||
{"status", "ok"},
|
|
||||||
{"service", "confs"},
|
|
||||||
{"config_dir", g_store->root_string()},
|
|
||||||
{"cached", (std::int64_t)g_store->cached_count()},
|
|
||||||
{"uptime", uptime_seconds()}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
std::cerr << "[confs2] 开始监听 tcp://" << g_config.host << ":"
|
|
||||||
<< g_config.port << std::endl;
|
|
||||||
g_log.info("confs", "开始监听 tcp://" + g_config.host + ":"
|
|
||||||
+ std::to_string(g_config.port));
|
|
||||||
|
|
||||||
rpc.start(); // 阻塞
|
|
||||||
|
|
||||||
} catch (const std::exception& e) {
|
|
||||||
std::cerr << "[confs2] 致命错误: " << e.what() << std::endl;
|
|
||||||
g_log.error("confs", std::string("致命错误: ") + e.what());
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""confs2 vs confs 对比测试 — 21 项路由逐一对比 Python 原版与 C++ 重写版。
|
|
||||||
|
|
||||||
智能比较策略:
|
|
||||||
- mtime 字段:截断到秒精度(Python float 与 C++ 纳秒转微秒有 ±1µs 精度差异)
|
|
||||||
- health 路由:比较结构(字段集 + status + service + config_dir),忽略 uptime/cached
|
|
||||||
- reload all:集合比较(dict 保序 vs unordered_map 哈希序,条目集一致即可)
|
|
||||||
"""
|
|
||||||
import sys, json, os, re
|
|
||||||
sys.path.insert(0, "/opt/micro_servers/confs/venv/lib/python3.12/site-packages")
|
|
||||||
sys.path.insert(0, "/opt/micro_servers/pymsg")
|
|
||||||
from pymsg import RpcClient
|
|
||||||
|
|
||||||
PORT_OLD = 8010 # Python confs
|
|
||||||
PORT_NEW = 8011 # C++ confs2
|
|
||||||
|
|
||||||
def call_both(route, params):
|
|
||||||
c_old = RpcClient("127.0.0.1", PORT_OLD, "test", timeout=5.0)
|
|
||||||
c_new = RpcClient("127.0.0.1", PORT_NEW, "test", timeout=5.0)
|
|
||||||
try:
|
|
||||||
r_old = c_old.call(route, params); old_ok = True
|
|
||||||
except Exception as e:
|
|
||||||
r_old = str(e); old_ok = False
|
|
||||||
try:
|
|
||||||
r_new = c_new.call(route, params); new_ok = True
|
|
||||||
except Exception as e:
|
|
||||||
r_new = str(e); new_ok = False
|
|
||||||
return r_old, r_new, old_ok, new_ok
|
|
||||||
|
|
||||||
def call_both_raw(route, params):
|
|
||||||
c_old = RpcClient("127.0.0.1", PORT_OLD, "test", timeout=5.0)
|
|
||||||
c_new = RpcClient("127.0.0.1", PORT_NEW, "test", timeout=5.0)
|
|
||||||
try:
|
|
||||||
r_old = c_old.call_raw(route, params); old_ok = r_old.get("success", False)
|
|
||||||
except Exception as e:
|
|
||||||
r_old = {"success": False, "error": str(e)}; old_ok = False
|
|
||||||
try:
|
|
||||||
r_new = c_new.call_raw(route, params); new_ok = r_new.get("success", False)
|
|
||||||
except Exception as e:
|
|
||||||
r_new = {"success": False, "error": str(e)}; new_ok = False
|
|
||||||
return r_old, r_new, old_ok, new_ok
|
|
||||||
|
|
||||||
def normalize_mtime(s):
|
|
||||||
if isinstance(s, str):
|
|
||||||
return re.sub(r'\.\d+(?=\+00:00|Z|$)', '', s)
|
|
||||||
return s
|
|
||||||
|
|
||||||
def normalize_obj(obj):
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return {k: (normalize_mtime(v) if k == 'mtime' else normalize_obj(v)) for k, v in obj.items()}
|
|
||||||
elif isinstance(obj, list):
|
|
||||||
return [normalize_obj(item) for item in obj]
|
|
||||||
return obj
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
def report(label, match, old, new, ok_old, ok_new):
|
|
||||||
status = "PASS" if match else "FAIL"
|
|
||||||
print(f"[{status}] {label}")
|
|
||||||
if not match:
|
|
||||||
print(f" OLD: ok={ok_old} -> {json.dumps(old, default=str)[:300]}")
|
|
||||||
print(f" NEW: ok={ok_new} -> {json.dumps(new, default=str)[:300]}")
|
|
||||||
results.append(match)
|
|
||||||
|
|
||||||
# 1. health (ignore uptime/cached)
|
|
||||||
r_old, r_new, ok_old, ok_new = call_both("health", {})
|
|
||||||
if ok_old and ok_new:
|
|
||||||
keys_match = set(r_old.keys()) == set(r_new.keys())
|
|
||||||
vals_match = all(r_old.get(k) == r_new.get(k) for k in ["status", "service", "config_dir"])
|
|
||||||
report("health", keys_match and vals_match, r_old, r_new, ok_old, ok_new)
|
|
||||||
else:
|
|
||||||
report("health", ok_old == ok_new, r_old, r_new, ok_old, ok_new)
|
|
||||||
|
|
||||||
# 2. list (normalize mtime)
|
|
||||||
r_old, r_new, ok_old, ok_new = call_both("list", {})
|
|
||||||
if ok_old and ok_new:
|
|
||||||
report("list", normalize_obj(r_old) == normalize_obj(r_new), r_old, r_new, ok_old, ok_new)
|
|
||||||
else:
|
|
||||||
report("list", ok_old == ok_new, r_old, r_new, ok_old, ok_new)
|
|
||||||
|
|
||||||
# 3. get full content - db.ini (normalize mtime)
|
|
||||||
r_old, r_new, ok_old, ok_new = call_both("get", {"name": "db.ini"})
|
|
||||||
if ok_old and ok_new:
|
|
||||||
report("get db.ini full", normalize_obj(r_old) == normalize_obj(r_new), r_old, r_new, ok_old, ok_new)
|
|
||||||
else:
|
|
||||||
report("get db.ini full", ok_old == ok_new, r_old, r_new, ok_old, ok_new)
|
|
||||||
|
|
||||||
# 4-6. get INI keys
|
|
||||||
for key, label in [("server.port", "server.port"), ("database.url", "database.url"), ("logs.retention_days", "logs.retention_days")]:
|
|
||||||
r_old, r_new, ok_old, ok_new = call_both("get", {"name": "db.ini", "key": key})
|
|
||||||
report(f"get db.ini {label}", ok_old and ok_new and r_old == r_new, r_old, r_new, ok_old, ok_new)
|
|
||||||
|
|
||||||
# 7. case-insensitive option
|
|
||||||
r_old, r_new, ok_old, ok_new = call_both("get", {"name": "db.ini", "key": "server.PORT"})
|
|
||||||
report("get db.ini server.PORT (case-insensitive)", ok_old and ok_new and r_old == r_new, r_old, r_new, ok_old, ok_new)
|
|
||||||
|
|
||||||
# 8-9. static.ini keys
|
|
||||||
for key, label in [("server.port", "server.port"), ("cors.allowed_origins", "cors.allowed_origins")]:
|
|
||||||
r_old, r_new, ok_old, ok_new = call_both("get", {"name": "static.ini", "key": key})
|
|
||||||
report(f"get static.ini {label}", ok_old and ok_new and r_old == r_new, r_old, r_new, ok_old, ok_new)
|
|
||||||
|
|
||||||
# 10. get full content - captcha.ini (normalize mtime)
|
|
||||||
r_old, r_new, ok_old, ok_new = call_both("get", {"name": "captcha.ini"})
|
|
||||||
if ok_old and ok_new:
|
|
||||||
report("get captcha.ini full", normalize_obj(r_old) == normalize_obj(r_new), r_old, r_new, ok_old, ok_new)
|
|
||||||
else:
|
|
||||||
report("get captcha.ini full", ok_old == ok_new, r_old, r_new, ok_old, ok_new)
|
|
||||||
|
|
||||||
# 11-13. error cases
|
|
||||||
for params, label in [
|
|
||||||
({"name": "nonexistent.ini"}, "get nonexistent (expect error)"),
|
|
||||||
({"name": "../../etc/passwd"}, "get path traversal (expect error)"),
|
|
||||||
({"name": "test.sh"}, "get invalid extension (expect error)"),
|
|
||||||
]:
|
|
||||||
r_old, r_new, ok_old, ok_new = call_both_raw("get", params)
|
|
||||||
report(label, ok_old == ok_new, r_old, r_new, ok_old, ok_new)
|
|
||||||
|
|
||||||
# 14. reload specific
|
|
||||||
r_old, r_new, ok_old, ok_new = call_both("reload", {"name": "db.ini"})
|
|
||||||
report("reload db.ini", ok_old and ok_new and r_old == r_new, r_old, r_new, ok_old, ok_new)
|
|
||||||
|
|
||||||
# 15. reload all (set comparison — dict vs unordered_map)
|
|
||||||
r_old, r_new, ok_old, ok_new = call_both("reload", {})
|
|
||||||
if ok_old and ok_new:
|
|
||||||
old_set = set(r_old.get("reloaded", []))
|
|
||||||
new_set = set(r_new.get("reloaded", []))
|
|
||||||
report("reload all", old_set == new_set, r_old, r_new, ok_old, ok_new)
|
|
||||||
else:
|
|
||||||
report("reload all", ok_old == ok_new, r_old, r_new, ok_old, ok_new)
|
|
||||||
|
|
||||||
# 16-21. error cases
|
|
||||||
for route, params, label in [
|
|
||||||
("reload", {"name": ""}, "reload empty string (expect error)"),
|
|
||||||
("reload", {"name": 123}, "reload non-string (expect error)"),
|
|
||||||
("get", {"name": ""}, "get empty name (expect error)"),
|
|
||||||
("get", {}, "get without name (expect error)"),
|
|
||||||
("get", {"name": "db.ini", "key": "nonexistent.key"}, "get db.ini nonexistent.key (expect error)"),
|
|
||||||
("get", {"name": "db.ini", "key": "noformat"}, "get db.ini noformat (expect error)"),
|
|
||||||
]:
|
|
||||||
r_old, r_new, ok_old, ok_new = call_both_raw(route, params)
|
|
||||||
report(label, ok_old == ok_new, r_old, r_new, ok_old, ok_new)
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
passed = sum(1 for r in results if r)
|
|
||||||
total = len(results)
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f"Results: {passed}/{total} passed")
|
|
||||||
if passed < total:
|
|
||||||
print(f"FAILED: {total - passed} tests")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
print("ALL TESTS PASSED!")
|
|
||||||
sys.exit(0)
|
|
||||||
@@ -130,6 +130,3 @@ go.sum
|
|||||||
# VS
|
# VS
|
||||||
x64
|
x64
|
||||||
.vs
|
.vs
|
||||||
|
|
||||||
# configs 符号链接(由 _set.sh 创建,指向 ../confs/configs/,内嵌 confs 服务使用)
|
|
||||||
configs
|
|
||||||
@@ -4,13 +4,4 @@ cd "$(dirname "$0")" || exit
|
|||||||
|
|
||||||
make clear && make $@
|
make clear && make $@
|
||||||
|
|
||||||
# 创建 configs 符号链接(指向 ../confs/configs/,内嵌 confs 服务配置文件零迁移)
|
|
||||||
if [ -L configs ] && [ ! -e configs ]; then
|
|
||||||
rm -f configs
|
|
||||||
fi
|
|
||||||
if [ ! -e configs ]; then
|
|
||||||
ln -s ../confs/configs configs
|
|
||||||
echo "[core] 已创建 configs → ../confs/configs 符号链接"
|
|
||||||
fi
|
|
||||||
|
|
||||||
make install
|
make install
|
||||||
File renamed without changes.
+1
-1
@@ -42,7 +42,7 @@ Target-Url: http://127.0.0.1:8006 // 可选,缺省取 Proxy.default
|
|||||||
|
|
||||||
两层加载:confs 拉取(pymsg `get`,`retry_on_refused=true`)→ 环境变量覆盖。
|
两层加载:confs 拉取(pymsg `get`,`retry_on_refused=true`)→ 环境变量覆盖。
|
||||||
|
|
||||||
配置文件:[confs/configs/gateway.ini](../confs/configs/gateway.ini)。
|
配置文件:[core/configs/gateway.ini](../core/configs/gateway.ini)。
|
||||||
|
|
||||||
- `Server.address`/`port`/`tls`:默认 `127.0.0.1:8445`(v0.9.0 起 8443 归 ca);env `LISTEN_ADDR`/`LISTEN_PORT`。
|
- `Server.address`/`port`/`tls`:默认 `127.0.0.1:8445`(v0.9.0 起 8443 归 ca);env `LISTEN_ADDR`/`LISTEN_PORT`。
|
||||||
- `Proxy.default` / `Proxy.opened` / `Proxy.closed`:转发目标与白名单(分号分隔)。
|
- `Proxy.default` / `Proxy.opened` / `Proxy.closed`:转发目标与白名单(分号分隔)。
|
||||||
|
|||||||
Reference in new issue
Block a user