用 Node.js 把聚合 API 平台封装成零依赖命令行工具:registry 驱动的工程实践

用 Node.js 把聚合 API 平台封装成零依赖命令行工具:registry 驱动的工程实践

很多团队会接入一个「聚合 API」平台:一个 Key、几十上百个 HTTP 接口,覆盖 IP 归属地、天气、OCR、地址解析、文生图等能力。直接用的时候,工程上有几个反复出现的痛点:

  • 每个接口都要手写一遍 fetch、拼 URL、塞鉴权头、判断返回码;
  • 平台返回的是统一信封 { code, msg, data, request_id },但每处都要重复判断 code === 0
  • 接口一多,命令行/脚本里就散落着大量裸 URL 和魔法参数;
  • 出错时只拿到一个数字 code,排查全靠翻文档。

本文用一个真实的 Node.js CLI 项目作为事实源,讲清楚一种可复用的解法:用一份接口清单(registry)来驱动整个命令行工具。新增接口只改数据、不改代码;鉴权、超时、错误处理、参数校验全部收敛到统一的一层。文中所有代码均可直接复制改造,需要按你自己平台补齐的字段会明确标出。

这套结构适用的场景:聚合/网关型 API 的内部 CLI、接口冒烟测试工具、运维一行命令查询、把 HTTP 接口包装给非后端同学使用。

一、整体设计:数据驱动而非硬编码

核心思路是把「接口长什么样」和「怎么调接口」彻底分开:

  • 接口清单(registry) :一个 JSON,描述每个接口的 name / method / path / params 等元数据,作为运行时唯一数据源。
  • 统一请求层:只负责构造请求、注入鉴权、处理超时、把平台返回码翻译成可读信息。
  • 通用执行器:拿着「接口定义 + 用户输入的参数值」,拆出 query/body、校验必填、发起调用、渲染输出。
  • 友好命令层:给最常用的几个能力套一层自然的位置参数用法和人性化摘要,本质仍然复用执行器。

这样带来的直接好处是:新增一个接口 = 往清单里加一条记录,请求逻辑一行都不用动。

清单的顶层结构是这样的(节选自真实项目,字段含义见注释):

json 复制代码
{
  "baseUrl": "https://v1.apizero.cn",
  "total": 93,
  "tags": {
    "life": "生活服务",
    "ocrdata": "文档识别",
    "ai": "AI 能力"
  },
  "endpoints": [
    {
      "name": "address-parse",
      "method": "POST",
      "path": "/api/address-parse",
      "tag": "geo",
      "summary": "中文地址解析",
      "contentType": "application/json",
      "params": [
        {
          "name": "address",
          "required": true,
          "type": "string",
          "description": "中文地址字符串,长度 ≤ 500",
          "example": "张三 13812345678 上海市浦东新区科苑路88号 201203"
        }
      ]
    }
  ]
}

每个 endpoint 至少需要:调用名 name、HTTP 方法 method、路径 path、参数列表 params(每个参数有 name / required / type / description)。baseUrl 抽到顶层,避免在每条记录里重复。

提示:上面的字段是这套实现真实使用的最小集合。如果你的平台返回结构不同(比如成功标识不是 code === 0、鉴权头不是固定名字),需要按你平台文档替换,下面会标明具体位置。

二、加载清单:单例缓存 + 路径解析

清单是只读数据,进程内加载一次缓存即可。注意用 import.meta.url 解析文件路径,保证无论从哪个工作目录运行 CLI 都能找到清单文件:

javascript 复制代码
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const REGISTRY_PATH = join(__dirname, "..", "registry.json");

let _registry = null;

export function loadRegistry() {
  if (_registry) return _registry;
  _registry = JSON.parse(readFileSync(REGISTRY_PATH, "utf8"));
  return _registry;
}

export function getBaseUrl() {
  return loadRegistry().baseUrl;
}

// 按调用名查找,找不到再退化为按 path 末段匹配
export function findEndpoint(name) {
  const reg = loadRegistry();
  const target = String(name).toLowerCase();
  return (
    reg.endpoints.find((e) => e.name === target) ||
    reg.endpoints.find((e) => e.path.split("/").pop() === target) ||
    null
  );
}

findEndpoint 做了一个小优化:优先精确匹配 name,匹配不到时再用 path 的末段兜底,这样用户写接口名时容错更高。

三、统一请求层:鉴权、构造、超时、错误码

这是整套设计的核心。它的职责边界很清晰:只管把一次调用发出去并解释返回,不关心是哪个业务接口。

3.1 鉴权与 URL 构造

鉴权采用请求头方式(本实现使用 X-Api-Key)。URL 构造时跳过空值参数,避免把 undefined 拼进查询串:

javascript 复制代码
function buildUrl(baseUrl, path, query) {
  // 规范化拼接,避免出现重复或缺失的斜杠
  const url = new URL(path.replace(/^\//, ""), baseUrl.replace(/\/?$/, "/"));
  for (const [k, v] of Object.entries(query || {})) {
    if (v === undefined || v === null || v === "") continue;
    url.searchParams.set(k, String(v));
  }
  return url.toString();
}

需按你平台补齐:鉴权头名字(这里是 X-Api-Key)。如果你的平台用 Authorization: Bearer xxx 或把 key 放 query,请改这一处。

3.2 GET / POST 的请求体处理

GET 走查询参数,POST 根据 contentType 决定是 JSON 还是表单编码。这一层把两种常见编码都覆盖了:

javascript 复制代码
const headers = {
  Accept: "application/json",
  "User-Agent": "apizero-cli",
};
if (apiKey) headers["X-Api-Key"] = apiKey;

const init = { method, headers };

if (method !== "GET" && body && Object.keys(body).length > 0) {
  if (contentType === "application/x-www-form-urlencoded") {
    headers["Content-Type"] = contentType;
    const form = new URLSearchParams();
    for (const [k, v] of Object.entries(body)) {
      if (v === undefined || v === null) continue;
      form.set(k, String(v));
    }
    init.body = form.toString();
  } else {
    headers["Content-Type"] = "application/json";
    init.body = JSON.stringify(body);
  }
}

3.3 超时控制

AbortController + setTimeout 实现可控超时,默认 30 秒,超时和网络失败抛出统一的错误类型:

javascript 复制代码
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
init.signal = controller.signal;

let res;
try {
  res = await fetch(url, init);
} catch (err) {
  clearTimeout(timer);
  if (err.name === "AbortError") {
    throw new ApiError(`请求超时(>${timeout}ms):${url}`, {});
  }
  throw new ApiError(`网络请求失败:${err.message}`, {});
}
clearTimeout(timer);

3.4 返回信封解析与错误码语义化

平台统一返回 { code, msg, data, request_id },约定 code === 0 为成功。这里做两件事:成功时返回结构化结果;失败时把数字 code 翻译成可读中文,并保留 request_id 方便追踪。

先维护一张「错误码 → 提示」的映射表(节选):

javascript 复制代码
const ERROR_HINTS = {
  4000: "参数错误,请检查输入的参数是否正确、完整。",
  4011: "API Key 无效,请重新设置。",
  4015: "匿名调用的每日免费额度已用完,请设置 Key。",
  4029: "调用过快,请稍后重试。",
  5020: "上游数据源暂不可用,请稍后重试。",
};

这张表是「把平台返回码翻译成人能看懂的话」的地方。具体有哪些 code、各自含义,必须以你平台文档为准,不要照搬。

然后是解析逻辑:

javascript 复制代码
const text = await res.text();
let json;
try {
  json = text ? JSON.parse(text) : {};
} catch {
  throw new ApiError(
    `服务返回非 JSON(HTTP ${res.status}):${text.slice(0, 200)}`,
    { httpStatus: res.status, body: text }
  );
}

const code = json.code;
const msg = json.msg || json.message || "";
const requestId = json.request_id || res.headers.get("x-request-id") || "";

if (code === 0) {
  return { ok: true, code, msg, data: json.data, requestId, raw: json, httpStatus: res.status };
}

// 业务失败:拼装友好提示
const hint = ERROR_HINTS[code];
let message = msg || `请求失败(code: ${code})`;
if (hint) message = `${message}\n  ${hint}`;
throw new ApiError(message, { code, httpStatus: res.status, requestId, body: json });

这里有两个细节值得借鉴:

  1. 返回非 JSON 也要兜住 。直接 res.json() 在网关返回 HTML 错误页时会抛出难懂的解析异常;先 text()JSON.parse,失败时把前 200 字符带进错误信息,排查一眼就能看出是不是被网关拦了。
  2. 始终透传 request_id。无论成功失败都带上它,线上排查时给平台提供这个 ID 能极大提速。

成功判定 code === 0 是本实现的约定。如果你的平台用 success: true 或 HTTP 状态码判定成功,改这一处条件即可。

四、通用执行器:校验、拆参、调用、输出

请求层之上是执行器,它把「接口定义 + 用户输入」变成一次实际调用。

4.1 必填校验前置

在发请求之前先做必填校验,缺参直接给出可操作的提示,避免无谓的网络往返:

javascript 复制代码
export function findMissingRequired(endpoint, values) {
  return endpoint.params
    .filter((p) => p.required && (values[p.name] === undefined || values[p.name] === ""))
    .map((p) => p.name);
}

4.2 按方法拆分 query / body

同一套参数值,GET 放进 query,其它方法放进 body,由接口定义里的 method 决定:

javascript 复制代码
export function buildRequest(endpoint, values) {
  const query = {};
  const body = {};
  for (const p of endpoint.params) {
    const v = values[p.name];
    if (v === undefined) continue;
    if (endpoint.method === "GET") {
      query[p.name] = v;
    } else {
      body[p.name] = v;
    }
  }
  return { query, body };
}

4.3 dry-run:先看请求再决定要不要发

调试期最有用的能力之一:只打印「将要发起的请求」,不真正调用。它对核对参数、确认鉴权是否注入、排查路径拼错非常有效:

javascript 复制代码
if (global.dryRun) {
  printKeyValues({
    接口: `${endpoint.method} ${endpoint.path}`,
    名称: endpoint.summary,
    Key: maskKey(resolveKey(global.key)),  // 脱敏展示,不打印明文
    query: Object.keys(query).length ? JSON.stringify(query) : "---",
    body: Object.keys(body).length ? JSON.stringify(body) : "---",
  });
  return 0;
}

注意 Key 用脱敏函数展示,避免把明文密钥打到终端或日志里。

4.4 双形态输出:给人看,也给脚本用

默认只输出 data 部分(更干净、适合人读),加 --json 则输出完整原始信封(适合脚本和 jq 处理)。request_id 走 stderr,这样它不会污染 stdout 的数据流,管道处理时互不干扰:

javascript 复制代码
if (global.json) {
  printJson(result.raw);
} else {
  if (result.data && typeof result.data === "object") {
    printJson(result.data);
  } else {
    printInfo(String(result.data));
  }
  if (result.requestId) {
    process.stderr.write(`request_id: ${result.requestId}\n`);
  }
}

「数据走 stdout、诊断信息走 stderr」是命令行工具对管道友好的关键实践,配合下游 jq 时尤其重要。

五、Key 解析:命令行 > 环境变量 > 配置文件

密钥来源做成清晰的三级优先级,兼顾临时覆盖、CI 注入和本地持久化三种使用方式:

javascript 复制代码
export function resolveKey(cliKey) {
  if (cliKey && cliKey.trim()) return cliKey.trim();                 // 1. 命令行 --key
  const envKey = process.env.APIZERO_KEY || process.env.APIZERO_API_KEY;
  if (envKey && envKey.trim()) return envKey.trim();                 // 2. 环境变量
  const config = readConfig();                                       // 3. 配置文件
  if (config.key && String(config.key).trim()) return String(config.key).trim();
  return "";
}

持久化时把配置写到用户目录,并收紧文件权限,降低密钥泄露风险:

javascript 复制代码
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
try {
  chmodSync(CONFIG_FILE, 0o600);  // 仅本人可读写
} catch {
  /* 个别平台不支持,忽略即可 */
}

脱敏展示也别忘了,查看配置状态时只露头尾:

javascript 复制代码
export function maskKey(key) {
  if (!key) return "(未设置)";
  if (key.length <= 10) return key.slice(0, 2) + "****";
  return key.slice(0, 8) + "****" + key.slice(-4);
}

六、友好命令层:把高频能力做顺手

通用 call <接口名> --参数 值 已经能调用任意接口,但对最常用的几个能力,再套一层位置参数会更顺手。每个友好命令只需声明:对应的接口名、如何把位置参数映射成字段、以及如何摘要输出。

以「IP 归属地查询」为例:

javascript 复制代码
export const FRIENDLY = {
  ip: {
    endpoint: "ip-pro",                       // 映射到清单里的接口
    usage: "apizero ip [IP地址]",
    map(positionals) {                        // 位置参数 -> 字段值
      const ip = positionals[0];
      return ip ? { ip } : {};                // 不传则查本机出口 IP
    },
    summarize(data) {                         // 人性化摘要(节选)
      printKeyValues({
        IP: data.ip,
        归属: [data.country, data.province, data.city, data.district].filter(Boolean).join(" "),
        运营商: data.isp || data.asn_org || data.owner,
      });
      return true;
    },
  },
};

endpoint 指向的接口名、summarize 里读取的字段(如 data.ipdata.isp),都要以你平台对应接口的真实返回结构为准。本文示例字段来自该项目对接的接口定义。

友好命令在执行时,如果用户加了 --json--dry-run,就自动回退到通用执行器走标准流程;否则才走自定义摘要。这样「人性化」和「可脚本化」两不耽误:

javascript 复制代码
if (global.json || global.dryRun || !def.summarize) {
  return runEndpoint(endpoint, values, global);  // 回退到标准流程
}
// 否则发起调用并走自定义摘要 def.summarize(...)

对于「本地文件入参」这类场景(例如 OCR 传本地图片),可以在 map 里做预处理------判断是 URL 还是本地路径,本地文件读成 base64 并做大小上限保护:

javascript 复制代码
function imageToInput(pathOrUrl) {
  if (/^https?:\/\//i.test(pathOrUrl)) {
    return { input_type: "url", input_data: pathOrUrl };
  }
  const buf = readFileSync(pathOrUrl);
  if (buf.length / 1024 / 1024 > 6) {
    throw new Error("图片过大,请压缩后重试。");  // 上限按平台要求设置
  }
  const b64 = buf.toString("base64");
  return { input_type: "base64", input_data: `data:image/jpeg;base64,${b64}` };
}

文件大小上限、入参字段名(input_type / input_data)、支持的图片格式,需按你平台对应接口文档确认。

七、调用示例

封装完成后,使用形态非常统一。通用调用任意接口:

bash 复制代码
# GET 类接口
apizero call exchange-rate --from USD --to CNY

# 直接用接口名(等价于 call)
apizero hitokoto

友好命令:

bash 复制代码
apizero ip 8.8.8.8
apizero weather 北京
apizero ocr ./idcard.png

调试与脚本化:

bash 复制代码
# 只看将要发起的请求,不真正调用
apizero ai "测试提示词" --dry-run

# 输出完整 JSON 并用 jq 提取字段
apizero ip 8.8.8.8 --json | jq '.data.country'

示例中的接口名(exchange-ratehitokoto 等)与参数,对应该项目清单里的真实接口。换到你自己的平台时,请用你清单里的接口名与参数。

八、异常边界与上线前检查

把容错点集中梳理一下,这些都是这套实现里已经处理、且上线前值得逐项确认的边界:

边界场景 处理方式
网络失败 / DNS 错误 捕获 fetch 异常,抛出统一 ApiError,给出可读信息
请求超时 AbortController 触发 AbortError,提示超时阈值
返回非 JSON(网关 HTML 错误页) text()JSON.parse,失败时带前 200 字符
业务失败码 按错误码映射表翻译,并保留 request_id
缺少必填参数 发请求前校验,直接提示缺哪个参数
密钥未设置 鉴权头不注入,由平台返回对应错误码后给出引导
密钥泄露风险 配置文件 0o600 权限 + 展示脱敏
大文件入参 读入后校验大小上限,超限直接报错

测试与上线前建议:

  • --dry-run 跑一遍主要接口,确认方法、路径、鉴权头、参数拼装都正确,再发真实请求。
  • --json 核对返回结构 ,确认你在摘要里读取的字段名与平台真实返回一致(字段名拼错只会得到 undefined,不会报错,最容易被忽略)。
  • 确认成功判定条件 :本文用 code === 0,务必对照你平台文档,别假设。
  • 确认鉴权方式:请求头名字 / Bearer / query,三者改的是同一处,别改错。
  • 跨平台注意 chmod:部分系统不支持,代码里已 try/catch 兜住,但要知道这一点。
  • stdout 只放数据 :诊断信息(如 request_id)走 stderr,保证管道处理干净。

九、总结

这套「registry 驱动」的命令行封装,把一个聚合 API 平台的几十上百个接口,收敛成了一致、可维护、可脚本化的工具:

  • 数据驱动:接口元数据放清单里,新增接口只改数据不改代码;
  • 单一请求层:鉴权注入、超时、返回码语义化、非 JSON 兜底集中在一处;
  • 执行器复用:必填校验、query/body 拆分、dry-run、双形态输出统一实现;
  • 友好命令:高频能力套一层自然用法,又能无缝回退到标准流程;
  • 密钥安全:三级解析优先级 + 文件权限收紧 + 展示脱敏。

整套实现纯 Node.js(>=18,直接用内置 fetch),零运行时依赖。把上文标注「需按你平台补齐」的几处------鉴权头、成功判定、错误码表、接口清单字段------换成你自己平台文档里的真实值,就能落地成你团队自己的接口 CLI。

相关推荐
濮水大叔3 小时前
浅论CabloyJS全栈框架提供的“两级页签”机制
typescript·node.js·next.js
meilindehuzi_a3 小时前
深入理解 Ajax 异步请求:从 XMLHttpRequest 到 Node.js HTTP 服务实践
http·ajax·node.js
SwJieJie3 小时前
Webpack vs Vite 构建工程化实战(Vue 项目深度解析)
前端·vue.js·webpack·node.js
l1o3v1e4ding4 小时前
windows安装Claude Code,并接入Deepseek-v4模型 ,提供离线安装包
git·npm·node.js·claude code·cc-switchcc
Rain50918 小时前
2.1 Nest.js 项目初始化与模块化架构
开发语言·前端·javascript·后端·架构·数据分析·node.js
YHHLAI21 小时前
从零搭建一个 RESTful Todo 服务 —— Bun + TypeScript 全栈最小闭环
后端·typescript·restful
MageGojo1 天前
R-Shell开源项目实战解析:用Rust打造命令行SSH工具,支持连接管理、远程执行、SFTP与MCP
运维·rust·开源项目·命令行工具·ssh客户端·mcp
矩阵科学1 天前
Langchain.js 实战五:Agent 实战
langchain·node.js
终将老去的穷苦程序员1 天前
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚
前端·npm·node.js