- 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 文档,新增完整测试覆盖
6.2 KiB
Context
DiAL 后端是基于 Bun 的拨测服务,当前有 2 个 checker 类型(http、command),target 规模预计增长到 100,checker 类型预计超过 5 种。
现状问题:
GET /api/targets对每个 target 单独查询getRecentSamples,产生 N+3 次 SQL 查询ProbeEngine.probeGroup中 rejected 结果仅console.warn,前端无法感知异常dev.ts和scripts/build.ts生成的 entry 各自维护相同的启动序列config-loader.ts中dataDir未基于configDir解析,相对路径依赖进程 cwdvalidatePagination无 pageSize 上限,可被滥用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。
接口设计:
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 泛型化
选择:
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 类型。