本页目录

HTTP 接口与服务分层

从请求字节到业务操作建立清晰边界,写出有输入校验、状态码、错误合同和请求关联标识的 HTTP 接口。

L2 · 能交付约 20 分钟阅读含示例、练习与验收

建议先读:Node 运行时与进程异步、并发与取消

本页内容

本章解决什么问题#

前端看到的是一个 request 函数,后端看到的是方法、路径、请求头和一段尚未解析的字节流。把 req.body 当成已经可信的对象,或者在路由里同时做校验、扣额度、写数据库和拼响应,都会让边界迅速混乱。本章目标是把接口拆成传输层、业务层和数据访问层,建立可供 Vue 页面稳定消费的成功与错误合同。前置是前两章,特别是 async 函数的失败需要显式接住。

以知识库笔记为例,页面点击保存后,后端必须判断媒体类型、请求大小、JSON 语法、字段类型与业务长度,最后才有资格保存。前端表单的必填提示能够改善体验,但脚本、过期客户端和被修改的请求都能绕过它。服务端校验是独立的可信边界。

HTTP 合同先于框架写法#

HTTP 方法表达请求意图,状态码表达请求结果。创建成功通常用 201;读取成功用 200;语法或字段不符合合同可用 400;未登录用 401;权限不足可用 403;资源不可见或不存在用 404;方法不支持用 405 并给出 Allow;状态冲突用 409;内容过大用 413;媒体类型不支持用 415。具体业务可细分,但不宜无论成功失败都返回 200 再让前端猜测。RFC 9110 定义了 HTTP 的基本语义。

幂等描述相同请求重复执行的预期效果,并不等于响应体必须相同,也不等于请求只会到达一次。浏览器断线重试可能把一个 POST 发送两次。创建任务、扣费等场景必须额外设计幂等键,后续事务章会给出实现。缓存、认证、跨域则是不同层面的问题:CORS 只约束浏览器能否读取跨源响应,不能替代登录鉴权。

前端状态通常围绕组件组织;后端可围绕稳定业务操作组织。传输层负责读取输入、校验格式、设置状态与响应头;服务层负责“能不能创建、默认状态是什么”等业务规则;仓储层负责持久化方式。不要为了看起来像架构就把每行代码拆进一个文件,先让依赖方向清楚,再按规模拆文件。

核心 API 与错误合同#

http.createServer(listener) 返回 Server,listener 收到 IncomingMessage 和 ServerResponse。请求体不会自动解析成 JSON;req.on('data') 得到 Buffer,end 表示流结束。res.writeHead(status, headers) 写响应头,res.end(body) 完成响应;响应已经发出后不能再写第二套状态与头。Node HTTP 文档 说明了这些对象的生命周期。

JSON.parse(text) 返回任意合法 JSON 值,可能是对象、数组、null、数字或字符串,不保证是业务 DTO。示例会显式拒绝非普通对象。业务错误用带 status 与 code 的 Error 表达;未知错误只向外返回稳定的 INTERNAL_ERROR,内部可以记录请求标识和堆栈。请求标识用于把前端报错与服务端日志关联,不应塞入密码、令牌或整个请求体。

完整示例:创建笔记并自检接口#

环境:Node 22.22 或 24,无第三方依赖。保存为 notes-api.mjs,运行 node notes-api.mjs。文件会在本机随机空闲端口启动 HTTP 服务,自动发起四个请求并关闭。它是可重复运行的协议练习,不是已具备认证与持久化的公开服务。

notes-api.mjs
// notes-api.mjs
import http from 'node:http';
import { once } from 'node:events';
import { randomUUID } from 'node:crypto';
import assert from 'node:assert/strict';

const repository = new Map();
const MAX_BYTES = 1024;

function problem(status, code, message) {
  return Object.assign(new Error(message), { status, code });
}

function readJson(req) {
  return new Promise((resolve, reject) => {
    let bytes = 0;
    let settled = false;
    const chunks = [];
    req.on('data', (chunk) => {
      if (settled) return; // 失败后继续消费,但不再累计内存。
      bytes += chunk.length;
      if (bytes > MAX_BYTES) {
        settled = true;
        chunks.length = 0;
        reject(problem(413, 'BODY_TOO_LARGE', '请求内容超过 1024 字节'));
        return;
      }
      chunks.push(chunk);
    });
    req.once('end', () => {
      if (settled) return;
      settled = true;
      try {
        resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
      } catch {
        reject(problem(400, 'INVALID_JSON', '请求不是有效 JSON'));
      }
    });
    req.once('error', reject);
    req.once('aborted', () => reject(problem(400, 'ABORTED', '请求已中断')));
  });
}

function createNote(input) {
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
    throw problem(400, 'INVALID_INPUT', '请求体必须是对象');
  }
  if (typeof input.title !== 'string') {
    throw problem(400, 'INVALID_TITLE', '标题必须是字符串');
  }
  const title = input.title.trim();
  if (title.length < 1 || title.length > 80) {
    throw problem(400, 'INVALID_TITLE', '标题长度必须为 1 到 80');
  }
  // 只挑选允许的字段,防止把客户端传来的内部字段直接保存。
  const note = { id: randomUUID(), title, status: 'draft' };
  repository.set(note.id, note);
  return note;
}

function json(res, status, payload, requestId, extraHeaders = {}) {
  if (res.destroyed || res.writableEnded) return;
  res.writeHead(status, {
    'content-type': 'application/json; charset=utf-8',
    'cache-control': 'no-store',
    'x-request-id': requestId,
    ...extraHeaders,
  });
  res.end(JSON.stringify(payload));
}

async function handle(req, res) {
  const requestId = randomUUID();
  try {
    const url = new URL(req.url, 'http://localhost');
    if (url.pathname !== '/notes') throw problem(404, 'NOT_FOUND', '接口不存在');
    if (req.method !== 'POST') {
      json(res, 405, { error: { code: 'METHOD_NOT_ALLOWED' }, requestId },
        requestId, { allow: 'POST' });
      req.resume();
      return;
    }
    const mediaType = (req.headers['content-type'] ?? '').split(';')[0].trim();
    if (mediaType !== 'application/json') {
      throw problem(415, 'UNSUPPORTED_MEDIA', '请发送 application/json');
    }
    const note = createNote(await readJson(req));
    json(res, 201, { data: note, requestId }, requestId);
  } catch (error) {
    req.resume();
    const known = Number.isInteger(error.status);
    if (!known) console.error({ requestId, message: error.message });
    json(res, known ? error.status : 500, {
      error: {
        code: known ? error.code : 'INTERNAL_ERROR',
        message: known ? error.message : '服务暂时不可用',
      },
      requestId,
    }, requestId);
  }
}

const server = http.createServer((req, res) => {
  void handle(req, res).catch(() => res.destroy());
});
server.requestTimeout = 5000;
server.headersTimeout = 5000;
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const origin = `http://127.0.0.1:${server.address().port}`;
try {
  const statuses = [];
  for (const body of [JSON.stringify({ title: '知识库草稿' }), '{', 'null',
    JSON.stringify({ title: 'x'.repeat(1100) })]) {
    const response = await fetch(`${origin}/notes`, {
      method: 'POST', headers: { 'content-type': 'application/json' }, body,
    });
    statuses.push(response.status);
    const result = await response.json();
    assert.ok(result.requestId);
  }
  assert.deepEqual(statuses, [201, 400, 400, 413]);
  console.log('接口状态验证通过:201、400、400、413');
} finally {
  await new Promise((resolve, reject) => server.close((error) => {
    if (error) reject(error); else resolve();
  }));
}

预期输出为一条接口状态验证通过的说明,程序随后退出。成功响应中的 UUID 每次不同,因此只验证存在性。若端口绑定受本机安全策略限制,应把它记为运行环境阻塞,不能把“代码看起来正确”写成接口已验证。

关键代码逐段说明#

readJson 按字节计数,比 JavaScript 字符串长度更接近网络和内存限制。超过上限后清空已有块并忽略后续内容,避免继续分配;连接和请求读取时间仍需服务器或网关限制。生产框架已有经过维护的 body parser 时,应使用其大小限制与错误配置,无需照搬一个教学解析器支持所有协议细节。

createNote 不接受客户端指定 status 或 id,这是一种显式字段选择,能够防止批量赋值错误。trim 后再检查长度,让存储规则与界面体验一致。示例用 UTF-16 长度定义八十的限制,若产品要求“用户感知字符数”,应统一前后端分段算法,不能声称 length 总是等于汉字或 emoji 数量。

Map 只承担仓储示意,服务层仍没有访问 req、res,因此更换 PostgreSQL 时不会迫使业务规则依赖 HTTP 对象。为了保持篇幅可运行,示例把边界放在同一个文件;实际拆分后仍应保留同样的依赖方向。

从一次请求的生命周期理解服务边界#

“团队文档与任务助手”的保存操作,从用户点击按钮到数据库落盘,中间至少经过浏览器编码、网络传输、HTTP 解析、输入验证、授权、业务写入和响应编码。每一层看到的数据类型不同:浏览器表单是字符串,网络是字节,JSON 解析结果是未知形状的 JavaScript 值,业务层需要已经满足约束的输入对象。直接把这些层合成一个 req.body,容易忘记对象在哪一步开始值得信任。

请求头到达时,请求体可能还没有传完;服务端已经可以根据方法、路径、媒体类型或身份决定拒绝。接收完请求体时,业务操作可能尚未开始;业务提交完成时,响应也可能还没送到用户。由此能解释两个重要现象:客户端取消不证明业务没有执行;服务端返回成功日志也不证明用户看到了成功页面。对会产生重要副作用的操作,应让用户能够凭任务编号或幂等键恢复结果。

浏览器往往把网络问题概括为“请求失败”,后端排查必须细分。连接根本没有建立、服务器收到无效 JSON、数据库拒绝写入、响应在途中断开,是四个不同问题。接口错误合同应该让调用方区分可纠正的输入、需要重新登录的身份、可以稍后重试的容量,以及必须保留原请求键查询的不确定结果。

API 细读:请求、响应和客户端消费#

http.createServer(options, requestListener) 返回 Server,监听函数默认不是一个会被框架自动等待的业务 Promise 管道。把监听器声明成 async 并不等于所有拒绝都会自动变成 JSON 500,因此示例显式接住 handle 的 Promise。server.listen(port, host) 启动监听,端口零由操作系统分配空闲端口;未指定 host 的可见范围可能比本机回环更广,教学自检明确绑定 127.0.0.1,减少环境干扰。

IncomingMessage 中 method 与 url 来自请求,headers 的常用键以小写读取。Node 原生 HTTP 不为业务自动解析 query、JSON 或 Cookie。new URL(req.url, base) 的第二个参数用于相对请求目标解析;不能把未经验证的 Host 头直接当作安全重定向目的地。读取 searchParams 得到字符串或 null,参数重复时还需决定取一个、取全部还是拒绝,不能把接口格式寄托在调用方恰好只传一次。

writeHead(statusCode, headers) 的关键输入是数值状态码和响应头映射;它返回响应对象,便于链式调用,但返回并不代表数据已被对端接收。res.end(data) 表示应用结束当前响应,也不证明业务事务已提交,因此应先完成关键写入再发送成功。headersSent 能帮助判断能否再写普通错误响应,writableEnded 表示应用已调用结束,destroyed 则表示流已经销毁,这几个状态不能当成同一个布尔值。

原生 fetch 默认使用 GET。传入 body 时通常需要明确 POST 或其他适合方法,并与 Content-Type 一致。HTTP 404、409、500 一般仍会兑现为 Response,应检查 status 或 ok;网络失败、无效请求或取消才可能直接拒绝。response.json() 读取并解析响应体,得到的仍是未知数据,空正文或非 JSON 会拒绝。正文通常只能消费一次,先 text 后 json 不是免费的两次读取;调试时应保存读到的文本,或在理解内存成本后使用 clone。

服务端的请求接收超时与业务执行超时应分开。Node 22 的 requestTimeout 默认是三十万毫秒,headersTimeout 默认受六万毫秒与 requestTimeout 较小值限制;它们控制接收请求阶段,不会自动中止数据库中运行很久的业务。本例明确设置五秒接收限制,但生产还应给数据库、模型调用和整个操作分别设截止时间,并把取消信号沿调用链传下去。

基础实验:HTTP 失败不等于 Promise 拒绝#

保存为 http-contract.mjs,Node 22.22 或 24 执行 node http-contract.mjs,无第三方依赖。它建立本机临时服务并自动关闭,观察 Response 的状态与正文消费,不调用任何外部地址。

http-contract.mjs
// http-contract.mjs
import http from 'node:http';
import { once } from 'node:events';
import assert from 'node:assert/strict';
const server = http.createServer((req,res) => {
  res.writeHead(404, {'content-type':'application/json; charset=utf-8'});
  res.end(JSON.stringify({error:{code:'DOCUMENT_NOT_FOUND'}}));
});
server.listen(0,'127.0.0.1');
await once(server,'listening');
try {
  const response = await fetch(`http://127.0.0.1:${server.address().port}/documents/42`);
  assert.equal(response.status,404);
  assert.equal(response.ok,false);
  const payload = await response.json();
  assert.equal(payload.error.code,'DOCUMENT_NOT_FOUND');
  await assert.rejects(() => response.json());
  console.log('404 得到 Response;正文第一次读取成功,第二次读取被拒绝');
} finally {
  await new Promise((resolve,reject) => server.close(error => error ? reject(error) : resolve()));
}

预期输出一条说明。如果前端 request 封装只写 catch 而从不检查 response.ok,HTTP 业务失败就可能被当作正常数据写进 store。反过来,后端若所有结果都返回 200,浏览器与网关也无法从协议状态区分失败。可以统一错误结构,但应该保留协议有用的信息,而不是让每个页面都重新猜测一次。

输入验证要区分格式、语义和状态#

格式验证包括字段类型、长度、枚举与数字范围,语义验证包括“这个文档版本是否属于指定文档”,状态验证包括“已经归档的文档是否允许再次编辑”。三者发生的时机不同:字符串长度可以在读取完成后立即判断,资源归属需要可信身份与数据库,状态转换可能需要事务中的条件更新。一个通用 schema 校验库通常只能覆盖其中部分,不能因为对象通过 schema 就跳过业务判断。

特别留意转换是否有损。查询参数 limit 的字符串可以按严格规则转成整数;空字符串、带单位的字符串、科学计数法是否接受都应明确。布尔值不能用 Boolean('false') 转换。可选字段要区分未提交、显式 null 和空字符串,否则 PATCH 可能把“保留原值”误做“清空字段”。文档标题的 trim 和长度定义也应写入合同,让 Vue 表单与服务端使用相同语义。

错误细节应该服务于修复输入。例如 INVALID_TITLE 可携带 field='title' 与允许长度,前端据此定位表单;内部 SQL、文件路径、凭据与堆栈不属于公开错误。请求标识由服务端产生或经过长度和字符限制后接受,避免用户把巨大值或换行写进日志。对外消息可随文案调整,但前端判断应依赖稳定 code,而不是匹配中文 message。

完整练习参考:创建后读取同一个资源#

原练习要求增加 GET 单条读取。下面给出可直接运行的完整文件 notes-read.mjs,无依赖,执行 node notes-read.mjs。服务仅用于本机协议自检,Map 数据随退出消失。练习限定单一演示空间,未提供认证;接入真实团队数据前,读取函数必须增加授权章的可信租户与成员条件。

完整代码已收录在本章末尾的练习参考答案中;可先阅读说明,再展开复制运行。

为什么 Location、任务编号和状态查询有用#

创建笔记后返回 Location,让调用方知道新资源的规范地址,不必自己猜后端 URL 规则。创建解析任务时可返回 202、任务编号与状态地址,页面先进入已接受状态,再持续查询。若用户刷新页面,前端可以从任务编号恢复,而不是因为组件内 Promise 消失就把任务当作取消。这是把网络交互建模为资源状态,不是给每个长请求随手增加一个大超时。

任务状态与业务结果也应分开。任务 done 表示处理流程完成,结果可能是“没有可提取文本”;failed 表示流程未成功完成,错误应说明是否可重试。响应中保存文档版本和任务编号,可防止旧任务晚返回后覆盖用户刚上传的新版本。前端的竞态防护与后端的版本合同在这里接起来:两边都知道结果属于哪一次输入。

调试时观察哪些证据#

先记录一份最小请求样本,包括方法、路径、必要头、去除敏感内容后的请求体,以及状态码和响应头。使用浏览器网络面板区分等待首字节、下载正文和前端渲染耗时;服务端日志再分别记录读取请求、业务处理与写响应的阶段耗时。仅凭“接口用了三秒”无法推断数据库慢,因为客户端上传本身可能占据大部分时间。

遇到乱码,先确认响应媒体类型和字符编码,再确认是否把分块 Buffer 独立解码。遇到请求体为空,检查方法、Content-Type 和客户端实际发送内容,不能只盯着后端类型声明。遇到重复响应头错误,寻找已经 res.end 后仍继续执行的分支,尤其是 catch、超时回调与异步任务完成回调同时尝试回应的路径。

本机自动请求证明基本协议行为,不能证明跨域部署、代理转发或真实浏览器 Cookie 正常。部署环境还应验证代理是否保留请求标识、是否限制上传大小、是否改变流式响应缓冲,以及客户端断线时下游工作是否按业务合同取消。将“本地单文件已通过”写成“线上接口完整验收”会掩盖这些边界。

项目集成:保持边界,但不要机械拆层#

当团队文档助手引入框架时,路由层通常交给框架解析体积受限的 JSON、匹配路径和统一错误处理;服务层保留 createDocument、enqueueParse 等业务动作;仓储层接收需要的数据库 Client。这样接口从 HTTP 换成后台队列触发时,业务动作仍能复用,而不会在队列里伪造 req、res。

拆层的判断标准是变化原因。HTTP 状态码属于传输合同,额度不足属于业务结果,连接获取属于资源管理,SQL 属于存储实现。一个简单只读接口可能只需要两个小函数,不必创造 Controller、Service、Manager、Handler 四层转发。反过来,把 SQL、Cookie、模型提示词和邮件发送全部放进一个匿名路由回调,会让错误边界与测试输入越来越难界定。

公开 API 的演进要考虑旧客户端。新增可选字段通常比删除字段温和;改变枚举值、日期格式或空值语义可能破坏正在运行的页面。可以在客户端使用前先验证响应结构,在服务端为关键合同保留代表性请求样本。版本号不是随意破坏兼容性的许可证,更不能用“前后端一起上线”忽视移动端缓存、旧网页标签和异步任务仍在运行的事实。

编辑冲突与条件请求#

两个成员同时打开同一份团队文档,先后保存时,后一次写入可能覆盖前一次修改。前端按钮状态无法发现另一台设备的修改。接口可以返回资源版本,并要求更新携带预期版本;数据库使用 id、tenant_id 与 version 一起作为条件,成功时递增版本。如果没有行被更新,应区分资源不可见与版本已变化,并让用户选择刷新、比较或合并,而不是静默覆盖。

HTTP 中 ETag 与 If-Match 可以表达这种条件请求。ETag 是服务端对表示版本生成的标识,客户端把上次获得的值放到 If-Match 中,服务端判断条件成立后才应用修改;条件失败可返回 412。ETag 不必等于数据库自增版本的原文,也不能把它当权限令牌。若响应包含按用户过滤的字段,其版本和缓存策略还要与表示本身对应,不能给不同权限视图错误复用相同缓存结果。

GET 的条件缓存则是另一个场景:If-None-Match 可用于判断客户端已有表示是否仍有效,满足相应条件时可返回没有正文的 304。前端封装若无条件对所有响应调用 json,就会在空正文上产生解析错误。因此客户端应先按状态判断是否存在可消费正文,再做数据解析。不要为了“所有接口格式统一”给本应没有正文的状态硬塞 JSON。

流式模型回答如何改变错误合同#

普通 JSON 接口可以在业务成功前暂不发送响应头,出错时选择相应状态;流式回答为了尽早展示 token,会提前发送头和部分正文。此后模型供应商失败,服务器已经不能把同一响应重新变成一个普通 500 JSON。需要在流协议里设计 error、done、usage 等事件,客户端据此区分完整答案、部分答案与失败,并决定是否保留已经显示的内容。

一个常见错误是看到输出流关闭就认为回答成功。连接关闭可能来自正常完成、用户取消、代理超时或服务器异常;显式完成事件比单纯的 EOF 更能表达业务结束。团队文档助手还应让每个流关联任务或消息编号,必要时重新查询持久化状态。服务端不应在流完成之前把“最终答案已保存”写成成功事实,客户端也不应把部分输出当成完整摘要覆盖旧版本。

流式接口同样需要限制输出长度、总体时长和同时连接数。长连接占用的不只是带宽,还包括模型额度、服务器对象、代理连接和用户状态订阅。是否使用普通响应、任务轮询或流式事件,取决于用户是否需要即时反馈以及任务能否独立恢复,而不是只因为模型接口支持 stream 就让所有后台解析都使用流。

边界、练习与参考解答#

常见错误包括:相信 Content-Length 而不计数实际字节;对 body 强行做类型断言;把数据库堆栈直接返回浏览器;在发送成功之后又执行可能失败的关键写入;把浏览器跨域配置当作访问控制。若必须流式返回模型 token,一旦响应头发出,后续错误需用约定的流内事件表示,不能再切换成普通 JSON 500。

练习:增加 GET /notes/:id。要求存在时返回 200,不存在时返回 404,不允许把 Map 全量数据交给客户端自行筛选。提示:先限定路由格式,再读仓储;本章未接入登录,下一阶段必须加入资源归属检查。

参考答案(含完整可运行实现)

在 handle 的路径判断前识别 /notes/ 后的单个 id,校验它符合 UUID 格式;GET 分支通过 repository.get 查询,未找到时抛 NOT_FOUND。成功只返回公开字段。新增自检应先保存创建成功返回的 id,再读取它,同时验证随机 id 返回 404。以后接入租户与用户后,仓储接口应变成同时接收可信身份与资源 id,不能在前端藏按钮后就放弃后端权限检查。

notes-read.mjs
// notes-read.mjs
import http from 'node:http';
import { once } from 'node:events';
import { randomUUID } from 'node:crypto';
import assert from 'node:assert/strict';
const notes = new Map();
function fail(status,code) { return Object.assign(new Error(code),{status,code}); }
function readBody(req) {
  return new Promise((resolve,reject) => {
    const chunks=[]; let size=0; let ended=false;
    req.on('data',chunk => {
      if (ended) return;
      size+=chunk.length;
      if (size>1024) { ended=true; chunks.length=0; reject(fail(413,'BODY_TOO_LARGE')); }
      else chunks.push(chunk);
    });
    req.once('end',() => {
      if (ended) return;
      ended=true;
      try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
      catch { reject(fail(400,'INVALID_JSON')); }
    });
    req.once('error',reject);
    req.once('aborted',() => reject(fail(400,'ABORTED')));
  });
}
function send(res,status,payload,headers={}) {
  if (res.destroyed || res.writableEnded) return;
  res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store',...headers});
  res.end(JSON.stringify(payload));
}
async function handle(req,res) {
  try {
    const path=new URL(req.url,'http://localhost').pathname;
    if (path==='/notes') {
      if (req.method!=='POST') { req.resume(); send(res,405,{error:{code:'METHOD_NOT_ALLOWED'}},{allow:'POST'}); return; }
      if ((req.headers['content-type']??'').split(';')[0].trim()!=='application/json') throw fail(415,'UNSUPPORTED_MEDIA');
      const body=await readBody(req);
      if (!body || Array.isArray(body) || typeof body.title!=='string') throw fail(400,'INVALID_TITLE');
      const title=body.title.trim();
      if (!title || title.length>80) throw fail(400,'INVALID_TITLE');
      const note={id:randomUUID(),title,status:'draft'};
      notes.set(note.id,note);
      send(res,201,{data:note},{location:`/notes/${note.id}`}); return;
    }
    const match=/^\/notes\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.exec(path);
    if (!match) throw fail(404,'NOT_FOUND');
    if (req.method!=='GET') { req.resume(); send(res,405,{error:{code:'METHOD_NOT_ALLOWED'}},{allow:'GET'}); return; }
    const note=notes.get(match[1]);
    if (!note) throw fail(404,'NOT_FOUND');
    send(res,200,{data:note});
  } catch(error) {
    req.resume(); send(res,error.status??500,{error:{code:error.code??'INTERNAL_ERROR'}});
  }
}
const server=http.createServer((req,res) => { void handle(req,res).catch(() => res.destroy()); });
server.requestTimeout=5000; server.headersTimeout=5000;
server.listen(0,'127.0.0.1'); await once(server,'listening');
try {
  const origin=`http://127.0.0.1:${server.address().port}`;
  const created=await fetch(`${origin}/notes`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({title:'团队会议纪要'})});
  assert.equal(created.status,201);
  const location=created.headers.get('location');
  const first=await created.json();
  const read=await fetch(`${origin}${location}`);
  assert.equal(read.status,200);
  assert.deepEqual((await read.json()).data,first.data);
  const missing=await fetch(`${origin}/notes/${randomUUID()}`);
  assert.equal(missing.status,404); await missing.text();
  console.log('创建返回 201 与 Location;读取返回 200;不存在返回 404');
} finally { await new Promise((resolve,reject) => server.close(error => error ? reject(error) : resolve())); }

可验证的验收标准#

四个内置请求得到预期状态;错误响应有稳定 code 和 requestId;客户端注入 status 不会覆盖 draft;错误内容不包含内部路径或堆栈;服务退出后端口关闭。能从代码中指出传输校验、业务规则与数据存储分别在哪里。

三个自测问题与答案#

  1. JSON.parse 成功是否代表 DTO 合法?答案:不代表,null、数组和数字也可能是有效 JSON。
  2. 业务失败都返回 200 会怎样?答案:HTTP 客户端、监控和缓存难以按协议理解结果,前端也被迫维护隐藏的第二套错误规则。
  3. 为什么请求体不能直接展开到数据库记录?答案:用户可能提交内部角色、状态或归属字段,必须显式挑选允许写入的属性。

本章验证记录#

编写时使用 Node 22.22.0 对本章全部 3 个 JavaScript 完整文件执行了语法检查。已在本机实际运行通过:notes-api.mjshttp-contract.mjsnotes-read.mjs。本机脚本检查不代表已经完成真实 HTTPS 浏览器、Cookie 或跨域部署验收。

本章官方参考#

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭