1
0
Files
DiAL/openspec/changes/backend-architecture-hardening/design.md
lanyuanxiaoyao 147a2559ae 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 文档,新增完整测试覆盖
2026-05-13 18:15:46 +08:00

122 lines
6.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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 类型。