← 中文资源

简体中文版本

构建并发 URL 健康检查命令行工具

并发检查一组 URL、跟随重定向、记录状态与响应时间、重试瞬时失败并按退出码汇总结果。

阅读英文原文

项目目标

本项目构建一个 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);