首页 / 文章

分层式的 Node.js API 设计:从臃肿的控制器到整洁架构

了解如何将 Node.js API 重构为控制器、服务层和数据访问层,从而解决复杂的业务逻辑、不一致的错误以及扩展难题。

发布者:REACTAPP.TOP阅读约 10 分钟2199 词

来源: I Built a Node.js API. Then I Realized I Was Doing It Wrong.

基于实际生产级 API 开发经验总结的关于如何正确构建、扩展和维护 Node.js 后端的实用技巧。

刚开始编写 Node.js API 时,后端工作看起来似乎简单至极。

定义一个路由。

将其与数据库连接起来。

返回一些 JSON 数据。

就这些。

在一段时间内,这种做法确实可行。

随后应用程序开始发展壮大。

突然之间你就面临以下问题:

  • 业务规则直接嵌入在控制器中
  • 数据库调用分散在各个路由文件中
  • 重复的验证逻辑
  • 不同端点之间存在差异的错误处理方式
  • 文件体积过大
  • 难以进行自动化测试的代码
  • 认证逻辑与业务逻辑相互纠缠
  • 细微改动就会导致看似无关的功能出错

从技术层面讲,该 API 并没有出现故障。

问题出在更微妙的地方:

对它进行修改已经变得极其困难。

就在这时,你才会意识到,仅仅能运行的 API 与能够承受持续实际开发压力的 API 之间存在真正差异。

1. 我早期的 Node.js 架构

早期典型的 API 可能长这样:

const express = require("express");
const app = express();
app.get("/users", async (req, res) => {
  const users = await db.query("SELECT * FROM users");  res.json(users);
});app.post("/users", async (req, res) => {
  const { name, email } = req.body;  const user = await db.query(
    "INSERT INTO users (name, email) VALUES ($1, $2)",
    [name, email]
  );  res.json(user);
});app.listen(3000);

这些代码本身都没有问题。

对于快速原型开发而言,这是个完全合理的起点。

问题会在项目开始扩展时出现。

想象一下需要叠加的各种功能,比如:

  • 身份验证
  • 权限控制
  • 输入校验
  • 邮箱验证
  • 支付处理
  • 优惠券管理
  • 订单管理
  • 库存跟踪
  • 通知功能
  • 后台任务处理
  • 基于Redis的缓存机制
  • 现在试想一下,如果把所有这些功能都直接塞进路由处理函数里会怎样。

    那样就不再有真正的架构可言了。

    剩下的只是一堆随机执行的代码而已。

    2. 问题并不在于Node.js

    这是初期很容易犯的错误。

    人们的本能反应是:

    “我的代码库变得混乱,只是因为应用规模变大了。”

    但这只是表面原因。

    真正的根本原因是职责划分不明确。

    一个控制器承担了过多的功能。

    它需要负责:

    1. 解析传入的请求
    2. 验证输入数据
    3. 检查身份认证
    4. 执行业务逻辑
  • 与数据库通信
  • 构建响应内容
  • 捕获并处理错误
  • 这六七项不同的任务被集中到同一个地方。

    每当需求发生变化时,就不得不再次编辑同一个文件。

    反复如此。

    3. 我现在使用的架构

    更清晰的策略是将应用程序拆分为不同的层次。

    例如:

    src/
    │
    ├── controllers/
    │   └── user.controller.ts
    │
    ├── services/
    │   └── user.service.ts
    │
    ├── repositories/
    │   └── user.repository.ts
    │
    ├── dto/
    │   └── create-user.dto.ts
    │
    ├── middleware/
    │   └── auth.middleware.ts
    │
    ├── routes/
    │   └── user.routes.ts
    │
    ├── utils/
    │   └── error-handler.ts
    │
    └── app.ts
    

    其基本原则很简单:

    每一层都应承担一个明确的职责。

    4. 控制器不应包含业务逻辑

    控制器的功能应仅限于处理HTTP相关事务。

    大致如下:

    export class UserController {
      constructor(
        private readonly userService: UserService
      ) {}
    
      async createUser(req: Request, res: Response) {
        try {
          const user = await this.userService.createUser(req.body);      return res.status(201).json({
            success: true,
            data: user,
          });
        } catch (error) {
          return res.status(500).json({
            success: false,
            message: error.message,
          });
        }
      }
    }
    

    控制器无需了解:

    • 用户记录存储的位置或方式
    • 所使用的数据库技术
  • 密码是如何被哈希处理的
  • 是否需要发送确认邮件
  • Redis是否在任何环节中被使用
  • 这些都不属于它的职责范围。

    它唯一的任务就是处理传入的请求并生成相应的响应。

    5. 将业务逻辑放入服务层

    实际的应用逻辑就存在于服务层。

    export class UserService {
      constructor(
        private readonly userRepository: UserRepository
      ) {}
    
      async createUser(data: CreateUserDto) {
        const existingUser =
          await this.userRepository.findByEmail(data.email);    if (existingUser) {
          throw new Error("User already exists");
        }    const hashedPassword = await hashPassword(
          data.password
        );    return this.userRepository.create({
          ...data,
          password: hashedPassword,
        });
      }
    }
    

    这样可以让逻辑被重复使用。

    系统的其他部分,例如,可以直接调用:

    userService.createUser(data);
    

    而完全无需模拟HTTP请求。

    这种可重用性对测试和长期维护都至关重要。

    6. 数据库访问应拥有独立的层

    业务逻辑不应被零散的原始SQL语句或ORM调用所充斥。

    这正是仓库模式要解决的问题。

    export class UserRepository {
    
      async findByEmail(email: string) {
        return prisma.user.findUnique({
          where: {
            email,
          },
        });
      }  async create(data: CreateUserInput) {
        return prisma.user.create({
          data,
        });
      }
    }
    

    这样一来,职责分离就变得显而易见了。

    Request
       ↓
    Controller
       ↓
    Service
       ↓
    Repository
       ↓
    Database
    

    每一层只了解它严格需要知道的信息,仅此而已。

    7. 为什么DTO的重要性超出你的预期

    一个常见的早期错误就是直接将原始请求体传入应用程序。

    类似这样的做法:

    const user = await userService.createUser(req.body);
    

    这很危险。

    req.body里到底包含什么?

    你能确定吗?

    DTO为所需内容建立了清晰明确的规范。

    class CreateUserDto {
      name: string;
      email: string;
      password: string;
    }
    

    从那之后,传入的数据会在接近应用程序逻辑之前进行验证。

    例如,像这样的数据内容:

    {
      "name": "Mit",
      "email": "mit@example.com",
      "password": "secret123"
    }
    

    可以依据以下规则进行检查:

    • 必填字段
    • 电子邮件格式
    • 密码长度
    • 数据类型
  • 允许的值
  • 这里的核心原则很简单:

    绝不要信任来自客户端的输入。

    前端并非安全边界。

    8. 错误处理的一致性很重要

    早期 API 中的一个相关问题是响应格式不一致。

    某个路由可能会返回:

    {
      "error": "User not found"
    }
    

    另一个路由的响应则是:

    {
      "message": "Not found"
    }
    

    还有另一个路由的响应形式类似:

    {
      "success": false,
      "data": null
    }
    

    对于任何基于此 API 开发前端的人而言,这种不一致性会让人十分困扰。

    采用统一的错误处理方式能极大提升 API 的使用便捷性。

    例如:

    {
      "success": false,
      "message": "User not found",
      "code": "USER_NOT_FOUND"
    }
    

    所选择的特定响应格式其实并不重要。

    重要的是它必须始终保持一致。

    9. 认证不等于授权

    这是许多开发者在初期容易混淆的概念。

    身份验证回答的是一个问题:

    你是谁?

    授权回答的是另一个问题:

    你被允许做什么?

    其处理流程大致如下:

    Login
      ↓
    Authentication
      ↓
    JWT / Session
      ↓
    User identified
      ↓
    Authorization
      ↓
    Can this user delete the product?
    

    仅仅因为用户通过了身份验证,并不意味着他们就可以随意执行任何操作。

    正因如此,角色与权限检查才需要与基本的登录/身份验证逻辑分开处理。

    10. 最终你必须考虑扩展性

    当代码的组织结构变得相对合理后,另一个问题就会出现:

    当流量增加时会发生什么?

    这时,Redis、任务队列和数据库索引等工具就开始发挥重要作用了。

    想象这样一个端点:它会在每次请求时都执行一次成本高昂的数据库查询。

    与其这样:

    Request
       ↓
    Database
       ↓
    Response
    

    系统或许最终会采用类似以下的方式:

    Request
       ↓
    Redis Cache
       ↓
    Cache Hit → Response
       │
       └── Cache Miss
              ↓
           Database
              ↓
           Redis
              ↓
           Response
    

    而对于那些不应阻塞响应的操作:

    API
     ↓
    Queue
     ↓
    Worker
     ↓
    Email / Notification / Processing
    

    这样就能避免缓慢且非关键的任务拖慢实际请求的处理速度。

    但这里有一个值得反复强调的注意事项:

    不要仅仅因为 Redis、队列、Kafka、微服务或 Kubernetes 看起来很厉害就选用它们。

    架构的存在是为了解决实际问题,而非制造新的问题。

    11. 这里最重要的教训

    真正的思维转变在于:

    停止追求代码编写的速度,而应注重其日后能否安全地修改。

    在启动第一个项目时,将所有内容都塞进一个文件里似乎效率很高。

    起初,确实如此。

    但编写初始版本仅是全部工作的一小部分。

    代码库后续会面临的大部分工作都在这时产生。

    随着时间推移,项目将需要:

    • 添加功能
    • 修复错误
    • 适应不断变化的需求
    • 更换库
    • 更新数据库结构
    • 添加身份验证功能
    • 引入权限系统
    • 加快慢查询速度
    • 连接第三方服务

    如果架构让这些任务都变得困难重重,那么初期获得的快速开发优势其实算不上真正的胜利。

    12. 更好的起点

    如今的新一代 Node.js 后端架构可以合理地按如下方式设计:

    src/
    │
    ├── modules/
    │   ├── users/
    │   │   ├── user.controller.ts
    │   │   ├── user.service.ts
    │   │   ├── user.repository.ts
    │   │   ├── user.dto.ts
    │   │   └── user.routes.ts
    │   │
    │   ├── products/
    │   │   ├── product.controller.ts
    │   │   ├── product.service.ts
    │   │   ├── product.repository.ts
    │   │   └── product.dto.ts
    │
    ├── middleware/
    ├── config/
    ├── database/
    ├── utils/
    └── app.ts
    

    对于规模较大的应用程序,围绕模块或功能来组织代码通常比将所有内容都塞进充满无关控制器和服务的巨大共享文件夹中更有效。

    具体的文件夹结构并非关键所在。

    重要的是每部分的功能职责都要清晰明确。

    13. 需要遵循的七条规则

    归根结底,这些内容可归纳为七条规则:

    1. 保持控制器简洁

    控制器的职责仅限于处理HTTP请求,除此之外别无其他功能。

    2. 将业务逻辑放在服务中

    应用程序的实际行为应存储在服务层中。

    3. 隔离数据库操作

    避免让数据库查询渗透到代码库的各个角落。

    4>验证外部输入

    不要假设req.body中一定包含预期的内容。

    5. 统一错误处理方式

    让错误响应具有可预测性和一致性。

    6. 仅在必要时添加基础设施

    Redis、队列和微服务是在有合理需求时才使用的工具,而非体现技术等级的标志。

    7. 为变更而设计

    负责维护这段代码的人,修改它的频率会远远高于从头编写新代码的频率。

    结论

    搭建一个可运行的 Node.js API 并不困难。

    在持续的功能开发过程中数月仍保持代码的可理解性,则是更大的挑战。

    人们很容易将优秀的后端开发等同于懂得如何进行连接:

    GET
    POST
    PUT
    DELETE
    

    那其实只是起点而已。

    真正的技能在于明白每段逻辑应放在何处、系统各部分如何相互通信,以及如何在不断演进的同时不破坏其他功能。

    并非每个项目都需要复杂的架构。

    它需要的是与实际问题的复杂程度相匹配的架构。

    因为目标从来都不是:

    “让它能运行起来。”

    真正的目标是:

    “在需求不可避免地发生变化时,使其易于调整。”