TypeORM 中的 Active Record 与 Data Mapper 两种 ORM 模式选型实战指南
【免费下载链接】typeormTypeScript & JavaScript ORM for Node.js — supports PostgreSQL, MySQL, MariaDB, SQLite, SQL Server, Oracle, and more.项目地址: https://gitcode.com/GitHub_Trending/ty/typeorm
TypeORM 同时支持Active Record(活动记录)与Data Mapper(数据映射器)两种持久化模式:前者把增删改查方法直接挂载到实体类上,让模型“自带数据库访问能力”;后者把实体与数据访问彻底分离,所有数据库操作收敛到 Repository 层。本指南围绕 guides/1-active-record-data-mapper.md 的系统讲解,结合 TypeORM 源码(如 src/repository/BaseEntity.ts)的实现细节,帮助你完整掌握两种模式的写法、底层工作原理、各自的优劣边界,以及在项目中如何做出正确选型。
Active Record 模式:把数据访问放进模型本身
Active Record 是一种“在模型内部访问数据库”的模式。使用它时,查询方法定义在实体类中,而对象的保存(save)、删除(remove)与加载(find)也通过实体自身携带的方法完成。在 TypeORM 中,所有 Active Record 实体必须继承BaseEntity类——正是这个父类向实体注入了全套数据访问能力。
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from "typeorm" @Entity() export class User extends BaseEntity { @PrimaryGeneratedColumn() id: number @Column() firstName: string @Column() lastName: string @Column() isActive: boolean }定义实体后,实例方法与静态方法即可直接操作数据库:
// 保存一条 Active Record 实体 const user = new User() user.firstName = "Timber" user.lastName = "Saw" user.isActive = true await user.save() // 删除该实体 await user.remove() // 加载实体集合 const users = await User.find({ skip: 2, take: 5 }) const newUsers = await User.findBy({ isActive: true }) const timber = await User.findOneBy({ firstName: "Timber", lastName: "Saw" })自定义业务查询:在实体上编写静态方法
当需要一个“按姓名查找用户”这类可复用查询时,可直接将其实现为实体上的静态方法,方法内部借助createQueryBuilder或标准查询 API 完成:
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from "typeorm" @Entity() export class User extends BaseEntity { @PrimaryGeneratedColumn() id: number @Column() firstName: string @Column() lastName: string @Column() isActive: boolean static findByName(firstName: string, lastName: string) { return this.createQueryBuilder("user") .where("user.firstName = :firstName", { firstName }) .andWhere("user.lastName = :lastName", { lastName }) .getMany() } }调用方式与其它实例/静态方法完全一致:
const timber = await User.findByName("Timber", "Saw")BaseEntity 到底提供了哪些能力(源码解读)
BaseEntity位于 src/repository/BaseEntity.ts,其设计目标是“几乎完整复刻标准Repository的对外 API”。实例方法部分(L43-L105)包括:
save(options?):若实体在数据库中不存在则插入,否则更新(L54-L57);remove(options?):从数据库删除当前实体(L64-L67);softRemove(options?):软删除,仅记录删除日期而非物理删除(L74-L77);recover(options?):恢复被软删除的实体(L84-L87);reload():从数据库重新加载实体数据并覆盖当前对象属性(L92-L105);hasId():检查实体是否已具备(可能为复合的)主键(L43-L46)。
而静态方法则几乎与Repository一一对应,包括find / findBy / findAndCount / findAndCountBy / findOne / findOneBy / findOneOrFail / findOneByOrFail、save / remove / softRemove / recover / insert / update / upsert / delete / clear、count / countBy、聚合函数sum / average / minimum / maximum、exists / existsBy,以及create / merge / preload / query等,还额外暴露了createQueryBuilder(L168-L173)用于自由拼装 SQL。可以看出:绝大多数场景下,Active Record 实体无需再显式接触Repository或EntityManager,这也印证了文档中“BaseEntity 具备标准 Repository 的大部分方法”的说法。
useDataSource 机制:Active Record 的数据源从何而来
静态方法能够工作,前提是BaseEntity已被绑定到一个已初始化的DataSource。其底层通过static useDataSource(dataSource)(L116-L118)保存数据源引用,再由getRepository静态方法(L123-L130)转发到数据源对应的 Repository 上;若尚未设置,会抛出 “DataSource is not set for this entity.” 错误。
关键的自动绑定发生在DataSource.initialize()过程中:初始化时会构建全部实体元数据,并对继承自BaseEntity的实体目标逐一调用target.useDataSource(this),见 src/data-source/DataSource.ts L758-L765。也就是说,只要实体通过entities配置注册进 DataSource 并被成功initialize,其实体上的静态数据访问方法即可直接使用。
仓库测试 test/functional/base-entity/base-entity.test.ts 专门验证了这一链路:测试先调用User.useDataSource(null)清空绑定,再创建 DataSource 并initialize(),随后User.save(...)、User.findOneByOrFail(...)均能正常工作——证明绑定动作确实由 DataSource 初始化流程自动完成,而非依赖用户手工设置。
Data Mapper 模式:把数据访问收进 Repository
Data Mapper 模式则相反:所有查询方法定义在独立的“Repository(仓库)”类中,实体的保存、删除与加载全部经由仓库对象完成。此时实体非常“笨”,只负责声明属性,最多附带一些无副作用的辅助方法。
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm" @Entity() export class User { @PrimaryGeneratedColumn() id: number @Column() firstName: string @Column() lastName: string @Column() isActive: boolean }对应的数据访问统一通过dataSource.getRepository(User)获取的仓库实例执行:
const userRepository = dataSource.getRepository(User) // 保存一条 Data Mapper 实体 const user = new User() user.firstName = "Timber" user.lastName = "Saw" user.isActive = true await userRepository.save(user) // 删除该实体 await userRepository.remove(user) // 加载实体集合 const users = await userRepository.find({ skip: 2, take: 5 }) const newUsers = await userRepository.findBy({ isActive: true }) const timber = await userRepository.findOneBy({ firstName: "Timber", lastName: "Saw", })在 TypeORM 中DataSource.getRepository(target)最终委托给内部EntityManager的同名方法(见 src/data-source/DataSource.ts L440-L444),因此全局 dataSource、dataSource.manager与仓库之间共享同一套元数据与连接体系。另外,若使用 MongoDB,可改用getMongoRepository获取 Mongo 专用仓库。
扩展标准 Repository:自定义仓库模式
Data Mapper 并不要求写样板胶水代码。当需要为UserRepository增加findByName(firstName, lastName)这样的自定义方法时,可以结合custom repository(自定义仓库)模式完成,详细用法见 working-with-entity-manager/4-custom-repository.md。
最常见也最简洁的做法是把仓库实例导出为全局单例,并在其上调用.extend()注入自定义方法:
// user.repository.ts export const UserRepository = dataSource.getRepository(User).extend({ findByName(firstName: string, lastName: string) { return this.createQueryBuilder("user") .where("user.firstName = :firstName", { firstName }) .andWhere("user.lastName = :lastName", { lastName }) .getMany() }, }) // user.controller.ts export class UserController { users() { return UserRepository.findByName("Timber", "Saw") } }从源码看,Repository.extend()通过生成一个继承当前仓库类的子类并把自定义方法写入其原型实现(src/repository/Repository.ts L815-L836),因此方法内this仍是完整的仓库实例,可继续访问createQueryBuilder等全部内建能力,最终返回的是功能完备的仓库对象。
需要注意事务边界:事务拥有自己独立的 queryRunner、EntityManager 与仓库实例,事务内必须使用事务回调提供的 manager,并通过manager.withRepository(...)获得绑定到该事务的自定义仓库,否则查询不会在事务作用域内执行:
await dataSource.transaction(async (manager) => { // 事务内必须使用回调提供的 manager,不能用全局 EntityManager/Repository const userRepository = manager.withRepository(UserRepository) await userRepository.createAndSave("Timber", "Saw") const timber = await userRepository.findByName("Timber", "Saw") })两种模式的 API 对照与等效替换
两种模式在语法层面几乎一一对应,理解这种映射关系有助于在项目内自由切换或统一团队风格:
| 操作语义 | Active Record(继承 BaseEntity) | Data Mapper(Repository) |
|---|---|---|
| 保存单条/多条 | user.save()/User.save([...]) | repo.save(user)/repo.save([...]) |
| 物理删除 | user.remove() | repo.remove(user) |
| 软删除 | user.softRemove() | repo.softRemove(user) |
| 批量查询(分页等 Find 选项) | User.find({ skip, take }) | repo.find({ skip, take }) |
| 纯条件查询 | User.findBy({ isActive: true }) | repo.findBy({ isActive: true }) |
| 单条条件查询 | User.findOneBy({...}) | repo.findOneBy({...}) |
| 自定义 SQL | User.createQueryBuilder("user") | repo.createQueryBuilder("user") |
| 新增自定义方法 | 实体上的static方法 | 通过repo.extend({...})的 custom repository |
| 底层执行引擎 | 由useDataSource绑定的仓库 | 由getRepository返回的仓库实例 |
到底该选哪一种:可维护性与应用规模的权衡
两种模式没有绝对的对错,选择权最终在你自己手里。TypeORM 文档建议把“我们未来将如何长期维护这套应用”作为首要决策依据:
- Data Mapper 更利于可维护性,更适合大型应用。实体保持纯数据定义,业务查询集中在仓库层,遵循“单一职责”与“关注点分离”;当团队规模、实体数量与领域逻辑增长时,实体类不会逐渐膨胀为“上帝对象”,测试也更容易针对仓库单独进行替换或隔离。文档原话即指出:Data Mapper 方式对可维护性更有帮助,在更大的应用中效果更好(“more effective in larger apps”)。
- Active Record 让一切保持简单,适合中小型应用。无需在实体与仓库之间来回跳转,增删改查就近写在模型上,代码量最少、上手门槛最低。文档原话亦指出:Active Record 方式有助于保持简单,在小应用中表现出色(“works well in smaller apps”)。
实践中还常看到第三种混合用法:实体仍保持纯 Data Mapper 形态,但通过全局导出的UserRepository = dataSource.getRepository(User).extend(...)单例保持调用时的简洁性。无论最终倾向哪种,TypeORM 的底层机制决定了它们最终都收敛到同一套 EntityManager/Repository 执行管线,切换成本并不高——关键是先明确团队规模与长期维护策略,再统一约定,避免同一代码库内两种风格混杂导致认知负担。
小结
本文从 guides/1-active-record-data-mapper.md 出发,系统梳理了 TypeORM 的两种持久化模式:
- Active Record:实体继承 BaseEntity,把保存/删除/查询与自定义业务方法直接放在模型中,适合追求简洁的中小型应用;
- Data Mapper:实体只声明属性,所有数据库操作经由
getRepository返回的 Repository(自定义方法可用.extend()),职责清晰、可维护性好,适合规模化应用; - 二者底层都由 DataSource 构建元数据后统一分发到 EntityManager/Repository 执行(绑定流程见 DataSource.ts L758-L765,测试佐证),实际是“同一引擎的两种外观”。
选型没有标准答案,但维护成本是恒定标尺:小项目追求简单选 Active Record,大项目追求边界清晰选 Data Mapper。进一步学习自定义仓库与事务内使用仓库的细节,可继续阅读 custom repository 指南。
【免费下载链接】typeormTypeScript & JavaScript ORM for Node.js — supports PostgreSQL, MySQL, MariaDB, SQLite, SQL Server, Oracle, and more.项目地址: https://gitcode.com/GitHub_Trending/ty/typeorm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考