TypeORM关系映射与事务管理详解


前言

在使用 NestJS + TypeORM 开发后端项目时,关系映射和事务管理是两个绕不开的核心话题。本文整理了两个常见的知识点:

  1. @OneToMany@ManyToOne 装饰器在数据库层面的实际行为
  2. this.xxxRepository.manager.transactionthis.manager.transaction 的区别

1. @OneToMany@ManyToOne 的区别

问题的起点

在使用 TypeORM 和 MySQL 等数据库时,@OneToMany 装饰器本身并不会直接在数据库中创建一个外键或表结构。它主要用于定义实体之间的关系。在数据库中,OneToMany 关系通常是通过在 “多” 的那一方(即 @ManyToOne 的实体)中设置外键来实现的。

很多刚接触 TypeORM 的开发者会下意识地认为:既然我在 UserInfo 实体上写了 @OneToMany(() => RefreshToken, ...),那数据库里应该会自动生成一个 refreshTokens 字段。但实际上不会——真正干活的是 @ManyToOne + @JoinColumn

具体说明:

  1. @OneToMany 装饰器

    • 在 “一” 的实体中(例如你的 UserInfo 实体),使用 @OneToMany 装饰器来定义与另一个实体(如 RefreshToken)的关系。
    • 这不会在数据库中直接创建表或字段,而是用于 TypeORM 的关系映射。
    • 它纯粹是一个虚拟的映射声明,告诉 TypeORM:”Hey,我 UserInfo 可以有多个 RefreshToken,记住这个关系,查询的时候帮我 join。”
  2. @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.transactionthis.manager.transaction 区别

问题的起点

在 NestJS + TypeORM 的架构中,this.roleRepository.manager.transactionthis.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,更简洁
适用场景 单一存储库内的事务操作 跨多个存储库的复杂事务操作

示例:跨存储库事务

假设有一个事务需要同时操作 RoleRepositoryUserInfoRepository

使用 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,测试不易发现。

最佳实践

  1. 单一存储库事务
  • 优先使用 this.roleRepository.manager.transaction,代码更简洁。
  1. 跨存储库事务
  • 使用 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 就用事务的,没有就走默认的——保证无论调用方是否在事务中,方法都能正常工作。

  1. 依赖注入配置
@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:适合跨多个存储库的复杂事务,需显式使用注入的实体管理器。

两者本质上都是使用同一个数据库连接池,关键区别在于事务上下文的管理方式。根据业务复杂度选择合适的方式即可。


文章作者: 弈心
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 弈心 !
评论
  目录