错误处理与故障排查
ZenWeb 提供了一套完整的错误处理机制,从业务逻辑中的 fail() 调用到最终 HTTP 响应输出,整个过程由框架统一管理。本文档将详细介绍错误处理流程、消息代码系统、结果配置选项以及常见错误的排查方法。
错误处理流程总览
当请求进入 ZenWeb 应用后,错误处理遵循以下流程:
客户端请求
|
v
Koa 中间件 (failCatch)
|
v
路由匹配 → Controller 方法执行
|
|--- 正常返回 ----→ successWrap → ResultRender → HTTP 响应
|
|--- fail() 抛出 → ResultFail → failWrap → ResultRender → HTTP 响应
|
|--- 未捕获异常 → exposeUnexpected 判断 → ResultFail → HTTP 响应
核心流程说明:
- Koa 中间件层:
@zenweb/result模块注册的failCatch中间件在最外层捕获所有异常 - Controller 层:业务代码通过
fail()抛出ResultFail异常,或者发生未预期的Error - ResultFail 处理:中间件检测到
ResultFail实例后,设置 HTTP 状态码和X-Fail-Code头信息 - ResultRender 层:
RenderManager匹配合适的渲染器(默认JSONRender),将结果序列化为 JSON 输出
fail() 的各种调用方式
fail() 是 ZenWeb 中最常用的错误输出方法。它本质上是一个异常抛出函数,调用后代码不会继续执行,因此不需要 return。
import { Get, fail } from 'zenweb';
export class UserController {
// 方式一:仅传入错误消息字符串
@Get()
simpleError() {
fail('操作失败');
// 下面的代码不会执行
}
// 方式二:传入错误代码和消息
@Get()
codeAndMessage() {
fail(1001, '用户名已存在');
}
// 方式三:传入错误代码、消息和附加数据
@Get()
codeMessageAndData() {
fail(1002, '参数校验失败', { field: 'email', rule: 'required' });
}
// 方式四:传入完整的 ResultFailDetail 对象
@Get()
detailObject() {
fail({
code: 2001,
message: '余额不足',
data: { currentBalance: 50, required: 100 },
status: 400,
});
}
}
throw fail() 与直接调用 fail()
fail() 函数内部已经执行了 throw new ResultFail(...),所以直接调用 fail() 即可终止执行。但在某些场景下你可能需要 throw:
import { Get, fail } from 'zenweb';
export class OrderController {
@Get()
async create() {
// 直接调用 fail() — 代码立即终止,不需要 return
if (!this.validateOrder()) {
fail(3001, '订单数据无效');
}
// 在表达式中使用 throw fail()
// 这在三元表达式或箭头函数中非常有用
const user = await getUser() || throw fail('用户不存在');
// 在辅助函数中配合 throw 使用
this.checkPermission() || throw fail(4003, '无权限操作');
}
private validateOrder() {
return false;
}
private async getUser() {
return null;
}
private checkPermission() {
return false;
}
}
由于 fail() 的返回类型是 never,TypeScript 会正确推断出调用 fail() 之后的代码不可达,因此不需要额外处理返回值。
ResultFail 对象结构
当 fail() 被调用时,框架会创建一个 ResultFail 实例。该类继承自 Error,包含以下属性:
| 属性 | 类型 | 说明 |
|---|---|---|
message | string | 错误消息文本 |
code | number | 业务错误代码 |
status | number | HTTP 状态码(覆盖默认值) |
data | unknown | 附加的错误数据 |
extra | Record<string, unknown> | 额外字段,会被展开到响应顶层 |
expose | boolean | 标识为可暴露信息,默认 true |
默认输出格式(未自定义 failWrap 时):
{
"code": 1001,
"message": "用户名已存在",
"data": null
}
使用 extra 字段时,额外数据会展开到响应顶层:
fail({
code: 4001,
message: '验证失败',
extra: {
errors: [
{ field: 'username', message: '用户名不能为空' },
{ field: 'email', message: '邮箱格式不正确' },
],
},
});
输出结果:
{
"code": 4001,
"message": "验证失败",
"errors": [
{ "field": "username", "message": "用户名不能为空" },
{ "field": "email", "message": "邮箱格式不正确" }
]
}
HTTP 状态码约定
ZenWeb 对错误场景的 HTTP 状态码有以下约定:
| 场景 | 默认状态码 | 说明 |
|---|---|---|
业务错误(fail()) | 422 | 通过 failStatus 配置,可被 ResultFail.status 覆盖 |
| 未预期异常 | 500 | 通过 unexpectedStatus 配置 |
| 路由未匹配 | 404 | Koa 默认行为 |
| 正常成功响应 | 200 | 控制器正常返回 |
import { Get, fail } from 'zenweb';
export class ApiController {
@Get()
businessError() {
// HTTP 422,默认业务错误状态码
fail('操作不允许');
}
@Get()
customStatus() {
// HTTP 403,自定义状态码
fail({
code: 4003,
message: '权限不足',
status: 403,
});
}
@Get()
notFound() {
// HTTP 404,资源不存在
fail({
code: 4004,
message: '用户不存在',
status: 404,
});
}
}
message-codes.json 详细使用
消息代码系统可以将错误提示与业务代码分离,统一管理所有提示信息。
文件格式
在项目根目录创建 message-codes.json 文件:
{
"user.notfound": "用户 {id} 不存在",
"user.disabled": "用户已被禁用,请联系管理员",
"user.username.short": "用户名长度不能少于 {min} 个字符",
"user.email.duplicate": "邮箱 {email} 已被注册",
"order.insufficient_balance": "余额不足,当前余额 {balance},需要 {required}",
"order.stock_zero": "商品 {name} 库存不足",
"auth.token.expired": "登录已过期,请重新登录",
"auth.permission.denied": "没有 {resource} 的 {action} 权限",
"400": "请求参数错误",
"404": "资源不存在"
}
参数替换
消息内容中使用 {key} 占位符,调用时传入参数对象进行替换:
import { Get, Context } from 'zenweb';
export class UserController {
@Get()
async info(ctx: Context) {
const userId = ctx.query.id;
const user = await findUser(userId);
if (!user) {
// 输出:用户 123 不存在
ctx.messageCodeResolver.fail('user.notfound', { id: userId });
}
return user;
}
@Get()
async register(ctx: Context) {
const username = ctx.query.username;
if (username.length < 3) {
// 输出:用户名长度不能少于 3 个字符
ctx.messageCodeResolver.fail('user.username.short', { min: 3 });
}
}
@Get()
async updateEmail(ctx: Context) {
const email = ctx.query.email;
const exists = await checkEmailExists(email);
if (exists) {
// 输出:邮箱 test@example.com 已被注册
ctx.messageCodeResolver.fail('user.email.duplicate', { email });
}
}
}
递归匹配机制
当消息代码为字符串类型时,系统会按照 . 分隔符逐级向上查找匹配项:
// 查找 "user.email.duplicate" 时的查找顺序:
// 1. "user.email.duplicate" → 匹配
// 如果不匹配则继续查找:
// 2. "user.email" → 未配置则继续
// 3. "user" → 未配置则继续
// 4. 都未匹配,直接使用代码字符串本身作为输出
这意味着你可以配置一个通用的前缀消息:
{
"user": "用户相关错误",
"user.notfound": "用户 {id} 不存在"
}
// 精确匹配到 "user.notfound"
ctx.messageCodeResolver.fail('user.notfound', { id: 123 });
// 输出:用户 123 不存在
// 未匹配到 "user.disabled",递归匹配到 "user"
ctx.messageCodeResolver.fail('user.disabled');
// 输出:用户相关错误
format() 方法
如果只需要获取格式化后的消息而不抛出异常,可以使用 format() 方法:
import { Get, Context } from 'zenweb';
export class LogController {
@Get()
async test(ctx: Context) {
// 获取格式化后的消息字符串
const msg = ctx.messageCodeResolver.format('user.notfound', { id: 123 });
console.log(msg); // "用户 123 不存在"
// 检查消息代码是否存在
const exists = ctx.messageCodeResolver.has('user.notfound');
console.log(exists); // true
return { message: msg };
}
}
加载多个消息代码文件
通过 autoLoadFilenames 配置项可以加载多个消息代码文件,支持 .json 和 .js 格式:
import { Core } from '@zenweb/core';
import inject from '@zenweb/inject';
import result from '@zenweb/result';
import messagecode from '@zenweb/messagecode';
const app = new Core();
app.setup(inject);
app.setup(result);
app.setup(messagecode, {
// 同时加载多个消息代码文件
autoLoadFilenames: [
'./message-codes.json', // 通用消息
'./message-codes-order.json', // 订单模块消息
'./message-codes-user.json', // 用户模块消息
],
// 也可以直接在代码中定义
codes: {
'custom.error': '自定义错误消息',
},
});
.js 文件需要使用 export default 导出代码映射对象,适合需要动态生成消息的场景。
result 配置选项
@zenweb/result 模块支持丰富的配置选项,用于控制错误输出行为。
基础配置
import { Core } from '@zenweb/core';
import inject from '@zenweb/inject';
import result from '@zenweb/result';
const app = new Core();
app.setup(inject);
app.setup(result, {
// 默认业务错误代码(当 fail() 未指定 code 时使用)
failCode: -1,
// 默认业务错误 HTTP 状态码
failStatus: 422,
// 默认错误消息
failMessage: '请求处理失败',
// 是否在响应头中输出错误代码
// true → 使用默认头名称 "X-Fail-Code"
// 字符串 → 自定义头名称
// false → 不输出
failCodeHeader: true,
// 是否暴露未预期异常的详细信息
// 生产环境建议设为 false
exposeUnexpected: process.env.NODE_ENV !== 'production',
// 未预期异常的 HTTP 状态码
unexpectedStatus: 500,
// 未预期异常的错误代码
unexpectedCode: 500,
});
failCodeHeader 响应头
当启用 failCodeHeader 后,业务错误会在响应头中包含错误代码,方便前端或网关层快速判断错误类型:
// 配置
failCodeHeader: true
// 响应头
// X-Fail-Code: 1001
// 自定义头名称
failCodeHeader: 'X-Error-Code'
// 响应头
// X-Error-Code: 1001
exposeUnexpected 暴露异常详情
当控制器中抛出未预期的 Error(非 ResultFail)时:
// 开发环境 exposeUnexpected: true
// 响应内容包含错误堆栈:
{
"code": 500,
"message": "Cannot read properties of undefined (reading 'name')",
"data": {
"name": "TypeError",
"stack": ["at UserController.info (/src/controller.ts:12:8)"]
}
}
// 生产环境 exposeUnexpected: false
// 异常不会被 @zenweb/result 处理,而是继续向外抛出
// 由 Koa 或外层中间件处理
自定义错误处理
自定义 successWrap / failWrap
通过 successWrap 和 failWrap 可以完全自定义响应数据格式:
import { Core } from '@zenweb/core';
import inject from '@zenweb/inject';
import result, { ResultFail } from '@zenweb/result';
import { Context } from '@zenweb/core';
const app = new Core();
app.setup(inject);
app.setup(result, {
// 自定义成功响应格式
successWrap(ctx: Context, data: unknown) {
return {
success: true,
result: data,
timestamp: Date.now(),
};
},
// 自定义失败响应格式
failWrap(ctx: Context, err: ResultFail) {
return {
success: false,
error: {
code: err.code,
message: err.message,
details: err.data,
},
timestamp: Date.now(),
};
},
});
成功响应:
{
"success": true,
"result": "Hello World",
"timestamp": 1713936000000
}
失败响应:
{
"success": false,
"error": {
"code": 1001,
"message": "用户名已存在",
"details": null
},
"timestamp": 1713936000000
}
自定义 ResultRender
如果需要支持非 JSON 的输出格式(如 XML、HTML),可以实现自定义 ResultRender:
import { ResultRender, Context } from 'zenweb';
export class HtmlRender implements ResultRender {
// 结果需要包装
enwrap = false;
// 输出类型
type = 'html';
// 当 Accept 头包含 text/html 时匹配
async match(ctx: Context) {
return ctx.accepts('text/html') === 'text/html';
}
// 渲染 HTML 内容
async render(ctx: Context, data: unknown) {
if (typeof data === 'string') {
return data;
}
// 将错误信息渲染为 HTML 页面
return `<!DOCTYPE html>
<html>
<head><title>Error</title></head>
<body>
<h1>Error</h1>
<p>${JSON.stringify(data)}</p>
</body>
</html>`;
}
}
注册自定义渲染器:
import { Core } from '@zenweb/core';
import inject from '@zenweb/inject';
import result from '@zenweb/result';
import { HtmlRender } from './HtmlRender';
const app = new Core();
app.setup(inject);
app.setup(result, {
renders: [HtmlRender],
});
渲染器按照后注册先匹配的顺序进行匹配。JSONRender 作为内置渲染器总是最后匹配,确保所有请求都能得到响应。
常见错误排查
"模块未安装" 错误
错误现象:启动时抛出 assertModuleExists 错误,提示某个模块未安装。
Error: need setup @zenweb/inject
原因:模块之间存在依赖关系,被依赖的模块需要在依赖模块之前安装。
解决方案:确保模块安装顺序正确:
// 错误:缺少依赖模块
app.setup(result); // 报错:need setup @zenweb/inject
// 正确:先安装依赖模块
app.setup(inject);
app.setup(result);
常见的依赖关系:
| 模块 | 依赖 |
|---|---|
@zenweb/inject | @zenweb/core |
@zenweb/controller | @zenweb/router, @zenweb/inject |
@zenweb/result | @zenweb/inject |
@zenweb/messagecode | @zenweb/inject, @zenweb/result |
依赖注入失败
错误现象:启动或请求时抛出依赖注入相关异常。
Error: No matching bindings found for serviceIdentifier: ...
原因:
- 未使用
@injectable()装饰器标注服务类 - 构造函数参数无法解析
- 循环依赖
解决方案:
import { injectable, inject } from '@zenweb/inject';
// 确保添加了 @injectable 装饰器
@injectable()
export class UserService {
constructor(
// 确保参数也是可注入的
@inject(Database) private db: Database,
) {}
}
// 避免循环依赖,使用接口解耦或延迟加载
数据库连接失败
错误现象:请求时抛出数据库连接错误。
Error: connect ECONNREFUSED 127.0.0.1:3306
排查步骤:
- 检查数据库服务是否启动
- 检查
.env文件中的数据库连接配置是否正确 - 检查网络连接和防火墙规则
- 检查数据库连接池配置是否合理
# 检查环境变量
cat .env
# 测试数据库连接
mysql -h 127.0.0.1 -u root -p
路由匹配失败
错误现象:请求返回 404,但确认控制器已定义。
排查步骤:
- 检查控制器文件是否在自动发现路径中(默认
./app/controller) - 检查
@Get()装饰器是否正确配置 - 检查请求方法(GET/POST)是否匹配
import { Get, Post } from 'zenweb';
export class UserController {
// 确保使用了 @Mapping 装饰器
@Get('/user/info')
info() {
return 'ok';
}
// POST 方法需要明确指定
@Post('/user/create')
create() {
return 'ok';
}
}
Body 解析失败
错误现象:POST 请求参数解析为空或抛出解析异常。
Error: Content-Type must be application/json
排查步骤:
- 确保安装了
@zenweb/body模块 - 确保请求头包含正确的
Content-Type - 确保请求体格式与
Content-Type一致
// 前端请求示例
fetch('/api/user/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // 必须设置
},
body: JSON.stringify({ name: 'test' }),
});
生产环境建议
日志配置
生产环境中应合理配置日志级别,确保错误信息可追溯:
import { Core } from '@zenweb/core';
import inject from '@zenweb/inject';
import result from '@zenweb/result';
import log from '@zenweb/log';
const app = new Core();
app.setup(inject);
app.setup(log, {
// 生产环境使用 warn 级别以上
level: process.env.NODE_ENV === 'production' ? 'warn' : 'debug',
});
app.setup(result, {
// 生产环境关闭异常详情暴露
exposeUnexpected: false,
});
exposeUnexpected 安全配置
在生产环境中,exposeUnexpected 应始终设为 false,避免泄露内部实现细节(如文件路径、堆栈信息等):
app.setup(result, {
// 推荐:通过环境变量控制,仅在开发环境开启
exposeUnexpected: process.env.NODE_ENV !== 'production',
// 或者使用环境变量显式控制
// exposeUnexpected: process.env.EXPOSE_UNEXPECTED === '1',
});
Sentry 集成
可以通过 Koa 中间件将未捕获的异常上报到 Sentry:
import * as Sentry from '@sentry/node';
import { Core } from '@zenweb/core';
import inject from '@zenweb/inject';
import result from '@zenweb/result';
// 初始化 Sentry
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 1.0,
});
const app = new Core();
app.setup(inject);
// 在 result 之前注册 Sentry 中间件
app.app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
// 只上报未预期的异常,业务错误(ResultFail)不上报
if (!(err instanceof Error && 'expose' in err)) {
Sentry.captureException(err);
}
throw err;
}
});
app.setup(result, {
exposeUnexpected: false,
});
ResultFail 是预期的业务错误,不应上报到 Sentry。只有非 ResultFail 类型的异常才代表系统中存在问题,需要上报和修复。
健康检查与错误监控
建议为生产环境添加健康检查端点和错误监控:
import { Get, fail } from 'zenweb';
export class HealthController {
@Get('/health')
async check() {
// 检查关键依赖(如数据库连接)
try {
await checkDatabaseConnection();
} catch {
fail({
status: 503,
code: 5003,
message: '服务暂时不可用',
});
}
return { status: 'ok', timestamp: Date.now() };
}
}