nest.js / hono.js 一起学!日志功能/统一返回格式/错误处理

前言

在继续搭建 NestJSHono.js 的通用框架之前,可以先回顾一下前几篇文章:

本篇文章主要实现 NestJS 与 Hono.js 的以下功能:

  1. 打印日志 & 收集日志
  2. 统一返回前端的数据格式
  3. 统一错误处理

这些功能在很多 NestJS 教程中都有覆盖,但我们会在此基础上做一些增强:

  • 日志打印增加 traceId 功能

    • 在 Node.js 高并发场景下,每个请求的 traceId 必须独立 ,因为它是单线程异步 模型,如果我们像写传统同步代码那样直接定义一个全局变量来存 traceId,就会发生严重的"串号"现象。

案例:

假设我们有一个 Web 服务,同时收到两个用户请求:

javascript 复制代码
let traceId; // 全局变量

function handleRequest(user) {
  traceId = `trace-${Math.floor(Math.random() * 1000)}`; // 给当前请求生成 traceId
  console.log(`[${traceId}] 开始处理 ${user} 的请求`);

  setTimeout(() => {
    // 异步操作,可能晚于其他请求执行
    console.log(`[${traceId}] 完成处理 ${user} 的请求`);
  }, Math.random() * 1000);
}

// 模拟两个用户几乎同时发起请求
handleRequest('Alice');
handleRequest('Bob');

可能的输出(串号)

csharp 复制代码
[trace-532] 开始处理 Alice 的请求
[trace-764] 开始处理 Bob 的请求
[trace-764] 完成处理 Alice 的请求   ← 错误,日志 traceId 被覆盖,因为全局目前的 traceId 已经是 trace-764 了
[trace-764] 完成处理 Bob 的请求

以上的问题有些笨办法可以处理,但我们最终会借助 node.js AsyncLocalStorage(在 nest.js 有对应模块,简单实现,在 hono.js 中我们自己封装一个类似功能的模块。)

还有一个必须了解的,这个 traceId 是服务端必须有的,因为我们的处理一个用户请求会经过很多中间件很多不同的模块处理,如何判断经过这么多模块的某个请求是同一个请求,就要用这个 traceId 来记录。

  • 统一返回前端的数据格式,例如,我们希望所有接口返回:

    css 复制代码
    ```
    {
      "code": 0,
      "data": {...},
      "message": "success"
    }
    ```
    • 可选配置:某些路由可以跳过统一格式返回。

接下来,我们将先从 nest.js 框架开始,逐步实现这些功能。

nest.js 配置

链路日志追踪功能

这套系统的灵魂在于 nestjs-clsWinston 的结合。

为什么要这么做?

在 Node.js 异步环境中,传统的全局变量无法区分哪个日志属于哪个用户请求。

  • ClsModule (上下文存储) :就像给每个进站的乘客(请求)发一个专属的"透明信封"。这个信封里装着 traceId
  • Winston (记录员) :当程序需要记录日志时,记录员会从这个"信封"里掏出 traceId 盖在日志条目上。

这样,即便服务器同时处理 1000 个请求,你只需要在日志里搜索特定的 traceId,就能看到该请求从"进入"到"报错"的所有心路历程。


代码功能深度拆解

我们将代码逻辑分为三个核心模块来理解:

第一部分:CLS 上下文初始化 (AppModule)

这是全链路追踪的起点。在根 module 配置,也就是 app.module.ts中增加:

TypeScript 复制代码
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClsModule } from 'nestjs-cls';
import { randomUUID } from 'crypto';

@Module({
  imports: [
    // 之前文章讲的环境变量配置模块
    ConfigModule.forRoot({
      isGlobal: true, // 全局可用,无需在每个模块导入
      load: [initConfig], // 使用自定义加载器
    }),
    // 1. 初始化 CLS 上下文,并自动生成 traceId
    ClsModule.forRoot({
      global: true,
      middleware: {
        mount: true,
        generateId: true,
        idGenerator: (req: any) =>
          (req.headers['x-request-id'] as string) ?? randomUUID(), // 优先使用 header 中的 ID
      },
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

通过优先读取 x-request-id,我们可以实现跨系统的全链路追踪(如果你的前端或上游网关也带了这个 ID,就能实现真正的端到端打通)。

这里我们简单说下 ClsModule 实现的基本原理:

我们分三步来实现:

  • 第一步:封装 ALS 工具类

这是我们的"储物柜"管理器。

TypeScript 复制代码
// cls.service.ts
import { AsyncLocalStorage } from 'async_hooks';

export class MyClsService {
  // 1. 创建一个物理存储对象
  private static storage = new AsyncLocalStorage<Map<string, any>>();

  // 2. 启动上下文(包裹请求)
  static run(callback: () => void) {
    const store = new Map();
    return this.storage.run(store, callback);
  }

  // 3. 存储数据
  static set(key: string, value: any) {
    const store = this.storage.getStore();
    if (store) store.set(key, value);
  }

  // 4. 获取数据
  static get(key: string) {
    const store = this.storage.getStore();
    return store?.get(key);
  }
}
  • 编写中间件 (Middleware)

在 NestJS 中,中间件是请求进来的第一站,我们在这里"开启"储物柜。

TypeScript 复制代码
// cls.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { MyClsService } from './cls.service';

@Injectable()
export class MyClsMiddleware implements NestMiddleware {
  use(req: any, res: any, next: () => void) {
    // 关键点:将后续所有的逻辑(next)都运行在 MyClsService.run 的回调里
    MyClsService.run(() => {
      const traceId = req.headers['x-request-id'] || randomUUID();
      MyClsService.set('traceId', traceId); // 存入 traceId
      next();
    });
  }
}

这个中间件添加之后,意味着所有的请求都会有一个 traceId 值,那在其它地方如何调用呢?请看

  • 第三步:在任何地方使用

由于 AsyncLocalStorage 跟踪的是异步调用栈,你不再需要通过参数传递 traceId

TypeScript 复制代码
// any.service.ts
@Injectable()
export class AnyService {
  doSomething() {
    // 像变魔术一样,直接拿!
    const traceId = MyClsService.get('traceId');
    console.log(`[当前任务] TraceID 是: ${traceId}`);
  }
}

我们接着讲日志功能,

第二部分:Winston 动态工厂 (common/logger)

这里体现了环境差异化处理的思想。

特性 开发环境 (Development) 生产环境 (Production)
输出目标 只有控制台 (Console) 控制台 + 每日滚动文件 (Daily File)
展示样式 漂亮的彩色、缩进、Nest 风格 严谨的 JSON 格式(方便日志分析系统 ELK 抓取)
日志保留 随启随看 保留 14 天,单文件 20MB(防止硬盘爆掉)

第三部分:异步注入 (WinstonModule.forRootAsync)

这是 NestJS 的高级用法,确保日志配置是在获取到系统配置(ConfigService)之后才生成的。

在 app.module.ts 中增加 winston配置

TypeScript 复制代码
import { WinstonModule } from 'nest-winston';
import { winstonConfigFactory } from './common/logger';

@Module({
  imports: [
     // ... 其它配置
    // 2. 注入 Winston
    // 3. 使用 forRootAsync 异步加载配置
    WinstonModule.forRootAsync({
      imports: [ConfigModule], // 导入 ConfigModule 以便使用 ConfigService
      inject: [ConfigService], // 注入 ConfigService
      useFactory: (configService: ConfigService) => {
        // 调用我们抽离出去的配置函数
        return winstonConfigFactory(configService);
      },
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

其中 winstonConfigFactory 的代码如下:

javascript 复制代码
import winston, { transports, format } from 'winston';
import 'winston-daily-rotate-file';
import { ConfigService } from '@nestjs/config';
import { utilities as nestWinstonModuleUtilities } from 'nest-winston';
import { ClsServiceManager } from 'nestjs-cls';
import { DEFAULT_LOG_DIR, DIR_NAME } from 'src/config/constants';

// 保持之前的 format 定义不变
const appendRequestId = format((info) => {
  const cls = ClsServiceManager.getClsService();
  const traceId = cls?.get<string>('traceId');
  if (traceId) {
    info.traceId = traceId;
  }
  return info;
});

// 核心修改:导出一个 Factory 函数,而不是静态对象
export const winstonConfigFactory = (configService: ConfigService) => {
  // 1. 从 ConfigService 获取配置,提供默认值
  const logDir = configService.get<string>(DIR_NAME, DEFAULT_LOG_DIR);
  const isProduction = configService.get<string>('NODE_ENV') === 'production';

  return {
    transports: isProduction
      ? [
          // 2. 定义 DailyRotateFile (使用动态路径)
          // 错误日志按天滚动,保留 14 天,每个文件最大 20MB
          // 仍然把错误打印到控制台(k8s / docker 日志)
          new winston.transports.Console({
            level: 'error',
            format: winston.format.combine(
              winston.format.colorize(),
              winston.format.printf((info: any) => {
                const trace = info.traceId ? ` [trace:${info.traceId}]` : '';
                return `${info.timestamp} ${info.level}${trace}: ${info.message}`;
              }),
            ),
          }),
          new transports.DailyRotateFile({
            dirname: logDir, // <--- 这里使用了配置中的路径
            filename: 'error-%DATE%.log',
            datePattern: 'YYYY-MM-DD',
            maxSize: '20m',
            maxFiles: '14d',
            level: 'error',
            format: format.combine(
              appendRequestId(),
              format.timestamp(),
              format.json(),
            ),
          }),
        ]
      : [
          // 3. 定义 Console Transport
          // 开发环境通常只需要 Console,也可以按需加 File
          new transports.Console({
            level: 'info',
            format: format.combine(
              format.timestamp(),
              nestWinstonModuleUtilities.format.nestLike('MyApp', {
                colors: true,
                prettyPrint: true,
              }),
            ),
          }),
        ],
  };
};

注意,上面的例子,我们后续例如单机使用 docker-compose 部署也好,还是使用k8s/k3s部署也好,就不需要使用winston本身的 DailyRotateFile 功能了,可以借助 docker 或者 k8s/k3s 本身的日志轮转功能实现

为了让这套系统更完美,在实际应用中,你可以这样使用:

如何在 Service 中打印带 TraceId 的日志?

这里使用 this.logger.log 的时候,你不需要手动去取 ID,因为我们在 winstonConfigFactory 里的 appendRequestId 已经自动处理了。所以会自带 traceId 属性在日志里。

TypeScript 复制代码
import { Logger, Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  private readonly logger = new Logger(AppService.name);

  doSomething() {
    this.logger.log('这是一条带有 traceId 的业务日志!'); 
    // 输出结果会自动带上: {"traceId":"...", "message":"...", "timestamp":"..."}
  }
}

接下来实现第二个功能,将返回前端的格式统一

统一返回格式

统一格式,在 nest.js 中有拦截器实现(hono.js就没有拦截器的概念),很简单:

nest.js 统一返回格式:用拦截器实现标准响应结构

在前后端分离的业务架构中,统一接口返回格式 已成为标配:

无论后端返回什么,都应该在一层标准的格式中输出,例如:

typescript 复制代码
{
  "code": 0,
  "message": "OK",
  "data": {...},
  "traceId": "xxx"
}

这样做有几个直接收益:

  1. 前端更容易做异常和展示逻辑,不需要每个接口单独处理。
  2. 服务端可以统一标记 traceId,方便链路排查问题。
  3. 协议一致后,可快速扩展错误码体系。

在 nest.js 中,我们可以使用 Interceptors(拦截器) 来完成这一目标。下面是一段完整的拦截器代码。

拦截器核心代码示例

typescript 复制代码
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { map } from 'rxjs/operators';
import { ClsService } from 'nestjs-cls';
import { ErrorCodes } from '../constants';

export interface StandardResponse<T> {
  code: number;
  message: string;
  data: T;
  traceId?: string;
}

@Injectable()
export class StandardResponseInterceptor<T> implements NestInterceptor<
  T,
  StandardResponse<T>
> {
  constructor(private readonly cls: ClsService) {}

  intercept(context: ExecutionContext, next: CallHandler) {
    if (context.getType() !== 'http') {
      return next.handle();
    }

    const handler = context.getHandler();
    if (Reflect.getMetadata('skip_transform', handler)) {
      return next.handle();
    }

    const traceId = this.cls.getId();

    return next.handle().pipe(
      map((data) => ({
        code: ErrorCodes.SUCCESS,
        message: 'OK',
        data,
        traceId,
      })),
    );
  }
}

说明:

  • ClsService 用于获取当前请求的 traceId
  • map() 包裹所有返回内容
  • 拦截器只在 HTTP 请求上执行(GraphQL、Microservice 可以忽略)
  • 可通过自定义 Decorator 跳过包装

如何注册拦截器(全局启用)

main.ts 中:

typescript 复制代码
app.useGlobalInterceptors(new StandardResponseInterceptor(app.get(ClsService)));

这样所有路由默认都统一格式输出。

某些接口不想被统一格式怎么办?

例如导出文件等特殊接口,不希望包裹。

你可以加自定义装饰器:

typescript 复制代码
import 'reflect-metadata';

export const SkipTransform = () => SetMetadata('skip_transform', true);

使用方式:

typescript 复制代码
@Get('upload')
@SkipTransform()
getCaptcha() {
  return someRawBinary;
}

当前路由会跳过统一包裹。

实际效果展示

原本控制器返回:

typescript 复制代码
@Get('user')
getUser() {
  return { id: 1, name: 'Tom' };
}

最终前端收到:

typescript 复制代码
{
  "code": 0,
  "message": "OK",
  "data": {
    "id": 1,
    "name": "Tom"
  },
  "traceId": "abc-123-xyz"
}

通用错误处理

在后端系统中,错误处理往往经历三个阶段:

  1. 能跑(框架兜底)
  2. 能返回(让前端知道错哪里)
  3. 能排障(日志链路、定位效率)

nestjs 的 ExceptionFilter 为第三阶段提供了完整治理能力。本节通过自定义 AllExceptionsFilter,从"拦截错误"开始,一层层增强错误输出、业务语义、日志能力和 traceId 支持。

第一层:拦截所有异常,而不是依赖框架默认行为

默认情况下,框架会尝试处理异常,但格式、内容和处理方式不可控。

通过 @Catch() 拦截全场景错误,我们将接管所有异常通道:

typescript 复制代码
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {

借助 ArgumentsHost,我们拿到 HTTP 请求上下文,后续就能构造专属响应:

typescript 复制代码
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();

此时的目标是"掌控入口"。

第二层:区分 HTTP 状态码,让协议正确表达成功或失败

框架层能力拦截之后,下一层诉求是协议语义正确。

对客户端返回的 HTTP Status 必须准确,否则代理、浏览器、监控系统均无法判断请求情况:

typescript 复制代码
const httpStatus =
  exception instanceof HttpException
    ? exception.getStatus()
    : HttpStatus.INTERNAL_SERVER_ERROR;

简单讲:

  • 可预期的业务错误维持 4xx
  • 其它错误不想让前端知道具体错误,直接返回 INTERNAL_SERVER_ERROR

这实现了"系统对外的协议治理"。

第三层:建立业务错误码体系,与协议语义解耦

HTTP Status 永远不适合作为业务判断依据。

因此体系化项目会分离"协议错误码"和"业务错误码"。这一步实现业务语义治理:

typescript 复制代码
let businessCode: number;
if (httpStatus >= 400 && httpStatus < 500) {
  businessCode = ErrorCodes.BUSINESS_ERROR;
} else {
  businessCode = ErrorCodes.SYSTEM_ERROR;
}

从此,前端不再需要看 400/500 决策,而是看业务码 1000/5000 做逻辑分支(如果需要有业务错误码的话,一般可以不加业务状态码)。

第四层:控制返回给用户的 message,不泄漏内部实现

继续向下走,我们需要控制"用户可见信息"。

对于 HttpException,返回异常信息安全可控;

而普通异常有堆栈、内部错误,不可直给客户端:

typescript 复制代码
let userMessage: string;
if (exception instanceof HttpException) {
  const exceptionResponse = exception.getResponse() as any;
  userMessage = exceptionResponse.message || exception.message;
} else {
  userMessage = 'Server Internal Error';
}

第五层:注入 traceId,将错误纳入链路追踪体系

协议治理与安全治理之后,我们需要可观测性。

依赖 ClsService 获取 traceId,使任意异常都能与一次请求绑定:

typescript 复制代码
const traceId = this.cls.getId();

这让后续日志检索有了线索,从"出错"变为"定位在哪个请求出错"。


第六层:构造统一错误返回体,让前端接收结构稳定

业务响应格式统一之后,客户端解析与 UI 逻辑才能标准化:

typescript 复制代码
const finalClientResponse = {
  code: businessCode,
  message: userMessage,
  traceId,
  error: {
    timestamp: new Date().toISOString(),
    path: request.url,
    method: request.method,
    httpStatus,
  },
};

这一层,从"不固定字段"变为"返回契约统一"。


第七层:按严重程度结构化日志,结合 traceId 可查可追

有了 traceId 后,日志才具备上下文意义。

对非预期错误记录堆栈并升为 error,对客户端调用错误降级为 warn

typescript 复制代码
if (httpStatus >= 500) {
  this.logger.error(`[${httpStatus}] Unhandled Exception: ${userMessage}`, {
    clientResponse: finalClientResponse,
    stack: (exception as Error).stack,
    traceId,
  });
} else {
  this.logger.warn(`[${httpStatus}] Client Error: ${userMessage}`, {
    clientResponse: finalClientResponse,
    traceId,
  });
}

此处是"日志治理 + 可观测性治理"。


第八层:保持 HTTP 状态原样返回,确保协议链路正常识别

最终输出必须保留协议语义,否则监控、负载均衡、浏览器都将错误判断请求:

typescript 复制代码
response.status(httpStatus).json(finalClientResponse);

接下来就可以在 main.ts 中使用这个全局错误管理器了。

typescript 复制代码
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ClsService } from 'nestjs-cls';
import { AllExceptionsFilter } from './common/filters/http-exception.filter';
import { type NestExpressApplication } from '@nestjs/platform-express';
import { type LoggerService } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule, {
    bufferLogs: true, // 先缓存日志,直到 Logger 替换完成
  });

  const configService = app.get(ConfigService);

  // 1. 替换 Nest 默认 Logger 为 Winston
  const logger: LoggerService = app.get(WINSTON_MODULE_NEST_PROVIDER);

  app.useLogger(logger);

  // 获取 CLS 服务实例
  const clsService = app.get(ClsService);

  // 4. 注册全局异常过滤器:捕获所有异常并打印日志 + 返回统一结构
  app.useGlobalFilters(new AllExceptionsFilter(clsService, logger));

  const port = configService.get<number>('port') ?? 3000;

  await app.listen(port, () => {
    logger.log(`Server on :${port}`);
  });
}
bootstrap();

honojs 配置

第一部分:链路日志追踪功能

首先我们封装记录 traceId 的功能,我们通过编写一个中间件来实现,这个中间件是所有请求过来,经过的第一步,这样所有请求就带了 traceId,具体实现如下:

首先我们要创建一个 AsyncLocalStorage 实例,让后续的中间件调用其 run 方法,为每个请求创建独立的上下文。

typescript 复制代码
import { AsyncLocalStorage } from "node:async_hooks";

export interface RequestContext {
  traceId: string;
}

// 初始化全局存储
export const ctx = new AsyncLocalStorage<RequestContext>();

// 封装一个获取 traceId 的快捷方法
export const getTraceId = (): string | undefined => {
  const store = ctx.getStore();
  return store?.traceId;
};

以下是 middleware 的实现

javascript 复制代码
import { createMiddleware } from "hono/factory";
import { ctx } from "../context"; // 导入您的 ctx

// 确保在 Node.js 环境下使用
import { randomUUID } from "node:crypto";

export const traceMiddleware = createMiddleware(async (c, next) => {
  // 1. 生成唯一的 Trace ID
    const traceId = c.req.header("x-request-id") || randomUUID();

  // 2. 将 Trace ID 存储在 AsyncLocalStorage 中,并运行后续的处理链
  await ctx.run({ traceId }, async () => {
    // 3. (可选) 记录请求开始日志
    // logger.info(`Request started: ${c.req.method} ${c.req.url}`, {
    //   traceId: getTraceId(),
    // });

    // 4. 继续处理链
    await next();

    // 5. (可选) 设置响应头
    c.res.headers.set("X-Trace-ID", traceId);

    // // 6. (可选) 记录请求结束日志
    // logger.info(`Request finished: ${c.req.method} ${c.req.url}`, {
    //   traceId: getTraceId(),
    //   status: c.res.status,
    // });
  });
});

通过优先读取 x-request-id,我们可以实现跨系统的全链路追踪(如果你的前端或上游网关也带了这个 ID,就能实现真正的端到端打通)。

主要代码就是 ctx.run({ traceId }, async () => { xxx }), 将后所有内容,包含在 AsyncLocalStorage 上下文中,也就是 traceId 会伴随整个请求的生命周期。

第二部分:Winston 动态工厂 (common/logger)

这里体现了环境差异化处理的思想。

特性 开发环境 (Development) 生产环境 (Production)
输出目标 只有控制台 (Console) 控制台 + 每日滚动文件 (Daily File)
展示样式 漂亮的彩色、缩进、Nest 风格 严谨的 JSON 格式(方便日志分析系统 ELK 抓取)
日志保留 随启随看 保留 14 天,单文件 20MB(防止硬盘爆掉)

以上的功能,我们封装到一个 logger.ts 中,代码如下:

javascript 复制代码
import { format, transports, createLogger } from "winston";
import "winston-daily-rotate-file";
import { getTraceId } from "../context";
import { PROD_ENV } from "../../../constant";

// 1. 自定义 Format:自动从 ALS 获取 Trace ID
const appendTraceId = format((info) => {
  // 使用 ctx.getStore() 获得
  const traceId = getTraceId();
  if (!traceId) {
    info.traceId = traceId;
  }
  return info;
});

/// 2. 基础配置
const logFormat = format.combine(
  appendTraceId(),
  format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
  format.errors({ stack: true }), // 自动捕获 error stack
  format.json()
);

const devTransport = new transports.Console({
  level: "info",
  format: format.combine(
    format.colorize(),
    format.printf(({ timestamp, level, message, traceId, stack }) => {
      const tid = traceId ? `[Trace: ${traceId}]` : "";
      const output = `${timestamp} ${level} ${tid}: ${message}`;
      return stack ? `${output}\n${stack}` : output;
    })
  ),
});

// 导出 Logger 实例
export const logger = createLogger({
  format: logFormat,
  transports:
    process.env.NODE_ENV === PROD_ENV
      ? // 生产环境 Transports
        [
          new transports.DailyRotateFile({
            filename: "logs/error-%DATE%.log",
            datePattern: "YYYY-MM-DD",
            level: "error",
            maxSize: "20m",
            maxFiles: "14d",
          }),
          new transports.Console({
            level: "error",
            format: format.combine(
              format.colorize(),
              format.printf(({ timestamp, level, message, traceId, stack }) => {
                const tid = traceId ? `[Trace: ${traceId}]` : "";
                const output = `${timestamp} ${level} ${tid}: ${message}`;
                return stack ? `${output}\n${stack}` : output;
              })
            ),
          }),
        ]
      : // 开发环境 Transport
        [devTransport],
});

注意,上面的例子,我们后续例如单机使用 docker-compose 部署也好,还是使用k8s/k3s部署也好,就不需要使用winston本身的 DailyRotateFile 功能了,可以借助 docker 或者 k8s/k3s 本身的日志轮转功能实现

如何在 Service 中打印带 TraceId 的日志?

如下,我们就可以在路由中使用 logger 功能,就会自带 traceId 了

TypeScript 复制代码
import { Hono } from "hono";
import { logger } from "./common/logger";
import { standardResponse } from "./utils";
import { getTraceId } from "./context";

const app = new Hono();

app.get("/users", async (c) => {
  logger.info("开始处理 /users 请求");

  const list = [{ id: 1, name: "Tom" }];

  logger.info(`查询用户数量=${list.length}`);

  return standardResponse(c, { list });
});

访问结果输出(示例):

yaml 复制代码
2025-01-05 22:11:01 info [Trace: 099f42...] 开始处理 /users 请求
2025-01-05 22:11:01 info [Trace: 099f42...] 查询用户数量=1

在 hono.js 中封装统一返回格式

与 nest.js 不同,hono.js 没有 Interceptor 的概念,因此无法在框架层自动包装所有路由的返回值。hono 更偏向极简设计:框架提供 Context(c)与响应方法(如 c.json()),开发者通过 middleware 或工具函数扩展能力。

在这种模式下,一个常见策略是:在 utils 层封装一个标准响应函数,每当路由处理完成业务逻辑后,调用该函数输出标准格式。示例如下:

typescript 复制代码
// utils/response.ts
import { type Context } from "hono";
import { getTraceId } from "../common/context";
import { CODES } from "../common/constants/codes";
import type { ContentfulStatusCode } from "hono/utils/http-status";

const SUCCESS_MESSAGE = "OK";

export const standardResponse = <T>(
  c: Context,
  data: T,
  httpStatus: ContentfulStatusCode = 200
): Response => {
  const traceId = getTraceId();

  const finalResponse = {
    code: CODES.SUCCESS,
    message: SUCCESS_MESSAGE,
    data,
    traceId,
  };
  
   // 使用 c.json() 返回 Response
  // c.json() 负责设置 Content-Type: application/json
  return c.json(finalResponse, httpStatus);
};

这一函数实现了几个能力:

  1. 统一业务结构:code / message / data 统一格式化。
  2. 链路可观测性:自动从 ALS 中获取 traceId,可跨路由跟踪调用链路。
  3. 控制协议行为:可指定 HTTP status,而不影响业务 code。
  4. 无框架侵入性:不需要全局 patch,路由自由决定是否使用。

实际使用方式非常直接:在路由处理函数中调用该函数包裹输出即可:

typescript 复制代码
import { standardResponse } from "./utils/response";

app.get("/user", (c) => {
  const user = { id: 1, name: "Tom" };
  return standardResponse(c, user);
});

输出结构:

typescript 复制代码
{
  "code": 0,
  "message": "OK",
  "data": {
    "id": 1,
    "name": "Tom"
  },
  "traceId": "abc-123-xyz"
}

错误处理

hono.js 并没有 nest.js 中那样的 exception(异常)处理器,但 hono 官方提供了两个方法来处理全局异常。

  • Error Handling 回调函数
  • Not Found 回调函数

其中 app.onError 允许我们处理未捕获的错误并返回自定义响应。例如

typescript 复制代码
const app = new Hono();
app.onError((err, c) => {
  console.error(`${err}`);
  return c.text("Custom Error Message", 500);
});

接下来我们实现一个生产级别的全局错误处理函数,代替上面的 onError 中的函数:

阶段一:只实现兜底,确保不会爆栈到框架

目标:任何异常都返回 500,避免框架直接输出默认错误页面。

typescript 复制代码
export const customErrorHandler = (exception: unknown, c: Context) => {
  return c.json(
    {
      message: "Internal Server Error",
    },
    500,
  );
};

此阶段的函数虽然简陋,但实现了基本"拦截能力"。技术人员可以理解到:不允许异常直接泄漏给框架。

阶段二:支持错误分类(业务可控错误 vs 系统错误)

需求出现:业务层需要显式抛出 4xx,不应该全部变成 500。因此我们引入 Hono 的 HTTPException。

typescript 复制代码
import { HTTPException } from "hono/http-exception";

export const customErrorHandler = (exception: unknown, c: Context) => {
  let httpStatus = 500;
  let message = "Internal Server Error";

  if (exception instanceof HTTPException) {
    httpStatus = exception.status;
    message = exception.message;
  }

  return c.json({ message }, httpStatus);
};

团队到此能看到两个差异:

  • 不再一刀切地用 500
  • 支持框架级错误语义

阶段三:支持普通 Error,并提取堆栈

当后端代码抛出 Error 对象时,我们不希望把内部堆栈暴露给客户端,但日志需要保留:

typescript 复制代码
if (exception instanceof Error) {
  httpStatus = 500;
  message = exception.message;
  stack = exception.stack;
}

与第二阶段结合后,代码局部已经具备三类来源:

typescript 复制代码
if (exception instanceof HTTPException) {
  // 合法业务错误
} else if (exception instanceof Error) {
  // 非预期系统错误
} else {
  // 字符串或其他类型,兜底转字符串
}

这一阶段解决"所有类型异常都能进入统一通路"。

阶段四:对外 Message 安全脱敏

生产会提出安全要求:500 段错误不能暴露内部 message。

typescript 复制代码
const userMessage =
  httpStatus >= 500 ? "Server Internal Error" : exceptionMessage;

阶段五:加入 Trace ID,实现可观测能力

在前几阶段之后,团队开始发现:仅凭日志很难定位请求调用链。因此引入 Trace ID:

typescript 复制代码
import { getTraceId } from "../context";

const traceId = getTraceId();

并将其放入响应,用于 API 与日志上下游联动。

阶段六:构建统一响应协议

现在错误信息、trace 信息都有了,我们需要形成一致返回结构,便于前端或 API 网关解析:

typescript 复制代码
const finalClientResponse = {
  code: 500,
  message: userMessage,
  traceId: traceId,
  error: {
    timestamp: new Date().toISOString(),
    path: c.req.path,
    method: c.req.method,
    httpStatus: httpStatus,
  },
};

这样可以回复格式统一的响应。

阶段七:结构化日志分级输出

生产要求日志可检索、可告警,因此需要按状态区分 error 与 warn,并携带堆栈:

typescript 复制代码
if (httpStatus >= 500) {
  logger.error(`[${httpStatus}] Unhandled Exception: ${exceptionMessage}`, {
    clientResponse: finalClientResponse,
    stack: exceptionStack,
    traceId: traceId,
    path: c.req.path,
  });
} else {
  logger.warn(`[${httpStatus}] Client Error: ${exceptionMessage}`, {
    clientResponse: finalClientResponse,
    traceId: traceId,
    path: c.req.path,
  });
}

团队能看到:

  • 500+ 是告警级别
  • 4xx 只是用户请求问题,不污染告警系统

阶段八:最终闭环,按状态返回响应

typescript 复制代码
return c.json(finalClientResponse, httpStatus);

请求闭环如下:

  1. 识别异常
  2. 分类
  3. 安全脱敏
  4. 绑定 Trace
  5. 构建协议
  6. 结构化日志
  7. 返回状态码与响应体

最终成果(合并段落)即为我们起初提供的完整生产代码:

typescript 复制代码
import { type Context } from "hono";
import { getTraceId } from "../context";
import { logger } from "../logger";
import { HTTPException } from "hono/http-exception";
import { type ContentfulStatusCode } from "hono/utils/http-status";

export const customErrorHandler = (exception: unknown, c: Context) => {
  let httpStatus: ContentfulStatusCode;
  let exceptionMessage: string = "Internal Server Error";
  let exceptionStack: string | undefined;

  if (exception instanceof HTTPException) {
    httpStatus = exception.status;
    exceptionMessage = exception.message;
    exceptionStack = exception.stack;
  } else if (exception instanceof Error) {
    httpStatus = 500;
    exceptionMessage = exception.message;
    exceptionStack = exception.stack;
  } else {
    httpStatus = 500;
    exceptionMessage = String(exception);
  }

  const userMessage =
    httpStatus >= 500 ? "Server Internal Error" : exceptionMessage;

  const traceId = getTraceId();

  const finalClientResponse = {
    code: 500,
    message: userMessage,
    traceId: traceId,
    error: {
      timestamp: new Date().toISOString(),
      path: c.req.path,
      method: c.req.method,
      httpStatus: httpStatus,
    },
  };

  if (httpStatus >= 500) {
    logger.error(`[${httpStatus}] Unhandled Exception: ${exceptionMessage}`, {
      clientResponse: finalClientResponse,
      stack: exceptionStack,
      traceId: traceId,
      path: c.req.path,
    });
  } else {
    logger.warn(`[${httpStatus}] Client Error: ${exceptionMessage}`, {
      clientResponse: finalClientResponse,
      traceId: traceId,
      path: c.req.path,
    });
  }

  return c.json(finalClientResponse, httpStatus);
};

接下来我们来处理全局捕获的 404 回调,也就是自定义 Not Found Response, 个人感觉生产环境就简单返回 404 状态码就足够了,所以函数定义如下:

javascript 复制代码
export const notFoundHandler = (c: Context) => {
  return c.json({ message: "Resource not found" }, 404);
};

欢迎一起交流

欢迎大家一起进群讨论前端全栈技术和 ai agent ,一起进步!

相关推荐
_膨胀的大雄_2 小时前
01-创建型模式
前端·设计模式
小林rush2 小时前
uni-app跨分包自定义组件引用解决方案
前端·javascript·vue.js
我的一行2 小时前
已有项目,接入pnpm + turbo
前端·vue.js
亮子AI2 小时前
【Svelte】怎样实现一个图片上传功能?
开发语言·前端·javascript·svelte
心.c2 小时前
为什么在 Vue 3 中 uni.createCanvasContext 画不出图?
前端·javascript·vue.js
咸鱼加辣2 小时前
【vue面试】ref和reactive
前端·javascript·vue.js
LYFlied2 小时前
【每日算法】LeetCode 104. 二叉树的最大深度
前端·算法·leetcode·面试·职场和发展
汤姆Tom2 小时前
前端转战后端:JavaScript 与 Java 对照学习指南(第五篇 —— 面向对象:类、接口与多态)
java·前端·后端