从 Express 到 NestJS:后端开发升级之路

129 阅读10分钟

从 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

项目启动后,结构一目了然:

image.png

  • 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 的组合提供了高效、可靠的企业级后端解决方案,尤其适合中大型项目和团队协作。