1
0

refactor: 后端架构加固 — 泛型化、批量查询、bootstrap 统一、路径修复与 pageSize 上限

- CheckerDefinition 泛型化,HTTP/Command checker 移除 resolved target 断言
- 新增 ProbeStore.getAllRecentSamples 消除 targets 路由 N+1 查询
- 统一 getAllTargetStats 与 getTargetStats 的 availability 精度
- Engine rejected 结果写入 internal error 记录,提升可观测性
- 新增 bootstrap.ts 统一 dev/production 启动序列
- dataDir 相对路径改为基于配置文件目录解析
- validatePagination 增加 pageSize 上限 200 校验
- 修复 ErrorBoundary override 标记
- 更新 README/DEVELOPMENT 文档,新增完整测试覆盖
This commit is contained in:
2026-05-13 18:15:46 +08:00
parent 6ea185315f
commit 147a2559ae
30 changed files with 930 additions and 129 deletions

View File

@@ -0,0 +1,121 @@
## Context
DiAL 后端是基于 Bun 的拨测服务,当前有 2 个 checker 类型http、commandtarget 规模预计增长到 100checker 类型预计超过 5 种。
现状问题:
1. `GET /api/targets` 对每个 target 单独查询 `getRecentSamples`,产生 N+3 次 SQL 查询
2. `ProbeEngine.probeGroup` 中 rejected 结果仅 `console.warn`,前端无法感知异常
3. `dev.ts``scripts/build.ts` 生成的 entry 各自维护相同的启动序列
4. `config-loader.ts``dataDir` 未基于 `configDir` 解析,相对路径依赖进程 cwd
5. `validatePagination` 无 pageSize 上限,可被滥用
6. `CheckerDefinition` 接口方法参数为 `ResolvedTargetBase`checker 内部需手动 `as` 断言
## Goals / Non-Goals
**Goals:**
- 消除 targets 路由的 N+1 查询,支撑 100 target 规模
- Engine 异常可观测rejected 结果写入数据库,前端可见
- 启动逻辑单一来源,降低维护成本
- 修复 dataDir 路径解析 bug
- API 防御性pageSize 上限
- CheckerDefinition 泛型化checker 开发者获得编译期类型安全
**Non-Goals:**
- 不做配置热更新
- 不做 API 认证/鉴权
- 不做通知/告警系统
- 不改变 `ResolvedTargetBase` 的 index signatureregistry 层仍用类型擦除)
- 不改变前端行为
## Decisions
### Decision 1: 批量查询 recentSamples 使用 window function
**选择**:在 `ProbeStore` 中新增 `getAllRecentSamples(limit: number)` 方法,使用 SQLite window function `ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY timestamp DESC)` 一次查询所有 target 的最近 N 条采样。
**替代方案**
- UNION ALL 拼接每个 target 的子查询 → SQL 长度随 target 数线性增长,不可控
- 应用层批量(一次查全部再内存分组)→ 数据量大时内存开销高
**理由**window function 是 SQLite 3.25+ 原生支持的特性Bun 内置的 SQLite 版本满足要求。单次查询SQL 固定长度,性能最优。
### Decision 2: Engine rejected 写入 internal error 记录
**选择**:在 `probeGroup` 中,对 `rejected` 的结果构造一条 `matched: false``failure: { kind: "error", phase: "internal", path: "engine", message: reason }` 的 check_result 写入 store。
**替代方案**
- 单独的错误日志表 → 增加 schema 复杂度,前端需要额外查询
- 仅保留 console.warn → 现状,不可观测
**理由**:复用现有 check_results 表和 failure 结构,前端无需改动即可展示异常状态。`phase: "internal"` 区分于正常的 checker 执行失败。通过 `Promise.allSettled` 的索引关联回 target 数组,确保能获取 targetName。
### Decision 3: 抽取 bootstrap.ts
**选择**:新增 `src/server/bootstrap.ts`,导出 `bootstrap(options: BootstrapOptions)` 函数封装完整启动序列loadConfig → ProbeStore → syncTargets → ProbeEngine → startServer → 注册 shutdown handler。
**接口设计**
```typescript
interface BootstrapOptions {
configPath: string;
mode: RuntimeMode;
staticAssets?: StaticAssets;
}
```
`dev.ts` 调用 `bootstrap({ configPath, mode: "development" })`
`build.ts` 生成的 entry 调用 `bootstrap({ configPath, mode: "production", staticAssets })`
**替代方案**
- 保持两处重复 → 维护负担随启动逻辑复杂化线性增长
### Decision 4: dataDir 基于 configDir 解析
**选择**:在 `config-loader.ts``loadConfig` 中,对 `dataDir` 使用 `resolve(configDir, dataDir)` 处理。如果 `dataDir` 是绝对路径,`resolve` 会直接返回绝对路径,不影响绝对路径用户。
**影响**:行为变更——之前相对路径基于 cwd现在基于配置文件目录。由于项目未上线无需向前兼容。
### Decision 5: pageSize 上限 200
**选择**:在 `middleware.ts``validatePagination` 中增加 `pageSize > 200` 的校验,返回 400。
**常量定义**`MAX_PAGE_SIZE = 200`,定义在 `middleware.ts` 中。
**理由**200 条/页对于拨测历史记录的展示场景足够。前端当前使用 20不受影响。
### Decision 6: CheckerDefinition 泛型化
**选择**
```typescript
interface CheckerDefinition<TResolved extends ResolvedTargetBase = ResolvedTargetBase> {
execute(target: TResolved, ctx: CheckerContext): Promise<CheckResult>;
resolve(target: RawTargetConfig, context: ResolveContext): TResolved;
serialize(target: TResolved): { config: string; target: string };
validate(input: CheckerValidationInput): ConfigValidationIssue[];
readonly configKey: string;
readonly schemas: CheckerSchemas;
readonly type: string;
}
```
- 默认泛型参数 `= ResolvedTargetBase` 保证 registry 等中间层无需指定泛型
- `CheckerRegistry` 内部存储 `CheckerDefinition<ResolvedTargetBase>`(类型擦除)
- 各 checker 实现 `implements CheckerDefinition<ResolvedHttpTarget>` 等具体类型
- checker 内部 `execute``serialize` 方法直接接收具体类型,无需 `as` 断言
**替代方案**
- Discriminated union → 每加 checker 改 union违背插件化设计
- 维持现状 → 5+ checker 时 `as` 断言散落各处
**影响范围**
- `runner/types.ts`:接口加泛型参数
- `runner/registry.ts`:内部 Map 类型为 `CheckerDefinition`(使用默认参数)
- `http/execute.ts``command/execute.ts``implements CheckerDefinition<具体类型>`,移除方法内的 `as` 断言
- `engine.ts``config-loader.ts``store.ts`:不变(依赖 base interface
## Risks / Trade-offs
- **window function 兼容性** → Bun 内置 SQLite >= 3.25,已验证支持。如果未来需要外部 SQLite需确认版本。
- **Engine rejected 写入依赖索引关联** → 通过 `Promise.allSettled` 的索引关联回 target 数组获取 targetName。前提是 `probeGroup` 的 targets 数组与 `Promise.allSettled` 结果数组保持一一对应,当前实现满足此条件。
- **bootstrap.ts 增加一层间接** → 启动流程从 2 处直接代码变为 1 处函数调用。复杂度不增加,只是位置移动。
- **泛型擦除在 registry 层** → `registry.get()` 返回 `CheckerDefinition`base 类型engine 调用时仍是 base 类型。这是设计意图:中间层不感知具体 checker 类型。