项目:nas-console | 版本:v1.2 | 生成日期:2026-10-06 | 依据:源码分析
前序篇目对代码的引用都是「取证式」的——摘一句注释、给一个行号,为文档结论服务。本篇反过来:以代码为主角,挑三个核心文件,按阅读顺序逐段贴出真实源码,做逐段解读,并在每段之后用一个小结回答同一个问题——「这段代码教会我们什么」。三个文件的选择标准是「监控领域核的最小闭环」:server/src/probe.ts 是数据的生产者(巡检调度与探测),server/src/store.ts 是数据的沉淀者(三表事务与翻转判定),server/src/sse.ts 是数据的广播者(SSE 推送中心)。三者串起来就是 05 篇第 1 节那张流程图的全部服务端主干,合起来不到 430 行,却承载了整个项目存在的理由。
读法约定:代码块一律用四反引号围栏包裹,内容与 server/src/ 现状逐字一致(含注释原貌,个别行的原文缩进奇特处如实保留);行号标注以文件真实行号为准。走读前建议先读过 07 篇第 3-5 节的函数级索引与 16 篇 ADR-004/005/011,本篇在其上补「为什么这么写」的最后一层。文中的「教训」小结是站在工程经验视角的提炼,不代表作者原意,凡属推断均以「推断」标明。
probe.ts 全文 222 行,是全系统唯一主动对外发起请求的模块。它的结构可以概括为「一个类管调度,三个函数管探测」:ProbeEngine 类持有定时器与内存快照,checkHttp/checkTcp 两个模块级函数是真正的探测器,certExpiresInDays 是 HTTP 探测的搭车收益。
import http from 'node:http'
import https from 'node:https'
import net from 'node:net'
import type { TLSSocket } from 'node:tls'
import type { CheckResult, ServiceConfig } from './types.js'
import type { Store } from './store.js'
const GLOBAL_INTERVAL_SEC = 30
const TIMEOUT_MS = 8000
const MAX_BODY_BYTES = 256 * 1024
export interface ProbeHandlers {
/** 每次检查完成(无论状态是否翻转) */
onResult?: (result: CheckResult) => void
/** 状态翻转入库事件 */
onEvent?: (event: { serviceId: string; ts: number; to: 'up' | 'down' }) => void
}
/**
* 巡检引擎:每服务独立定时器(间隔可按服务覆盖),启动时立即以并发窗 4 跑一轮。
* apply() 幂等:清单变化后重入即可,旧定时器全部作废。
*/
export class ProbeEngine {
private timers = new Map<string, NodeJS.Timeout>()
private running = false
private latest = new Map<string, CheckResult>()
constructor(
private store: Store,
private handlers?: ProbeHandlers
) {}开篇三个常量就是本模块的全部「魔法数字」预算:全局巡检间隔 30 秒、单检超时 8 秒、关键词检查的响应体读取上限 256KB。它们与设计稿第 3 节「并发窗口 4,单检超时 8s」的承诺一一对应(并发窗 4 写在 runInitial 里)。ProbeHandlers 接口是引擎对外的唯一声带:引擎不知道告警管理器与 SSE Hub 的存在,只承诺「每次检查完成喊一声、翻转了喊一声」。构造函数把 store 与 handlers 作为依赖注入——注意 handlers 是可选的(?),意味着引擎可以脱离任何消费者独立运行(测试或独立探针场景),而 store 是必选的,因为「不落库的巡检」在这个系统里没有意义。
这段代码教会我们什么:依赖注入不必借助任何框架——一个可选的回调接口参数就是全部基础设施。引擎与消费者之间的耦合被压缩到「两个函数签名」,而这两个签名恰好是领域语言(CheckResult/翻转事件)而非技术语言(req/res/channel)。当你在自己的项目里想引入事件总线框架之前,先数一数自己需要几个回调:不超过三个的话,接口参数更便宜、类型更安全、调试时调用栈更短。
/** 全量重排:清旧定时器、按新清单调度并立即补一轮;清理已下线/隐藏服务的 stale 快照 */
apply(services: ServiceConfig[]) {
const activeIds = new Set(services.map((s) => s.id))
for (const id of [...this.latest.keys()]) {
if (!activeIds.has(id)) this.latest.delete(id)
}
for (const t of this.timers.values()) clearTimeout(t)
this.timers.clear()
const active = services.filter((s) => s.check.type !== 'none')
void this.runInitial(active)
for (const s of active) {
const interval = Math.max(15, s.check.intervalSec ?? GLOBAL_INTERVAL_SEC) * 1000
this.timers.set(s.id, setInterval(() => void this.checkAndRecord(s), interval))
}
}这个方法被调用的时机有两个:进程启动(server.ts 装配时)与 PUT /api/services 热更新后。它的第一半在做「清理」三件事——把新清单里不存在的 id 从内存快照 latest 中删掉(否则下线服务的结果会残留到 /api/health/latest 响应里)、清掉全部旧定时器、清空定时器表;第二半在做「重建」——过滤 check.type === 'none' 的服务,先异步补跑一轮首检,再为每个活跃服务各设一个 setInterval。两处细节值得逐字读:其一,Math.max(15, ...) 把用户可配的 intervalSec 强制夹到 15 秒下限,这是「配置永远不能击穿系统预算」的防御式写法(对比 server.ts 里 clampInt 对查询参数的同款处理——同一哲学在两处独立实现);其二,setInterval(() => void this.checkAndRecord(s), interval) 里的 void 不是装饰——checkAndRecord 返回 Promise,若不加 void,TS 的 noFloatingPromises 类规则会报错,更重要的是它显式声明「这是故意不等待的火后不理」(fire-and-forget),配合检查器的 resolve-only 设计(ADR-011)构成完整闭环。
这段代码教会我们什么:「先全部清空、再按新清单重建」是处理可变配置的黄金模式——它让 apply() 天然幂等,重复调用不会产生双跑定时器,调用方完全不需要知道「哪些服务变了」。比起增量 diff 更新(只调变过的服务的定时器),全量重排多花的那点 CPU 在这个量级下为零,却把「清单变更 → 生效」的正确性从复杂的比对逻辑变成了结构保证。另外,「配置下限强制夹取」应当内建在消费配置的代码里,而不是依赖配置作者自律。
private async runInitial(active: ServiceConfig[]) {
if (this.running) return
this.running = true
try {
const queue = [...active]
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
for (let s = queue.shift(); s; s = queue.shift()) {
await this.checkAndRecord(s)
}
})
await Promise.all(workers)
} finally {
this.running = false
}
}
```
这是全文件最精巧的 15 行:启动时 19 个服务需要立即各查一次(否则面板要等 30 秒才有数据),但不能同时打出 19 个请求。解法是「固定 4 个 worker 共享一个队列」的消费者模式——每个 worker 循环从队列头领取任务、串行执行,直到队列空。`Math.min(4, queue.length)` 处理了服务数不足 4 的边界(不会创建多余的空 worker);`running` 标志防止重入——若 `apply()` 在上一轮首检未完成时被再次调用(用户 60 秒同步周期内连续 PUT 两次),第二次调用直接返回,避免两轮并发补跑。`try/finally` 保证 `running` 无论成功失败都会复位。这个模式与前端降级探测 `src/api/health.ts` 的 `checkServices` 几乎逐行相同(后者是浏览器版),两处独立实现同一模式,推断是作者刻意让降级路径与主路径行为对齐。
**这段代码教会我们什么**:限并发不需要引入 p-limit 这类库——「N 个 worker 抢一个队列」七行写完,且语义比通用库更透明(这里 worker 之间完全独立、任务之间无优先级)。同样重要的是「防重入 + finally 复位」这对搭档:任何「进行中」标志位都必须与 try/finally 同生共死,否则一次异常就会让标志永久卡死、后续所有调用被静默跳过——而「静默跳过」恰恰是最难排查的 bug 形态。
### 1.4 checkAndRecord() 与 check():一次检查的完整生命周期(第 70-96 行)
private async checkAndRecord(service: ServiceConfig) {
const result = await this.check(service)
this.latest.set(service.id, result)
const event = this.store.record(result)
this.handlers?.onResult?.(result)
if (event) this.handlers?.onEvent?.(event)
}async check(service: ServiceConfig): Promise<CheckResult> {
const { check, id } = service
try {
if (check.type === 'http') {
return await checkHttp(id, service.url, check, TIMEOUT_MS)
}
if (check.type === 'tcp') {
return await checkTcp(id, check.tcpHost ?? hostOf(service.url), check.tcpPort ?? service.internalPort, TIMEOUT_MS)
}
} catch (error) {
return { id, status: 'down', latencyMs: null, checkedAt: Date.now(), error: msg(error) }
}
return { id, status: 'down', latencyMs: null, checkedAt: Date.now(), error: 'check type 未配置' }
}
`checkAndRecord` 是引擎的心跳:五行代码走完「探测 → 更新内存快照 → 落库 → 通知消费者」的完整闭环。顺序有讲究——`latest.set` 在 `store.record` 之前,保证 API 读到的是最新结果(哪怕落库瞬间失败,内存快照仍然成立);`onResult` 在 `record` 之后,保证消费者(SSE 广播)推出去的结果一定已经持久化。注意 `store.record` 的返回值就是翻转事件(或 null),事件在**这里**才分发给 `onEvent`——引擎不关心翻转判定逻辑,那是 store 的职责,它只负责把 store 的话带给外面。
`check()` 的 if 链只有两个真实分支,但 catch 与兜底 return 各占了一段:即使检查器内部有未预料的同步异常(类型错误、URL 构造炸裂),也会以 `down + error` 收场;`check.type` 未配置时同样如此。这层 catch 与检查器自身的 resolve-only 是双保险,区分在于语义——检查器的 resolve-only 处理「目标服务的异常」,这层 catch 处理「探测代码自己的异常」,两者的 error 文案可以据此区分(后者通常是 JS 错误消息而非网络错误)。
**这段代码教会我们什么**:「永远返回结果、把异常降级为结果的一种」是长时运行进程的自保之道——19 个定时器每 30 秒各跑一次,任何一次未捕获异常都是 7×24 进程的潜在死因。而分层兜底(检查器内层 + 引擎外层)让异常的**归属**可辨:改这个文件时,看到「ECONNREFUSED」知道是网络问题,看到「Cannot read properties of undefined」就该去看自己的代码。另外注意 `store.record` 把「是否翻转」的判定权交给存储层——单一职责的边界画在「数据在哪里,判定就在哪里」,因为翻转的真相(上一条记录的状态)只存在于数据库里。
### 1.5 certExpiresInDays():搭车采集(第 112-123 行)
/** HTTPS 响应的证书剩余天数;非 TLS 连接/读取失败返回 undefined */ function certExpiresInDays(res: http.IncomingMessage): number | undefined { const socket = res.socket as TLSSocket | null if (!socket?.getPeerCertificate) return undefined try { const cert = socket.getPeerCertificate() if (!cert?.valid_to) return undefined return Math.floor((new Date(cert.valid_to).getTime() - Date.now()) / 86_400_000) } catch { return undefined } }
证书剩余天数不是独立探测,而是 HTTP 检查的搭车产物:响应到达时顺手从 TLSSocket 上读出证书有效期,`Math.floor` 向下取整(证书还剩不到 1 整天时显示 0 天,宁报早不报晚)。函数的全部防御都围绕「这不是 TLS 连接怎么办」:`socket?.getPeerCertificate` 的存在性检查过滤掉纯 http 与复用场景,`cert?.valid_to` 再过滤证书对象为空的情况(`agent: false` 注释里提到的 TLS 会话恢复返回空对象就是其中之一),外层 try/catch 兜住一切意外。任何一步不满足都静默返回 undefined——消费端(StatusPage 的证书 Tag)对 undefined 的处理是不渲染该 Tag,而非渲染「未知」。
**这段代码教会我们什么**:「搭车采集」是低成本扩展监控维度的利器——证书天数没有自己的调度器、没有自己的超时预算、没有自己的重试,它寄生在已有探测的响应路径上,边际成本为零。代价是它与宿主探测的生命周期绑定(宿主 down 了它就没有,进程重启后要等下一轮 HTTPS 检查才恢复——types.ts 第 100 行注释明言「仅内存快照,不入库」)。决定一个指标要不要搭车,判断标准与 ADR-008 的「在场性」同构:这个指标需要独立于宿主的采集节奏吗?证书到期显然不需要。
### 1.6 checkHttp():一个探测器的全部细节(第 125-191 行)
这是全文件最长也最见功力的函数,分三段读。
第一段,请求的发出:
return new Promise((resolve) => {
if (!url) {
resolve({ id, status: 'down', latencyMs: null, checkedAt: Date.now(), error: '缺少 URL' })
return
}
const startedAt = performance.now()
const mod = url.startsWith('https:') ? https : http
const req = mod.request(
url,
{
method: 'GET',
timeout: timeoutMs,
agent: false, // 禁用连接复用:延迟含完整握手更真实,且 TLS 会话恢复时 getPeerCertificate 返回空
rejectUnauthorized: !check.tlsSkipVerify,
headers: { 'user-agent': 'nas-console-probe/0.3', accept: '*/*' }
},
Promise 的参数列表里只有 `resolve`——没有 reject,这就是 resolve-only 设计的字面形态。`agent: false` 的行内注释是本文件最重要的一个决策记录:禁用连接复用的两个理由(延迟含完整握手更贴近访客体感、TLS 会话恢复时证书读不到)分别指向「测量的真实性」与「搭车采集的正确性」,两个理由共同压倒了连接复用省下的 CPU。`rejectUnauthorized: !check.tlsSkipVerify` 一行实现了「自签证书服务可跳过校验」的需求,而 `tlsSkipVerify` 未配置时默认 undefined(取反后为 true),保持严格校验——「可选配置缺省即最安全值」是值得记住的写法。
第二段,响应的判定与关键词检查:
(res) => {
const certDays = certExpiresInDays(res)
const finish = (error?: string) => {
res.destroy()
const latencyMs = Math.round(performance.now() - startedAt)
const expected = check.expectStatus
const statusOk = !res.statusCode || res.statusCode < 400
const codeOk = expected ? expected.includes(res.statusCode ?? 0) : statusOk
resolve({
id,
status: codeOk && !error ? 'up' : 'down',
latencyMs: codeOk && !error ? latencyMs : null,
checkedAt: Date.now(),
error: error ?? (codeOk ? undefined : `HTTP ${res.statusCode}`),
certExpiresInDays: certDays
})
}
if (!check.keyword) {
res.resume()
res.on('end', () => finish())
res.on('error', () => finish())
return
}
// 关键词检查:限量读取响应体(捕获到局部变量,回调内 TS 窄化失效)
const keyword = check.keyword
let body = ''
let overflow = false
res.on('data', (chunk: Buffer) => {
if (body.length <= MAX_BODY_BYTES) body += chunk.toString('utf8')
else overflow = true
})
res.on('end', () => finish(overflow ? undefined : body.includes(keyword) ? undefined : '关键词未命中'))
res.on('error', () => finish('响应读取失败'))
}
判定逻辑收敛在一个 `finish` 闭包里:状态码两层判定(未配 `expectStatus` 时 `<400` 即 up,配置后要求精确命中),关键词结果作为 `error` 参数并入——`codeOk && !error` 一行完成「状态码对、关键词中、无读取异常」三条件的合成。关键词分支有三个易错点全部被正确处理:**响应体限量**(256KB 后只置 `overflow` 标志不再拼接,超长页面不会放大内存);**截断不误判**(`overflow` 时 `finish(undefined)`——截断导致的关键词未命中不算 down,因为那是检查器的问题不是服务的问题);**TS 窄化失效**(`check.keyword` 在回调里被捕获到局部常量 `keyword`,因为 TS 无法证明异步回调执行时 `check.keyword` 仍是 string)。无关键词时 `res.resume()` 把响应体导向丢弃模式,尽早释放 socket,不白读一段永远不用的字节。
第三段,超时与网络错误:
req.on('timeout', () => {
req.destroy()
resolve({ id, status: 'down', latencyMs: null, checkedAt: Date.now(), error: `超过 ${timeoutMs / 1000}s 未响应` })
})
req.on('error', (error) => {
resolve({ id, status: 'down', latencyMs: null, checkedAt: Date.now(), error: msg(error) })
})
req.end()
})
三个事件(timeout/error/响应 end)竞争同一个 Promise 的 resolve——Node 的 Promise 只认第一次 resolve,后续调用是空操作,所以这里不需要任何「已结束」标志位。`timeout` 事件后必须手动 `req.destroy()`(Node 的 timeout 不会自动断开请求),否则 socket 泄漏;error 文案直接使用 Node 的原始消息(ECONNREFUSED、ENOTFOUND 等),它们最终会出现在 Tooltip 与告警正文里,是排障的第一现场。
**这段代码教会我们什么**(合并三段):写一个「永不失手」的网络探测器,难点不在主路径而在十个边界——空 URL、自签证书、超时、拒连、DNS 失败、响应中断、超长响应体、截断误判、socket 泄漏、回调窄化。这段代码把每个边界都压到一两行的防御,同时主路径保持直白(状态码 + 关键词 + 延迟三件事)。它示范的通用原则是:**错误路径的代码量应当是主路径的两倍以上,且每一条错误路径都要有「为什么这样处理」的注释或命名**——`overflow` 标志的「截断不判错」决定若没有 `finish(overflow ? undefined : ...)` 这行的精确语义,读者无法区分「作者没想到」与「作者想到了但选择宽容」。
### 1.7 checkTcp():克制是它最好的品质(第 193-221 行)
function checkTcp(
id: string,
host: string | undefined,
port: number | undefined,
timeoutMs: number
): Promise<CheckResult> {
return new Promise((resolve) => {
if (!host || !port) {
resolve({ id, status: 'down', latencyMs: null, checkedAt: Date.now(), error: 'tcp 检查缺少 host/port' })
return
}
const startedAt = performance.now()
const socket = net.connect({ host, port })
const done = (status: 'up' | 'down', error?: string) => {
socket.destroy()
resolve({
id,
status,
latencyMs: status === 'up' ? Math.round(performance.now() - startedAt) : null,
checkedAt: Date.now(),
error
})
}
socket.setTimeout(timeoutMs)
socket.once('connect', () => done('up'))
socket.once('timeout', () => done('down', 超过 ${timeoutMs / 1000}s 未连上))
socket.once('error', (error) => done('down', msg(error)))
})
}
与 checkHttp 对照着读最有收获:tcp 检查没有状态码、没有关键词、没有证书——「TCP 能连上」本身就是全部语义(MySQL/Redis/MinIO 这类服务的端口开着就是健康)。`done` 辅助函数把「销毁 socket + resolve」合并为原子动作,三个事件各自 once 即可——因为 done 之后 socket 已销毁,后续事件不会再触发。注意 `net.connect` 没有 `agent` 概念(天然无复用),延迟同样是完整连接耗时。host/port 的推断链在调用方(第 90 行):`check.tcpHost ?? hostOf(service.url)`、`check.tcpPort ?? service.internalPort`——配置缺省时的行为被明确编码为「从 URL 与 NAS 端口推断」,而非报错。
**这段代码教会我们什么**:同样是探测器,HTTP 版 67 行、TCP 版 29 行,差距不是能力差异而是**领域语义的密度差异**。当一种检查类型的判定空间只有一维(连上/没连上)时,就应该写成 29 行——不要为了让两个函数「对称」而给 TCP 加它不需要的配置面。代码的长度应当正比于它所表达的业务复杂度,而不是相邻函数的长度。
## 2. server/src/store.ts —— 存储层:三表、一个事务、一个类型断言的失真
store.ts 全文 150 行,是全项目唯一接触 SQL 的文件。头注释已经给出架构定位:「SQLite 存储:checks 原始记录(90 天)、daily 按天聚合(永久)、events 状态翻转。WAL 模式 + 同步 better-sqlite3 API,单实例个人规模绰绰有余。」
### 2.1 构造函数即迁移脚本(第 15-48 行)
export class Store { private db: Database.Database
constructor(dbPath: string) {
this.db = new Database(dbPath)
this.db.pragma('journal_mode = WAL')
this.db.exec(`
CREATE TABLE IF NOT EXISTS checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id TEXT NOT NULL,
ts INTEGER NOT NULL,
status TEXT NOT NULL,
latency_ms INTEGER,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_checks_service_ts ON checks(service_id, ts);
CREATE TABLE IF NOT EXISTS daily (
day TEXT NOT NULL,
service_id TEXT NOT NULL,
up INTEGER NOT NULL DEFAULT 0,
down INTEGER NOT NULL DEFAULT 0,
latency_sum INTEGER NOT NULL DEFAULT 0,
latency_n INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (day, service_id)
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id TEXT NOT NULL,
ts INTEGER NOT NULL,
to_status TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_service ON events(service_id, ts);
`)
}
三张表的形态直接映射三种消费场景:`checks` 支撑延迟走势(索引 `(service_id, ts)` 服务「取某服务最近记录」与时间窗扫描)、`daily` 支撑可用率条(主键 `(day, service_id)` 天然去重,UPSERT 的落点)、`events` 支撑事件列表(索引同理)。注意两处列命名细节:`to_status` 而非 `to`(`to` 是 SQL 关键字,见 2.4 节),`latency_sum`/`latency_n` 拆成两列而非存均值(均值无法累加,和与计数才能 UPSERT——这是设计稿第 4 节就定下的形状)。建表用 `IF NOT EXISTS`,构造函数即迁移,个人规模下的刻意取舍(07 篇第 4 节有评述)。
**这段代码教会我们什么**:表结构设计的最好出发点不是实体关系,而是**查询场景倒推**——这张表将支撑什么 UI?可用率条要的是「每天一行、可累加」,于是 daily 的形状自然浮现。同样值得学的是把「聚合的原料」(latency_sum/latency_n)而非「聚合的结果」(avg)持久化——结果不可合并,原料才支持增量 UPSERT,这是流式聚合的通用技巧。
### 2.2 record():事务边界与翻转判定(第 50-90 行)
/** 写入一轮检查结果;与该服务上一条记录状态不同时记翻转事件 */
record(result: CheckResult): ProbeEvent | null {
const insert = this.db.prepare(
'INSERT INTO checks (service_id, ts, status, latency_ms, error) VALUES (?, ?, ?, ?, ?)'
)
const upsertDaily = this.db.prepare(`
INSERT INTO daily (day, service_id, up, down, latency_sum, latency_n)
VALUES (@day, @serviceId, @up, @down, @latencySum, @latencyN)
ON CONFLICT (day, service_id) DO UPDATE SET
up = up + excluded.up,
down = down + excluded.down,
latency_sum = latency_sum + excluded.latency_sum,
latency_n = latency_n + excluded.latency_n
`)
const last = this.db
.prepare('SELECT status FROM checks WHERE service_id = ? ORDER BY id DESC LIMIT 1')
.get(result.id) as { status: string } | undefined
const tx = this.db.transaction(() => {
insert.run(result.id, result.checkedAt, result.status, result.latencyMs, result.error ?? null)
const latency = result.latencyMs
upsertDaily.run({
day: dayjs(result.checkedAt).format('YYYY-MM-DD'),
serviceId: result.id,
up: result.status === 'up' ? 1 : 0,
down: result.status === 'down' ? 1 : 0,
latencySum: latency ?? 0,
latencyN: latency != null ? 1 : 0
})
})
tx()
if (last && last.status !== result.status) {
const event: ProbeEvent = { serviceId: result.id, ts: result.checkedAt, to: result.status }
this.db
.prepare('INSERT INTO events (service_id, ts, to_status) VALUES (?, ?, ?)')
.run(event.serviceId, event.ts, event.to)
return event
}
return null
}
这是全文件的核心。事务边界要读准:`tx()` 包住的只有「INSERT checks + UPSERT daily」两步;「查询上一条状态」(`last`)在事务**外**先执行,「INSERT events」在事务**外**后执行。这个安排的安全性依赖两个前提——单进程、每服务的定时器串行(同一服务不可能有两个 record 并发)——`last` 因此读到的一定是本服务上一轮的结果,不存在竞态。daily 的 UPSERT 用 `ON CONFLICT DO UPDATE SET up = up + excluded.up ...` 四列累加,避免了「先读后写」的往返;`latencyN: latency != null ? 1 : 0` 一行把「down 不计延迟」的语义编进数据(latency_n 是除数,up 采样才计入)。翻转判定只有一行逻辑:`last && last.status !== result.status`——首轮检查时 `last` 为 undefined,不产生事件,这恰好实现了「首轮只建立基线」的语义(前端浏览器模式的同款语义在 `health-history.ts` 第 123 行注释里由 `lastStatus` 承担)。
还有一个值得点破的实现事实:三条预处理语句**每次调用都重新 prepare**,而非构造时缓存。better-sqlite3 内部对语句有管理,0.6 次/秒的调用频率下无感;10 篇第 1.1 节把「prepare 提升到构造函数」列为零风险优化点,触发条件是服务数上一个数量级。
**这段代码教会我们什么**:第一,「事务包多大」取决于**一致性真正需要的范围**——checks 与 daily 必须同生共死(半条可用率数据比丢一条更糟),而 events 的写入即使失败也只是丢一个通知事件,下次翻转还会补;把非核心写进事务只会扩大锁窗口。第二,「上一条是什么」的查询放在写事务外,是「以单写者假设换简洁」的典型交易——它成立的前提(每服务串行)写在调度层而不是存储层,**跨层假设必须有人记得**(07 篇第 4 节把它记下来了)。第三,UPSERT 累加四列这种写法,把并发安全从「应用层加锁」下沉为「数据库层原子操作」——能用一条 SQL 表达的不变量,就不要用三行应用代码。
### 2.3 recentSamples():降采样发生在读侧(第 110-132 行)
/** 最近 ms 毫秒的原始采样,每服务均匀降采样至 ≤limit 条(供延迟走势) */
recentSamples(ms: number, limit = 240): Record<string, RecentSample[]> {
const cutoff = Date.now() - ms
const rows = this.db
.prepare('SELECT service_id, ts, status, latency_ms FROM checks WHERE ts >= ? ORDER BY ts ASC')
.all(cutoff) as { service_id: string; ts: number; status: string; latency_ms: number | null }[]
const per = new Map<string, RecentSample[]>()
for (const r of rows) {
const list = per.get(r.service_id) ?? []
list.push({
t: r.ts,
s: r.status === 'up' ? 1 : 0,
l: r.latency_ms
})
per.set(r.service_id, list)
}
const out: Record<string, RecentSample[]> = {}
for (const [id, samples] of per) {
const step = samples.length > limit ? Math.ceil(samples.length / limit) : 1
out[id] = samples.filter((_, i) => i % step === 0)
}
return out
}
延迟走势的数据源是 checks 原始行(48 小时窗口约 11 万行),一次性读进 Node 再按服务分组、按 `i % step === 0` 均匀抽样到 ≤240 点。三个设计判断值得注意:**降采样在读侧而非写侧**——原始行是延迟走势的权威数据(10 篇第 5 节「不建议做的」明确反对降采样后删原始行),抽样步长随数据量自适应,limit 参数化让不同消费方可取不同密度;**列名转换集中于此**——蛇形(service_id/latency_ms)到驼峰短名(t/s/l)的 DTO 映射只发生在这一个方法里,转换点唯一;**短键名是传输优化**——`t/s/l` 的三字符键在 48h×240 点的 JSON 里省下的字节数可观,代价是可读性,因此 RecentSample 接口的字段注释(「1 = up」「延迟 ms,down 时为 null」)承担了全部解释责任。
**这段代码教会我们什么**:「原始数据永久保真、视图按需采样」与「物化视图」是存储设计的两个互补方向,小数据量下前者更简单——11 万行的全表窗口扫描在 SQLite 里是毫秒级,为它建聚合表是过早优化。同时注意 DTO 映射的「单点原则」:全项目只有 store 的查询方法与前端 `fetchProbeEvents` 两处做蛇形↔驼峰转换(04 篇第 3.2 节),转换点越少,字段名漂移的可能性越小。
### 2.4 events():一个诚实的类型断言(第 134-150 行)
events(limit: number): ProbeEvent[] {
// `to` 是 SQL 关键字,别名需加引号
return this.db
.prepare('SELECT service_id, ts, to_status AS "to" FROM events ORDER BY ts DESC LIMIT ?')
.all(limit) as ProbeEvent[]
}
/** 清理 90 天前的原始记录,每天调一次即可 */
prune(days = 90) {
const cutoff = Date.now() - days * 24 * 3600_000
this.db.prepare('DELETE FROM checks WHERE ts < ?').run(cutoff)
}
close() {
this.db.close()
}
最后这三个方法藏着全文件最有教学价值的一个细节:`as ProbeEvent[]` 这个类型断言**在运行时是失真的**。better-sqlite3 的 `.all()` 返回以列名为键的对象,这里的键是 `service_id`(蛇形)而非 `ProbeEvent` 声明的 `serviceId`(驼峰)——SQL 里的 `AS "to"` 只别名了 `to` 一列。也就是说,这个方法的返回值实际形状是 `{ service_id, ts, to }`,TS 类型却声称是 `{ serviceId, ts, to }`。这个失真被下游诚实消化了:`/api/events` 端点原样下发,前端 `fetchProbeEvents`(`src/api/probe.ts` 第 61-66 行)按 `e.service_id` 读取并映射回驼峰——**断言的错误由消费方的真实读取方式修正**,链路正确但类型是假的。这是 19 篇数据契约里必须写明的一条线格式(wire format):`/api/events` 的 JSON 键是 `service_id`。
prune 只有一条 DELETE:`checks` 90 天裁剪,daily 永久符合设计稿、events 无任何清理与设计稿不符——这行「缺失的代码」已在 13 篇问题二完整登记,此处只提示一个走读视角:**读 prune 时应当下意识追问「同类的其他表呢」**,这正是那笔债务被发现的方式(04 篇第 2 节的 ⚠️ 注记先于 13 篇出现)。
**这段代码教会我们什么**:类型断言(as)是把「你希望它是」贴在「它实际是」上的胶带——用在不一致处,类型系统从此对这条链路失明。这段代码的两点可取:其一,`AS "to"` 的注释解释了**为什么**需要引号(SQL 关键字),下一个读的人不用去查 SQLite 文档;其二,失真没有扩散——消费方以真实形状读取,等于用代码承认了断言的局限。更正确的做法当然是显式声明查询的行类型再手动映射,但那要多写五行——个人项目里这个取舍可以理解,前提是像本文件一样把「哪里不真」留在注释与消费方的眼里。
## 3. server/src/sse.ts —— 推送中心:53 行的可靠性教科书
sse.ts 是三个文件里最小的,53 行,却浓缩了本项目对分布式链路可靠性最有味道的思考。全文分段读。
### 3.1 serve():连接的建立(第 4-27 行)
/**
SSE hub:向所有浏览器客户端推送巡检结果。
status:单服务单次检查结果(实时)batch:全量快照(周期兜底,防丢帧)心跳注释行防代理空闲断连;客户端断开自动摘除。 */ export class SseHub { private clients = new Set<ServerResponse>() private heartbeat?: NodeJS.Timeout
serve(req: IncomingMessage, res: ServerResponse) { res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store', connection: 'keep-alive', 'x-accel-buffering': 'no' }) res.write(': connected\n\n') this.clients.add(res) req.on('close', () => { this.clients.delete(res) }) this.ensureHeartbeat() }
响应头四个键各司其职:`text/event-stream` 声明流式语义(浏览器 EventSource 识别的前提);`no-store` 禁止任何环节缓存这条「永远写不完的响应」;`keep-alive` 维持连接;`x-accel-buffering: no` 是写给 nginx 的——禁用响应缓冲,否则事件会被代理攒到缓冲区满才下发,「实时」变「批量」。`: connected` 这行注释帧在连接建立时立即写出:它对浏览器不可见(SSE 规范里冒号开头的行为注释),但**它让响应的字节立刻开始流动**——若没有这行,从响应头到第一条真实事件之间(可能长达 30 秒)链路上每一层都只能看到「头已发出、无 body」,某些激进的代理或浏览器会视为空响应处理。`req.on('close')` 是摘除机制:无论优雅断开还是网络中断,Node 的 close 事件都会触发,Set 的 delete 幂等,重复触发无害。
**这段代码教会我们什么**:穿过不可控中间层的协议,必须在**每一跳都留下自保线索**——`x-accel-buffering` 给代理、`: connected`/`: ping` 给空闲超时计时器、`no-store` 给缓存。这些线索单独看都「不必要」(同源直连时全无用处),但生产链路的本质是「你不知道中间有谁」,按最坏情况发送元数据,是把网络环境的不确定性变成协议的常识。
### 3.2 broadcast() 与 clientCount():广播(第 29-38 行)
broadcast(event: 'status' | 'batch' | 'event', data: CheckResult | CheckResult[] | unknown) {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
for (const res of this.clients) {
res.write(payload)
}
}
clientCount(): number {
return this.clients.size
}
SSE 的帧格式在此可见:`event: <类型>\ndata: <单行 JSON>\n\n`——事件名一行、数据一行、空行结束。`JSON.stringify(data)` 只执行**一次**(序列化在循环外),N 个客户端共享同一个字符串,这是广播语义的自然写法,也隐含一个约束:data 必须是可直接 JSON.stringify 的对象(没有 undefined 字段过滤等定制需求)。`data` 的参数类型是 `CheckResult | CheckResult[] | unknown`——三个联合成员里最终一个是 unknown,等于「类型上不设防」,注释层的三个事件名才是真正的约束。对已半断开的 res 调用 write 不会同步抛错(写入错误走异步 error 事件),残留连接最多存活到下一个 close 事件,个人规模下没有泄漏之虞(07 篇第 5 节已述)。
**这段代码教会我们什么**:「广播」要区分**每客户端成本**与**每事件成本**——序列化一次、循环写 N 次的结构,把 CPU 大头(JSON 序列化)与 I/O 大头(socket 写入)分离开,前者 O(1) 后者 O(N)。当未来某天需要按客户端过滤内容(例如公开面与鉴权面推送不同字段),这个循环结构也是最小改动的起点:过滤逻辑只需要进循环体,帧的拼装不需要动。
### 3.3 stop() 与 ensureHeartbeat():生命周期收尾(第 40-53 行)
stop() {
if (this.heartbeat) clearInterval(this.heartbeat)
for (const res of this.clients) res.end()
this.clients.clear()
}
private ensureHeartbeat() {
if (this.heartbeat) return
this.heartbeat = setInterval(() => {
for (const res of this.clients) res.write(': ping\n\n')
}, 25_000)
this.heartbeat.unref?.()
}
心跳是惰性单例:第一个客户端连上时才启动(没有客户端时一个空转定时器也不存在),后续连接检查到 `this.heartbeat` 已存在直接返回。`unref()` 让定时器不阻止进程退出——若没有它,SSE 心跳会把这个进程变成「永远退不出去」的僵尸(`index.ts` 的 shutdown 顺序里 `hub.stop()` 先于 `server.close`,两者共同保证优雅停机)。25 秒的间隔与 nginx 默认 60 秒的 `proxy_read_timeout` 之间留了一倍余量;`: ping` 是注释帧,浏览器不派发事件,但连接上有字节流动,所有基于「空闲即断」逻辑的中间层都被喂饱。
stop() 的顺序也值得读:先停心跳(不再有新写入),再 end 所有客户端(让浏览器收到正常的流结束),最后清空集合。`res.end()` 之后浏览器侧 EventSource 会触发 onerror——EventSource 的自动重连随即尝试重连,而此时进程正在退出,重连失败、浏览器进入降级轮询——整条降级链**不需要任何显式协调**就完成了。
**这段代码教会我们什么**:资源生命周期的两个收尾技巧——**惰性启动**(没有消费者就不创建资源)与 **unref**(后台定时器不该钉住进程)。它们共同保证:SSE Hub 在「无人使用」时的运行时足迹为零,在「有人使用」时的存在不影响进程的正常退出。长连接系统的可靠性从来不只是「不断线」,还包括「断得干净」——从服务端视角,能让客户端平滑回到轮询的断线,比永不断线更重要,因为后者做不到。
## 4. 收尾:三个文件共同的工程哲学
把三个文件放在一起回看,有四条反复出现的写法,可以视为这个项目「监控领域核」的工程哲学。**其一,异常是数据不是控制流**:probe.ts 的 resolve-only、store.ts 的「down 也入桶」、sse.ts 的静默摘除断连客户端,三处都在把「出错」转化为「一种要被记录/传递/清理的正常状态」——监控系统的第一原则是自身必须比被监控对象更不容易死。**其二,边界处写防御、核心处写直白**:三个文件的防御代码(timeout/try-catch/existsSync/unref)全部集中在与外部世界接触的边界(网络、SQL、进程信号),核心逻辑(apply 的重排、record 的累加、broadcast 的循环)短到没有藏错的余地。**其三,配置必须被夹取、假设必须被注释**:`Math.max(15, ...)`、`Math.min(4, ...)`、`agent: false` 的注释、「上一条查询在事务外」的跨层假设——数值边界内建于代码,行为假设外化于注释,两者缺一则代码不可信。**其四,小即是可靠**:430 行覆盖生产/调度/存储/推送,每读一次都会发现新的细节,但没有任何一处「为了未来扩展」而存在的抽象层——这份克制正是 09 篇「补测试从纯函数起步」与 13 篇「路线图保持全开放」能成立的前提。
对想深入其余部分的接手者:`server/src/alerts.ts` 的防抖/抑制/安静时段三重判定(07 篇第 6 节已函数级拆解)、`server/src/auth.ts` 的会话签发校验(08 篇第 2 节)、以及前端 `health-history.ts` 的模块级 store(本篇 2.3/2.4 节的对照面)是下一批值得走读的对象,走读方法可直接复用本篇的四步式:贴原文 → 讲清它做什么 → 讲清它为什么这样做 → 提炼可迁移的教训。
---
*本文代码摘自 `server/src/probe.ts`(222 行)、`server/src/store.ts`(150 行)、`server/src/sse.ts`(53 行)于 2026-10-06 的版本(v0.3.0);行号以文件实际行号为准,若后续重构请以函数名为锚点。函数级行为索引见 07-模块详解第 3-5 节;调度与容错的流程视角见 05-核心流程第 1 节;A11/ADR-004/005/011 的决策语境见 16-决策记录ADR。*