项目目标
本项目构建一个 URL 健康检查工具,为每个地址记录状态与响应时间,对瞬时失败做退避重试,并用退出码供 cron 或 CI 使用。检查函数返回结果对象而非抛异常,使并发池无需异常处理。
核心特性
- 从参数或文件读取并校验 URL
- 以可配置并发上限同时检查
- 记录状态码与延迟并重试瞬时错误
- 打印表格摘要,任一失败即非零退出
架构与分层
输入解析产出 URL 列表;检查器带超时执行一次请求并分类结果;并发池在上限内运行检查;报告器格式化结果并推导退出码。重试逻辑包裹单次检查,使池保持简单。
实施阶段
六个阶段为:输入解析、单次检查与分类、重试包装、并发池、报告器与退出码、CLI 与测试。分类规则按状态与超时明确划分。
测试与验收
用 vitest 运行。分类测试断言 200 为 ok、404 为客户端失败、500 可重试、超时原因明确;并发池测试断言并发不超过上限且每个 URL 只访问一次;报告测试断言任一失败时退出码非零。
延伸方向
校验响应体、测量首字节时间或检查 TLS 证书都是结果对象上的新字段。带逐项期望的 YAML 输入可把它变成冒烟测试运行器,而无需改动并发池。
本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。
代码示例
export async function checkOnce(url, { fetchImpl = fetch, timeoutMs = 5000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const started = Date.now();
try {
const res = await fetchImpl(url, { redirect: 'follow', signal: controller.signal });
const latencyMs = Date.now() - started;
return classify(res.status, latencyMs);
} catch (err) {
return { url, ok: false, reason: err.name === 'AbortError' ? 'timeout' : 'network', latencyMs: Date.now() - started };
} finally {
clearTimeout(timer);
}
}
mkdir health-check && cd health-check
npm init -y
npm install -D vitest@^2.1.0
{
"name": "health-check",
"type": "module",
"scripts": {
"check": "node src/cli.js",
"test": "vitest run"
}
}
import { readFile } from 'node:fs/promises';
export async function loadUrls({ args = [], file }) {
const fromFile = file
? (await readFile(file, 'utf8')).split('\n').map((l) => l.trim())
: [];
const all = [...args, ...fromFile];
return all.filter((line) => line && !line.startsWith('#'));
}
export function validateUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
export function classify(status, latencyMs) {
const ok = status >= 200 && status < 300;
const retryable = status >= 500 || status === 429;
return { ok, status, latencyMs, retryable, reason: ok ? 'ok' : retryable ? 'server' : 'client' };
}
export async function checkOnce(url, { fetchImpl = fetch, timeoutMs = 5000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const started = Date.now();
try {
const res = await fetchImpl(url, { redirect: 'follow', signal: controller.signal });
return classify(res.status, Date.now() - started);
} catch (err) {
return { url, ok: false, retryable: err.name === 'AbortError', reason: err.name === 'AbortError' ? 'timeout' : 'network', latencyMs: Date.now() - started };
} finally {
clearTimeout(timer);
}
}
export async function withRetry(fn, { attempts = 3, backoff = (n) => 100 * n } = {}) {
let last;
for (let i = 1; i <= attempts; i++) {
last = await fn();
if (last.ok || !last.retryable) return last;
if (i < attempts) await new Promise((r) => setTimeout(r, backoff(i)));
}
return last;
}
export async function runChecks(urls, { concurrency = 8, runner } = {}) {
const results = new Array(urls.length);
let next = 0;
async function worker() {
while (next < urls.length) {
const index = next++;
results[index] = await runner(urls[index]);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, urls.length) }, worker));
return results;
}
export function summarize(results) {
const passed = results.filter((r) => r.ok).length;
return {
total: results.length,
passed,
failed: results.length - passed,
exitCode: passed === results.length ? 0 : 1,
};
}
export function formatTable(results) {
return results.map((r) => {
const status = r.ok ? 'OK ' : 'FAIL';
return status + ' ' + String(r.status ?? '-').padStart(3) + ' ' + String(r.latencyMs) + 'ms ' + (r.url ?? '');
}).join('\n');
}
import { loadUrls } from './input.js';
import { checkOnce } from './check.js';
import { withRetry } from './retry.js';
import { runChecks } from './pool.js';
import { formatTable, summarize } from './report.js';
const args = parse(process.argv.slice(2));
const urls = await loadUrls(args);
const results = await runChecks(urls, {
concurrency: Number(args.concurrency ?? 8),
runner: (url) => withRetry(() => checkOnce(url, { timeoutMs: Number(args.timeout ?? 5000) })),
});
console.log(formatTable(results));
const summary = summarize(results);
console.error('passed ' + summary.passed + '/' + summary.total);
process.exit(summary.exitCode);