从 Express 到 NestJS:后端开发升级之路
在现代 Web 后端开发中,选择合适的框架直接影响项目的可维护性、可扩展性和团队协作效率。从轻量级的 Express 开始,许多开发者能够快速构建原型并验证业务逻辑;然而,随着项目规模增长、功能复杂度提升,代码结构松散、依赖管理困难等问题逐渐显现。这时,NestJS 作为一款基于 TypeScript 的渐进式 Node.js 框架,以其模块化架构、依赖注入机制和对企业级设计的支持,成为许多团队从“快速实现”向“结构化、可测试、可维护”转型的首选。
Express 起步:快速搭建 API 服务器
最初接触 Node.js 后端时,几乎是清一色的 Express 起步。
——轻量、灵活,Node.js 生态的常青树
安装 npm i express 然后直接写代码
三五行代码就能起一个服务器:
const express = require('express');
const app = express();
app.use(express.json());
app.get('/', (req, res) => {
res.send('Hello Express!');
});
app.post('/todos', (req, res) => {
// 业务逻辑直接写这里……
res.json({ message: 'Todo 创建成功' });
});
app.listen(3000, () => console.log('Server running'));
爽!快!自由度极高!想加个 cors、中间件、日志、错误处理?npm i 装上,app.use() 一塞就行。原型验证、个人项目、小接口、爬虫服务……Express 几乎是“万金油”。
然而,好景不长。
当项目进入第三个迭代,问题开始集中爆发:
- 路由文件急剧膨胀,单个文件轻松超过 800 行;
- 业务逻辑、数据库操作、参数校验、权限判断全部堆积在路由回调中;
- 服务间互相 require,容易形成循环依赖;
- 单元测试异常困难,到处充斥 new、require 和隐式全局状态;
- 新人上手需花费数天绘制“路由全景图”才能理解整体结构;
- 更换数据库、引入缓存或拆分微服务时,牵一发动全身。
这些问题本质上是缺乏内置架构约束导致的“自由过度”。难道 Node.js 后端注定只能“快速但不可维护”?
直到我接触到 NestJS——一款受 Angular 启发、深度集成 TypeScript 的框架,被誉为“Node.js 版的 Spring Boot”。它通过模块化、依赖注入和装饰器体系,提供了一套开箱即用的企业级设计范式,让代码从一开始就具备可扩展性和可测试性。
NestJS 初体验:从 CLI 生成到工厂式架构
使用 Nest CLI 一键生成项目:
npm i -g @nestjs/cli
nest new nest-test-demo
项目启动后,结构一目了然:
- main.ts:应用入口
- app.module.ts:根模块
- app.controller.ts:控制器
- app.service.ts:服务层
大量装饰器如 @Module、@Controller、@Injectable 映入眼帘。这让我立刻联想到 Java Spring Boot 的设计哲学。
起初会疑惑:为什么不直接写函数,而是层层封装?答案在于解耦与可维护。随着项目规模扩大,控制器专注路由处理,服务层承载核心业务逻辑,模块负责组装与依赖管理。NestJS 的 IoC 容器自动完成依赖注入,无需手动 new 对象,避免了 Express 中常见的“意大利面式”代码。
运行 npm run start:dev,访问 localhost:3000 即可看到 “Hello World!” 输出。那一刻,我深刻感受到:NestJS 不仅仅是工具,更是一种设计思想——自由 + 秩序。
项目结构解析:模块化与依赖注入的核心
咱们来复盘一下项目结构。
app.module.ts
我的项目里,app.module.ts 是根模块,它导入其他模块,像 TodosModule 和 DatabaseModule。代码是这样的:
import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { TodosModule } from "./todos/todos.module";
import { DatabaseModule } from "./database/database.module";
@Module({
imports: [TodosModule, DatabaseModule],
controllers: [AppController],
providers: [AppService]
})
export class AppModule {}
这里 @Module 装饰器定义了模块的 imports、controllers 和 providers。
imports 是导入子模块,controllers 是路由控制器,providers 是服务提供者。
为什么这样设计?因为 NestJS 强调模块化,每个功能块独立,比如 TodosModule 只管 Todo 相关的事,不会污染全局。这让我想到 MVC 模式:Model-View-Controller,但 NestJS 更细粒度,服务层就是业务逻辑的厨师,控制器只是端菜的。
app.controller.ts
接下来是 app.controller.ts,我在这里处理根路由。起初我直接在控制器里写逻辑,结果代码臃肿。后来学聪明了,注入 AppService:
import { Body, Controller, Get, Post, Inject } from "@nestjs/common";
import { AppService } from "./app.service";
@Controller()
export class AppController {
constructor(
@Inject('PG_CONNECTION') private readonly db: any,
private readonly appService: AppService
) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
@Get('welcome')
getWelcome(): string {
return this.appService.getWelcome();
}
@Post('login')
login(@Body() body: { username: string, password: string }) {
const { username, password } = body;
console.log(username, password);
if (!username || !password) {
return { code: 400, message: "用户名或密码不能为空" };
}
if (password.length < 6) {
return { code: 400, message: "密码长度不能小于6位" };
}
return this.appService.handleLogin(username, password);
}
@Get('db-test')
async testConnection() {
try {
const res = await this.db.query('SELECT * from posts');
return { status: '连接成功', data: res.rows };
} catch (error) {
return { status: '连接失败', error: error.message };
}
}
}
看这个 constructor,@Inject('PG_CONNECTION') 注入数据库连接,这是依赖注入的精髓。NestJS 的 IoC 容器(Inversion of Control)像工厂一样,自动创建和注入实例。
app.service.ts
服务层 app.service.ts 就简单了,它是 Injectable 的,被注入到控制器:
import { Injectable } from "@nestjs/common";
@Injectable()
export class AppService {
getHello(): string {
return '你好!!';
}
getWelcome(): string {
return '欢迎来到nest测试项目';
}
handleLogin(username: string, password: string) {
if (username === "user" && password === "123456") {
return { status: 200, message: "登录成功" };
} else {
return { status: 400, message: "登录失败" };
}
}
}
这里的服务像厨师,控制器是服务员。业务逻辑放这里,保持控制器干净。性能上也更好,因为服务是单例的,不会每次请求都创建。
RESTful 路由与管道(Pipe)校验
NestJS 的路由设计语义清晰,天然支持 RESTful:
RESTful 的好处是可读性强,客户端一看 URL 和 Method 就知道干嘛。
- GET:安全、幂等,只读操作,用于查询资源。典型场景:获取 Todo 列表、获取用户信息。示例:@Get()、@Get(':id')。
- POST:不安全、不幂等,用于创建新资源。典型场景:新增 Todo、用户注册、上传文件。请求体通常携带创建所需的数据。示例:@Post() → 创建一个新的 Todo。
- PUT:不安全、幂等,用于整体替换资源。典型场景:更新整个用户资料(包括所有字段)。客户端需要传完整的资源对象。如果资源不存在,通常创建(但更推荐用 POST 创建)。
- PATCH:不安全、幂等,用于局部更新资源。典型场景:只修改 Todo 的 completed 状态、修改用户昵称。请求体只传需要变更的字段(JSON Patch 或简单对象均可)。这是最常被误用的方法,很多项目把 PATCH 当 PUT 用,导致接口语义混乱。
- DELETE:不安全、幂等,用于删除资源。典型场景:删除某条 Todo、注销账号。示例:@Delete(':id')。
我在 todos.controller.ts 里实现了:
import { Controller, Get, Post, Body, Delete, Param, ParseIntPipe } from "@nestjs/common";
import { TodosService } from "./todos.service";
@Controller('todos')
export class TodosController {
constructor(private readonly todosService: TodosService) {}
@Get()
getTodos() {
return this.todosService.findAll();
}
@Post()
addTodo(@Body('title') title: string) {
return this.todosService.addTodo(title);
}
@Delete(':id')
deleteTodo(@Param('id', ParseIntPipe) id: number) {
console.log(typeof id, '////');
return this.todosService.deleteTodo(id);
}
}
额外补充两个常见扩展:
-
PUT /todos/:id → 整体替换某条 Todo(title、completed 都要传)
-
PATCH /todos/:id → 局部更新(只传 { completed: true } 即可)
注意: 这里有一个很容易犯错的地方
这里是上面Delete的业务逻辑部分
deleteTodo(id:number){
this.todos=this.todos.filter(todo=>todo.id!==id);
return{
message:'成功删除',
code:200
}
}
为什么上面要用 ParseIntPipe?
因为 param 默认是 string ,我一开始没加,id 是字符串,filter 时出 bug——todo.id 是 number,!== 不匹配。加了 Pipe 后,自动转 number,还能校验。
内存模拟 → 真实数据库接入(PostgreSQL)
起步阶段用内存数组模拟 Todo 列表非常方便:
import { Injectable } from "@nestjs/common";
export interface Todo {
id: number;
title: string;
completed: boolean;
}
@Injectable()
export class TodosService {
private todos: Todo[] = [
{ id: 1, title: '嘻嘻嘻', completed: false },
{ id: 2, title: 'sprit', completed: false }
];
findAll() {
return this.todos;
}
addTodo(title: string) {
const todo: Todo = { id: +Date.now(), title, completed: false };
this.todos.push(todo);
return todo;
}
deleteTodo(id: number) {
this.todos = this.todos.filter(todo => todo.id !== id);
return { message: '成功删除', code: 200 };
}
}
简单吧?但这只是起步。真正企业级,得接数据库。我选择了 PSQL,因为免费、可靠,还支持 JSONB 等高级特性。
先看 database.module.ts:
import { Module, Global } from "@nestjs/common";
import { Pool } from 'pg';
import * as dotenv from 'dotenv';
dotenv.config();
@Global()
@Module({
providers: [{
provide: 'PG_CONNECTION',
useValue: new Pool({
user: process.env.DB_USER,
host: process.env.DB_HOST,
database: process.env.DB_NAME,
password: process.env.DB_PASSWORD,
port: parseInt(process.env.DB_PORT || '5432', 10)
})
}],
exports: ['PG_CONNECTION']
})
export class DatabaseModule {}
@Global()让模块全局可用,无需每个地方重复导入。- 使用 pg Pool 创建连接池:比单连接高效,能复用连接,减少开销。如果用单连接,请求多时就可能会卡住。
dotenv.config()加载 .env 文件存储敏感信息(如密码)。提示: 别忘了在 .gitignore 中忽略 .env,避免密码泄露。
接入后,在控制器中注入 'PG_CONNECTION',即可查询(如 testConnection 方法)。
PSQL 基本指令:快速上手指南
在实际操作 PSQL 时,这些基本指令超级实用(用 psql 命令行工具):
- 连接数据库:
psql -h localhost -U your_user -d your_db(输入密码)。 - 查看数据库列表:
\l。 - 切换数据库:
\c your_db。 - 查看表列表:
\dt。 - 查询数据:
SELECT * FROM users LIMIT 10;(限制条数防卡顿)。 - 插入数据:
INSERT INTO users (name, password) VALUES ('test', 'hashed_pw');。 - 更新数据:
UPDATE users SET password = 'new_pw' WHERE id = 1;。 - 删除数据:
DELETE FROM users WHERE id = 1;。 - 退出:
\q。 - 注意:
;结尾 是操作数据库必不可少的
这些指令能帮你快速调试和维护。
接下来进入实战实例(以文章数据库为例)
建表设计:Users 表(用户基础信息)
建表注重规范性和扩展性,先建 users 表:
SQL
CREATE TABLE users (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
- BIGINT + IDENTITY:自增 ID,防溢出。
- name UNIQUE:自动创建索引,防止重复用户名。
- VARCHAR(255) for password:适配 bcrypt 等哈希算法。
- 审计字段(created_at、updated_at):自动记录时间,便于追踪变化。
建表设计:Posts 表(内容与外键关联)
然后建 posts 表,外键关联 users:
SQL
CREATE TABLE posts (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT,
"userId" BIGINT NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_posts_user FOREIGN KEY ("userId") REFERENCES users(id) ON DELETE CASCADE
);
- TEXT for content:适合长文本,无长度限制。
- 外键 fk_posts_user + ON DELETE CASCADE:删除用户时自动级联删除 posts,防止孤儿记录。
- 注意引号 "userId":PSQL 字段名大小写敏感。我一开始忘加约束,导致数据不一致,调试半天。
数据初始化:安全哈希与批量插入
初始化数据用 INSERT,确保密码哈希(用 bcrypt 等工具生成):
SQL
INSERT INTO users (id, name, password) VALUES
(1, '王皓', '$2b$10$CsO/ykedPpuxqUETBZTYm.F2U4TXDdo01rLmoRPwjKBv3pIL5pnWq');
-- 省略其他...
- 哈希密码:安全第一,绝不存明文。
- 批量插入:效率高,适合种子数据。
NestJS 设计哲学与实际收益
NestJS 的工厂模式(依赖注入 + 模块化)让代码高度可测试、可替换:换数据库只需修改 DatabaseModule。模块支持懒加载,性能友好。在实际项目中,我用 NestJS + PostgreSQL 构建博客系统,查询效率远超之前 Express 方案,大团队协作时代码风格统一,维护成本显著降低。
当然,NestJS 并非完美:TypeScript 编译稍慢、装饰器学习曲线陡峭。新手建议从小项目入手,逐步引入模块、守卫、拦截器等高级特性。
总结:从“野路子”到企业级思维的转型
通过这次实践,我完成了从 Express “快速但混乱”到 NestJS “结构化、可维护”的升级。NestJS + PostgreSQL 的组合提供了高效、可靠的企业级后端解决方案,尤其适合中大型项目和团队协作。