在 Node.js 项目度过最初的“能跑就行”阶段后,随着接口增多、业务逻辑堆积,app.js(或 index.js)里动辄上千行的代码很快就会变得难以维护。一个合理的后端项目结构,本质上是在按照职责拆分代码,让每个文件只做一件事,且可以被替换和测试。这就是分层架构的价值。
目前 Node.js 社区最常见的分层方式是 三层架构:控制器层(Controller)、服务层(Service)、数据访问层(DAO/Repository),并辅以中间件层、工具层、常量层等横切关注点。这套结构不依赖特定框架,无论是 Express、Koa 还是 NestJS,都可以在其上映射。
17.1.1 核心三层的职责与边界
1. 控制器层(Controller)
控制器是请求的第一个入口,它的唯一职责是解析 HTTP 请求参数、调用对应的服务层逻辑、组装 HTTP 响应。控制器不应该包含任何业务判断,更不应直接操作数据库。
// user.controller.js
const userService = require('./user.service');
exports.getUserById = async (req, res, next) => {
try {
const { id } = req.params;
// 参数解析与校验
if (!id) {
return res.status(400).json({ message: '缺少用户ID' });
}
// 调用服务层
const user = await userService.findUserById(id);
if (!user) {
return res.status(404).json({ message: '用户不存在' });
}
// 组装响应
res.json({ data: user });
} catch (err) {
next(err);
}
};
一个好的控制器函数应该很短,典型的职责包括:
- 从
req中获取参数、路径、查询字符串、请求体 - 调用 service 层的一个或多个方法
- 根据返回结果构造 HTTP 状态码和 JSON 响应体
- 不直接访问数据库,不拼接 SQL/ORM 语句
- 不做复杂的业务判断(例如“用户是否有权限”应交给 service)
2. 服务层(Service)
服务层是业务逻辑的集中所在地。它从控制器接收已解析的参数,执行具体的业务规则,调用数据访问层获取或持久化数据,最后将结果返回给控制器。服务层并不知道 HTTP 的存在,它的输入输出都是普通的 JavaScript 对象。
// user.service.js
const userDao = require('./user.dao');
exports.findUserById = async (id) => {
// 业务规则:例如用户状态校验
const user = await userDao.getById(id);
if (user && user.status === 'banned') {
throw new Error('用户已被禁用');
}
// 可能还需要组合其他数据源
return user;
};
服务层的典型特征:
- 函数命名体现业务意图:
registerUser、transferBalance、calculatePrice - 可能会调用多个 DAO 或外部 API 的组合
- 负责事务、缓存、权限判断等业务规则的实现
- 与通信协议无关,因此可以被单元测试直接调用,不需要启动 HTTP 服务
3. 数据访问层(DAO/Repository)
这一层的唯一任务是与数据源交互,提供读写数据的操作方法。数据源通常是数据库(MySQL、MongoDB),也可能是 Redis、搜索引擎、外部 HTTP 服务等。每个 DAO 通常对应一张表或一个数据集合。
// user.dao.js
const db = require('../config/database');
exports.getById = async (id) => {
const [rows] = await db.execute('SELECT * FROM users WHERE id = ?', [id]);
return rows[0] || null;
};
如果使用 ORM(如 Sequelize、TypeORM、Prisma),DAO 层会直接封装 ORM 的操作,并可以添加一些简单查询封装(如分页默认值、软删除过滤等)。关键点:
- 不处理业务逻辑,只负责存取
- 提供清晰的接口,服务层无需知道底层用的是 MySQL 还是 MongoDB
- 便于后续更换数据库时集中修改
17.1.2 三层之外的横切关注点
除了纵向的三层,一个成熟的 Node.js 后端还需要处理横切关注点(Cross-cutting Concerns),它们不归属于某一层,而是贯穿请求处理的全过程。
中间件层(Middleware)
中间件是 Node.js Web 框架(特别是 Express/Koa)的核心机制,适合处理与请求生命周期相关的通用逻辑:
- 请求日志(morgan、pino-http)
- 跨域 CORS
- 身份认证与鉴权(JWT 验证、Session 恢复)
- 请求限流(express-rate-limit)
- 请求体解析(express.json、multer)
- 响应压缩(compression)
这些中间件应该在路由处理(控制器)之前或之后挂载,保持控制器代码的纯净。
工具层(Utils/Helpers)
纯粹的、无状态的工具函数集合,例如:
- 日期格式化(dayjs 封装)
- 加密解密函数(AES、MD5)
- 随机字符串生成、唯一 ID 生成
- 对象深拷贝、数组去重等通用操作
这些函数应该可以被任何层直接调用,且不依赖于项目状态。将它们集中放置,可以避免重复实现,也便于单元测试。
常量层(Constants)
项目中所有硬编码的常量都应该集中管理,例如:
- HTTP 状态码常量
- 错误消息枚举
- 用户角色枚举
- 订单状态常量
- 配置项键名
这不仅提高代码可读性,也让全局修改(如修改错误提示文案)变得容易。通常建议按业务域划分常量文件,如 constants/error-code.js、constants/user-status.js。
异常处理层(Error Handling)
统一的错误处理流程也是分层的重要体现。通常做法:
- 自定义业务异常类(如
AppError),携带状态码和错误消息 - 服务层抛出自定义异常
- 控制器或全局错误处理中间件捕获异常,转换为 HTTP 错误响应
这样业务逻辑产生的错误可以干净地传递到外层,而不必在每个控制器里写重复的 try-catch。
17.1.3 典型的项目目录结构
结合以上分层,一个常见的 Express 项目目录可能如下:
project/
├── src/
│ ├── controllers/ # 控制器层
│ │ ├── user.controller.js
│ │ └── order.controller.js
│ ├── services/ # 服务层
│ │ ├── user.service.js
│ │ └── order.service.js
│ ├── dao/ # 数据访问层
│ │ ├── user.dao.js
│ │ └── order.dao.js
│ ├── middlewares/ # 中间件
│ │ ├── auth.js
│ │ ├── error-handler.js
│ │ └── request-logger.js
│ ├── utils/ # 工具函数
│ │ ├── hash.js
│ │ └── validator.js
│ ├── constants/ # 常量定义
│ │ ├── error-code.js
│ │ └── user-role.js
│ ├── config/ # 配置文件(数据库、环境变量)
│ ├── routes/ # 路由定义(组装中间件与控制器)
│ └── app.js # 应用入口,挂载中间件与路由
├── tests/ # 测试文件,结构与 src 对应
├── package.json
└── .env
注意:路由文件可以单独抽出一层,或者由控制器导出一组路由(如 NestJS 的装饰器路由),但其本质仍是绑定 URL 到控制器。
17.1.4 分层带来的实际收益
1. 可测试性
服务层和 DAO 层都是纯逻辑,不依赖 HTTP 请求对象,可以直接用 Jest 或 Vitest 进行单元测试,不需要启动服务。控制器也可以 mock 掉 service 来测试接口行为。
2. 可替换性
如果将来决定把 MySQL 换成 PostgreSQL,只需修改 DAO 层的实现,服务层和控制器层完全无感。同理,把 Express 换成 Fastify,控制器中的 req, res 处理需要调整,但服务层依然可以复用。
3. 团队协作清晰
新人接手可以快速定位某段代码属于哪一层,进而知道该层应该关注什么输入输出。代码评审时也可以按层级检查责任是否符合规范。
4. 复用性增强
服务层的函数可以被多个控制器调用(例如用户服务可被 web 端、管理后台、定时任务共用),避免了同样的业务逻辑在多处重复。
17.1.5 实践中避免过度分层
分层架构不是强制的教条。对于小型项目或原型,全栈框架的“单文件模块”可能更高效,不必为了分层而分层。一个好的判断标准是:当某个功能有超过两个地方的相似代码,或者单文件超过 200 行,就可以考虑拆分。始终以可维护性和可测试性作为最终目标,而不是机械地套用目录结构。
此外,分层后不应出现“穿越层”的调用。例如控制器直接访问 DAO,或者服务层直接返回 HTTP 状态码给控制器,这都是违反分层原则的,长期会破坏架构的清晰度。在代码评审中应持续留意这些越界行为。
合理的后端分层,能够让 Node.js 项目从“脚本堆砌”升级为“可持续维护的应用”。在接下来的小节中,我们将继续探讨 RESTful API 设计规范以及代码质量保障工具,使这套分层结构在实际开发中更加稳固。