前言
在使用 NestJS + TypeORM 开发后端项目时,关系映射和事务管理是两个绕不开的核心话题。本文整理了两个常见的知识点:
@OneToMany与@ManyToOne装饰器在数据库层面的实际行为this.xxxRepository.manager.transaction与this.manager.transaction的区别
1. @OneToMany 与 @ManyToOne 的区别
问题的起点
在使用 TypeORM 和 MySQL 等数据库时,@OneToMany 装饰器本身并不会直接在数据库中创建一个外键或表结构。它主要用于定义实体之间的关系。在数据库中,OneToMany 关系通常是通过在 “多” 的那一方(即 @ManyToOne 的实体)中设置外键来实现的。
很多刚接触 TypeORM 的开发者会下意识地认为:既然我在 UserInfo 实体上写了 @OneToMany(() => RefreshToken, ...),那数据库里应该会自动生成一个 refreshTokens 字段。但实际上不会——真正干活的是 @ManyToOne + @JoinColumn。
具体说明:
@OneToMany装饰器:- 在 “一” 的实体中(例如你的
UserInfo实体),使用@OneToMany装饰器来定义与另一个实体(如RefreshToken)的关系。 - 这不会在数据库中直接创建表或字段,而是用于 TypeORM 的关系映射。
- 它纯粹是一个虚拟的映射声明,告诉 TypeORM:”Hey,我 UserInfo 可以有多个 RefreshToken,记住这个关系,查询的时候帮我 join。”
- 在 “一” 的实体中(例如你的
@ManyToOne装饰器:- 在 “多” 的实体中(如
RefreshToken实体),使用@ManyToOne装饰器来定义与 “一” 的实体的关系。 - 这通常会在数据库中创建一个外键字段,用于关联到 “一” 的实体的主键。
- 它才是真正干活的那个,数据库里
user_id字段就是它搞出来的。
- 在 “多” 的实体中(如
换句话记:@OneToMany 是 ORM 层的”口头约定”,@ManyToOne 是数据库层的”真实约束”。
补充一句:如果你的
@ManyToOne没有加@JoinColumn,TypeORM 会自动生成一个默认外键列名(格式为{属性名}Id,比如userId)。加@JoinColumn({ name: "user_id" })的好处是可以显式控制外键列名,避免 TypeORM 自动命名带来的不确定性。
示例:
假设你有以下两个实体:
UserInfo 实体
@Entity("t_user_info")
export class UserInfo {
@PrimaryGeneratedColumn()
id: number;
@Column()
username: string;
@OneToMany(() => RefreshToken, refreshToken => refreshToken.user)
refreshTokens: RefreshToken[];
}
这里 @OneToMany 只是声明了 “我有一堆 RefreshToken”,在数据库的 t_user_info 表中不会多出任何字段。
RefreshToken 实体
@Entity("refresh_token")
export class RefreshToken {
@PrimaryGeneratedColumn()
id: number;
@Column()
token: string;
@Column()
expiresTime: Date;
@Column({ default: false })
revoked: boolean;
@ManyToOne(() => UserInfo, userinfo => userinfo.refreshTokens)
@JoinColumn({ name: "user_id" }) // 这里假设你在 RefreshToken 中有 user_id 字段
user: UserInfo;
}
@ManyToOne + @JoinColumn({ name: "user_id" }) 这一行,才是数据库里 user_id 外键字段的真正来源。
建表时的 SQL
在数据库中,RefreshToken 表会包含一个 user_id 字段作为外键,指向 UserInfo 表的 id 字段。UserInfo 表则不会直接有 refreshTokens 字段。最终的 SQL 语句可能如下:
CREATE TABLE `refresh_token` (
`id` INT NOT NULL AUTO_INCREMENT,
`token` VARCHAR(255) NOT NULL,
`expires_time` DATETIME NOT NULL,
`revoked` TINYINT(1) DEFAULT 0,
`user_id` INT, -- 外键字段
PRIMARY KEY (`id`),
FOREIGN KEY (`user_id`) REFERENCES `t_user_info`(`id`) ON DELETE SET NULL
);
synchronize: true 的坑
如果你的 TypeORM 配置里 synchronize: true(开发阶段常用),上面这些实体定义会自动同步成对应的 DDL。但要注意:
- 生产环境不建议开
synchronize: true,用 migration 更安全。 @JoinColumn里写的name: "user_id"会直接决定数据库列名,改了这个名字后如果synchronize: true,TypeORM 会尝试先删旧列再建新列,可能导致数据丢失。生产环境迁移一定要手写 migration 文件。
总结
@OneToMany和@ManyToOne装饰器用于定义关系,但只有在 “多” 的实体中定义的外键字段会在数据库中实际创建。- 在设计数据库时,理解这些关系的映射非常重要,以确保数据的完整性和一致性。
- 牢记口诀:
@OneToMany是嘴,@ManyToOne是腿。嘴上说有关系,腿才真正跑进数据库。
2. this.roleRepository.manager.transaction 与 this.manager.transaction 区别
问题的起点
在 NestJS + TypeORM 的架构中,this.roleRepository.manager.transaction 和 this.manager.transaction 的区别主要在于 实体管理器(EntityManager)的作用域 和 事务上下文。让我详细解释:
平时写单表 CRUD 的时候你可能感觉不出区别,但遇到跨多个 Repository 的事务场景时,选错写法轻则代码啰嗦,重则事务根本没生效——你对表 A 和表 B 的操作跑在两个不同的连接上,要么全成功了、要么一个回滚一个没回滚,数据一致性就炸了。
前置知识:EntityManager 是什么
TypeORM 中所有的数据库操作最终都是通过 EntityManager 完成的。每个 Repository 内部都持有一个 EntityManager 引用。当你调用 repository.find() 时,本质上是在调用 repository.manager.find()。
事务的关键在于:transaction() 方法会创建一个新的 EntityManager 实例,这个实例绑定到当前事务的数据库连接上。如果你在事务回调里用了一个跟事务无关的 EntityManager(比如 this.roleRepository.manager),那这个 manager 走的是另一个连接,不在事务范围内。
核心区别
1. this.roleRepository.manager.transaction
- 作用域:使用当前存储库(
RoleRepository)关联的实体管理器。 - 特点:
- 如果
RoleRepository是通过依赖注入获取的(例如使用@InjectRepository(Role)),则manager是全局实体管理器。 - 在事务中创建的查询会自动使用该事务的上下文。
- 注意:前提是你在事务回调里用的就是回调参数的
entityManager,而不是外部this.roleRepository.manager。
- 如果
- 示例:
@Injectable()
export class RoleService {
constructor(
@InjectRepository(Role)
private readonly roleRepository: Repository<Role>
) {}
async deleteRole(id: number) {
return this.roleRepository.manager.transaction(async entityManager => {
// 在事务中使用 entityManager 执行操作
const role = await entityManager.findOne(Role, { where: { id } });
// ...
});
}
}
关键点:
this.roleRepository.manager本身就是一个正常的EntityManager实例。调用它的.transaction()后,回调里的entityManager是一个新的事务专用实例。如果你在回调里写的是this.roleRepository.findOne(...)而不是entityManager.findOne(...),那它走的是外层的 manager,事务白开了。
2. this.manager.transaction
- 作用域:使用通过依赖注入直接获取的实体管理器(通常通过
@InjectEntityManager())。 - 特点:
- 实体管理器是全局共享的,与具体存储库无关。
- 适用于需要跨多个存储库执行事务的场景。
- 因为不绑定到任何一个具体的 Repository,思路更清晰:”当前整个 Service 的数据库操作都统一走这个 manager”。
- 示例:
@Injectable()
export class RoleService {
constructor(
@InjectEntityManager()
private readonly manager: EntityManager
) {}
async deleteRole(id: number) {
return this.manager.transaction(async entityManager => {
// 在事务中使用 entityManager 执行操作
const role = await entityManager.findOne(Role, { where: { id } });
// ...
});
}
}
关键差异点
| 对比项 | this.roleRepository.manager.transaction |
this.manager.transaction |
|---|---|---|
| 获取方式 | 通过存储库实例获取 | 通过依赖注入直接获取 |
| 事务上下文一致性 | 同一存储库内的操作自动使用相同事务上下文 | 需要确保所有操作都使用注入的 entityManager |
| 跨存储库事务 | 需显式传递 entityManager 到其他存储库方法 |
直接使用注入的 entityManager,更简洁 |
| 适用场景 | 单一存储库内的事务操作 | 跨多个存储库的复杂事务操作 |
示例:跨存储库事务
假设有一个事务需要同时操作 RoleRepository 和 UserInfoRepository:
使用 this.manager.transaction
@Injectable()
export class RoleService {
constructor(
@InjectEntityManager()
private readonly manager: EntityManager,
private readonly userInfoRepository: UserInfoRepository
) {}
async deleteRole(id: number) {
return this.manager.transaction(async entityManager => {
// 获取角色
const role = await entityManager.findOne(Role, { where: { id } });
// 检查用户(使用事务中的 entityManager)
const usersCount = await entityManager.count(UserInfo, { where: { userRole: { id } } });
// 或者通过 repository 并传递 entityManager
const usersCountAlt = await this.userInfoRepository.countByRoleId(id, entityManager);
// ...
});
}
}
这个写法最干净:所有操作统统走 entityManager,不需要担心哪个 Repository 的事务上下文跑了偏。
使用 this.roleRepository.manager.transaction
@Injectable()
export class RoleService {
constructor(
@InjectRepository(Role)
private readonly roleRepository: Repository<Role>,
private readonly userInfoRepository: UserInfoRepository
) {}
async deleteRole(id: number) {
return this.roleRepository.manager.transaction(async entityManager => {
// 获取角色
const role = await entityManager.findOne(Role, { where: { id } });
// 检查用户(需要确保 UserInfoRepository 支持传入 entityManager)
const usersCount = await this.userInfoRepository.countByRoleId(id, entityManager);
// ...
});
}
}
注意这里:操作 Role 可以愉快地用回调参数 entityManager,但操作 UserInfo 就必须通过自定义 Repository 方法并手动传入 entityManager。如果你的 UserInfoRepository.countByRoleId 内部写的是 this.manager.count(...),那它就用了外层非事务的 manager,不会回滚——这属于静默 bug,测试不易发现。
最佳实践
- 单一存储库事务:
- 优先使用
this.roleRepository.manager.transaction,代码更简洁。
- 跨存储库事务:
- 使用
this.manager.transaction,并确保所有操作都通过注入的entityManager执行。 - 在自定义存储库中添加支持事务的方法:
@EntityRepository(UserInfo)
export class UserInfoRepository extends Repository<UserInfo> {
async countByRoleId(roleId: number, entityManager?: EntityManager) {
const manager = entityManager || this.manager;
return manager.count(UserInfo, { where: { userRole: { id: roleId } } });
}
}
这里 const manager = entityManager || this.manager 是关键:如果有事务的 entityManager 就用事务的,没有就走默认的——保证无论调用方是否在事务中,方法都能正常工作。
- 依赖注入配置:
@Module({
imports: [TypeOrmModule.forFeature([Role, UserInfo])],
providers: [RoleService, UserInfoRepository],
})
export class RoleModule {}
常见错误写法(避坑)
// ❌ 错误:回调里用的不是事务的 entityManager
return this.manager.transaction(async entityManager => {
const role = await this.roleRepository.findOne({ where: { id } }); // 走了外层 manager,不在事务里!
await entityManager.save(userInfo); // 只有这是事务内的
});
// ✅ 正确:统一用事务回调参数的 entityManager
return this.manager.transaction(async entityManager => {
const role = await entityManager.findOne(Role, { where: { id } });
await entityManager.save(userInfo);
});
记住:事务回调里不能混用 Repository 方法(除非它支持透传 entityManager)和回调参数 entityManager,要统一就统一到底。
总结
this.roleRepository.manager.transaction:适合单一存储库内的事务操作,依赖当前存储库的实体管理器。this.manager.transaction:适合跨多个存储库的复杂事务,需显式使用注入的实体管理器。
两者本质上都是使用同一个数据库连接池,关键区别在于事务上下文的管理方式。根据业务复杂度选择合适的方式即可。