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

## 目标与前置

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

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

## 先划分三种职责

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

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

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

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

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

```js 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`，无需安装依赖。程序包括仓库、正文读取、路由与自动验证。请先按前面三层模型阅读函数名称，再查看每一层的具体实现。

```js 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 路由取得列表后调用。

<details><summary>参考答案：完整查询契约验证</summary>

```js 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，而不是每次读出所有任务再过滤，这正是下一阶段查询章节要解决的问题。

</details>

## 核心存储 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](https://nodejs.org/docs/latest-v24.x/api/http.html)，文件行为见 [File system](https://nodejs.org/docs/latest-v24.x/api/fs.html)，输入 URL 见 [URL](https://nodejs.org/docs/latest-v24.x/api/url.html)，错误传播见 [Errors](https://nodejs.org/docs/latest-v24.x/api/errors.html)。本章只验证本地文件与 HTTP 流程，未连接外部数据库、模型服务或生产部署环境。
