Skip to content

生命周期钩子、订阅者与特殊列(了解就好) ​

本章目标 ​

  1. 会用实体方法钩子(@BeforeInsert、@AfterLoad 等)在数据生命周期的关键节点自动执行逻辑。
  2. 会用订阅者(@EventSubscriber + EntitySubscriberInterface)集中监听实体事件,并说清它与钩子的区别。
  3. 掌握特殊列:@CreateDateColumn / @UpdateDateColumn / @DeleteDateColumn / @VersionColumn / @Generated。
  4. 会用软删除 softDelete() / restore(),并知道查询如何自动过滤。
  5. 理解乐观锁在 1.1.1 中的真实用法(setLock),并能演示并发冲突。

核心概念 ​

生命周期钩子 = "流水线质检员"。一条数据从创建、保存、读取到删除,就像包裹在流水线上移动;钩子是钉在流水线固定工位上的质检员——包裹经过时自动触发,不用你手动喊。典型用途:保存前把明文密码加密、读取后算出派生字段。

订阅者 = "中央监控室"。钩子写在实体类里(跟着实体走),订阅者则是独立类,集中监听一类(或所有)实体的事件,适合做审计日志、发通知这类"横切"逻辑。

特殊列 = "自动填表员"。创建时间、更新时间、删除时间、版本号这类列,每次手写太繁琐也容易忘,TypeORM 帮你自动维护。

实体方法钩子 ​

装饰器触发时机
@BeforeInsert插入前(save 新实体)
@AfterInsert插入后
@BeforeUpdate更新前(save 已有实体)
@AfterUpdate更新后
@BeforeRemove / @AfterRemove物理删除前后(remove)
@BeforeSoftRemove / @AfterSoftRemove软删除前后(softRemove)
@BeforeRecover / @AfterRecover软删除恢复前后(recover)
@AfterLoad从数据库读出、组装成实体之后(find 系列)

经典例子——保存前自动加密密码:

ts
import { createHash } from "crypto";
import { BeforeInsert, BeforeUpdate, AfterLoad } from "typeorm";

@Entity()
class User {
  @Column()
  password: string;

  displayName?: string; // 非列字段:不存库

  @BeforeInsert()
  hashPasswordOnInsert() {
    this.password = createHash("md5").update(this.password).digest("hex");
  }

  @BeforeUpdate()
  hashPasswordOnUpdate() {
    this.password = createHash("md5").update(this.password).digest("hex");
  }

  @AfterLoad()
  computeDisplayName() {
    this.displayName = `【用户】${this.username}#${this.id}`;
  }
}

2. 订阅者(Subscriber) ​

订阅者是实现 EntitySubscriberInterface 的独立类,用 @EventSubscriber() 装饰,并在 DataSource 配置里注册(查 subscriber/EntitySubscriberInterface.d.ts):

ts
import {
  EventSubscriber,
  EntitySubscriberInterface,
  InsertEvent,
} from "typeorm";

@EventSubscriber()
class AuditSubscriber implements EntitySubscriberInterface<User> {
  listenTo() {
    return User; // 只监听 User;省略此方法则监听所有实体
  }

  beforeInsert(event: InsertEvent<User>) {
    console.log(`即将插入:${event.entity.username}`);
  }
}

const ds = new DataSource({
  // ...
  subscribers: [AuditSubscriber], // 在这里注册
});

订阅者可监听的事件比实体钩子更多:beforeQuery/afterQuery、事务事件(afterTransactionCommit 等)都只有订阅者能监听。

钩子 vs 订阅者对比:

维度实体钩子订阅者
写在哪实体类内部的方法上独立的类
能拿到什么只有 this(实体自己)event 对象:entity、entityManager、queryRunner 等
适合做什么与实体自身强相关的逻辑(加密、计算派生字段)横切逻辑(审计日志、通知、统计)
事件范围仅实体增删改/加载还包括 query、事务等全局事件

3. ⚠️ 哪些操作触发钩子?(本章实测,重要) ​

这是本章最大的坑,务必记住实测结论:

操作实体钩子(@BeforeInsert 等)订阅者
repo.save(entity)✅ 触发✅ 触发(event.entity 完整)
repo.remove(entity)✅ 触发✅ 触发
repo.insert({...})❌ 不触发✅ 触发(event.entity 有值)
repo.update(id, {...})❌ 不触发✅ 触发(但 event.entity 是 undefined,只有 criteria/部分字段)
repo.delete(id)❌ 不触发✅ 触发(entity 为 undefined)

原因:insert()/update()/delete() 直接拼 SQL 发给数据库,全程不创建实体实例,挂在实体方法上的钩子自然无从触发;而订阅者是框架级事件,照样会广播。本章演示 1 的实验 4 用 insert() 插入用户,密码以明文入库(@BeforeInsert 未执行)——如果加密逻辑只写在钩子里,这就是一个真实的生产事故。

结论:依赖钩子保证的逻辑(如密码加密),就必须坚持用 save() 走实体;或者把逻辑放进订阅者的 beforeInsert(它对 insert() 也生效)。

4. 特殊列 ​

ts
@Entity()
class Product {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  @Generated("uuid") // 插入时自动生成 UUID(应用侧生成,非主键也能用)
  sku: string;

  @CreateDateColumn()
  createdAt: Date; // 首次插入时自动写入当前时间

  @UpdateDateColumn()
  updatedAt: Date; // 每次更新(含 softDelete/restore)自动刷新

  @DeleteDateColumn()
  deletedAt: Date | null; // 软删除标记:null=正常,有值=已删

  @VersionColumn()
  version: number; // 每次更新自动 +1(含 softDelete/restore)
}
装饰器行为
@CreateDateColumn插入时写当前时间,之后不变
@UpdateDateColumn每次更新时刷新(softDelete/restore 也算更新,实测 version 从 2 → 4)
@DeleteDateColumn软删除的时间戳;它的存在本身就开启了该实体的软删除能力
@VersionColumn更新时自动 version + 1(SQL 层面 version = version + 1)
@Generated("uuid")插入时生成 UUID;另有 "increment"(非主键自增)、"rowid"(仅 CockroachDB)

5. 软删除 ​

ts
await repo.softDelete(product.id); // 软删除:deletedAt 写入当前时间,行还在表里
await repo.find(); // 普通查询自动过滤已删除的行
await repo.find({ withDeleted: true }); // 连已删除的一起查
await repo.restore(product.id); // 恢复:deletedAt 置回 null

软删除 = "扔进回收站"而不是"粉碎"。行仍在表里(原始 SQL 直查可见),只是 TypeORM 的查询自动附加 deletedAt IS NULL。

相关 API(查 repository/Repository.d.ts):按条件用 softDelete(criteria) / restore(criteria);拿着实体实例用 softRemove(entity) / recover(entity)(后者会触发实体钩子)。

6. 乐观锁 @VersionColumn(1.1.1 的真实用法,与直觉不符) ​

先记住结论:1.1.1 中 save() 不会替你校验版本号! 乐观锁校验要用 QueryBuilder 的 setLock("optimistic", 期望版本号)(查 query-builder/SelectQueryBuilder.js 确认:OptimisticLockVersionMismatchError 只在这里抛出)。

并发场景:两个"会话"同时读到 version=4 的同一行,A 先保存(version → 5),B 再操作:

ts
// B 在读入环节带上自己手中的旧版本号做校验
await ds
  .createQueryBuilder()
  .select("product")
  .from(Product, "product")
  .where("product.id = :id", { id })
  .setLock("optimistic", sessionB.version) // B 手中的旧版本 4
  .getOne();
// 当前库中已是 5 → 抛 OptimisticLockVersionMismatchError:
// "The optimistic lock on entity Product failed, version 4 was expected, but is actually 5."

如果 B 不做校验直接 save(sessionB),本章实测会发生更隐蔽的坏事:TypeORM 先把数据库当前行(version=5)读出来做 diff,发现 B 的 version=4 "不一样",于是把 version=4 当作普通字段更新显式写回(UPDATE ... SET price=?, version=? -- [149, 4])——不但静默覆盖了 A 的价格,还把版本号拖回了旧值 4。没有乐观锁校验时,并发覆盖连痕迹都不会留下。