Nest 使用笔记
第八章:TypeORM 集成——帖子服务的增删改查、分页、事务与迁移
打开 learnhub 的 PostService,在真实代码里讲透 TypeORM 的 Repository、QueryBuilder 分页、事务、404、所有权校验,以及生产必备的 migration 工作流。每段代码都能在 learnhub 里指到对应文件。
- Nest
- TypeORM
- 数据层
前面几章里 Nest 的骨架已经搭起来了。这一章开始接数据层——用 TypeORM 把 MySQL 接进 learnhub,让帖子真正落库。和「写个 Article 跑通四条 SQL」不同,这一章直接打开 learnhub 已经在生产里用的 PostService,在真实代码里讲透四件初学最容易跳过、上线最容易踩的事:事务、分页、404、所有权,再加一套迁移(migration)工作流。
先搞懂:TypeORM 是什么 / 为什么需要 / 企业级怎么用
TypeORM 是什么:Nest 生态最常用的 ORM(对象关系映射)库——把数据库表映射成一个 TS 类(Entity),字段是属性、列由 @Column 描述,操作数据库不写 SQL 字符串,而是调对象方法(repo.find()、repo.save()、repo.update()、repo.delete()),底层自动生成并执行 SQL。
为什么需要它:手写裸 SQL 的痛点很真实——字符串没类型提示、字段名拼错只有运行时炸、动态条件拼接容易 SQL 注入、表一改满项目找 SQL 改。ORM 一次性解决:类型安全的查询 API、参数化查询天然防注入。但前提是你要懂它背后生成的 SQL——否则 N+1、冗余 JOIN、慢查询会悄悄咬人。
企业级怎么用(下面每一条,这一章都会在 learnhub 里真做一遍,不是空头支票):
- 严格分 Controller(接 HTTP)→ Service(业务)→ Repository(数据访问) 三层,Service 注入
Repository而不是直接碰库; - schema 用 migrations 管,生产绝不开
synchronize(会按 Entity 直接 ALTER/DROP 表丢数据)——本章第六步落地; - 写操作要考虑原子性:一个动作涉及多张表(发帖 + 绑标签)就包进事务——本章第四步
create落地; - 查询警惕 N+1,列表接口必须有分页——本章第四步
findMany落地; - 改删接口要做所有权校验(只能动自己的)——本章第四步
assertAuthor落地。
这一章你会做出什么
- 打开 learnhub 的
src/modules/post/post.service.ts,逐方法讲清楚它为什么这么写。 - 在真实代码里吃透:事务创建(帖子 + 标签原子化)、QueryBuilder 分页 + 过滤、NotFoundException、所有权校验。
- 跑通一套迁移工作流:
migration:generate / run / revert,以及容器启动自动迁移。
前置:learnhub 的 MySQL 在跑(cd learnhub/docker && docker compose up mysql),仓库代码已拉到本地能打开看。
第一步:连接数据库——forRootAsync,配置走 env,synchronize 永远 false
新手写 TypeORM 连接,十有八九是这样的(别学这个):
// 反面教材:硬编码 + 生产定时炸弹(别学这个)
TypeOrmModule.forRoot({
type: "mysql",
host: "localhost",
username: "root",
password: "123456", // 密码写进代码、提交进 git
database: "nest_demo",
synchronize: true, // 生产会按 Entity 直接改表,丢数据
});
两个问题:配置硬编码(换环境就得改代码、密码泄露),synchronize: true(学习方便,生产会按 Entity 把表 ALTER/DROP 掉,数据说没就没)。learnhub 的真实写法是 forRootAsync + ConfigService 注入,配置全走环境变量:
// learnhub/src/app.module.ts
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: "mysql",
host: config.get<string>("mysql.host"),
port: config.get<number>("mysql.port"),
username: config.get<string>("mysql.username"),
password: config.get<string>("mysql.password"),
database: config.get<string>("mysql.database"),
autoLoadEntities: true, // 自动收集各模块 forFeature 注册的 Entity
synchronize: false, // 永远 false,靠 migration
logging: config.get("nodeEnv") === "development", // 学习期打 SQL,生产关
timezone: "+08:00",
charset: "utf8mb4",
}),
}),
为什么是 forRootAsync 而不是 forRoot:连接参数要从 ConfigService 拿(ConfigService 自己也是个 provider,要等 IoC 容器起来才能注入),forRoot 是静态同步的、拿不到。useFactory 把「等配置就绪 → 再建连接」这件事交给 Nest,工厂函数的参数就是 inject 里声明的依赖。
注意:synchronize: false 是铁律。它一旦为 true,TypeORM 启动时会拿 Entity 和库里的表做 diff,少了的列加上、多了的列删掉——开发期方便,生产期等于把删表权限交给了代码。schema 变更只能走 migration,第六步讲。logging 在开发期打开,你能直接在终端看到每一步生成的 SQL,是学 TypeORM 最快的办法。
第二步:定义 Post 实体
实体就是把一张表写成一个类。learnhub 的帖子表:
// learnhub/src/modules/post/entities/post.entity.ts
@Entity("post")
export class Post {
@PrimaryGeneratedColumn()
id: number;
@Column({ length: 100 })
title: string;
@Column({ type: "text" })
content: string;
@Column({ name: "view_count", type: "int", default: 0, comment: "浏览量(阶段5 改由 Redis 维护)" })
viewCount: number;
@Column({ name: "like_count", type: "int", default: 0 })
likeCount: number;
@Column({ default: false, comment: "是否置顶(仅 admin)" })
pinned: boolean;
@CreateDateColumn({ name: "create_time" })
createTime: Date;
@UpdateDateColumn({ name: "update_time" })
updateTime: Date;
// 下面四个是关系字段,第九章细讲,这章只用、不展开
@ManyToOne(() => User, (u) => u.posts)
author: User;
@OneToMany(() => Comment, (c) => c.post)
comments: Comment[];
@ManyToMany(() => Tag, (t) => t.posts)
@JoinTable({ name: "post_tag" })
tags: Tag[];
}
几个要点:@Column({ name: "view_count" }) 是因为 TS 驼峰、MySQL 下划线,用 name 显式映射列名,避免歧义;@CreateDateColumn / @UpdateDateColumn 是 TypeORM 的魔法列,插入/更新时自动写时间,不用自己维护;comment 写进 DDL 注释,方便后人看库时理解字段。关系字段(author / comments / tags)这章只当它们「能用」,映射细节留给第九章。
第三步:注册实体、注入 Repository
实体要在模块里登记,Nest 才会把 Repository<Post> 造出来给你注入:
// learnhub/src/modules/post/post.module.ts
@Module({
imports: [TypeOrmModule.forFeature([Post, Tag])], // 注册本模块用到的实体
controllers: [PostController],
providers: [PostService],
})
export class PostModule {}
forFeature([Post, Tag]) 干两件事:把 Post、Tag 登记进 autoLoadEntities 的收集清单,同时把 Repository<Post>、Repository<Tag> 注册成可注入的 provider。然后在 service 构造函数里拿:
// learnhub/src/modules/post/post.service.ts
@Injectable()
export class PostService {
constructor(
@InjectRepository(Post) private readonly postRepo: Repository<Post>,
@InjectRepository(Tag) private readonly tagRepo: Repository<Tag>,
@InjectDataSource() private readonly dataSource: DataSource, // 事务要用
// ...其它依赖:UserService / RankingService / AmqpService / SearchService
) {}
@InjectRepository(Post) 是「按实体拿它的 Repository」;@InjectDataSource() 拿到的是 DataSource 本身,事务要靠它(第四步 create 用)。注意 Service 注入的是 Repository 和 DataSource,不直接 import 任何 SQL——所有数据访问都经过这层,方便替换、测试、加日志。
第四步:Service——逐方法深讲(本章核心)
这是和「写个玩具 CRUD」差距最大的一节。把 PostService 五个方法一个一个拆开,看真实项目为什么这么写。
create:事务保住「帖子 + 标签」的原子性
发帖不只是插一条 post 记录,还要把选中的标签绑到中间表 post_tag 上。两步操作必须要么都成功、要么都回滚——否则标签绑了一半服务挂了,库里就留下一条没有标签、或标签错乱的帖子。learnhub 用 dataSource.transaction 包住它:
// learnhub/src/modules/post/post.service.ts
async create(dto: CreatePostDto, userId: number): Promise<Post> {
const post = await this.dataSource.transaction(async (manager) => {
const post = manager.create(Post, {
title: dto.title,
content: dto.content,
author: { id: userId } as any,
});
if (dto.tagIds?.length) {
post.tags = await manager.findBy(Tag, { id: In(dto.tagIds) });
}
return manager.save(post); // 一次 save:post + 关系 + 标签,全在一个事务里
});
// 事务提交成功后,再发领域事件、同步 ES(这些不属于帖子创建的原子范围)
this.amqp.publish("post.published", { postId: post.id, title: post.title, authorId: userId });
this.indexPost(post);
return post;
}
关键点:transaction 的回调参数 manager 是一个事务专属的 EntityManager,回调里所有 manager.save(...) 走的是同一条事务连接,回调一抛错整体回滚。注意 manager.save(post) 一次保存就把 post 和它的 tags 关系一起落库(TypeORM 的级联保存),不需要自己再写中间表的 INSERT。
注意:领域事件(amqp.publish)和 ES 同步(indexPost)放在事务外面。这是个有意的设计——发 MQ 消息、写 ES 不属于「帖子创建」的原子范围,不该因为 MQ 暂时不可用就把已经成功的帖子创建也回滚。事务的边界要画在对的地方:只包住「必须一起成功或一起失败」的库操作。
findMany:QueryBuilder 分页 + 过滤
列表接口最容易写坏的就是 findAll 把整张表查回来——表一上万行,接口直接拖垮内存和带宽。learnhub 的列表是分页 + 关键词 + 标签/作者过滤,用 QueryBuilder 拼:
// learnhub/src/modules/post/post.service.ts
async findMany(q: QueryPostDto): Promise<PageResult<Post>> {
const qb = this.postRepo
.createQueryBuilder("p")
.leftJoinAndSelect("p.author", "author")
.leftJoinAndSelect("p.tags", "tag")
.orderBy("p.pinned", "DESC") // 置顶优先
.addOrderBy("p.createTime", "DESC")
.skip(q.skip)
.take(q.take);
if (q.keyword) {
qb.andWhere("(p.title LIKE :kw OR p.content LIKE :kw)", { kw: `%${q.keyword}%` });
}
if (q.authorId) qb.andWhere("author.id = :aid", { aid: q.authorId });
if (q.tagId) qb.andWhere("tag.id = :tid", { tid: q.tagId });
const [list, total] = await qb.getManyAndCount();
return PageResult.of(list, total, q.page, q.pageSize);
}
几件事值得讲:
leftJoinAndSelect一次 JOIN 把author、tags带出来,避免 N+1(否则查 10 条帖子、再各查一次作者,就是 11 条 SQL)。skip(q.skip).take(q.take)做分页,skip/take来自QueryPostDto继承的PageDto((page-1)*pageSize)。getManyAndCount()一次返回[当前页数据, 总条数]——它内部会跑两条 SQL:一条LIMIT取数据,一条COUNT(*)取总数。total是分页前端画页码要的,但它本身是一次全表计数,数据量极大时也是成本,超大数据量会改用游标分页(cursor)省掉 COUNT。LIKE :kw用参数绑定(:kw),不是字符串拼接,天然防 SQL 注入。
分页参数的校验和计算封在公共 DTO 里,各业务模块继承复用:
// learnhub/src/common/dto/page.dto.ts
export class PageDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
page: number = 1;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100)
pageSize: number = 10;
@IsOptional() @IsString()
keyword?: string;
get skip(): number { return (this.page - 1) * this.pageSize; }
get take(): number { return this.pageSize; }
}
// learnhub/src/common/dto/page-result.dto.ts
export class PageResult<T> {
list: T[]; total: number; pageSize: number; page: number;
static of<T>(list: T[], total: number, page: number, pageSize: number) { /* 赋值返回 */ }
}
QueryPostDto extends PageDto 只加帖子专属的 tagId / authorId。这样每个资源的列表接口都自动有了一致的分页校验和返回结构。
注意:LIKE '%关键词%' 这种前后都加通配的模糊匹配不走索引,数据量大时会全表扫描。learnhub 的列表模糊查用 LIKE 是因为它是 MySQL 内建的简单方案;真要做海量全文检索,走第二十一章的 Elasticsearch(learnhub 的 search 接口就是 ES 实现的,贴标题加权 + 高亮)。
findOne:查不到就 404,不要返回 null
详情接口最常见的坑:查不到记录时返回 null,前端拿到一个 null 不知道是该报错还是没数据。learnhub 直接抛 NotFoundException,让全局异常过滤器统一回 404:
// learnhub/src/modules/post/post.service.ts
async findOne(id: number): Promise<Post> {
const post = await this.postRepo.findOne({
where: { id },
relations: { author: true, tags: true, comments: { author: true } },
});
if (!post) throw new NotFoundException("帖子不存在");
// 浏览量 +1 走 Redis(增量),由 RankingService 定时 flush 进库
this.ranking.recordView(id).catch(() => undefined);
return post;
}
relations: { author: true, tags: true, comments: { author: true } } 这种对象写法一次把作者、标签、评论(连带评论的作者)都带出来,等价于几个 JOIN,不会有 N+1。嵌套对象 comments: { author: true } 表示「评论要带,而且评论的作者也要带」。
思考:浏览量 +1 为什么不直接 post.viewCount++ 然后 save,而要走 Redis?——因为详情页是高并发接口,每次都 UPDATE post SET view_count = view_count + 1 会把这一行锁热,并发一上来数据库扛不住。learnhub 把增量先攒在 Redis 里,由定时任务批量 flush 进库(第五阶段 RankingService 干这事,后面的 Redis 章节会讲透)。这里的 .catch(() => undefined) 是降级:Redis 暂时挂了也不影响你看帖子详情,浏览量这次少计一次而已。
update / remove:所有权校验——只能动自己的
改和删最容易忘的就是权限:没有所有权校验的话,知道别人的帖子 id 就能改/删。learnhub 把校验抽成一个 assertAuthor,update 和 remove 都先调它:
// learnhub/src/modules/post/post.service.ts
async update(id: number, dto: UpdatePostDto, userId: number): Promise<Post> {
const post = await this.assertAuthor(id, userId); // 先校验:本人或 admin
Object.assign(post, { title: dto.title ?? post.title, content: dto.content ?? post.content });
if (dto.tagIds) post.tags = await this.tagRepo.findBy({ id: In(dto.tagIds) });
const saved = await this.postRepo.save(post);
this.indexPost(saved); // 更新后同步 ES
return saved;
}
async remove(id: number, userId: number): Promise<void> {
await this.assertAuthor(id, userId);
await this.postRepo.delete(id);
this.searchService.removePost(id).catch(() => undefined); // ES 同步删除
}
/** 所有权校验:本人或 admin 才能操作 */
private async assertAuthor(id: number, userId: number): Promise<Post> {
const post = await this.postRepo.findOne({ where: { id }, relations: { author: true } });
if (!post) throw new NotFoundException("帖子不存在");
const isAdmin = await this.isAdmin(userId);
if (post.author.id !== userId && !isAdmin) {
throw new ForbiddenException("只能操作自己的帖子");
}
return post;
}
ForbiddenException → 403,和 NotFoundException → 404 一样,交给全局异常过滤器统一处理。注意 assertAuthor 里查帖子时带了 relations: { author: true }——因为要拿 post.author.id 比对,不带出 author 就拿不到。「需要哪个关联就在查询时显式带上」是避 N+1 的基本原则:不是无脑全带(查列表带评论就浪费),也不是全不带(拿不到 author 又报错),而是按需。
注意:这里 assertAuthor 做的是数据级权限(这条帖子是不是你的),和第十二章 RBAC 的功能级权限(你有没有 post:create 这个能力)是两回事。功能级权限在 Controller 上用 @RequirePermission 守(见第五步),数据级权限在 Service 里校验,两层都要。
第五步:Controller——路由、版本、权限标记
Service 写完,Controller 就薄了,只负责接 HTTP、转交给 Service:
// learnhub/src/modules/post/post.controller.ts
@ApiTags("帖子")
@ApiBearerAuth()
@UseInterceptors(ClassSerializerInterceptor)
@Controller({ path: "posts", version: "1" }) // → /api/v1/posts
export class PostController {
constructor(private readonly postService: PostService) {}
@Post()
@RequirePermission("post:create") // 功能级权限:需要 post:create
create(@Body() dto: CreatePostDto, @CurrentUser() user: RequestUser) {
return this.postService.create(dto, user.userId);
}
@Public() // 列表公开,无需登录
@Get()
list(@Query() q: QueryPostDto) {
return this.postService.findMany(q);
}
@Public()
@Get("search") // 必须在 @Get(":id") 之前,否则 "search" 会被 :id 吃掉
search(@Query("q") q: string, @Query("page") page = 1, @Query("size") size = 10) {
return this.postService.search(q ?? "", Number(page), Number(size));
}
@Public()
@Get(":id")
detail(@Param("id") id: number) {
return this.postService.findOne(id);
}
@Patch(":id")
update(@Param("id") id: number, @Body() dto: UpdatePostDto, @CurrentUser() user: RequestUser) {
return this.postService.update(id, dto, user.userId);
}
@Delete(":id")
async remove(@Param("id") id: number, @CurrentUser() user: RequestUser) {
await this.postService.remove(id, user.userId);
}
}
几个真实项目才会注意的点:
@Controller({ path: "posts", version: "1" })配合全局的 URI 版本控制,路由是/api/v1/posts。第十章 Prisma 会做一个/api/v2/posts对照——同一份业务、两套 ORM 共存,靠版本号区分。@Public()标记的接口(列表、详情、搜索)不走全局登录守卫;没标的(发帖、改、删)必须登录。@CurrentUser()把 JWT 解出来的当前用户塞进来——登录和守卫的机制第十一章细讲,这里知道「带 token 才能调、用户信息从@CurrentUser()拿」就行。- 路由顺序:
@Get("search")必须写在@Get(":id")前面。Nest 路由是按声明顺序匹配的,:id是动态参数,会吃掉search这个字面量——写反了,访问/api/v1/posts/search会进detail,拿"search"去Number()变NaN,查不到返回 404,而且这个错误非常难发现。
第六步:迁移——生产怎么管 schema
这是旧版课程只在注释里提、从来不真做的一节。生产环境改表结构不能靠 synchronize,得靠迁移:每次 schema 变更写成一个带 up / down 的类,能正着执行、也能反着回滚,团队协作时按顺序重放。
为什么单独一份 data-source.ts
TypeORM 的迁移命令(typeorm migration:run)需要一个静态、可直接加载的 DataSource 实例。但 Nest 应用里的 DataSource 是在 forRootAsync 里、依赖 IoC 容器的,CLI 拿不到。所以得单独写一份不依赖 IoC 的:
// learnhub/src/data-source.ts
export default new DataSource({
type: "mysql",
host: process.env.MYSQL_HOST || "127.0.0.1",
port: parseInt(process.env.MYSQL_PORT || "3306", 10),
username: process.env.MYSQL_USER || "root",
password: process.env.MYSQL_PASSWORD || "",
database: process.env.MYSQL_DATABASE || "learnhub",
charset: "utf8mb4",
timezone: "+08:00",
synchronize: false, // 永远 false
entities: [User, Role, Permission, Post, Comment, Tag, ChatRoom, ChatMessage],
migrations: [path.join(__dirname, "migrations", "*.{ts,js}")],
});
__dirname 在 dev(ts-node)下是 src/、编译后是 dist/,所以 migrations 用相对 __dirname 的 glob,开发和生产都能跑。这份文件的连接参数和 app.module.ts 里的 forRootAsync 读的是同一套环境变量,所以不会出现「应用连 A 库、迁移跑到 B 库」的事故。
迁移文件长什么样
learnhub 有三个迁移:Init(建全部表)、Seed(灌种子数据:admin 账号、角色权限)、AddChat(后来加聊天功能时补的表)。每个迁移是一个实现 MigrationInterface 的类:
// learnhub/src/migrations/1784362383603-Init.ts
export class Init1784362383603 implements MigrationInterface {
name = "Init1784362383603";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE \`post\` (\`id\` int NOT NULL AUTO_INCREMENT, ...) ENGINE=InnoDB`);
await queryRunner.query(`CREATE TABLE \`post_tag\` (\`postId\` int NOT NULL, \`tagId\` int NOT NULL, ...) ENGINE=InnoDB`);
// ...更多建表、加外键
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE \`post_tag\``);
await queryRunner.query(`DROP TABLE \`post\``);
// ...倒序回滚
}
}
这个 Init 不是手写的——是 migration:generate 拿 Entity 和空库做 diff 自动生成的(你能看到那些 FK_c6fb082a3114f35d0cc27c518e0 一样的 hash 命名,就是自动生成的标志)。up 是正着执行(建表),down 是反着回滚(删表)。
命令、启动自动跑、和一条铁律
常用命令(learnhub 的 package.json 里封好了):
npm run migration:generate -- src/migrations/AddChat.ts # diff Entity 和库,生成新迁移
npm run migration:run # 执行所有没跑过的迁移
npm run migration:revert # 回滚最后一次
线上每次重启容器都手动跑迁移太脆,learnhub 在容器入口里做了自动迁移:
# learnhub/docker/docker-entrypoint.sh
if [ "$RUN_MIGRATIONS_ON_BOOT" = "true" ]; then
node node_modules/typeorm/cli.js migration:run -d dist/data-source.js || exit 1
fi
exec "$@"
RUN_MIGRATIONS_ON_BOOT=true 时,容器起来先跑迁移、再启动 Nest;迁移失败直接退出,不让带着旧 schema 的应用起来。
注意(铁律):migration:generate 生成的迁移一定要人眼 review 再 run。它是机器 diff,偶尔会误判——比如你只是调了个列顺序、或换了 ORM 版本,它可能生成「删掉整列再重建」的迁移,跑下去那一列的数据就没了。养成习惯:生成 → 看一眼 up 里有没有 DROP / 危险操作 → 再 run。
第七步:跑起来
cd learnhub
docker compose -f docker/docker-compose.yml up -d mysql # 起 MySQL
npm install
npm run migration:run # 建表 + 灌种子
npm run start:dev # 起服务
种子数据里有 admin / admin123456(带全部权限)。发帖需要登录拿 token(登录机制第十一章讲,这里先用):
# 1. 登录拿 token(第十一章细讲)
curl -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123456"}'
# {"accessToken":"eyJ...","refreshToken":"eyJ..."}
# 2. 带 token 发帖(绑两个标签)
curl -X POST http://localhost:3000/api/v1/posts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJ..." \
-d '{"title":"TypeORM 三种关系怎么记","content":"一对多看 owning side...","tagIds":[1,2]}'
# 3. 列表(分页,公开接口,不用 token)
curl 'http://localhost:3000/api/v1/posts?page=1&pageSize=10&keyword=关系'
# {"list":[...],"total":1,"page":1,"pageSize":10}
# 4. 详情(公开,浏览量 +1)
curl http://localhost:3000/api/v1/posts/1
start:dev 的终端里开着 logging,每一步生成的 SQL 都会打出来——发帖那条你能看到事务 BEGIN → INSERT post → INSERT post_tag → COMMIT,分页那条能看到 LIMIT + 一条 COUNT(*)。对着 SQL 学 TypeORM,比看文档快得多。
这一章的成果
- 用
forRootAsync+ConfigService接 MySQL,配置走 env、synchronize永远 false。 - 在真实
PostService里吃透四件上线必备:事务(create包住帖子+标签)、分页(findMany的 QueryBuilder +PageResult)、404(findOne抛NotFoundException)、所有权(assertAuthor本人或 admin)。 - 跑通一套迁移工作流:独立
data-source.ts、generate/run/revert、容器启动自动迁移,以及「生成的迁移要 review」这条铁律。 - 理解 Controller 层的功能级权限(
@RequirePermission)和 Service 层的数据级权限(assertAuthor)是两道关。
常见问题
- 启动报
EntityRepository/找不到实体:forRoot里要么entities: [...]列全,要么用autoLoadEntities: true(learnhub 用这个),并确保对应模块在AppModule.imports里。 N+1查询:列表/详情返回的数据缺关联,或终端 SQL 翻倍,就是 N+1。用relations: {...}或 QueryBuilder 的leftJoinAndSelect一次 JOIN 带出。判断方法:开logging看 SQL 条数。synchronize到底能不能开:开发期图方便可以,生产绝不能。它按 Entity 直接改库,会丢数据。生产一律走 migration。migration:generate提示「没有变化」:Entity 和库已经一致,自然没东西可生成。先改 Entity,再 generate。- 时区/中文乱码:连接配置加
timezone: '+08:00'、charset: 'utf8mb4'(learnhub 都加了)。
下一章讲 表与表之间的关系——把这一章里点到为止的 author / comments / tags 展开讲透:一对一、一对多、多对多在 TypeORM 里怎么映射、谁是 owning side、leftJoinAndSelect 和 relations 各自的边界。