本页目录

组合成服务:从本地路由到可验证的持久化

将 HTTP、输入边界、文件持久化、串行写入和关闭组合成完整本地任务服务,并说明如何继续进入数据库与授权章节。

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

建议先读:从 TCP 到 HTTP:连接、消息与请求生命周期npm 与项目结构:依赖、锁文件和可重现命令错误与调试:从回调到 async 的传播边界性能与内存:从现象找到保留链与热点

本页内容

目标与前置#

这一章把前面的机制组合起来:团队文档与任务助手先拥有任务列表与创建任务接口,数据保存到一个受控 JSON 文件,再通过真实 HTTP 请求验证输入、并发与重新打开后的状态。前置是网络、错误、模块和资源生命周期。你不需要先学习数据库,也不需要安装框架,目标是看清服务的基本构成,然后再选择成熟框架与数据库。

所有实验自动绑定回环地址和临时端口,测试完成后关闭服务。持久化实验只创建自己的临时目录,验证重新打开数据后再清理,不会写入现有项目文件。它是可运行的教学服务,尚未包含登录、多租户与生产部署;这些能力不是隐藏在某个默认配置中,而是后续必须显式加入的业务边界。

先划分三种职责#

传输层负责理解 HTTP:路由、方法、内容类型、正文大小、状态码和响应序列化。业务层负责理解任务:标题是否合法、创建意味着什么、哪些结果可以展示。存储层负责读写状态:如何加载、何时提交以及如何处理写入失败。三者的失败应该在边界转换,不能让前端收到任意磁盘路径与堆栈。

分层不要求一开始创建很多目录。本章把完整程序放在一个文件中,但函数边界已经把职责分开。以后把仓库实现换成 PostgreSQL,只要保留明确的 list 和 add 契约,路由就不必知道 SQL 的具体形状。反过来,若路由直接到处修改共享数组,替换存储时就会连业务行为一起重写。

接口也是前后端之间的协议。前端可以在提交前验证标题以改善体验,服务端仍必须重新验证,因为请求可能来自脚本或旧版页面。响应字段应该稳定,错误代码应与文案分离。不能因为只有自己的 Vue 页面调用,就把调用方提供的租户编号和用户身份当成已经验证的事实。

实验一:一个只读服务的完整生命周期#

环境 Node 22.22.0,无依赖。保存为 service-readonly.mjs。先只关注启动、路由、状态和关闭,数据固定在内存中。

service-readonly.mjs
import http from 'node:http';
import { once } from 'node:events';
import assert from 'node:assert/strict';

const tasks = [{ id: 'task-1', title: '阅读 Node 文档' }];
function send(response, status, value, headers = {}) {
  const body = JSON.stringify(value);
  response.writeHead(status, {
    'content-type': 'application/json; charset=utf-8',
    'content-length': Buffer.byteLength(body),
    ...headers
  });
  response.end(body);
}
const server = http.createServer((request, response) => {
  request.resume();
  const url = new URL(request.url, 'http://localhost');
  if (request.method !== 'GET') { send(response, 405, { error: 'METHOD_NOT_ALLOWED' }, { allow: 'GET' }); return; }
  if (url.pathname === '/tasks') { send(response, 200, { items: tasks }); return; }
  send(response, 404, { error: 'NOT_FOUND' });
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}`;
try {
  const response = await fetch(`${base}/tasks`);
  assert.equal(response.status, 200);
  assert.deepEqual((await response.json()).items, tasks);
  const missing = await fetch(`${base}/missing`);
  assert.equal(missing.status, 404);
  await missing.text();
  console.log('只读路由、JSON 响应与关闭通过');
} finally {
  await new Promise((resolve) => server.close(resolve));
}

执行 node service-readonly.mjs,预期通过。send 把状态与 JSON 编码集中起来,内容长度按 UTF-8 字节计算;请求目标经 URL 解析后使用 pathname,查询参数不会被误当成路径的一部分。监听成功后才取得端口,客户端消费完整响应后再关闭服务器,生命周期不依赖人工按停止按钮。

本例只验证受控请求,没有实现完整输入防护与方法响应细节。下一组会把错误边界、Allow 头和实际创建操作加入。不要把简单示例里的“所有非 GET 都返回同一个结果”无意识扩大成所有接口的最终设计,方法与资源是否存在需要根据契约分别判断。

创建操作需要哪些边界#

创建任务只接受 JSON 正文,实际读取量最多一千零二十四字节。Content-Length 可以帮助提前判断,但它是请求声明,仍要累计收到的字节。正文解析失败返回四百,类型不支持返回四百一十五,标题规则失败返回四百二十二,超过限制返回四百一十三。内部存储失败则返回通用五百,具体原因留在内部诊断。

标题采用去除首尾空白后的非空字符串,最多八十个 Unicode 码点。这里明确使用码点数量,而不是声称完全等于用户看到的字素数量;需要更复杂的可见字符规则时再引入相应分段能力。保存规范化值后,前端应以响应中的标题为准,避免展示与实际存储不一致。

异步路由必须把拒绝接到统一边界。http.createServer 不会因为回调声明成 async 就自动把所有拒绝转换成响应。我们让 handle 返回 Promise,再显式 catch;如果响应已经开始,就销毁连接,不能继续发送第二份 JSON。这个结构把前面错误章节的传播规则放到了真实请求中。

文件仓库如何避免同进程丢失更新#

两个创建请求可能都在等待磁盘,若各自先读旧数组、再追加、再写回,后写的可能覆盖前写结果。仓库用一个 Promise 链串行执行读改写逻辑,让每次写入看到前一次成功后的状态。这个保证只在同一个仓库实例、同一个进程内成立,不是跨进程锁,也不是数据库事务。

每次写入先写一个本次操作独占的临时文件,再 rename 到正式文件。只有文件发布成功后才更新内存状态;失败时当前请求拒绝,后续任务仍能继续排队。临时写入减少读取到半份 JSON 的风险,但不在这里承诺断电耐久性、多个进程协调或所有文件系统上的相同保证。真实多实例应用应进入数据库章节。

加载时只把文件不存在解释为首次空仓库。文件已经存在但 JSON 损坏、结构不符合约定或编号重复,都应阻止启动,不能悄悄覆盖为空数组。这样启动失败虽然明显,却保护了已有数据不被“自动恢复”误删。验证存量数据也是服务边界,不只验证新请求。

实验二:完整持久化任务服务与真实请求验收#

保存为 service-persistent.mjs,无需安装依赖。程序包括仓库、正文读取、路由与自动验证。请先按前面三层模型阅读函数名称,再查看每一层的具体实现。

service-persistent.mjs
import http from 'node:http';
import { once } from 'node:events';
import { readFile, open, rename, rm, mkdtemp, realpath } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, dirname, basename } from 'node:path';
import { randomUUID } from 'node:crypto';
import assert from 'node:assert/strict';

function problem(status, code) {
  return Object.assign(new Error(code), { status, code });
}
function normalizeTitle(value) {
  if (typeof value !== 'string' || value.trim() === '' || [...value.trim()].length > 80) {
    throw problem(422, 'INVALID_TITLE');
  }
  return value.trim();
}
async function openRepository(file) {
  let state;
  try { state = JSON.parse(await readFile(file, 'utf8')); }
  catch (error) {
    if (error.code === 'ENOENT') state = [];
    else throw error;
  }
  if (!Array.isArray(state)) throw new Error('存储结构错误');
  const ids = new Set();
  for (const item of state) {
    if (!item || typeof item.id !== 'string' || ids.has(item.id) || item.id === '') throw new Error('任务编号错误');
    if (normalizeTitle(item.title) !== item.title) throw new Error('存储标题未规范化');
    ids.add(item.id);
  }
  let tail = Promise.resolve();
  return {
    async list() { await tail; return structuredClone(state); },
    add(title) {
      const operation = tail.then(async () => {
        const task = { id: randomUUID(), title: normalizeTitle(title) };
        const next = [...state, task];
        const temporary = `${file}.${randomUUID()}.tmp`;
        let created = false;
        try {
          const handle = await open(temporary, 'wx');
          created = true;
          try { await handle.writeFile(JSON.stringify(next), 'utf8'); }
          finally { await handle.close(); }
          await rename(temporary, file);
        } catch (error) {
          if (created) await rm(temporary, { force: true }).catch((cleanup) => console.error('临时文件清理失败', cleanup.code));
          throw error;
        }
        state = next;
        return structuredClone(task);
      });
      // 失败交给本次调用方,队列本身继续接受后续操作。
      tail = operation.catch(() => {});
      return operation;
    },
    async flush() { await tail; }
  };
}
function readJSON(request) {
  return new Promise((resolve, reject) => {
    let chunks = [];
    let bytes = 0;
    let failed = false;
    request.on('data', (chunk) => {
      if (failed) return;
      bytes += chunk.length;
      if (bytes > 1024) {
        failed = true;
        chunks = [];
        reject(problem(413, 'BODY_TOO_LARGE'));
        return;
      }
      chunks.push(chunk);
    });
    request.once('end', () => {
      if (failed) return;
      try {
        const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
        resolve(JSON.parse(text));
      } catch { reject(problem(400, 'INVALID_JSON')); }
    });
    request.once('error', reject);
    request.once('aborted', () => reject(problem(400, 'REQUEST_ABORTED')));
  });
}
function send(response, status, value, headers = {}) {
  const body = JSON.stringify(value);
  response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body), ...headers });
  response.end(body);
}
async function startService(repository) {
  async function handle(request, response) {
    const url = new URL(request.url, 'http://localhost');
    if (url.pathname !== '/tasks') { request.resume(); send(response, 404, { error: 'NOT_FOUND' }); return; }
    if (request.method === 'GET') { request.resume(); send(response, 200, { items: await repository.list() }); return; }
    if (request.method !== 'POST') {
      request.resume();
      send(response, 405, { error: 'METHOD_NOT_ALLOWED' }, { allow: 'GET, POST' });
      return;
    }
    const type = request.headers['content-type']?.split(';')[0].trim().toLowerCase();
    if (type !== 'application/json') { request.resume(); throw problem(415, 'JSON_REQUIRED'); }
    const value = await readJSON(request);
    if (!value || Array.isArray(value) || typeof value !== 'object') throw problem(422, 'OBJECT_REQUIRED');
    const title = normalizeTitle(value.title);
    send(response, 201, { item: await repository.add(title) });
  }
  const server = http.createServer((request, response) => {
    request.on('error', () => {}); // 正文读取器另行接收自己的失败。
    void handle(request, response).catch((error) => {
      if (response.destroyed) return;
      if (response.headersSent) { response.destroy(error); return; }
      if (!error.status) console.error('任务请求失败', error);
      send(response, error.status ?? 500, { error: error.status ? error.code : 'INTERNAL_ERROR' });
    });
  });
  server.requestTimeout = 5000;
  server.headersTimeout = 5000;
  server.listen(0, '127.0.0.1');
  await once(server, 'listening');
  return {
    base: `http://127.0.0.1:${server.address().port}`,
    async close() {
      await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
      await repository.flush();
    }
  };
}
const directory = await mkdtemp(join(tmpdir(), 'team-service-'));
const file = join(directory, 'tasks.json');
let service;
try {
  service = await startService(await openRepository(file));
  async function request(body, method = 'POST', type = 'application/json') {
    const response = await fetch(`${service.base}/tasks`, { method, headers: { 'content-type': type }, body: method === 'GET' ? undefined : body });
    return { status: response.status, value: await response.json(), allow: response.headers.get('allow') };
  }
  const created = await request(JSON.stringify({ title: '  第一项  ' }));
  assert.equal(created.status, 201);
  assert.equal(created.value.item.title, '第一项');
  assert.equal((await request('{broken')).status, 400);
  assert.equal((await request('{"title":" "}')).status, 422);
  assert.equal((await request('x'.repeat(1025))).status, 413);
  assert.equal((await request('{}', 'POST', 'text/plain')).status, 415);
  const method = await request('{}', 'PUT');
  assert.equal(method.status, 405);
  assert.equal(method.allow, 'GET, POST');
  const batch = await Promise.all(Array.from({ length: 5 }, (_, index) => request(JSON.stringify({ title: `并发-${index}` }))));
  assert.ok(batch.every((result) => result.status === 201));
  assert.equal((await request(undefined, 'GET')).value.items.length, 6);
  await service.close();
  service = undefined;
  // 新仓库和新服务重新读取同一个文件,验证状态确实写入磁盘。
  service = await startService(await openRepository(file));
  const restored = await fetch(`${service.base}/tasks`);
  assert.equal((await restored.json()).items.length, 6);
  console.log('输入错误、5 个并发创建、重新打开后 6 项均通过');
} finally {
  if (service) await service.close();
  const actualDirectory = await realpath(directory);
  const expectedParent = await realpath(tmpdir());
  if (dirname(actualDirectory) !== expectedParent || !basename(actualDirectory).startsWith('team-service-')) {
    throw new Error('临时目录清理边界不匹配');
  }
  await rm(actualDirectory, { recursive: true, force: true });
}

执行 node service-persistent.mjs,预期打印完整通过信息后自行退出。程序实际发出创建、非法正文、超限、内容类型错误、方法错误和并发请求;关闭第一台服务后创建新仓库实例,再读取六项数据,证明结果不只是保留在旧数组里。最后通过 realpath 核对目录的实际父目录与名称前缀,再删除本程序用 mkdtemp 创建的目录。

逐段解释容易忽略的决定#

仓库把等待链与本次操作结果分开:调用方拿到 operation,失败会收到拒绝;队列保存一个已经接住失败的 tail,使后续创建仍能执行。若直接把失败 Promise 留作下一次的起点,后续 then 可能全部跳过。state 只在 rename 成功后更新,所以失败不会先在内存中展示一个实际上没保存的任务。

读取器超限时清空已累计块并拒绝,但继续消费到来的数据,不再保存它们。这样本地受控测试能够返回四百一十三,而不是因为立即销毁请求流导致客户端只看到连接重置。真实服务面对持续慢速或巨量输入,还需要总时间预算、代理限制和关闭策略,不能无限为恶意客户端排空数据。

关闭函数先停止接受新连接并等待已有请求处理,再等待仓库队列。测试请求都消费完整响应,因此可以自然关闭;长期生产请求则需要最大等待预算与最终终止策略。把这些逻辑写成一个拥有者可调用的 close,比在模块顶层散落多个退出钩子更容易验证。

练习:给列表查询增加受控筛选#

实现一个独立 selectTasks(items, url),支持可选 q 文本和 limit。q 去空白后最多二十个码点,limit 默认为十,只接受一到十的十进制整数,按标题包含关系筛选并返回副本。输入不合法必须抛错,不能静默使用默认值。这个练习把查询参数解析与存储分开,完成后可在 GET 路由取得列表后调用。

参考答案:完整查询契约验证
service-query.mjs
import assert from 'node:assert/strict';

function selectTasks(items, url) {
  const query = url.searchParams.get('q')?.trim() ?? '';
  const raw = url.searchParams.get('limit') ?? '10';
  if ([...query].length > 20 || !/^(?:[1-9]|10)$/.test(raw)) throw new RangeError('查询参数不合法');
  return items.filter((item) => item.title.includes(query)).slice(0, Number(raw)).map((item) => ({ ...item }));
}
const items = [{ id: '1', title: '阅读文档' }, { id: '2', title: '整理文档' }, { id: '3', title: '创建任务' }];
const selected = selectTasks(items, new URL('http://localhost/tasks?q=文档&limit=1'));
assert.equal(selected.length, 1);
selected[0].title = '外部修改';
assert.equal(items[0].title, '阅读文档');
assert.throws(() => selectTasks(items, new URL('http://localhost/tasks?limit=1x')), RangeError);
assert.throws(() => selectTasks(items, new URL('http://localhost/tasks?limit=0')), RangeError);
assert.equal(selectTasks(items, new URL('http://localhost/tasks')).length, 3);
console.log('查询格式、筛选数量与返回副本通过');

执行 node service-query.mjs,预期通过。返回对象只有字符串字段,所以浅复制能满足本题隔离要求;若结构增加嵌套对象,应重新定义返回模型。真实数据库列表应把筛选与分页下推到 SQL,而不是每次读出所有任务再过滤,这正是下一阶段查询章节要解决的问题。

核心存储 API 的契约与选择理由#

readFile(path, encoding) 接收文件路径或文件 URL,指定 utf8 时兑现为字符串,不指定编码时通常返回 Buffer;找不到文件、权限不足等操作失败通过 Promise 拒绝。仓库读取整个小型状态文件,是因为本例有意展示最简单的持久化边界,不是建议对任意大文档都这样读取。文件规模增长后,读入、解析和重写成本都会随总数据增长。

writeFile(path, data, options) 完成时兑现,不返回新文件内容。默认写入标志通常会创建或截断目标,本例通过 open(path, wx) 排他创建并取得 FileHandle,成功后才标记本次拥有该文件,再用 handle.writeFile 写入并关闭句柄。若排他创建失败,不删除那个并非本次创建的路径;若创建后写入失败,才清理自己拥有的临时文件。随机名称降低冲突概率,排他创建再提供明确的冲突检查,两者职责不同。不能只因为名字很随机,就把覆盖行为当成不可能发生。

rename(oldPath, newPath) 完成路径重命名或替换,成功后 Promise 兑现,不返回业务对象。本例临时文件与正式文件在同一目录,避免跨文件系统移动;跨文件系统可能出现失败,权限和平台行为也需要在真实部署环境验证。这里由 rename 成功作为发布边界,但并未调用文件同步刷新,因此没有作掉电后耐久性的额外承诺。

structuredClone(value) 创建结构化副本,适合本例只有普通对象与字符串的任务模型。它不是任意 JavaScript 值都能克隆的函数,包含函数等不支持内容时会失败。返回副本的目的是让调用方修改响应模型时不触及仓库内部数组;若以后返回数据库驱动对象,也应先转换成明确的公共数据模型,再交给传输层。

为什么服务启动也需要失败策略#

启动过程应先读取并验证配置,再初始化存储,最后开始接受请求。若数据文件损坏,服务应在监听前失败;否则用户可能进入一个表面可用、实际每次请求都报错的实例。本例先 openRepository 再 startService,已经体现这种顺序。生产环境还可以区分进程存活与业务就绪,让流量入口只把请求交给已经完成初始化的实例。

关闭则是相反方向的资源回收。先停止新请求,等待已接受任务在预算内完成,再关闭数据库连接和其他资源。不能先关闭存储再等待请求,否则正在运行的任务会因为自己的依赖被提前撤走而失败。每个资源最好在创建处同时定义对应关闭方式,入口负责按依赖逆序调用。

读写失败的恢复不能只靠重启。文件损坏需要保留现场并恢复可信数据,权限错误需要修正配置,磁盘不足需要释放或扩展容量。重启可能暂时清空内存队列,却不会自动修复这些外部条件。把内部错误记录下来,同时给客户端稳定错误响应,是为了让产品交互与运维诊断各自得到需要的信息。

用前端思维检查接口,但补上服务端的约束#

Vue 页面里的列表通常通过响应式状态渲染,后端仓库中的数组则是长期共享状态。前端一个组件实例可能随路由切换销毁,服务进程却会连续处理多个用户。不能把当前登录用户、当前租户或当前请求对象放进模块共享变量里,再让后续异步操作读取它。请求上下文应沿参数或明确的上下文机制传递。

前端提交成功后可以把响应中的任务插入列表,但发生超时后不能立即认定创建失败。服务器可能已经发布文件,只是客户端没等到响应。生产版本应加入幂等请求标识,或者通过任务状态查询让用户确认结果。仅在按钮上禁用重复点击能够减少误操作,却不能覆盖网络重试、页面刷新和多个客户端的重复提交。

错误状态还应影响交互方式。标题不合法可以保留输入并提示修正,服务内部失败可以保留草稿并允许适当重试,未登录与无权访问需要不同导航或提示。后端给出稳定代码以后,前端才能把这些行为写成明确分支,而不必分析后端异常文字。接口文档应包含这些失败样例,不能只展示成功响应。

何时应替换本章的文件方案#

当任务数量增多,每次创建都重写全部 JSON 会增加写入成本;多个服务实例同时运行时,各自的 Promise 队列互不协调,会重新出现覆盖问题;需要按租户、时间和状态筛选时,把所有数据加载到内存也难以扩展。这些都是引入数据库的具体原因,不是因为文件存储听起来不够正式。

迁移到 PostgreSQL 后,仍然需要保留输入验证与统一错误边界,但并发控制交给事务、唯一约束和适当锁策略。SQL 值必须参数化,事务中的语句必须使用同一连接,租户与资源归属必须进入查询条件或数据库策略。把本章仓库接口保留下来,可以让你集中比较存储实现变化,而不是同时重做整个 HTTP 层。

框架也应在理解边界后引入。成熟框架可以提供路由、正文解析、错误插件和生命周期管理,减少手工维护;但它不会自动知道业务需要哪些权限、哪些操作必须幂等,以及多少输入才算合理。学完这章后再使用框架,应该能够指出每个插件替代了本例的哪一层,以及还剩哪些应用责任。

从实验继续进入完整全栈能力#

接下来阅读 HTTP 接口章节理解服务分层、状态和参数契约,阅读 PostgreSQL 建模与查询章节替换文件仓库,阅读事务章节处理多实例并发与幂等,阅读会话和权限章节建立资源归属。本文串行队列只协调一个进程,文件中没有租户约束,也没有密码或 Cookie。它们需要被明确实现,不能因为示例能运行就认为已经具备。

前端对接时先固定接口样例和错误分支,再加入加载、取消和重复提交处理。客户端取消等待不一定撤销已经提交的创建,重复点击也可能产生多次有效请求;生产接口需要幂等策略与任务查询。你现在已经能从字节、请求、Promise 和存储几个层次解释这个问题,而不只是把它视为按钮防抖。

验收要求三个完整实验都通过,持久化服务重开后仍有六项任务,非法输入不增加条目,并发创建不丢失。自测一:为什么成功响应要等仓库 add 完成?答案:否则客户端可能看到尚未保存的成功。自测二:单进程 Promise 队列能否协调两个服务实例?答案:不能,需要跨实例存储并发机制。自测三:为什么损坏文件不默认当作空数组?答案:那会掩盖故障并可能覆盖已有数据。

本章实际验证范围#

三个程序实际通过。完整服务覆盖输入错误、内容类型、方法 Allow 头、五项并发创建和重开后六项状态,最后完成自己的临时目录边界核对与清理。未连接数据库、模型接口或生产认证系统。

官方参考#

网络边界见 HTTP,文件行为见 File system,输入 URL 见 URL,错误传播见 Errors。本章只验证本地文件与 HTTP 流程,未连接外部数据库、模型服务或生产部署环境。

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

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