坚不可摧的 node.js 项目架构 🛡️ 简介 目录 文件夹结构 🏢 三层架构 🥪 使用 Pub/Sub 层 🎙️ 依赖注入 💉 单元测试示例 🕵🏻 Cron Jobs 和循环任务 ⚡ 配置和机密 🤫 加载器 🏗️ 结论 在此处查看示例存储库 ✋ 嘿!在你离开之前 🏃‍

2025-05-26

坚不可摧的 node.js 项目架构🛡️

介绍

目录

文件夹结构🏢

三层架构

也使用 Pub/Sub 层🎙️

依赖注入💉

单元测试示例🕵🏻

Cron Jobs 和重复任务⚡

配置和秘密🤫

装载机🏗️

结论

请参阅此处的示例存储库

✋ 嘿!走之前🏃‍

最初发布于softwareontheroad.com

更新于 2019 年 4 月 21 日GitHub 存储库中的实现示例

介绍

Express.js 是制作 node.js REST API 的绝佳框架,但是它没有提供任何有关如何组织 node.js 项目的线索。

虽然听起来可能有点傻,但这是一个真正的问题。

正确组织您的 node.js 项目结构将避免代码重复,提高稳定性,并且如果正确完成,将有可能帮助您扩展服务。

这篇文章是一份广泛的研究,源自我多年处理结构不良的 node.js 项目、不良模式以及无数小时重构代码和移动内容的经验。

如果您需要帮助调整您的 node.js 项目架构,请给我写一封信至santiago@softwareontheroad.com

目录

文件夹结构🏢

以下是我所谈论的 node.js 项目结构。

我在构建的每个 node.js REST API 服务中都使用它,让我们详细了解每个组件的作用。



  src
  │   app.js          # App entry point
  └───api             # Express route controllers for all the endpoints of the app
  └───config          # Environment variables and configuration related stuff
  └───jobs            # Jobs definitions for agenda.js
  └───loaders         # Split the startup process into modules
  └───models          # Database models
  └───services        # All the business logic is here
  └───subscribers     # Event handlers for async task
  └───types           # Type declaration files (d.ts) for Typescript


Enter fullscreen mode Exit fullscreen mode

它不仅仅是一种排序 javascript 文件的方式......

三层架构

这个想法是使用关注点分离原则将业务逻辑从 node.js API 路由中移开。

3层图案

因为有一天,您会希望在 CLI 工具上使用您的业务逻辑,或者在重复任务中使用您的业务逻辑。

从 node.js 服务器对其自身进行 API 调用并不是一个好主意...

node.js REST API 的 3 层模式

☠️ 不要将你的业务逻辑放在控制器里面!!☠️

您可能只想使用 express.js 控制器来存储应用程序的业务逻辑,但这很快就会变成意大利面条式代码,一旦您需要编写单元测试,您最终将处理reqres express.js 对象的复杂模拟。

区分何时发送响应以及何时在“后台”继续处理(比如说在将响应发送给客户端之后)是很复杂的。

以下是不该做的事情的示例。



  route.post('/', async (req, res, next) => {

    // This should be a middleware or should be handled by a library like Joi.
    const userDTO = req.body;
    const isUserValid = validators.user(userDTO)
    if(!isUserValid) {
      return res.status(400).end();
    }

    // Lot of business logic here...
    const userRecord = await UserModel.create(userDTO);
    delete userRecord.password;
    delete userRecord.salt;
    const companyRecord = await CompanyModel.create(userRecord);
    const companyDashboard = await CompanyDashboard.create(userRecord, companyRecord);

    ...whatever...


    // And here is the 'optimization' that mess up everything.
    // The response is sent to client...
    res.json({ user: userRecord, company: companyRecord });

    // But code execution continues :(
    const salaryRecord = await SalaryModel.create(userRecord, companyRecord);
    eventTracker.track('user_signup',userRecord,companyRecord,salaryRecord);
    intercom.createUser(userRecord);
    gaAnalytics.event('user_signup',userRecord);
    await EmailService.startSignupSequence(userRecord)
  });



Enter fullscreen mode Exit fullscreen mode

# 使用服务层来实现业务逻辑

您的业​​务逻辑应该存在于此层。

它只是一个具有明确目的的类的集合,遵循应用于 node.js 的SOLID原则。

此层中不应存在任何形式的“SQL 查询”,而应使用数据访问层。

  • 将你的代码从 express.js 路由器中移开

  • 不要将 req 或 res 对象传递给服务层

  • 不要返回与 HTTP 传输层相关的任何内容,例如来自服务层的状态代码或标头。

例子



  route.post('/', 
    validators.userSignup, // this middleware take care of validation
    async (req, res, next) => {
      // The actual responsability of the route layer.
      const userDTO = req.body;

      // Call to service layer.
      // Abstraction on how to access the data layer and the business logic.
      const { user, company } = await UserService.Signup(userDTO);

      // Return a response to client.
      return res.json({ user, company });
    });


Enter fullscreen mode Exit fullscreen mode

以下是您的服务在幕后的工作方式。



  import UserModel from '../models/user';
  import CompanyModel from '../models/company';

  export default class UserService {

    async Signup(user) {
      const userRecord = await UserModel.create(user);
      const companyRecord = await CompanyModel.create(userRecord); // needs userRecord to have the database id 
      const salaryRecord = await SalaryModel.create(userRecord, companyRecord); // depends on user and company to be created

      ...whatever

      await EmailService.startSignupSequence(userRecord)

      ...do more stuff

      return { user: userRecord, company: companyRecord };
    }
  }



Enter fullscreen mode Exit fullscreen mode

访问示例存储库

也使用 Pub/Sub 层🎙️

发布/订阅模式超越了这里提出的经典 3 层架构,但它非常有用。

现在创建用户的简单 node.js API 端点可能想要调用第三方服务,也许是分析服务,或者也许启动电子邮件序列。

不久之后,这个简单的“创建”操作将完成几件事,最终您将得到 1000 行代码,所有这些都包含在一个函数中。

这违反了单一职责原则。

因此,最好从一开始就分离职责,以便您的代码保持可维护性。



  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  import SalaryModel from '../models/salary';

  export default class UserService() {

    async Signup(user) {
      const userRecord = await UserModel.create(user);
      const companyRecord = await CompanyModel.create(user);
      const salaryRecord = await SalaryModel.create(user, salary);

      eventTracker.track(
        'user_signup',
        userRecord,
        companyRecord,
        salaryRecord
      );

      intercom.createUser(
        userRecord
      );

      gaAnalytics.event(
        'user_signup',
        userRecord
      );

      await EmailService.startSignupSequence(userRecord)

      ...more stuff

      return { user: userRecord, company: companyRecord };
    }

  }


Enter fullscreen mode Exit fullscreen mode

对依赖服务进行命令式调用并不是最好的方法。

更好的方法是发出事件,即“用户使用此电子邮件注册”。

您已经完成了,现在听众有责任完成他们的工作。



  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  import SalaryModel from '../models/salary';

  export default class UserService() {

    async Signup(user) {
      const userRecord = await this.userModel.create(user);
      const companyRecord = await this.companyModel.create(user);
      this.eventEmitter.emit('user_signup', { user: userRecord, company: companyRecord })
      return userRecord
    }

  }


Enter fullscreen mode Exit fullscreen mode

现在您可以将事件处理程序/侦听器拆分到多个文件中。



  eventEmitter.on('user_signup', ({ user, company }) => {

    eventTracker.track(
      'user_signup',
      user,
      company,
    );

    intercom.createUser(
      user
    );

    gaAnalytics.event(
      'user_signup',
      user
    );
  })


Enter fullscreen mode Exit fullscreen mode


  eventEmitter.on('user_signup', async ({ user, company }) => {
    const salaryRecord = await SalaryModel.create(user, company);
  })


Enter fullscreen mode Exit fullscreen mode


  eventEmitter.on('user_signup', async ({ user, company }) => {
    await EmailService.startSignupSequence(user)
  })


Enter fullscreen mode Exit fullscreen mode

您可以将 await 语句包装到 try-catch 块中,或者您可以让它失败并处理“unhandledPromise” process.on('unhandledRejection',cb)

依赖注入💉

DI 或控制反转(IoC)是一种常见模式,它通过“注入”或通过构造函数传递类或函数的依赖项来帮助组织代码。

通过这种方式,您将获得注入“兼容依赖项”的灵活性,例如,当您为服务编写单元测试时,或者在另一个上下文中使用该服务时。

没有 DI 的代码



  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  import SalaryModel from '../models/salary';  
  class UserService {
    constructor(){}
    Sigup(){
      // Caling UserMode, CompanyModel, etc
      ...
    }
  }


Enter fullscreen mode Exit fullscreen mode

具有手动依赖注入的代码



  export default class UserService {
    constructor(userModel, companyModel, salaryModel){
      this.userModel = userModel;
      this.companyModel = companyModel;
      this.salaryModel = salaryModel;
    }
    getMyUser(userId){
      // models available throug 'this'
      const user = this.userModel.findById(userId);
      return user;
    }
  }


Enter fullscreen mode Exit fullscreen mode

现在您可以注入自定义依赖项。



  import UserService from '../services/user';
  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  const salaryModelMock = {
    calculateNetSalary(){
      return 42;
    }
  }
  const userServiceInstance = new UserService(userModel, companyModel, salaryModelMock);
  const user = await userServiceInstance.getMyUser('12346');


Enter fullscreen mode Exit fullscreen mode

服务可以拥有的依赖关系数量是无限的,并且在添加新服务时重构它的每个实例是一项无聊且容易出错的任务。

这就是创建依赖注入框架的原因。

这个想法是你在类中声明你的依赖项,当你需要该类的实例时,你只需调用“服务定位器”。

让我们看一个使用typedi的示例,这是一个将 DI 引入 node.js 的 npm 库

您可以在官方文档中阅读有关如何使用 typedi 的更多信息

警告 TypeScript 示例



  import { Service } from 'typedi';
  @Service()
  export default class UserService {
    constructor(
      private userModel,
      private companyModel, 
      private salaryModel
    ){}

    getMyUser(userId){
      const user = this.userModel.findById(userId);
      return user;
    }
  }


Enter fullscreen mode Exit fullscreen mode

服务/用户.ts

现在typedi将负责解决 UserService 所需的任何依赖关系。



  import { Container } from 'typedi';
  import UserService from '../services/user';
  const userServiceInstance = Container.get(UserService);
  const user = await userServiceInstance.getMyUser('12346');


Enter fullscreen mode Exit fullscreen mode

滥用服务定位器调用是一种反模式

在 Node.js 中使用 Express.js 的依赖注入

在 express.js 中使用 DI 是这个 node.js 项目架构的最后一块拼图。

路由层



  route.post('/', 
    async (req, res, next) => {
      const userDTO = req.body;

      const userServiceInstance = Container.get(UserService) // Service locator

      const { user, company } = userServiceInstance.Signup(userDTO);

      return res.json({ user, company });
    });


Enter fullscreen mode Exit fullscreen mode

太棒了!项目看起来棒极了!
条理清晰,让我迫不及待地想写点代码。

访问示例存储库

单元测试示例🕵🏻

通过使用依赖注入和这些组织模式,单元测试变得非常简单。

您不必模拟 req/res 对象或 require(...) 调用。

示例:注册用户方法的单元测试

测试/单元/服务/user.js



  import UserService from '../../../src/services/user';

  describe('User service unit tests', () => {
    describe('Signup', () => {
      test('Should create user record and emit user_signup event', async () => {
        const eventEmitterService = {
          emit: jest.fn(),
        };

        const userModel = {
          create: (user) => {
            return {
              ...user,
              _id: 'mock-user-id'
            }
          },
        };

        const companyModel = {
          create: (user) => {
            return {
              owner: user._id,
              companyTaxId: '12345',
            }
          },
        };

        const userInput= {
          fullname: 'User Unit Test',
          email: 'test@example.com',
        };

        const userService = new UserService(userModel, companyModel, eventEmitterService);
        const userRecord = await userService.SignUp(teamId.toHexString(), userInput);

        expect(userRecord).toBeDefined();
        expect(userRecord._id).toBeDefined();
        expect(eventEmitterService.emit).toBeCalled();
      });
    })
  })



Enter fullscreen mode Exit fullscreen mode

Cron Jobs 和重复任务⚡

因此,现在业务逻辑已封装到服务层,因此可以更轻松地从 Cron 作业中使用它。

您永远不应该依赖 node.jssetTimeout或其他延迟代码执行的原始方式,而应该依赖一个将您的作业及其执行持久化到数据库中的框架。

这样,你就能掌控失败的任务,并得到成功任务的反馈。
我已经写了一篇关于这方面的最佳实践,所以,可以查看我的指南,了解如何使用 agenda.js(node.js 最佳任务管理器)

配置和秘密🤫

遵循经过实践检验的node.js十二要素应用概念,存储 API 密钥和数据库字符串连接的最佳方法是使用dotenv

放置一个.env永远不能提交的文件(但它必须在您的存储库中具有默认值),然后,npm 包dotenv加载 .env 文件并将变量插入到process.envnode.js 的对象中。

这可能已经足够了,但我想添加一个额外的步骤。
创建一个config/index.ts文件,其中包含dotenvnpm 包并加载 .env 文件,然后我使用一个对象来存储变量,这样我们就有了一个结构和代码自动完成功能。

配置/index.js



  const dotenv = require('dotenv');
  // config() will read your .env file, parse the contents, assign it to process.env.
  dotenv.config();

  export default {
    port: process.env.PORT,
    databaseURL: process.env.DATABASE_URI,
    paypal: {
      publicKey: process.env.PAYPAL_PUBLIC_KEY,
      secretKey: process.env.PAYPAL_SECRET_KEY,
    },
    paypal: {
      publicKey: process.env.PAYPAL_PUBLIC_KEY,
      secretKey: process.env.PAYPAL_SECRET_KEY,
    },
    mailchimp: {
      apiKey: process.env.MAILCHIMP_API_KEY,
      sender: process.env.MAILCHIMP_SENDER,
    }
  }


Enter fullscreen mode Exit fullscreen mode

这样,您就避免了在代码中充斥着process.env.MY_RANDOM_VAR指令,并且通过自动完成功能,您不必知道如何命名环境变量。

访问示例存储库

装载机🏗️

我从W3Tech 微框架中获取了这种模式,但不依赖于他们的包。

这个想法是将 node.js 服务的启动过程分成可测试的模块。

让我们看一个经典的 express.js 应用程序初始化



  const mongoose = require('mongoose');
  const express = require('express');
  const bodyParser = require('body-parser');
  const session = require('express-session');
  const cors = require('cors');
  const errorhandler = require('errorhandler');
  const app = express();

  app.get('/status', (req, res) => { res.status(200).end(); });
  app.head('/status', (req, res) => { res.status(200).end(); });
  app.use(cors());
  app.use(require('morgan')('dev'));
  app.use(bodyParser.urlencoded({ extended: false }));
  app.use(bodyParser.json(setupForStripeWebhooks));
  app.use(require('method-override')());
  app.use(express.static(__dirname + '/public'));
  app.use(session({ secret: process.env.SECRET, cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false }));
  mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });

  require('./config/passport');
  require('./models/user');
  require('./models/company');
  app.use(require('./routes'));
  app.use((req, res, next) => {
    var err = new Error('Not Found');
    err.status = 404;
    next(err);
  });
  app.use((err, req, res) => {
    res.status(err.status || 500);
    res.json({'errors': {
      message: err.message,
      error: {}
    }});
  });


  ... more stuff 

  ... maybe start up Redis

  ... maybe add more middlewares

  async function startServer() {    
    app.listen(process.env.PORT, err => {
      if (err) {
        console.log(err);
        return;
      }
      console.log(`Your server is ready !`);
    });
  }

  // Run the async function to start our server
  startServer();


Enter fullscreen mode Exit fullscreen mode

正如您所见,应用程序的这一部分可能非常混乱。

这里介绍一种有效的处理方法。



  const loaders = require('./loaders');
  const express = require('express');

  async function startServer() {

    const app = express();

    await loaders.init({ expressApp: app });

    app.listen(process.env.PORT, err => {
      if (err) {
        console.log(err);
        return;
      }
      console.log(`Your server is ready !`);
    });
  }

  startServer();


Enter fullscreen mode Exit fullscreen mode

现在加载器只是具有简洁用途的小文件

加载器/index.js



  import expressLoader from './express';
  import mongooseLoader from './mongoose';

  export default async ({ expressApp }) => {
    const mongoConnection = await mongooseLoader();
    console.log('MongoDB Intialized');
    await expressLoader({ app: expressApp });
    console.log('Express Intialized');

    // ... more loaders can be here

    // ... Initialize agenda
    // ... or Redis, or whatever you want
  }


Enter fullscreen mode Exit fullscreen mode

快速装载机

加载器/express.js




  import * as express from 'express';
  import * as bodyParser from 'body-parser';
  import * as cors from 'cors';

  export default async ({ app }: { app: express.Application }) => {

    app.get('/status', (req, res) => { res.status(200).end(); });
    app.head('/status', (req, res) => { res.status(200).end(); });
    app.enable('trust proxy');

    app.use(cors());
    app.use(require('morgan')('dev'));
    app.use(bodyParser.urlencoded({ extended: false }));

    // ...More middlewares

    // Return the express app
    return app;
  })



Enter fullscreen mode Exit fullscreen mode

mongo 加载器

加载器/mongoose.js



  import * as mongoose from 'mongoose'
  export default async (): Promise<any> => {
    const connection = await mongoose.connect(process.env.DATABASE_URL, { useNewUrlParser: true });
    return connection.connection.db;
  }


Enter fullscreen mode Exit fullscreen mode

请参阅此处的完整加载器示例

结论

我们深入研究了经过生产测试的 node.js 项目结构,以下是一些总结的提示:

  • 使用3层架构。

  • 不要将您的业务逻辑放入 express.js 控制器中。

  • 使用 PubSub 模式并为后台任务发出事件。

  • 通过依赖注入让您安心。

  • 永远不要泄露您的密码、秘密和 API 密钥,请使用配置管理器。

  • 将您的 node.js 服务器配置拆分为可以独立加载的小模块。

请参阅此处的示例存储库

✋ 嘿!走之前🏃‍

如果您喜欢这篇文章,我建议您订阅我的电子邮件列表,这样您就不会错过另一篇类似的文章。⬇️ ⬇️

电子邮件列表表格

我保证,我不会向你推销任何东西

并且不要错过我的最新帖子,我相信你会喜欢它:)

阅读我对下载次数最多的前端框架的研究,结果会让您大吃一惊!

别忘了访问我的博客,获取更多类似softwareontheroad.com 的精彩文章

文章来源:https://dev.to/santypk4/bulletproof-node-js-project-architecture-4epf
PREV
2019 年和 2020 年最佳 10 个 Node.js 框架介绍 什么是 Node.js 框架?如何为我的应用程序选择 Node.js 框架?10. Adonis 👉 获取更多高级 Node.js 开发文章 9. Feathers 8. Sails 7. Loopback 6. Fastify 5. Restify 👉 获取更多高级 Node.js 开发文章 4. Nest.js 3. Hapi 2. Koa 1. Express 总结 👉 获取更多高级 Node.js 开发文章
NEXT
后端开发人员路线图、技能、资源