组合成服务:从本地路由到可验证的持久化
将 HTTP、输入边界、文件持久化、串行写入和关闭组合成完整本地任务服务,并说明如何继续进入数据库与授权章节。
建议先读:从 TCP 到 HTTP:连接、消息与请求生命周期npm 与项目结构:依赖、锁文件和可重现命令错误与调试:从回调到 async 的传播边界性能与内存:从现象找到保留链与热点
本页内容
目标与前置#
这一章把前面的机制组合起来:团队文档与任务助手先拥有任务列表与创建任务接口,数据保存到一个受控 JSON 文件,再通过真实 HTTP 请求验证输入、并发与重新打开后的状态。前置是网络、错误、模块和资源生命周期。你不需要先学习数据库,也不需要安装框架,目标是看清服务的基本构成,然后再选择成熟框架与数据库。
所有实验自动绑定回环地址和临时端口,测试完成后关闭服务。持久化实验只创建自己的临时目录,验证重新打开数据后再清理,不会写入现有项目文件。它是可运行的教学服务,尚未包含登录、多租户与生产部署;这些能力不是隐藏在某个默认配置中,而是后续必须显式加入的业务边界。
先划分三种职责#
传输层负责理解 HTTP:路由、方法、内容类型、正文大小、状态码和响应序列化。业务层负责理解任务:标题是否合法、创建意味着什么、哪些结果可以展示。存储层负责读写状态:如何加载、何时提交以及如何处理写入失败。三者的失败应该在边界转换,不能让前端收到任意磁盘路径与堆栈。
分层不要求一开始创建很多目录。本章把完整程序放在一个文件中,但函数边界已经把职责分开。以后把仓库实现换成 PostgreSQL,只要保留明确的 list 和 add 契约,路由就不必知道 SQL 的具体形状。反过来,若路由直接到处修改共享数组,替换存储时就会连业务行为一起重写。
接口也是前后端之间的协议。前端可以在提交前验证标题以改善体验,服务端仍必须重新验证,因为请求可能来自脚本或旧版页面。响应字段应该稳定,错误代码应与文案分离。不能因为只有自己的 Vue 页面调用,就把调用方提供的租户编号和用户身份当成已经验证的事实。
实验一:一个只读服务的完整生命周期#
环境 Node 22.22.0,无依赖。保存为 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,无需安装依赖。程序包括仓库、正文读取、路由与自动验证。请先按前面三层模型阅读函数名称,再查看每一层的具体实现。
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 路由取得列表后调用。
参考答案:完整查询契约验证
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 流程,未连接外部数据库、模型服务或生产部署环境。