- 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 文档,新增完整测试覆盖
122 lines
6.2 KiB
Markdown
122 lines
6.2 KiB
Markdown
## Context
|
||
|
||
DiAL 后端是基于 Bun 的拨测服务,当前有 2 个 checker 类型(http、command),target 规模预计增长到 100,checker 类型预计超过 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 signature(registry 层仍用类型擦除)
|
||
- 不改变前端行为
|
||
|
||
## 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 类型。
|