生命周期钩子、订阅者与特殊列(了解就好)
本章目标
- 会用实体方法钩子(@BeforeInsert、@AfterLoad 等)在数据生命周期的关键节点自动执行逻辑。
- 会用订阅者(@EventSubscriber + EntitySubscriberInterface)集中监听实体事件,并说清它与钩子的区别。
- 掌握特殊列:@CreateDateColumn / @UpdateDateColumn / @DeleteDateColumn / @VersionColumn / @Generated。
- 会用软删除 softDelete() / restore(),并知道查询如何自动过滤。
- 理解乐观锁在 1.1.1 中的真实用法(setLock),并能演示并发冲突。
核心概念
生命周期钩子 = "流水线质检员"。一条数据从创建、保存、读取到删除,就像包裹在流水线上移动;钩子是钉在流水线固定工位上的质检员——包裹经过时自动触发,不用你手动喊。典型用途:保存前把明文密码加密、读取后算出派生字段。
订阅者 = "中央监控室"。钩子写在实体类里(跟着实体走),订阅者则是独立类,集中监听一类(或所有)实体的事件,适合做审计日志、发通知这类"横切"逻辑。
特殊列 = "自动填表员"。创建时间、更新时间、删除时间、版本号这类列,每次手写太繁琐也容易忘,TypeORM 帮你自动维护。
实体方法钩子
| 装饰器 | 触发时机 |
|---|---|
@BeforeInsert | 插入前(save 新实体) |
@AfterInsert | 插入后 |
@BeforeUpdate | 更新前(save 已有实体) |
@AfterUpdate | 更新后 |
@BeforeRemove / @AfterRemove | 物理删除前后(remove) |
@BeforeSoftRemove / @AfterSoftRemove | 软删除前后(softRemove) |
@BeforeRecover / @AfterRecover | 软删除恢复前后(recover) |
@AfterLoad | 从数据库读出、组装成实体之后(find 系列) |
经典例子——保存前自动加密密码:
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):
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. 特殊列
@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. 软删除
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 再操作:
// 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。没有乐观锁校验时,并发覆盖连痕迹都不会留下。