Node.js 项目结构最佳实践

随着应用规模的增长,良好的项目结构变得至关重要。一个清晰、一致的目录组织不仅可以提高代码的可读性和可维护性,还能让新成员快速上手。本章将介绍构建 Node.js 项目时推荐的项目结构、分层原则以及常见模式的实现。

1. 为什么项目结构很重要?

糟糕的项目结构会导致:

  • 代码耦合严重,难以修改和扩展。
  • 命名冲突和循环依赖。
  • 测试困难,因为职责混杂。
  • 新开发者需要很长时间才能理解代码库。

好的结构遵循关注点分离、单一职责和模块化原则,使应用更具弹性。

2. 通用原则

  • 按功能分层:将路由、控制器、服务、数据访问等分离。
  • 避免过度嵌套:通常不超过三层。
  • 使用模块化:每个模块有清晰的边界。
  • 配置与环境分离:使用环境变量和配置文件。
  • 测试与源码保持对应:测试文件放在 __tests__ 或与源码并列。

3. 推荐的目录结构

一个典型的 Express + MongoDB 项目结构可能如下:

my-node-app/ ├── src/ # 源代码目录 │ ├── config/ # 配置(数据库、环境变量解析等) │ ├── controllers/ # 路由控制器(处理请求、响应) │ ├── middleware/ # 自定义中间件 │ ├── models/ # 数据模型(Mongoose 模型等) │ ├── services/ # 业务逻辑层 │ ├── utils/ # 工具函数、辅助方法 │ ├── validations/ # 请求数据验证 │ ├── routes/ # 路由定义(通常与控制器对应) │ └── app.js # 应用初始化(中间件、路由挂载) ├── tests/ # 测试文件(或与 src 并列的 __tests__) ├── .env.example # 环境变量示例 ├── .gitignore ├── package.json └── server.js # 入口文件(启动服务器)

这个结构遵循了关注点分离的原则,每个目录有明确的职责。

4. 各目录详解

4.1 config/ —— 配置管理

集中管理所有配置,如数据库连接、第三方 API 密钥、应用设置。推荐使用 dotenv 加载环境变量,并提供一个配置对象导出。

// src/config/index.js
require('dotenv').config();

module.exports = {
  port: process.env.PORT || 3000,
  database: {
    url: process.env.DB_URL || 'mongodb://localhost:27017/myapp',
    options: { useNewUrlParser: true, useUnifiedTopology: true }
  },
  jwtSecret: process.env.JWT_SECRET,
  logLevel: process.env.LOG_LEVEL || 'info'
};

4.2 models/ —— 数据模型

定义数据结构和数据库交互。使用 ODM/ORM(如 Mongoose、Sequelize)时,模型通常放在这里。

// src/models/user.model.js
const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true },
  password: { type: String, required: true }
});

module.exports = mongoose.model('User', userSchema);

4.3 services/ —— 业务逻辑层

封装核心业务逻辑,避免在控制器中直接处理复杂业务。服务层可调用模型、外部 API、工具函数等。

// src/services/user.service.js
const User = require('../models/user.model');

class UserService {
  async createUser(userData) {
    const existing = await User.findOne({ email: userData.email });
    if (existing) throw new Error('邮箱已存在');
    const user = new User(userData);
    await user.save();
    return user;
  }
  // 其他方法...
}

module.exports = new UserService();

4.4 controllers/ —— 请求处理器

接收请求,调用服务,处理响应(包括错误处理和状态码)。不应包含业务逻辑。

// src/controllers/user.controller.js
const userService = require('../services/user.service');

exports.createUser = async (req, res, next) => {
  try {
    const user = await userService.createUser(req.body);
    res.status(201).json({ success: true, data: user });
  } catch (err) {
    next(err); // 传递给错误处理中间件
  }
};

4.5 routes/ —— 路由定义

将 HTTP 路径映射到对应的控制器方法。可以使用 express.Router 组织。

// src/routes/user.routes.js
const express = require('express');
const { createUser } = require('../controllers/user.controller');
const router = express.Router();

router.post('/', createUser);
// 其他路由...

module.exports = router;

然后在 app.js 中集中挂载所有路由模块:

// src/app.js
const express = require('express');
const userRoutes = require('./routes/user.routes');
// ... 其他中间件

const app = express();
app.use(express.json());
app.use('/api/users', userRoutes);
// ... 错误处理中间件

module.exports = app;

4.6 middleware/ —— 自定义中间件

身份验证、日志、请求解析等中间件。

// src/middleware/auth.js
module.exports = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: '未授权' });
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch {
    res.status(401).json({ error: '无效的 token' });
  }
};

4.7 utils/ —— 通用工具函数

诸如密码哈希、日期格式化、日志封装等。

// src/utils/logger.js
const winston = require('winston');
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' })
  ]
});
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({ format: winston.format.simple() }));
}
module.exports = logger;

4.8 validations/ —— 输入验证

可以使用 express-validatorjoi 定义验证规则。

// src/validations/user.validation.js
const Joi = require('joi');

exports.createUserSchema = Joi.object({
  name: Joi.string().required(),
  email: Joi.string().email().required(),
  password: Joi.string().min(6).required()
});

然后在控制器中使用验证中间件或手动验证。

4.9 入口文件 (server.js / app.js)

通常分为两个文件:app.js 负责配置 Express 应用(不监听端口),server.js 负责启动服务器并连接数据库。这样便于测试时导入 app 而不启动端口。

// server.js
const mongoose = require('mongoose');
const app = require('./src/app');
const config = require('./src/config');

mongoose.connect(config.database.url, config.database.options)
  .then(() => {
    app.listen(config.port, () => {
      console.log(`Server running on port ${config.port}`);
    });
  })
  .catch(err => {
    console.error('Database connection failed:', err);
    process.exit(1);
  });

5. 配置与环境变量

使用 dotenv 管理不同环境的配置。在项目根目录创建 .env(不提交)和 .env.example(模板)。通过 process.env 访问。

推荐使用配置对象将环境变量集中导出,如上文的 config/index.js

6. 集中错误处理

定义一个统一的错误处理中间件,捕获所有错误并返回标准格式的响应。还可以自定义错误类以区分错误类型。

// src/middleware/errorHandler.js
const logger = require('../utils/logger');

module.exports = (err, req, res, next) => {
  logger.error(err.stack);
  const status = err.status || 500;
  res.status(status).json({
    success: false,
    message: err.message || '服务器内部错误'
  });
};

app.js 中在所有路由之后使用该中间件。

7. 测试结构

测试文件可以放在 tests/ 目录,并保持与 src/ 类似的子目录结构。使用 Jest 时,可以在 package.json 中配置 testMatch 匹配 tests/**/*.test.js

tests/
├── unit/
│   ├── services/
│   │   └── user.service.test.js
│   └── utils/
│       └── logger.test.js
├── integration/
│   └── api/
│       └── user.test.js
└── setup.js                 # 测试全局配置

8. 使用依赖注入 (可选)

对于大型项目,可以使用依赖注入容器(如 awilix)管理依赖,使模块更易于测试和替换。将服务、控制器等注册到容器中,从容器中获取实例。

9. 模块化与边界

如果项目非常大,可以按业务领域划分模块,每个模块有自己的路由、控制器、服务、模型。例如:

src/modules/
├── user/
│   ├── controllers/
│   ├── services/
│   ├── models/
│   └── routes/
├── product/
│   └── ...
└── order/
    └── ...

这种结构称为“按组件划分”,内聚性更强。

10. 最佳实践清单

  • 将启动逻辑与应用逻辑分离(server.js vs app.js)。
  • 使用环境变量管理配置,不要硬编码。
  • 封装业务逻辑在服务层,保持控制器轻量。
  • 使用错误处理中间件统一处理异常。
  • 日志记录应结构化,便于分析。
  • 为路由添加版本前缀(如 /api/v1)。
  • 编写可测试的代码:依赖注入或模块化。
  • 保持一致的命名约定(如 user.controller.js)。
  • 定期检查并移除无用依赖。
  • 使用 .env.example 同步必需的变量。
总结: 良好的项目结构不是一蹴而就的,需要根据项目规模和团队习惯不断调整。上述结构是一个成熟且广泛采用的模板,你可以在此基础上定制。关键是保持一致性,让代码易于理解和维护。