ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

NestJS与TypeORM实战:实体映射、动态查询与事务处理指南

NestJS与TypeORM实战:实体映射、动态查询与事务处理指南 NestJS 和 TypeORM 这两个词做 Node 后端的人应该都不陌生。NestJS 是目前最主流的渐进式 Node.js 框架TypeORM 则是 TypeScript 生态里最老牌的 ORM 框架两者搭配解决的是后端项目里最核心的问题怎么把数据库表变成工程里可维护、可类型检查、可测试的代码模型。这个组合适合谁刚接触 NestJS 想选 ORM 的新手、在用 TypeORM 但遇到各种坑的老手还有准备从旧项目迁移到 NestJS 的开发者。我接下来会从环境配置、实体映射、关联查询到事务处理把这一整条链路完整过一遍重点讲代码层面的实战经验。不是官网文档的复述而是我实际项目里踩过坑之后留下的那套「能直接抄」的用法。1. 项目背景为什么我在 NestJS 里选择了 TypeORM1.1 NestJS TypeORM 能解决什么问题NestJS 本身不绑定数据库方案你可以用原生 mysql、也可以接 Prisma、Mongoose、Knex。但绝大多数项目都会选一个 ORM。TypeORM 在其中算是“亲儿子”级别的存在——NestJS 官方文档里的数据库章节第一个示例就是 TypeORM而且nestjs/typeorm这个包由 Nest 团队维护和框架的模块体系深度集成。你只需要在模块里 import TypeOrmModule就可以通过依赖注入拿到 Repository不需要自己管理连接池、不需要手动拼 SQL实体类上的装饰器会自动帮你完成表结构和代码模型的双向同步。它解决的最直接问题是把“数据库设计”和“业务代码”之间的翻译成本降到最低。拿用户表举例传统写法是维护一份 SQL 建表脚本再写一份类型定义再写一堆 CRUD 函数用 TypeORM 以后一张表对应一个实体类建表由 synchronize 或 migration 完成增删改查直接调用 repository 的方法类型提示贯穿全过程字段写错了编译期就能发现。这对一个长期迭代的项目来说维护成本下降不是一点半点。还有一点容易被忽略TypeORM 的实体和 NestJS 的依赖注入体系是同构的。你在服务层构造函数里InjectRepository(User)注入仓库不需要在模块里手动 new 什么连接对象NestJS 的生命周期管理会自动完成。这对做单元测试非常有利mock 一个 Repository 就能把服务层逻辑完整测一遍不需要起真实数据库。1.2 几种 ORM 方案放在一起怎么选既然 NestJS 不强制你用哪个 ORM那选型就得认真对待。我把当前主流的三个方案放在一起对比过方案核心模型动态查询装饰器集成迁移机制学习曲线TypeORM实体类 装饰器QueryBuilder极佳migrations平缓Prisma独立 schema 文件条件筛选一般prisma migrate中等Sequelize模型定义链式查找一般migrations平缓TypeORM 走的是“实体类 装饰器”路线和 NestJS 的依赖注入、模块化风格天然一致。Prisma 的 schema 文件更集中但它生成的客户端类型是独立的和 NestJS 的装饰器体系是两个世界写起来有割裂感。Sequelize 直接用 Promise 链灵活度差一些。我个人做技术选型的逻辑很简单如果团队里已经有人熟悉 SQL想保留 SQL 也方便拼动态条件TypeORM 的 QueryBuilder 就是最舒服的如果想要一股脑把所有表结构都集中管理不想要装饰器这种“散落”的感觉Prisma 更适合。就 NestJS 生态的整合度来说TypeORM 确实是最省心的选择这也是我项目里最终定型的原因。不过选 TypeORM 也得接受它的副作用版本迭代速度快API 有变动网上的旧教程经常失效。所以写这篇文章时我特意按当前稳定版的行为来描述你照着操作应该能直接跑通。2. 环境准备与核心配置先跑通再说2.1 依赖安装与项目初始化先创建一个 NestJS 项目nest new my-project cd my-project npm install nestjs/typeorm typeorm pg我一般用 PostgreSQL所以装pg驱动。用 MySQL 就把pg替换成mysql2npm install mysql2。如果是本地快速测试还可以考虑 SQLite驱动换成better-sqlite3TypeORM 也一样支持。安装完以后不要急着写实体先把数据源配置搞定。我见过不少新手在这儿卡住TypeORM 版本升级以后部分 API 有变化网上旧教程的写法可能已经失效。我这边以目前比较常用的稳定版本为例。2.2 数据源配置的完整套路在app.module.ts里配置import { Module } from nestjs/common; import { TypeOrmModule } from nestjs/typeorm; Module({ imports: [ TypeOrmModule.forRoot({ type: postgres, host: process.env.DB_HOST || localhost, port: Number(process.env.DB_PORT) || 5432, username: process.env.DB_USER || postgres, password: process.env.DB_PASS || postgres, database: process.env.DB_NAME || test, entities: [__dirname /**/*.entity{.ts,.js}], synchronize: true, autoLoadEntities: true, }), ], }) export class AppModule {}这里有三个配置项值得单独说明。entities指定实体文件的加载路径。__dirname /**/*.entity{.ts,.js}是历史惯用写法把编译后的.js和源码里的.ts都包含了。不过如果你用了autoLoadEntities: trueTypeORM 会自动收集在该模块里通过forFeature注册的实体这个路径就可以省略。但注意省略 entities 配置后迁移功能往往找不到表结构所以我通常两个都配置上双保险。retryAttempts是连接数据库失败后自动重试的次数默认 10 次。在容器环境、网络不稳定的场景可以适当调大。启动阶段如果数据库还没就绪TypeORM 会不断重试等数据库起来以后自动连上这个特性在 docker-compose 里很实用。synchronize开发环境可以开它会根据实体自动创建和修改表结构。生产环境一定要关掉否则表结构被意外改动出问题你是反应不过来的。这一点后面单独展开说。模块级注册用forFeatureimport { Module } from nestjs/common; import { TypeOrmModule } from nestjs/typeorm; import { User } from ./entities/user.entity; import { UserService } from ./user.service; import { UserController } from ./user.controller; Module({ imports: [TypeOrmModule.forFeature([User])], controllers: [UserController], providers: [UserService], exports: [TypeOrmModule], }) export class UserModule {}这样 UserRepository 就会被注入到 UserService 里不用自己 new。exports那句也很关键如果你希望其他模块也能使用 User 的 Repository必须把这个模块的 TypeOrmModule 导出去否则只能在当前模块内部用。2.3 别踩这些初始化坑第一个坑是 synchronize 和线上数据的问题。我之前有个项目开发阶段一直开着 synchronize上线时忘了关。有一段时间测试环境里手改过一张表的字段结果 TypeORM 启动时检测到实体和表不一致自动把表重构了几十条测试数据全没了。数据都是小问题关键是这种不确定性很吓人。所以我的习惯是synchronize 永远只在本地和 CI 的测试分支开预发布和生产一律用 migration 管理表结构。第二个坑是时区问题。PostgreSQL 的 timestamp 和 Node 的时区概念经常打架。TypeORM 默认会把数据库返回的时间解析成所在进程的时区。如果你在服务器上跑 Node 进程但数据库时间设置在 UTC那么查出来时间的本地展示会和预期差几个小时。解决方式是明确配置timezone: ZUTC或者统一使用timestamptz类型让数据库自己带时区信息。这里没有银弹关键是“一处定死”不要在多个地方来回转换否则排查起来会疯掉。第三个坑是连接池和并发。TypeORM 默认连接池可能只有 10 个连接高并发场景下不够用会出现TimeoutError: connection acquisition timed out。我一般会根据业务量把extra.max调大比如 20~50同时设置extra.idleTimeoutMillis和extra.connectionTimeoutMillis避免连接被无限占用。连接池不是越大越好太大反而会给数据库造成额外负担需要结合数据库的 max_connections 一起看。3. 实体映射与关联关系从表结构到代码模型3.1 实体定义的核心写法实体定义就是一个类加装饰器。以用户表为例import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from typeorm; Entity(users) export class User { PrimaryGeneratedColumn(uuid) id: string; Column({ length: 100 }) name: string; Column({ unique: true }) email: string; Column({ type: varchar, default: }) avatar: string; Column({ type: enum, enum: [active, disabled], default: active }) status: string; CreateDateColumn() createdAt: Date; UpdateDateColumn() updatedAt: Date; DeleteDateColumn() deletedAt: Date | null; }几个细节值得说清楚。Entity(users)里的参数是指定表名不写的话默认用类名。类名是 User表名就是 user有时候会觉得没毛病但很多项目的表名都是复数或者带前缀直接指定比较省心。PrimaryGeneratedColumn(uuid)推荐用 uuid对分布式系统更友好避免自增 ID 在外键关联和迁移时出问题。不过 uuid 主键也有代价索引相对较大写入性能会略低于自增整数。如果是纯单机小项目自增也完全没问题。DeleteDateColumn是做逻辑删除的关键配合 repository 的softDelete和softRemove使用时查询会自动过滤掉已删除记录。注意逻辑删除列如果自己写 SQL 关联查询要自己注意过滤条件TypeORM 不会自动帮你处理手写 SQL 里的软删条件。枚举用enum类型数据库层面也建一个枚举但注意这会给未来表结构变更带来麻烦。如果枚举值经常变我建议直接用 varchar 代码常量校验省心不少。数据库枚举一旦上线加一个值要做 migration小项目还好大项目里每次因为枚举值加一个类型都要发一次变更很磨人。3.2 一对多/多对多关联实操业务里最常见的就是关联查询。我用“用户-文章-标签”这个经典组合来说明。一对多一个用户有多篇文章。Entity(users) export class User { // ... OneToMany(() Post, post post.user) posts: Post[]; } Entity(posts) export class Post { PrimaryGeneratedColumn(uuid) id: string; Column() title: string; ManyToOne(() User, user user.posts) JoinColumn({ name: user_id }) user: User; }这里注意JoinColumn放在多的一方指定外键列名。多对一的关系里外键一定在“多”方这个不要搞反。如果你想在查询时把关联数据带出来要么在查询里写relations要么给关系加上eager: true。我一般倾向于显式写 relations因为 eager 是默认全部带出关联数据数据量大的时候很容易把不必要的关联数据都捞出来接口响应体积变大数据库压力也变大。多对多文章和标签。Entity(posts) export class Post { // ... ManyToMany(() Tag, tag tag.posts) JoinTable() tags: Tag[]; }JoinTable会帮我们自动生成一张中间表默认表名是 post_tags。如果要自定义中间表的表名和字段可以在JoinTable的参数里指定。多对多的中间表在设计时要考虑清楚是否要额外的排序字段、是否要记录创建时间如果需要建议把中间表升级成独立实体这样可以放更多业务字段。比如“用户收藏文章”这种关系中间表通常要记录收藏时间只靠自动生成的表就存不了。3.3 字段类型映射和常见陷阱TypeORM 的字段类型映射比较“写实”数据库类型TypeORM 写法说明INTint32位整数BIGINTbigint64位整数注意超出 JS 安全整数范围的问题VARCHARvarchar字符串TEXTtext长文本TIMESTAMPdatetime/timestamptz注意时区JSONjsonbPostgreSQL 推荐 jsonbDECIMALdecimal金额计算时用避免浮点误差decimal 的精度问题尤其容易踩坑。数据库里decimal(10,2)是固定精度但 TypeORM 返回的数字类型可能把它解析成字符串如果不注意前端展示和后端计算会莫名对不上。处理方式是显式声明列类型加 transformerColumn({ type: decimal, precision: 10, scale: 2, transformer: { to: (value: number) value, from: (value: string) Number(value), }, }) balance: number;这样从数据库取出来就是 number写入的时候 TypeORM 也能正确处理。JS 的浮点运算本身有精度问题涉及金额计算时建议在应用层用整数分来运算只在展示层转成元这是最稳妥的方案。JSON 字段用的是jsonb在 PostgreSQL 里做条件查询很强大但在 TypeORM 里查询要小心不同数据库对 JSON 字段的操作语法不一样跨数据库迁移时需要重点排查。如果你只是存一个配置对象不打算按里面的字段过滤直接用jsonb加序列化就行如果经常要按 JSON 字段内容筛选建议还是拆成独立表查询效率高很多。4. 事务处理与动态查询实战环节4.1 Repository 模式操作NestJS 里最常规的操作用 RepositoryInjectable() export class UserService { constructor( InjectRepository(User) private userRepository: RepositoryUser, ) {} async findOne(id: string) { return this.userRepository.findOneBy({ id }); } async create(data: PartialUser) { return this.userRepository.save(this.userRepository.create(data)); } }save和insert、update的区别值得讲清。save是 upsert 语义如果实体主键存在就更新不存在就插入它会先把实体查询出来比较字段差异后再执行语句所以调用成本比 insert 高。insert和update不触发实体 hooks也不处理关联关系。我一般优先用insert和update做简单场景用save做复杂场景比如需要在保存后拿到完整实体。还有个日常高频操作是分页。直接手写skip和take没问题但当数据量到几十万级别以后深分页的性能问题会非常明显数据库要扫描掉前 N 行才能拿到你要的那一页。优化方案是改用游标分页也就是基于createdAt或 id 做范围查询这个后面有机会单独写一篇实战里很值得做。4.2 事务的三种写法数据库操作里事务是保命手段。以转账为例从 A 账户扣钱、往 B 账户加钱两个操作必须同时成功或同时失败。我实际项目里最常用的写法是第一种通过 DataSource 拿一个事务性的 managerInjectable() export class TransferService { constructor(private dataSource: DataSource) {} async transfer(fromId: string, toId: string, amount: number) { await this.dataSource.transaction(async manager { const from await manager.findOneBy(User, { id: fromId }); const to await manager.findOneBy(User, { id: toId }); from.balance - amount; to.balance amount; await manager.save(from); await manager.save(to); }); } }事务函数里manager.save用的是事务连接如果中途抛异常整个事务自动回滚这是最推荐的方式。它不需要额外引包也不需要关心事务状态的清理代码内容一目了然。第二种使用Transactional()装饰器。这是社区包typeorm-transactional-cls-hooked的写法借助 AsyncLocalStorage 实现。它的优点是代码侵入小不用把 manager 透传到各个方法里。但它依赖包维护状态如果版本和 TypeORM 对不上坑很多。我实际用过一段时间遇到过一次事务不生效的诡异问题排查到最后是包和 Node 版本不兼容后来还是切回了原生写法。所以我建议能用第一种就尽量用第一种。第三种手动 begin/commit/rollbackconst queryRunner this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { await queryRunner.manager.save(user); await queryRunner.commitTransaction(); } catch (err) { await queryRunner.rollbackTransaction(); throw err; } finally { await queryRunner.release(); }这种方式适合里面要混合原生 SQL、并且需要手动控制释放的场景。平时能用第一种就不用这个代码写多了反而容易忘 release连接池就会被打满。如果你非用不可记得 promise finally 里释放连异常路径都不要漏掉。4.3 QueryBuilder 动态查询查询条件不固定时QueryBuilder 比 repository 灵活得多async findUsersByFilter(filter: { name?: string; status?: string; page: number; size: number }) { const qb this.userRepository.createQueryBuilder(user); if (filter.name) { qb.andWhere(user.name LIKE :name, { name: %${filter.name}% }); } if (filter.status) { qb.andWhere(user.status :status, { status: filter.status }); } qb.orderBy(user.createdAt, DESC) .skip((filter.page - 1) * filter.size) .take(filter.size); const [list, total] await qb.getManyAndCount(); return { list, total }; }关键点有三个。andWhere是链式拼接动态加条件非常顺手且不会互相覆盖。参数用:name占位符TypeORM 会自动做参数化处理杜绝 SQL 注入风险这个务必养成习惯不要用字符串拼接值进去。getManyAndCount一次返回数据和总数分页接口足够用了不需要发两条 SQL。关联查询要配合leftJoinAndSelect比如qb.leftJoinAndSelect(user.posts, post)。注意这里在 Entity 里定义好关系后字符串里的user.posts必须是关系属性名不是表名。写错的话 TypeORM 会告诉你找不到这个关系排查起来也很直接。另外如果只想关联部分字段就不要用 leftJoinAndSelect改用 leftJoin 加上 select 里的字段列表避免把大字段比如文章正文全部捞出来。5. 常见问题与排查技巧实录5.1 连接失败与初始化失败启动时最常遇到的报错就是Connection default was not found或者getaddrinfo ENOTFOUND。前者多半是 forRoot 配置和实体加载没对上后者是数据库 host 写错。容器环境下服务名和连接地址经常变更建议用环境变量统一管理不要在代码里写死。我一般会在项目根目录放一个.env.example把数据库相关的变量列清楚新同事拉代码后只需复制一份并按需修改不用读源码去找配置。数据库连接报告authentication failed时先确认用户名密码、再确认数据库权限。有些云数据库默认只允许白名单内 IP 访问公网环境连不上是正常的需要把服务器 IP 加进白名单。这个坑在本地跑得通、部署到服务器就失败的时候尤其多见。5.2 懒加载与关系不生效TypeORM 的 lazy 关系在实体属性类型上有要求比如PromisePost[]访问时是异步的用起来不直观容易忘 await。我在一个项目里见过这样的代码const post user.posts然后直接post.length拿到的其实是一个 Promise结果自然是 undefined。排查半天才意识到是懒加载的问题。一般场景我更建议用relations显式加载代码可读性好也方便控制查询时机。还有 N1 查询的问题。用relations加载关联数据时TypeORM 会先生成一条主查询然后再对每一条主记录执行一条关联查询。如果主记录有 100 条就会多出 100 条 SQL。数据量上来以后数据库压力会很明显。解决方式分两种数量少时不用管直接 relations数量大了就用 QueryBuilder 里的innerJoinAndSelect然后利用主查询的 join 把关联数据一次性带出来避免逐条查询。5.3 字段不更新问题用update方法不返回最新实体这是很多人踩过的坑。有同学直接const res await this.userRepo.update(id, data); return res;发现返回值不是实体后面拿 res 字段就报错。原因很简单UPDATE语句本身不返回被更新的行TypeORM 的 update 就是这个语义。正确做法是update之后再用findOneBy查一次或者直接改成save。save也有自己的坑它会触发实体的 beforeUpdate / afterUpdate 钩子如果你在钩子里修改了实体的某个字段注意不要把它改成不希望入库的值。另外 save 是“先查再写”没有主键时会走 insert 逻辑传错主键类型比如字符串 id 传成数字不会报错而是生成一条新数据这个在调试时很坑。5.4 migrations 的使用生产环境表结构变更必须用 migration。TypeORM 的 migration 机制本质上是一组 up/down 方法记录“本次变更”和“回滚操作”。初始化的时候需要在数据源配置文件里加上migrations路径和migrationsTableName然后通过命令行工具操作。npm run typeorm migration:generate -- -d path/to/data-source.ts src/migrations/AddUsersTable npm run typeorm migration:run -- -d path/to/data-source.tsmigration:generate会对比当前数据库和实体定义自动生成变更 SQL。注意这不是万能的如果实体定义语义模糊生成的 SQL 可能需要人工检查尤其是枚举类型、默认值、索引这类细节脚本经常生成得不尽人意。团队协作时核心规则是生成一次、提交一次、永不修改。每次合并代码前先检查自己的 migration 和别人是不是冲突否则数据库状态会断掉。冲突了不要改已提交的 migration 文件而是再写一个新的 migration 去修正这样才能保证迁移历史是线性的、可回溯的。我个人在实际项目里把 TypeORM 用在 NestJS 上也有三四年了。踩过的坑不少但整体下来的感受是TypeORM 的学习曲线不算陡麻烦的地方主要在于版本迭代快网上信息新旧混杂。所以我的建议是一定以官方文档为基准并且把synchronize从默认思维里去掉尽早把 migration 流程拉起来。这样到了项目后期你会感谢当初的自己。如果后面有空我可以再单独聊聊大数据量分页、连接池调优以及 NestJS 服务拆分时 TypeORM 怎么保持多数据源同步这些话题我都有不少值得记录的经验。
返回列表