后端接收客户端请求,执行授权与业务规则,读写持久数据,再通过稳定的 HTTP 契约返回结果。
能匹配路由并返回 JSON 只是起点;未经验证的输入、进程内状态和不明确的失败语义,会在并发、重试或重启时出错。
先定义请求与响应契约,再分开处理传输、领域和持久化边界,并用真实 HTTP 请求测试成功与失败路径。
是什么,为什么存在
后端(backend)是运行在用户设备之外、代表应用处理请求和状态的代码。对于 HTTP 服务,它接收方法、目标 URI、请求头和可选消息体,执行规则后返回状态码、响应头和可选消息体。部署位置可能是一台服务器、多个进程或无服务器运行时;「后端」描述职责,不限定机器形态。
客户端不应该直接拥有所有业务状态或数据库权限。价格计算、资源授权和库存扣减等规则需要一个受控边界,所有客户端都通过它得到一致结果。后端还负责把短暂的网络请求连接到寿命更长的持久数据。
对客户端而言,后端首先是一份 API 契约(API contract) 。契约不只有 JSON 字段,还包括方法语义、路径、输入限制、认证要求、状态码、相关响应头以及重试后的行为。客户端依赖的是这些可观察结果,不是服务内部用了哪种框架或数据库。
你会在网页提交表单、移动应用同步数据、命令行工具查询状态或另一个服务调用接口时遇到后端。一次交互通常跨过不可靠网络,因此超时、连接中断和重复请求是正常情况,不是边缘事故。
后端真正负责的工作
一个小型 HTTP 后端通常需要承担这些职责:
- 根据 HTTP 方法和路径选择操作,同时拒绝没有定义的组合。
- 解析并验证不可信输入,再把它转换为领域层理解的数据。
- 认证调用方,并对具体资源和操作执行授权。
- 维护持久不变量,让并发请求和进程重启不会破坏数据。
- 返回稳定响应,并记录足够信息以便诊断失败。
语言、框架和数据库只是实现这些职责的工具。刚开始学习时,先用一种语言完成一条可测试的请求路径,比同时比较多套技术栈更容易看清边界。本文使用 Node 24 的内置 HTTP API,因此示例没有框架依赖。
工作原理
HTTP 服务器把连接上的字节解析为请求对象。应用根据方法和目标选择处理器,再通过 中间件(middleware) 或显式函数完成请求标识、认证、限流和日志等横切工作。处理器验证输入、调用领域逻辑和存储,最后把结果编码成 HTTP 响应。
下面的图是责任流,不表示每个系统都必须拆成这些进程。小程序可以把路由、应用逻辑和存储适配器放在同一个代码库中,但各层仍应有清楚的输入和输出。
一次请求的生命周期
可以按下面的顺序追踪一条请求:
- 服务器读取请求行、请求头和消息体,并应用大小与时间限制。
- 请求级中间件建立请求 ID、日志上下文和经过验证的调用方身份。
- 路由器按方法和路径选择一个处理器,找不到时返回明确的
404或405。 - 处理器解析传输格式,验证字段,再调用不依赖 HTTP 对象的业务操作。
- 存储层在需要时开启事务,检查持久约束并提交状态变化。
- 响应映射把结果转换为状态码、响应头和表示,随后完成请求级清理。
每个请求都应拥有独立的 请求上下文(request context) 。请求 ID、认证主体和截止时间属于该请求,不应放进会被其他请求覆盖的模块级可变变量。数据库连接可以来自共享连接池,但事务和连接借用必须在请求或业务操作结束时释放。
契约先于处理器
在写路由代码前,先写出可观察契约。下面的表格展示一个创建任务操作至少要决定什么;具体选择可以不同,但不能把含义留给客户端猜测。
| 契约部分 | 示例决定 | 为什么客户端需要它 |
|---|---|---|
| 方法与目标 | POST /tasks | 区分创建操作与读取集合 |
| 请求媒体类型 | application/json | 决定消息体的解析方式 |
| 输入约束 | title 是长度不超过 80 的非空字符串 | 让无效请求得到稳定错误 |
| 成功响应 | 201 Created 与 Location | 表明已创建可寻址资源 |
| 失败响应 | 400、401、403、409 | 区分修正输入、认证和冲突 |
| 重试规则 | 接受调用方稳定提供的幂等键 | 避免结果未知时重复创建 |
传输验证只能证明消息形状可接受。标题是字符串,不代表调用方有权在某个项目中创建任务,也不代表该项目仍然开放。授权和领域不变量需要独立检查,持久唯一性还应由数据库约束兜底。
状态与失败边界
服务器内存适合请求期间的临时值和可重建缓存,不适合作为唯一业务记录。多个工作进程拥有不同内存,重启会清空它,两个并发写入还可能观察到不同旧值。需要在重启后存在的数据应进入持久存储。
网络断开时,客户端可能不知道服务端是否已经提交操作。这叫结果未知:没有收到响应不能证明操作没有发生。安全重试依靠明确的方法语义,或对非幂等创建操作使用 幂等性(idempotency) 协议和原子去重。
错误响应也是契约的一部分。生产服务不应把内部堆栈和数据库消息发给调用方;它应返回稳定的机器可读类别、适合用户的说明和请求 ID。 问题详情(Problem Details) 是一种标准错误表示选择,但采用它仍需定义本服务的错误类型与字段。
示例
三个示例逐步加入路由、输入验证和真实 HTTP 往返。它们都已用本地 Node 24 执行,下面的输出来自实际运行结果。
用方法与路径选择操作
第一个示例把请求看成普通数据。路由函数只做分派和响应映射,因此可以在启动网络服务器前测试接口最外层的决定。
function routeRequest(request) {
const key = `${request.method} ${request.path}`;
if (key === "GET /tasks") {
return { status: 200, body: { tasks: [] } };
}
if (key === "POST /tasks") {
return {
status: 201,
headers: { location: "/tasks/t-101" },
body: { id: "t-101", title: request.body.title },
};
}
return { status: 404, body: { error: "not_found" } };
}
const requests = [
{ method: "GET", path: "/tasks" },
{ method: "POST", path: "/tasks", body: { title: "Ship docs" } },
{ method: "GET", path: "/missing" },
];
for (const request of requests) {
console.log(JSON.stringify(routeRequest(request)));
}{"status":200,"body":{"tasks":[]}}
{"status":201,"headers":{"location":"/tasks/t-101"},"body":{"id":"t-101","title":"Ship docs"}}
{"status":404,"body":{"error":"not_found"}}GET /tasks 与 POST /tasks 是两个不同操作;只匹配路径会把它们混在一起。创建分支用 201 和 Location 表达资源已经创建,未知组合则得到 404,而不是伪装成成功响应。
这个函数尚未验证 request.body,也没有真正保存任务。把未完成的边界写得明显很重要:示例下一步补上验证,持久化则留给真实存储适配器,不能用数组冒充。
把不可信输入变成有效值
验证函数拒绝非对象消息体,检查字段类型和长度,并在成功时返回规范化值。它不依赖 HTTP,因此单元测试不需要监听端口。
function validateTask(input) {
if (input === null || typeof input !== "object" || Array.isArray(input)) {
return { ok: false, errors: ["body must be an object"] };
}
const errors = [];
if (typeof input.title !== "string" || input.title.trim() === "") {
errors.push("title must be a non-empty string");
} else if (input.title.length > 80) {
errors.push("title must be at most 80 characters");
}
if (input.priority !== undefined && !["low", "normal", "high"].includes(input.priority)) {
errors.push("priority is invalid");
}
return errors.length === 0
? { ok: true, value: { title: input.title.trim(), priority: input.priority ?? "normal" } }
: { ok: false, errors };
}
const samples = [
{ title: " Ship docs ", priority: "high" },
{ title: "", priority: "urgent" },
["Ship docs"],
];
for (const sample of samples) {
console.log(JSON.stringify(validateTask(sample)));
}{"ok":true,"value":{"title":"Ship docs","priority":"high"}}
{"ok":false,"errors":["title must be a non-empty string","priority is invalid"]}
{"ok":false,"errors":["body must be an object"]}成功结果包含后续逻辑可以信任的值,失败结果则可以映射为 400 Bad Request。用 String(input.title) 强制转换看似省事,却会把缺失值变成字符串 "undefined";严格检查能保留错误的真实含义。
长度规则还需要说明计量单位。JavaScript 的 string.length 计算 UTF-16 代码单元,不等同于用户看到的字符数,也不等同于 UTF-8 字节数。这里的 80 是示例契约;真实服务必须让 UI、API 和数据库采用一致且经过测试的规则。
运行一个真实 HTTP 往返
最后的示例让操作系统分配临时端口,再用 Node 的 fetch() 发起请求。sendJson() 集中设置媒体类型和编码,处理器则保证成功与失败响应都带同一个请求 ID。
const { createServer } = require("node:http");
function sendJson(response, status, body) {
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify(body));
}
const server = createServer((request, response) => {
const requestId = "req-7";
if (request.method === "GET" && request.url === "/health") {
sendJson(response, 200, { status: "ok", requestId });
return;
}
sendJson(response, 404, { error: "not_found", requestId });
});
server.listen(0, "127.0.0.1", async () => {
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
for (const path of ["/health", "/missing"]) {
const response = await fetch(`${baseUrl}${path}`);
console.log(response.status, await response.text());
}
server.close();
});200 {"status":"ok","requestId":"req-7"}
404 {"error":"not_found","requestId":"req-7"}监听端口 0 可避免在测试中硬编码一个可能被占用的端口。应用读取 server.address() 获得实际地址,然后在结束时关闭服务器;生产进程还需要停止接收新请求、等待在途工作并设置强制退出期限。
健康端点只说明这个进程能处理该请求。它不自动证明数据库可写、队列正常或某项业务就绪。把存活检查与就绪检查分开,并让负载均衡器使用符合部署语义的那个端点。
陷阱
把请求体直接当成领域数据
**修复:**按操作建立允许字段列表,严格解析类型和边界,再从经过认证的身份与服务端状态补充受保护字段。数据库约束负责最终持久不变量,应用验证负责尽早返回有用错误。
把进程内数组当成数据库
**修复:**明确哪些状态允许丢失,哪些必须持久化。把唯一性、外键和原子更新交给支持这些保证的存储,在应用层捕获并映射约束冲突。
在提交完成前返回成功
**修复:**先定义提交点和结果未知时的重试协议。需要同时保存记录并发布事件时,可在一个事务中写入业务记录和 outbox,再由独立发布者处理事件。
用一个状态码掩盖所有结果
**修复:**为每类可观察结果定义少量稳定状态码和错误类型。保留 500 给未预期的服务端失败,记录内部原因,但不要把堆栈、SQL 或秘密写进外部响应。
先堆中间件,后确认顺序
**修复:**画出进入与返回顺序,记录每个中间件读取和产生的上下文字段。对每条受保护路由发送无凭据、无权限和有效凭据请求,不能只检查全局配置看起来正确。
让健康检查执行有副作用的工作
**修复:**存活检查保持便宜且无业务副作用。就绪检查只验证接流量所需的依赖,并为每项探测设置短期限;详细诊断放在受保护的运维接口中。
一个端点背后的边界
一个处理器很容易长成解析、授权、SQL、第三方调用和响应格式混在一起的大函数。按边界拆分不是为了增加文件数量,而是让每一层只接受它能够判断的数据。边界越明确,测试越能定位是哪项契约失败。
传输边界
传输层只处理 HTTP 可观察信息:方法、路径参数、查询参数、请求头、媒体类型、消息体和状态码。它把不可信字节转换为有类型的命令,也把应用结果转换为响应。业务函数不需要知道响应对象如何写入 socket。
解析必须有上限和失败路径。服务应在读取完整消息体前限制允许大小,为慢速输入设置超时,并区分不支持的媒体类型、畸形 JSON 和字段验证失败。框架的默认值可能方便开发,但它不是你的公开契约。
领域边界
领域层决定操作在业务上是否允许。它接收经过传输验证的值和可信主体,检查资源所有权、状态转换与跨字段规则。例如,合法字符串形式的任务标题仍可能因为项目已归档而被拒绝。
领域错误应是应用能识别的结果,而不是解析数据库错误文本。ProjectArchived、TaskAlreadyExists 这类内部类别可以稳定映射到 HTTP;存储适配器更换后,外部错误含义不应跟着改变。
持久化边界
存储层实现查询、事务和持久约束。它不应接收完整 HTTP 请求,也不应自行决定当前调用方是否有业务权限。接口用领域含义命名,比到处暴露任意 SQL 字符串更容易审查。
| 边界 | 接受的输入 | 产出的结果 | 不应自行决定 |
|---|---|---|---|
| 传输 | HTTP 请求字节与元数据 | 已解析命令或协议错误 | 资源是否允许改变 |
| 领域 | 已验证命令、主体与当前状态 | 领域结果或领域错误 | HTTP 序列化细节 |
| 持久化 | 查询条件与原子写入意图 | 数据或约束冲突 | 调用方身份是否可信 |
这些边界不要求三层都变成类。小型服务可以用三个普通函数表达它们,前提是参数和返回值明确。先保留清楚的依赖方向,等重复出现后再抽象共享接口。
请求作用域与并发
Node 可以在一个进程中交错处理多个请求。即使 JavaScript 回调不同时执行,await 仍会让另一个请求在共享变量被读取后、写回前继续运行。模块级 currentUser 或 currentRequestId 因此会串到别的请求中。
把请求数据作为参数显式传递最容易推理。需要跨多层读取上下文时,可以使用运行时提供的异步上下文机制,但仍要限制内容,并在后台工作开始前提取可序列化值。请求对象、响应对象和打开的事务不应跨越请求生命周期。
并发正确性最终常落在存储层。SELECT 后在应用中判断「不存在」,再执行 INSERT,不能阻止另一个请求在两步之间插入同一个键。唯一约束、条件更新或适当隔离级别才可以把检查与写入变成可执行保证。
提交点与响应
提交点是操作从「可以回滚」变成「其他请求可以依赖」的位置。对单个数据库事务,它通常是成功提交;对跨系统工作,则需要更明确的协议。发送 HTTP 响应本身不能让两个系统原子提交。
如果事务失败,处理器可以返回已定义错误;如果提交成功后连接断开,服务端不能把提交撤回,客户端也看不到响应。对创建操作使用调用方稳定提供的幂等键时,服务应把键、请求指纹和结果在同一原子边界保存。
相同键配合不同请求内容应被拒绝,而不是悄悄复用旧结果。键还要有主体或租户作用域,否则一个调用方可能碰撞另一个调用方的操作。保留期限必须覆盖客户端可能重试的窗口,并写入公开契约。
从第一个服务走向生产
学习路径应沿着一个服务增加可验证能力,而不是每周更换框架。一个合理顺序如下:
- 用纯函数定义方法、路径、输入和响应,再测试成功与失败映射。
- 启动本地 HTTP 服务器,通过真实客户端验证状态码、响应头和序列化结果。
- 接入关系数据库,用迁移、约束和事务替换进程内业务状态。
- 加入认证与资源级授权,并对每个受保护操作测试拒绝路径。
- 添加结构化日志、指标、超时和优雅关闭,观察故障而不是猜测。
- 在契约测试通过后部署,再练习回滚、兼容变更和数据恢复。
每一步都应保留上一层的测试。更换框架时,HTTP 契约和领域用例应该继续成立;如果所有测试都只能通过框架内部对象调用,迁移会掩盖真正的外部行为变化。
可观测性不是打印所有内容
请求 ID 可以把入口日志、数据库错误和下游调用串在一起,但它不证明调用方身份,也不应替代追踪上下文。服务要接受或生成符合信任策略的标识,并确保响应和日志使用同一个值。
日志记录事件类别、持续时间、结果和必要资源标识即可。密码、授权头、会话 cookie、完整请求体和数据库连接串通常不应出现。对用户提供的换行与控制字符还要编码,避免伪造日志条目。
指标回答聚合问题,例如某类状态码的数量和操作耗时分布。不要把用户 ID 或请求 ID 当成指标标签,否则高基数会使存储和查询成本失控。需要逐请求细节时,用日志或追踪。
用不同测试回答不同问题
纯函数单元测试适合验证解析、领域规则和结果映射。它们运行快,并能覆盖很多边界值,但不会发现实际服务器省略响应头、错误解码路径参数或中间件顺序错误。
HTTP 集成测试通过监听中的应用发送请求,验证完整交换。它应检查状态码、相关响应头、媒体类型和消息体,而不只调用 response.json() 后看一个字段。测试结束时还要关闭服务器和连接池。
数据库集成测试应使用与生产相同的数据库引擎和迁移。内存替身可以帮助单元测试,但无法证明隔离级别、约束、排序和数据库特有类型的行为。并发测试需要真正重叠事务,而不是顺序调用两次函数。
| 测试层 | 最适合证明 | 不能单独证明 |
|---|---|---|
| 单元测试 | 输入边界和领域分支 | HTTP 编码与真实事务 |
| HTTP 集成测试 | 路由、中间件和外部契约 | 生产代理与网络策略 |
| 数据库集成测试 | 迁移、约束和事务行为 | 完整客户端兼容性 |
| 部署探测 | 启动、就绪和关闭路径 | 所有业务不变量 |
生产前还要做一项失败测试:在存储调用、下游调用和响应发送之间注入错误。只测顺利路径会漏掉未释放连接、部分写入和重复副作用,而这些问题往往只在超时或重试时出现。
选择下一主题
完成一个带持久化和测试的小型服务后,再深入 API 设计、数据库设计、后端安全和后端测试会更有效。这些主题分别扩展契约、状态模型、信任边界和验证证据,仍然围绕同一条请求路径。
缓存、消息队列和微服务应该由已观察到的需求驱动。缓存引入失效规则,队列引入投递与去重语义,服务拆分引入网络失败;在单体边界尚未清楚时提前加入它们,只会增加无法解释的状态。
4个问题 · 1 道输出预测题 · 1 道找错题