用 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 });
这里有两个细节值得借鉴:
- 返回非 JSON 也要兜住 。直接
res.json()在网关返回 HTML 错误页时会抛出难懂的解析异常;先text()再JSON.parse,失败时把前 200 字符带进错误信息,排查一眼就能看出是不是被网关拦了。 - 始终透传
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.ip、data.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-rate、hitokoto等)与参数,对应该项目清单里的真实接口。换到你自己的平台时,请用你清单里的接口名与参数。
八、异常边界与上线前检查
把容错点集中梳理一下,这些都是这套实现里已经处理、且上线前值得逐项确认的边界:
| 边界场景 | 处理方式 |
|---|---|
| 网络失败 / 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。