Skip to content

常见陷阱 ​

先是计划列出的八个陷阱,然后是用当前版本(NestJS 12、TypeORM 1.1、BullMQ 6)搭建 TaskFlow 时实际遇到的陷阱。每个 陷阱都按同样的结构展开:你看到的现象、出现的原因、修正方法,以及详细讨论该主题的章节。

陷阱你看到的现象章节
忘了 await错误丢失,写入完成前响应就发出去了J1
生产环境开着 synchronize: true列被删除,数据一起丢失J4
原样返回实体JSON 里出现 passwordHashJ8
循环依赖应用无法启动J3
单例中的可变状态高负载下,一个用户看到另一个用户的数据J3
有类型没校验非法数据被接受J2
相等比较与 this比较结果错误,this 是 undefinedJ1
控制器里的 @Res()序列化被忽略,请求一直没有响应J2
类型元数据退化为 Object"8080" 仍是字符串,找不到依赖J7
测试中的 reflect-metadataReflect.getMetadata is not a functionJ10
ConditionalModule 与字符串变量没有设置,模块却被加载了J9
BullMQ 6 缺少 ioredis加载队列时崩溃J9

计划中的八个陷阱 ​

1. 忘了 await ​

现象。 写入明明失败了,路由却返回 201;响应发出之后,日志里才出现错误;try/catch 什么也没捕获到。有时进程 因为未处理的 Promise 拒绝而退出。

原因。 不带 await 调用的 async 函数会立即返回一个 Promise,调用方的代码继续执行。等到 Promise 失败时, 外层的 try/catch 早已执行完毕。没有人监听的被拒绝 Promise 会被 Node 报告为未处理的拒绝;从 Node 15 开始,默认 行为是终止进程。常见的变体:items.forEach(async (item) => …),因为 forEach 不会等待任何一个 Promise。

修正。

  • 每个异步调用前都加上 await,并把 forEach 换成 for … of(顺序执行)或 Promise.all(items.map(…))(并发执行)。
  • 启用检测浮动 Promise 的 lint 规则:ESLint 中是 @typescript-eslint/no-floating-promises,oxlint 中是 typescript/no-floating-promises(Nest 12 starter 的默认配置,已验证:不带 await 的 save(); 会报错)。
  • 确实不需要等待时,用 void 明确表达出来:
ts
// SpringApplication.run(TaskflowApplication.class, args)
async function bootstrap() {
  const app = await NestFactory.create(AppModule, { bufferLogs: true });
  setupApp(app);
  setupSwagger(app);
  await app.listen(app.get(ConfigService).getOrThrow<number>('PORT'));
}
// `void` marks the promise as deliberately not awaited (no-floating-promises).
void bootstrap();

章节:J1,async/await; J9,异步错误,讲的是没有人等待的监听器和定时任务。

2. 生产环境开着 synchronize: true ​

现象。 重命名实体的某个属性后,重启时对应的列被删除,再重新创建成一个空列。或者生产环境的表结构变了,仓库里 却没有任何记录。

原因。 synchronize: true 在每次启动时让表结构与实体保持一致,类似 ddl-auto=update,但更激进:TypeORM 可能 删除列,没有历史记录,也没有人审阅。

修正。 除了随时可以丢弃的数据库,所有地方都用 synchronize: false。表结构通过生成的迁移来演进 (npm run migration:generate src/database/migrations/Nom),迁移经过审阅、纳入 git 版本控制,然后再执行 (npm run migration:run)。TaskFlow 让应用和 TypeORM CLI 共用这些选项:

ts
// Shared by the Nest app (database.module.ts) and the TypeORM CLI
// (data-source.ts), so migrations run against the same settings.
export function postgresOptions(db: DatabaseConfig): DataSourceOptions {
  return {
    type: 'postgres',
    ...db,
    // Never true outside a throwaway database: it alters tables to match the
    // entities and can drop columns with their data. Use migrations instead.
    synchronize: false,
    migrations: [join(__dirname, 'migrations', '*.{ts,js}')],
    migrationsTableName: 'typeorm_migrations',
  };
}

数据丢失

被 synchronize 删除的列会带走其中的数据。绝不要让开启了 synchronize: true 的开发配置指向存有需要保留的数据的 数据库。

章节:J4,synchronize、J5,迁移。

3. 原样返回 TypeORM 实体 ​

现象。 JSON 响应里包含 passwordHash 或内部使用的列。

原因。 Nest 会把处理函数返回的对象原样序列化。实体包含它的所有列,包括那些不应该对外暴露的列。

修正。 两种做法:只包含公开字段的响应 DTO,或者在实体上用 @Exclude() 并启用 ClassSerializerInterceptor。 TaskFlow 用的是第二种(拦截器通过 APP_INTERCEPTOR 注册);端到端测试验证过,register 的响应里没有 passwordHash。

ts
@Entity('users')
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'varchar', length: 254, unique: true })
  email: string;

  // Never serialized: ClassSerializerInterceptor applies @Exclude on responses.
  // @ApiHideProperty keeps it out of the OpenAPI schema as well.
  @Exclude()
  @ApiHideProperty()
  @Column({ name: 'password_hash', type: 'varchar' })
  passwordHash: string;

  @Column({ type: 'enum', enum: Role, enumName: 'user_role', default: Role.Member })
  role: Role;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt: Date;
}

@Exclude() 只对该类的实例生效:复制出来的对象({ ...user })或原生查询的结果没有元数据,这个字段又会出现在 响应里。

@Exclude() 只影响返回的 JSON。OpenAPI schema 是 Swagger 插件在编译时生成的:没有 @ApiHideProperty() 的话, passwordHash 会出现在 schema 里,尽管没有任何响应包含它(J11)。

章节:J8,用户。

4. 模块之间的循环依赖 ​

现象。 应用拒绝启动:Nest 无法解析某个依赖,或者报告 imports 中的某一项是 undefined,并建议使用 forwardRef()。

原因。 两个模块互相导入(或者两个 provider 互相注入)。在文件层面,循环导入也可能导致装饰器读取某个类时,它 还是 undefined。

修正。 在到处加 forwardRef 之前,先重新审视模块的拆分:

  • 把公共部分提取到第三个模块,让两个模块都导入它;
  • 用事件反转依赖:在 TaskFlow 中,TasksService 发出 task.status.changed,由 NotificationsListener 监听,两个 模块之间没有任何导入;
  • 只导出另一个模块需要的东西(ProjectsModule 导出 ProjectsService,而不是它的 store)。

实在没有办法时,在两个模块的 imports 里使用 forwardRef(() => AutreModule),并在相关参数上使用 @Inject(forwardRef(() => AutreService))。

章节:J3,模块; 差异,循环依赖。

5. 单例中的可变状态 ​

现象。 在负载下,一个用户收到了属于另一个用户的数据。在本地一次只发一个请求时,一切正常。

原因。 provider 是所有请求共享的单例。在 Spring 中,风险来自并发的线程;在 Node 中,风险来自交错执行:每次 await 时,事件循环都可能去执行另一个请求,而那个请求会改写这个字段。

ts
// 示意:不要这样做
@Injectable()
export class ReportService {
  private currentUser: AuthUser; // 所有请求共享

  async build(query: ProjectQueryDto, user: AuthUser) {
    this.currentUser = user;
    const projects = await this.projects.findAll(query, user); // 另一个请求在这里插进来
    return this.format(projects, this.currentUser);            // ……并改掉了 currentUser
  }
}

修正。 把请求相关的数据作为参数传递:在 TaskFlow 中,每个 service 方法都接收 user: AuthUser。如果确实需要按 请求区分的上下文,Scope.REQUEST 会在每个请求时重新创建 provider(以及依赖它的整条链),这是有开销的;Node 的 AsyncLocalStorage(参见 Async local storage 这篇 recipe) 可以携带上下文,而不必改变作用域。

章节:J3,作用域。

6. 有 TS 类型,但没有运行时校验 ​

现象。 某个路由接受了 { "name": 42, "admin": true },或者 query.page 一直是字符串 "2",比较结果悄悄出错。

原因。 TypeScript 类型在编译时被擦除。只有当 ValidationPipe 已启用、并且 DTO 是类时,DTO 才会被校验(接口 不会留下任何痕迹)。

修正。 一个全局 ValidationPipe,通过 APP_PIPE 声明一次,开启 whitelist、forbidNonWhitelisted 和 transform;DTO 写成用 class-validator 装饰的类;简单参数用管道处理(ParseUUIDPipe、ParseEnumPipe)。TaskFlow 的端到端测试验证过:未知字段 → 400,name: '' → 400,?page=0 → 400,?status=LATE → 400, /v1/projects/not-a-uuid → 400。配置也遵循同样的规则:TaskFlow 在启动时校验环境变量 (J7)。

章节:J2,ValidationPipe。

7. 相等比较与 this ​

现象。 同一个比较,用 == 和用 === 结果不同;作为回调传入的方法抛出 TypeError: Cannot read properties of undefined。

原因。

  • == 在比较之前会先转换类型:'1' == 1、0 == '' 和 null == undefined 都为真。
  • === 按引用比较对象,和 Java 的 == 一样,而且没有 equals():两个相等的 Date 并不 ===。反过来,字符串是 基本类型的值:'abc' === 'abc' 为真,而 Java 比较的是引用。
  • this 取决于函数如何被调用,而不是在哪里定义。items.map(this.format) 把方法和它所属的对象分开了:方法内部的 this 是 undefined。

修正。 始终使用 === 和 !==(eqeqeq 规则可以强制这一点,ESLint 和 oxlint 都提供;TaskFlow 没有启用它), 用 getTime() 比较日期,并传入箭头函数,它会捕获外层的 this:items.map((item) => this.format(item))。TaskFlow 在日志配置的 genReqId 里就是这么做的。

章节:J1。

8. 控制器里的 @Res() ​

现象。 某个路由上,序列化(@Exclude)或拦截器对响应的转换不再生效;或者请求一直没有响应。

原因。 用 @Res() 注入响应对象会让处理函数进入手动模式:Nest 不再发送返回值,需要你自己调用 res.json() 或 res.send()。拦截器对返回值所做的处理全部丢失,而忘了发送则会让请求一直挂起。

修正。 返回一个值,并使用 @HttpCode()、@Header() 或抛出异常。如果需要响应对象来设置响应头或 cookie, @Res({ passthrough: true }) 会提供这个对象,同时仍由 Nest 发送返回值。要返回文件,就返回一个 StreamableFile。

章节:J2,陷阱。

在 TaskFlow 上验证过的陷阱 ​

类型元数据退化为 Object ​

现象。 环境中设置了 PORT=8080,配置校验却失败(PORT 不是整数)。在其他地方:构造函数参数上出现 “Nest can't resolve dependencies of …”,或者 TypeORM 拒绝一个类型为 Object 的列。

原因。 emitDecoratorMetadata 只反映写出来的类型。对于 PORT = 3000(推断类型),TypeScript 输出的 design:type 是 Object,而不是 Number(用 TypeScript 6.0 验证过): plainToInstance(..., { enableImplicitConversion: true }) 不知道这里需要一个数字,于是让 "8080" 保持字符串。接口、 通过 import type 导入的类型,或者 string | null 这样的联合类型,也都输出为 Object,而它指代不了任何有用的东西。

修正。

  • 给带装饰器的属性写上类型:PORT: number = 3000。
  • 接口通过 token 注入(@Inject(PROJECTS_STORE));按类型注入的类要作为值导入(import { ProjectsService })。
  • 对于可为空的属性,在 @Column 中显式给出列的 type。
ts
// Every variable the app reads, with its type and default. Like a
// @ConfigurationProperties class validated with @Validated at startup.
// Write the types out (`PORT: number = 3000`): enableImplicitConversion reads
// the emitted design:type metadata, and an inferred type emits `Object`, so
// "8080" would stay a string.
class EnvironmentVariables {
  @IsEnum(Environment)
  NODE_ENV: Environment = Environment.Development;

  @IsInt()
  @Min(1)
  @Max(65535)
  PORT: number = 3000;

  @IsString()
  DB_HOST: string = 'localhost';

  @IsInt()
  DB_PORT: number = 5432;

  @IsString()
  DB_USER: string;

  @IsString()
  DB_PASSWORD: string;

  @IsString()
  DB_NAME: string;

  @IsString()
  @MinLength(8)
  JWT_SECRET: string;

  @Matches(/^\d+[smhd]$/)
  JWT_EXPIRES_IN: string = '15m';

  @IsIn(['typeorm', 'memory'])
  PROJECTS_STORE: 'typeorm' | 'memory' = 'typeorm';

  @IsOptional()
  @IsString()
  REDIS_URL?: string;
}

export function validateEnv(config: Record<string, unknown>) {
  const validated = plainToInstance(EnvironmentVariables, config, {
    enableImplicitConversion: true,
  });
  const errors = validateSync(validated);
  if (errors.length > 0) {
    // Fail fast: the app refuses to start with a wrong configuration.
    throw new Error(`Invalid environment:\n${errors.map(String).join('\n')}`);
  }
  return validated;
}

章节:J7,环境变量校验; 差异,design:type。

单元测试中的 reflect-metadata ​

现象。 一个不使用 Test.createTestingModule 的单元测试失败,报错 Reflect.getMetadata is not a function。

原因。 Reflect.getMetadata 来自 reflect-metadata polyfill,Nest 启动时会加载它。一个直接调用依赖元数据的代码 (这里是开启了 enableImplicitConversion 的 class-transformer)、却从不加载 Nest 的测试,就没有这个函数。

修正。 像 TaskFlow 那样,在测试文件开头导入它(或者把 reflect-metadata 加到 Jest 的 setupFiles 里)。TypeORM CLI 使用的 data-source.ts 脚本同样不启动 Nest,它也以同样的导入开头。

ts
// Nest loads reflect-metadata when it boots; a test that never boots Nest must
// load it itself, or enableImplicitConversion fails ("Reflect.getMetadata is
// not a function").
import 'reflect-metadata';
import { validateEnv } from './env.validation';

const valid = { DB_USER: 'taskflow', DB_PASSWORD: 'taskflow', DB_NAME: 'taskflow', JWT_SECRET: 'a-long-enough-secret' };

describe('validateEnv', () => {
  it('converts strings and fills defaults', () => {
    const env = validateEnv({ ...valid, PORT: '8080' });
    expect(env.PORT).toBe(8080);
    expect(env.JWT_EXPIRES_IN).toBe('15m');
    expect(env.PROJECTS_STORE).toBe('typeorm');
  });

  it('refuses to start with a missing or malformed variable', () => {
    expect(() => validateEnv({ ...valid, JWT_SECRET: undefined })).toThrow(/JWT_SECRET/);
    expect(() => validateEnv({ ...valid, JWT_EXPIRES_IN: 'soon' })).toThrow(/JWT_EXPIRES_IN/);
    expect(() => validateEnv({ ...valid, NODE_ENV: 'staging' })).toThrow(/NODE_ENV/);
  });
});

章节:J10,Jest。

用字符串调用 ConditionalModule ​

现象。 没有设置 REDIS_URL,BullMQ 队列模块却加载了:取决于模块的代码,应用要么启动失败(TaskFlow 用 getOrThrow 读取 REDIS_URL),要么尝试连接一个并不存在的 Redis。

原因。 字符串形式的 ConditionalModule.registerWhen(Module, 'REDIS_URL') 只检查值不是 "false"。变量不存在 也能通过检查。

修正。 传入一个函数,它会接收环境变量:

ts
@Module({
  imports: [
    // A function, not the string 'REDIS_URL': the string form only checks
    // that the value is not "false", so an unset variable would still load
    // the queue module and make it try to reach Redis.
    ConditionalModule.registerWhen(NotificationsQueueModule, (env) => Boolean(env.REDIS_URL)),
  ],
  providers: [NotificationsListener],
})
export class NotificationsModule {}

在使用方,NotificationsListener 用 @Optional() 接收队列,队列不存在时退而只写一条日志。

章节:J9,通知队列。

BullMQ 6 缺少 ioredis ​

现象。 应用在加载队列模块时退出,报错 “BullMQ could not load the optional 'ioredis' package”。

原因。 BullMQ 6 把 ioredis 声明为可选的 peer dependency(>=5.0.0):npm 不会随它一起安装。

修正。 显式安装它。TypeORM 1.1 也把 ioredis 声明为可选的 peer dependency,版本为 ^5.0.4:所以 TaskFlow 固定 了一个 5.x 版本,同时满足两者。

bash
npm install --save-exact ioredis@5.11.1

已在 Redis 上端到端验证:状态变化 → 事件 → 监听器 → 队列 → NotificationsProcessor 日志。

章节:J9,BullMQ。

其他验证过的意外 ​

  • 对所有路由生效的中间件:在 Nest 11 和 12 中,写成 forRoutes({ path: '{*splat}', method: RequestMethod.ALL }) (path-to-regexp v8 语法)。已验证:每个响应都会带回 x-correlation-id 头 (J6)。
  • PostgreSQL 的 date 列返回的是字符串 'YYYY-MM-DD',而不是 Date(J5)。
  • “今天”取决于时区:夏季巴黎时间凌晨 1 点,toISOString() 给出的仍是前一天的日期(UTC 23 点)。TaskFlow 用 localDate() 和 APP_TIME_ZONE(定时任务使用的时区)计算当天日期;有一个单元测试覆盖这种情况 (J9)。
  • softDelete() 会保留关联的行:已删除项目的成员仍然留在 project_members 里。因此 TaskFlow 的访问控制还要检查 项目是否存在,否则它以前的成员仍然能读取或创建它的任务(J8)。
  • Swagger 插件只读取 *.dto.ts 和 *.entity.ts:放在别处的 DTO 会悄无声息地从文档中消失,所以文件名是 pagination.dto.ts(J11)。
  • 不做转换的话,?archived=false 是真值:字符串 "false" 是 truthy (J2)。
  • 限流器也会计入测试发出的请求:端到端测试套件会触及每分钟 5 次登录的上限,所以 429 的测试放在最后 (J10)。
  • 定时任务在每个实例上都会运行:有多个副本时,使用队列或锁(J9)。
  • liveness 探针不应依赖任何东西:如果 /health/live 检查数据库,一次 PostgreSQL 故障就会让所有 pod 重启 (J11)。