Portfolio
← 返回博客列表
NestJS后端+Postgrelsql

2026-09-10 · 阅读 12

NestJS后端+Postgrelsql

NestJS

一次讲透 NestJS 与 PostgreSQL:IoC、DTO 两道防线、JOIN 家族、索引与事务

本文是《一次讲透 XSS 与 CSRF》《一次讲透 JWT 与双 Token 机制》《一次讲透 Redis》《一次讲透 React 渲染与性能优化》的续篇。为什么选 NestJS 而不是 Express 裸写?答案不是"它火",而是它的灵魂——IoC 容器。这篇把依赖注入、DTO 校验管道、TS 类型擦除、PG 的 JOIN 家族/索引/事务一条线讲透,顺带拆三个我面试真栽过的坑:单例 Service 存请求级状态、JOIN 概念层级说乱、"内连接性能慢"说反。

0. 没有 IoC 的世界:三个痛点

为什么需要依赖注入?先看传统写法(每个 Service 自己 new 依赖)到底烂在哪:

  1. 自己 new = 自己负责:UserService 必须知道每个依赖怎么构造——依赖的依赖、传什么参数、什么顺序,全要自己操心,业务代码被基建代码污染;
  2. 没法单独测试:单测想跑 UserService,就得连真数据库;想换 mock?改不到里面去,因为 new 写死在构造函数里;
  3. 重复创建:UserService、FileService、AuthService 各自 new Database()——三个连接池,资源爆炸。

一句话:创建权和业务逻辑搅在一起,耦死、测不动、重复造。

1. IoC 与 DI:控制反转,注入落地

1.1 两个概念,各一句话

  • IoC(控制反转):对象创建的控制权,从你手里反转给容器——我不 new,只声明我需要什么;
  • DI(依赖注入):实现 IoC 的具体方式——容器把创建好的依赖,通过构造函数传递给你。

1.2 类比:自己做饭 vs 点外卖

  • 自己做饭(自己 new):买菜、洗菜、开火、洗碗全程自己负责,每顿饭都要从头到尾管一遍;
  • 点外卖(IoC):只需要声明"要一份什么外卖"(构造函数里写类型),平台(容器)负责采购原料(Repository)、组装、配送(注入)。

1.3 NestJS 里的样子

@Injectable() // 注册:我是可被容器管理的
export class UserService {
  // 注入:只声明需要什么,不问它从哪来
  constructor(private readonly fileService: FileService) {}
}

@Injectable() 把类注册进容器,@Module() 的 providers 是依赖分区名册。容器在应用启动时扫描构造、实例化依赖图,全局单例,之后所有请求共享同一个实例。

1.4 IoC 的四大好处

  1. 解耦:Controller 只依赖 Service 的类型声明,Service 内部换实现,Controller 一行不改;
  2. 可测试:单测时把真 Repository 换成 mock 注入进 UserService,业务代码零改动;
  3. 统一生命周期:单例、连接池、销毁时机由容器统一管理;
  4. 结构清晰:Module 名册让依赖关系一目了然。

1.5 经典坑:单例 Service 里存请求级状态

面试官问:"为什么不能在 UserService 里存 this.currentUser = 用户信息?"——答对是亮点,答砸是送命题。

错在哪:Service 是容器管理的全局单例,所有请求共享同一个实例。往里面存请求级状态,B 请求会覆盖 A 请求的数据,造成数据串号——用户 A 突然看到用户 B 的文件,安全事故级 bug。

正确姿势:

  • Service 必须无状态——只放方法和共享配置;
  • 请求级数据挂 request 上——JWT Guard 解析 token 后把用户信息放到 req.user,后续_pipe/interceptor/handler_ 从 request 里取,天然按请求隔离。

时间尺度要对齐:单例是应用级生命周期,request 是请求级生命周期——请求级数据放进应用级的壳子里,必然互相踩。

1.6 为什么选 NestJS(30 秒话术)

"选 NestJS 核心是选它的 IoC 容器。传统写法每个 Service 自己 new 依赖,耦合死、没办法单独测试、重复创建连接池。NestJS 里我在 constructor 里声明依赖、@Injectable 注册进容器,框架负责创建和注入,天然可单测——单测时可以随意把 Repository 换成 mock 注入进去,业务代码零改动。再配上模块化的 Module 和开箱的 Guard、拦截器、过滤器,工程结构是约定好的,多人协作不容易失控。"

2. DTO 校验管道:没有 DTO 的 Controller 都在裸奔

2.1 不写 DTO 直接收 body 的三个风险

  1. 脏数据直通数据库:字段类型、长度、格式没有任何拦截;
  2. 越权攻击(mass assignment 批量赋值):攻击者在 body 里多塞一个 role: 'admin',如果代码把整个 body 直接存库——他自己就成了管理员;
  3. 手写 if 校验:每个字段一段 if,又长又漏,无法复用。

2.2 解法:DTO 声明契约 + ValidationPipe 执行契约

  • DTO(Data Transfer Object):用一个类声明"这个接口收什么形状的数据"——一份类型契约;
  • Pipe(管道):在数据到达 handler 之前执行契约校验/转换,不合格直接 400 打回。

NestJS 全局注册:

app.useGlobalPipes(new ValidationPipe({ whitelist: true }));

whitelist: true 防的就是 mass assignment:body 里凡是 DTO 没声明的多余字段(比如偷偷塞的 role),直接剥掉,永远到不了业务代码。

2.3 Nest 请求生命周期四件套

组件职责一句话
Guard 守卫认证授权JWT Guard 解析 token,决定放不放行
Pipe 管道校验/转换数据合不合格、类型对不对
Interceptor 拦截器包裹前后日志、缓存、响应转换
Filter 过滤器异常兜底统一错误格式

注意分工:鉴权是 Guard 的事,校验是 Pipe 的事——别把两个词混着说,面试官听得出来。

2.4 前端不是校验过了吗,后端为什么再校验?

前端校验是用户体验,后端校验是安全底线。

前端校验随时可以被绕过:Postman 直接打接口、改页面 JS。所以两层校验不是重复,是分工——前端管提示,后端管拦人。

3. TS:编译时防线 + 运行时防线,缺一不可

3.1 TS 的本质:把爆雷时机提前

JS 的问题是把错误推迟到运行时——用户先看到,我才看到。TS 把爆雷的点从线上提前到编辑器:写错类型当场爆红,重构时所有引用爆红,改到不红为止。

3.2 类型擦除(type erasure):TS 编译后类型全没了

关键认知:TS 编译成 JS 后,类型全部擦除,跑起来就是纯 JS。

也就是说 TS 类型只存在于编译时——它防得住自己写错代码,防不住外部传来的脏数据(Postman 想发什么发什么,编译器管不着运行时的请求)。

3.3 两道防线

防线时机防什么
TS 类型编译时防自己写错(引用爆红、重构护栏)
DTO + ValidationPipe运行时防外部数据不合法(脏数据、多余字段)

为什么 DTO 装饰器能活到运行时,TS 类型却不能?因为 class-validator 的装饰器编译后是真实存在的 JS 代码,而 string、number 这些类型标注编译后就蒸发了——前者是代码,后者只是注释级的声明。

3.4 项目话术(30 秒)

"JS 把错误推迟到运行时,用户先看到我才看到;TS 把爆雷点从线上提前到编辑器,重构时所有引用爆红,改到不红为止。但 TS 类型只在编译时存在,编译后全部擦除,防不了外部数据。所以我配 DTO 加 ValidationPipe 做运行时校验,whitelist 剥掉未声明的多余字段,防批量赋值越权——编译时和运行时两道防线,缺一不可。"

4. PostgreSQL 专项:JOIN 家族、索引、事务

4.1 JOIN 家族树:先把概念层级修对

我第一次面试的翻车现场:答到 JOIN 只说了"左连接右连接内连接,漏了全连接和外连接",还补了一句"内连接性能相对来说比较慢"。两个病灶:

  1. 概念层级乱了:左连接、右连接、全连接本身就是外连接的三种——它们不是和"外连接"并列的第四种东西;
  2. 性能说反了:INNER 的结果集是 LEFT 的子集(LEFT = INNER + 左表没匹配上的那些行),结果集更小只会更快。连接的性能真正取决于 ON 字段有没有索引,跟连接类型关系不大。

正确的一棵树:

JOIN
├── INNER JOIN(内连接):只要交集——两表都匹配的行
└── OUTER JOIN(外连接)家族:主表全保留,没匹配上的补 NULL
    ├── LEFT JOIN(左连接):左表全保留
    ├── RIGHT JOIN(右连接):右表全保留
    └── FULL JOIN(全连接):两表都全保留

一句话区分:INNER = 只要交集;OUTER = 以某张表为主全保留,没匹配上的补 NULL。

用 CloudStore 的 users / files 两张表当例子做选型:

需求用哪个判定诀窍
所有用户及文件(没传过的也要出现)LEFT JOIN要出现"没有对方记录"的行 → 必然外连接
有文件的用户(活跃统计)INNER JOIN只要交集
以文件为主、连用户都没了的孤儿文件也要RIGHT JOIN(或换表顺序用 LEFT)—
两边都要全保留FULL JOIN—

场景题加分点:"没传过文件的显示 0"——不用写 if 判断,LEFT JOIN + COUNT(f.id) 自带这个效果:COUNT 不数 NULL,没匹配上的行文件 id 是 NULL,数出来自然是 0。

4.2 索引:空间换时间,但不是免费的

索引的底层是 B+ 树,类比书的目录:不查索引 = 一页页翻(全表扫描,O(n));查索引 = 先看目录定位章节再翻过去(O(log n))。

为什么不是建得越多越好?代价两条:

  1. 额外存储:每棵索引树都要占地方;
  2. 拖慢写入:每次 INSERT/UPDATE 都要同步维护所有索引树——索引越多,写入越慢。

失效场景(建了也白建,退化成全表扫描):

  • 对索引列做函数运算:WHERE YEAR(created_at) = 2024;
  • LIKE 前导通配符:LIKE '%文件'(反过来 '文件%' 可以用上索引);
  • 联合索引不满足最左前缀:建了 (a, b) 却只按 b 查。

4.3 事务 ACID:用"下单"场景背

场景:下单 = 扣库存 + 创建订单,两步必须放进同一个事务。

特性一句话下单场景里保证什么
A 原子性要么全做要么全不做扣库存+建订单绑成一个整体,全成或全回滚——不会"扣了库存订单没建"
C 一致性合法状态到合法状态库存+已售=总量这条不变式事务前后都成立——不会扣成负数
I 隔离性并发事务互不干扰两单同时抢最后一件库存,不会都成功——不超卖
D 持久性提交即永久提交后宕机重启,订单还在

两个常见答歪点:原子性说的是两个操作绑成一个整体,不是单个操作自身的成败;一致性不是"订单数据合法"这种表层,而是业务不变式不被破坏。

5. 总结记忆卡

  • 没有 IoC 三痛点:自己 new 全负责 / 没法换 mock 单测 / 重复创建连接池
  • IoC vs DI:IoC = 创建权反转给容器;DI = 容器经构造函数注入,是 IoC 的实现方式
  • 实例归属:容器启动时创建,全局单例,所有请求共享同一个实例
  • 四大好处:解耦 / 可测试 / 统一生命周期 / Module 名册结构清晰
  • 单例坑:Service 是应用级单例,存请求级状态 = B 覆盖 A 数据串号;请求级数据挂 req.user
  • DTO = 类型契约(声明收什么形状);ValidationPipe = 执行契约
  • 三风险:脏数据直通库 / mass assignment 越权 / 手写 if
  • whitelist: true:剥掉 DTO 没声明的字段,防批量赋值攻击
  • 生命周期四件套:Guard 认证授权 / Pipe 校验转换 / Interceptor 包裹前后 / Filter 异常兜底
  • 前后端校验分工:前端是体验,后端是安全底线(Postman 可绕过前端)
  • 类型擦除:TS 编译后是纯 JS,类型只活在编译时
  • 两道防线:TS 类型 = 编译时防线(防自己错);DTO + Pipe = 运行时防线(防外部脏数据)
  • 装饰器为什么能活到运行时:它编译后是真实 JS 代码,类型标注只是会被擦除的声明
  • JOIN 层级树:左/右/全连接本身就是外连接的三种,不是并列关系
  • INNER vs OUTER:INNER 只要交集;OUTER 主表全保留、没匹配补 NULL
  • 场景题"所有用户+没传的显示 0":LEFT JOIN + COUNT(COUNT 不数 NULL,自带 0)
  • 连接性能:不看连接类型,看 ON 字段有没有索引;INNER 结果集更小只会更快
  • 索引代价:额外存储 + 拖慢写入
  • 索引失效:函数运算 / LIKE 前导通配 / 不满足最左前缀
  • ACID 下单版:原子=两操作绑整体全成全回滚;一致=库存+已售=总量不变式;隔离=不超卖;持久=宕机数据还在

本文基于个人项目实战(CloudStore 全栈开发)与面试复盘整理,如有疏漏欢迎指出。系列前篇:《一次讲透 XSS 与 CSRF》《一次讲透 JWT 与双 Token 机制》《一次讲透 Redis》《一次讲透 React 渲染与性能优化》。

评论 (0)

0 / 1000

还没有评论,来抢沙发吧~