目录
引言
目录结构
3 层结构
使用服务层(service)来处理业务 
使用发布/订阅模式
依赖注入
一个单元测试的例子
定时任务
配置项和私密信息
加载器
结语
首页 web前端 js教程 详解Nodejs Express.js项目架构

详解Nodejs Express.js项目架构

Sep 15, 2020 am 11:09 AM
node.js

详解Nodejs Express.js项目架构

视频教程推荐:nodejs 教程 

引言

在 node.js 领域中,Express.js 是一个为人所熟知的 REST APIs 开发框架。虽然它非常的出色,但是该如何组织的项目代码,却没人告诉你。

通常这没什么,不过对于开发者而言这又是我们必须面对的问题。

一个好的项目结构,不仅能消除重复代码,提升系统稳定性,改善系统的设计,还能在将来更容易的扩展。

多年以来,我一直在处理重构和迁移项目结构糟糕、设计不合理的 node.js 项目。而这篇文章正是对我此前积累经验的总结。

目录结构

下面是我所推荐的项目代码组织方式。

它来自于我参与的项目的实践,每个目录模块的功能与作用如下:

  src
  │   app.js          # App 统一入口
  └───api             # Express route controllers for all the endpoints of the app
  └───config          # 环境变量和配置信息
  └───jobs            # 队列任务(agenda.js)
  └───loaders         # 将启动过程模块化
  └───models          # 数据库模型
  └───services        # 存放所有商业逻辑
  └───subscribers     # 异步事件处理器
  └───types           # Typescript 的类型声明文件 (d.ts)
登录后复制

而且,这不仅仅只是代码的组织方式...

3 层结构

这个想法源自 关注点分离原则,把业务逻辑从 node.js API 路由中分离出去。

3 层示意图

因为将来的某天,你可能会在 CLI 工具或是其他地方处理你的业务。当然,也有可能不会,但在项目中使用API调用的方式来处理自身的业务终究不是一个好主意...

3 node.js REST API 的三层示意图

不要在控制器中直接处理业务逻辑!! 

在你的应用中,你可能经为了图便利而直接的在控制器处理业务。不幸的是,这么做的话很快你将面对相面条一样复杂的控制器代码,“恶果”也会随之而来,比如在处理单元测试的时候不得不使用复杂的 requestresponse  模拟。

同时,在决定何时向客户端返回响应,或希望在发送响应之后再进行一些处理的时候,将会变得很复杂。

请不要像下面例子这样做.

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

    // 这里推荐使用中间件或Joi 验证器
    const userDTO = req.body;
    const isUserValid = validators.user(userDTO)
    if(!isUserValid) {
      return res.status(400).end();
    }

    // 一堆义务逻辑代码
    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...

    // 这里是“优化”,但却搞乱了所有的事情
    // 向客户端发送响应...
    res.json({ user: userRecord, company: companyRecord });

    // 但这里的代码仍会执行 :(
    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)
  });
登录后复制

使用服务层(service)来处理业务 

在单独的服务层处理业务逻辑是推荐的做法。

这一层是遵循适用于 node.js 的 SOLID 原则的“类”的集合

在这一层中,不应该有任何形式的数据查询操作。正确的做法是使用数据访问层.

  • 从 express.js 路由中清理业务代码。

  • 服务层不应包含 request 和 response。

  • 服务层不应返回任何与传输层关联的数据,如状态码和响应头。

示例

  route.post('/', 
    validators.userSignup, // 中间件处理验证
    async (req, res, next) => {
      // 路由的实际责任
      const userDTO = req.body;

      // 调用服务层
      // 这里演示如何访问服务层
      const { user, company } = await UserService.Signup(userDTO);

      // 返回响应
      return res.json({ user, company });
    });
登录后复制

下面是服务层示例代码。

  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); // 依赖用户的数据记录 
      const salaryRecord = await SalaryModel.create(userRecord, companyRecord); // 依赖用户与公司数据

      ...whatever

      await EmailService.startSignupSequence(userRecord)

      ...do more stuff

      return { user: userRecord, company: companyRecord };
    }
  }
登录后复制

在 Github 查看示例代码

https://github.com/santiq/bulletproof-nodejs

使用发布/订阅模式

严格来讲发布/订阅模型并不属于 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 };
    }

  }
登录后复制

同步的调用依赖服务仍不是最佳的解决办法.

更好的做法是触发事件,如:一个新用户使用邮箱方式注册了。

就这样,创建用户的Api 完成了它的功能,剩下的就交给订阅事件的处理器来负责。

  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
    }

  }
登录后复制

并且你可以把事件处理器分割为多个独立的文件。

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

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

    intercom.createUser(
      user
    );

    gaAnalytics.event(
      'user_signup',
      user
    );
  })
登录后复制
  eventEmitter.on('user_signup', async ({ user, company }) => {
    const salaryRecord = await SalaryModel.create(user, company);
  })
登录后复制
  eventEmitter.on('user_signup', async ({ user, company }) => {
    await EmailService.startSignupSequence(user)
  })
登录后复制

你可以使用 try-catch 来包裹 await 语句,或 通过 process.on('unhandledRejection',cb) 的形式注册 'unhandledPromise' 的事件处理器

依赖注入

依赖注入或者说控制反转(IoC) 是一个通用的模式,通过 ‘注入’ 或者传递类或函数中涉及的依赖项的构造器,帮助你组织代码。

例如,当你为服务编写单元测试,或者在另外的上下文中使用服务时,通过这种方式,注入依赖项就会变得很灵活。

不使用依赖注入的代码

  import UserModel from '../models/user';
  import CompanyModel from '../models/company';
  import SalaryModel from '../models/salary';  
  class UserService {
    constructor(){}
    Sigup(){
       //调用 UserMode, CompanyModel,等
      ...
    }
  }
登录后复制

手动依赖注入的代码

  export default class UserService {
    constructor(userModel, companyModel, salaryModel){
      this.userModel = userModel;
      this.companyModel = companyModel;
      this.salaryModel = salaryModel;
    }
    getMyUser(userId){
      // 通过this获取模型
      const user = this.userModel.findById(userId);
      return user;
    }
  }
登录后复制

现在你可以注入自定义的依赖。

  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');
登录后复制

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

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

其思想是在类中声明依赖项,当需要该类的实例时,只需调用「服务定位器」。

我们可以参考一个在 nodejs 引入 D.I npm库typedi的例子。

你可以在官方文档上查看更多关于使用 typedi的方法

https://www.github.com/typestack/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;
    }
  }
登录后复制

services/user.ts

现在,typedi 将负责解析 UserService 所需的任何依赖项。

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

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

在 Express.js 中使用依赖注入

在express.js中使用 D.I. 是 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 });
    });
登录后复制

太棒了,项目看起来很棒!
它是如此有组织,以至于我现在就想写代码了。

在github上查看源码

https://github.com/santiq/bulletproof-nodejs

一个单元测试的例子

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

您不必模拟 req/res 对象或要求(…)调用。

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

tests/unit/services/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();
      });
    })
  })
登录后复制

定时任务

因此,既然业务逻辑封装到了服务层中,那么在定时任务中使用它就更容易了。

您永远不应该依赖 node.js的 setTimeout 或其他延迟执行代码的原生方法,而应该依赖一个框架把你的定时任务和执行持久化到数据库。

这样你就可以控制失败的任务和成功的反馈信息。

我已经写了一个关于这个的好的练习,

 查看我关于使用 node.js 最好的任务管理器 agenda.js 的指南.

https://softwareontheroad.com/nodejs-scalability-issues

配置项和私密信息

根据 应用程序的12个因素 的最佳概念,我们存储 API 密钥和数据库连接配置的最佳方式是使用 .env文件。

创建一个 .env 文件,一定不要提交 (在你的仓库里要有一个包含默认值的.env 文件)dotenv 这个npm 包会加载 .env 文件,并把变量添加到 node.js 的 process.env 对象中。

这些本来已经足够了,但是我喜欢添加一个额外的步骤。
拥有一个 config/index.ts 文件,在这个文件中,dotenv 这个npm 包会加载 .env 文件,然后我使用一个对象存储这些变量,至此我们有了一个结构和代码自动加载。

config/index.js

  const dotenv = require('dotenv');
  //config()方法会读取你的 .env 文件,解析内容,添加到 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,
    }
  }
登录后复制

这样可以避免代码中充斥着 process.env.MY_RANDOM_VAR 指令,并且通过自动完成,您不必知道 .env 文件中是如何命名的。

在github 上查看源码

https://github.com/santiq/bulletproof-nodejs

加载器

加载器源于 W3Tech microframework 但不依赖于他们扩展。

这个想法是指,你可以拆分启动加载过程到可测试的独立模块中。

先来看一个传统的 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 !`);
    });
  }

  // 启动服务器
  startServer();
登录后复制

天呐,上面的面条代码,不应该出现在你的项目中对吧。

再来看看,下面是一种有效处理初始化过程的示例:

  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();
登录后复制

现在,各加载过程都是功能专一的小文件了。

loaders/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');

    // ... 更多加载器

    // ... 初始化 agenda
    // ... or Redis, or whatever you want
  }
登录后复制

express 加载器。

loaders/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;
  })
登录后复制

mongo 加载器

loaders/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;
  }
登录后复制

在 Github 查看更多加载器的代码示例

https://github.com/santiq/bulletproof-nodejs

结语

我们深入的分析了 node.js 项目的结构,下面是一些可以分享给你的总结:

  • 使用3层结构。

  • 控制器中不要有任何业务逻辑代码。

  • 采用发布/订阅模型来处理后台异步任务。

  • 使用依赖注入。

  • 使用配置管理,避免泄漏密码、密钥等机密信息。

  • 将 node.js 服务器的配置拆分为可独立加载的小文件。

前往 Github 查看完整示例

https://github.com/santiq/bulletproof-nodejs

原文地址:https://dev.to/santypk4/bulletproof-node-js-project-architecture-4epf

译文地址:https://learnku.com/nodejs/t/38129

更多编程相关知识,请访问:编程入门!!

以上是详解Nodejs Express.js项目架构的详细内容。更多信息请关注PHP中文网其他相关文章!

本站声明
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn

热AI工具

Undresser.AI Undress

Undresser.AI Undress

人工智能驱动的应用程序,用于创建逼真的裸体照片

AI Clothes Remover

AI Clothes Remover

用于从照片中去除衣服的在线人工智能工具。

Undress AI Tool

Undress AI Tool

免费脱衣服图片

Clothoff.io

Clothoff.io

AI脱衣机

AI Hentai Generator

AI Hentai Generator

免费生成ai无尽的。

热门文章

R.E.P.O.能量晶体解释及其做什么(黄色晶体)
1 个月前 By 尊渡假赌尊渡假赌尊渡假赌
R.E.P.O.最佳图形设置
1 个月前 By 尊渡假赌尊渡假赌尊渡假赌
威尔R.E.P.O.有交叉游戏吗?
1 个月前 By 尊渡假赌尊渡假赌尊渡假赌

热工具

记事本++7.3.1

记事本++7.3.1

好用且免费的代码编辑器

SublimeText3汉化版

SublimeText3汉化版

中文版,非常好用

禅工作室 13.0.1

禅工作室 13.0.1

功能强大的PHP集成开发环境

Dreamweaver CS6

Dreamweaver CS6

视觉化网页开发工具

SublimeText3 Mac版

SublimeText3 Mac版

神级代码编辑软件(SublimeText3)

一文聊聊Node中的内存控制 一文聊聊Node中的内存控制 Apr 26, 2023 pm 05:37 PM

基于无阻塞、事件驱动建立的Node服务,具有内存消耗低的优点,非常适合处理海量的网络请求。在海量请求的前提下,就需要考虑“内存控制”的相关问题了。 1. V8的垃圾回收机制与内存限制 Js由垃圾回收机

图文详解Node V8引擎的内存和GC 图文详解Node V8引擎的内存和GC Mar 29, 2023 pm 06:02 PM

本篇文章带大家深入了解NodeJS V8引擎的内存和垃圾回收器(GC),希望对大家有所帮助!

聊聊如何选择一个最好的Node.js Docker镜像? 聊聊如何选择一个最好的Node.js Docker镜像? Dec 13, 2022 pm 08:00 PM

选择一个Node​的Docker镜像看起来像是一件小事,但是镜像的大小和潜在漏洞可能会对你的CI/CD流程和安全造成重大的影响。那我们如何选择一个最好Node.js Docker镜像呢?

深入聊聊Node中的File模块 深入聊聊Node中的File模块 Apr 24, 2023 pm 05:49 PM

文件模块是对底层文件操作的封装,例如文件读写/打开关闭/删除添加等等 文件模块最大的特点就是所有的方法都提供的**同步**和**异步**两个版本,具有 sync 后缀的方法都是同步方法,没有的都是异

Node.js 19正式发布,聊聊它的 6 大特性! Node.js 19正式发布,聊聊它的 6 大特性! Nov 16, 2022 pm 08:34 PM

Node 19已正式发布,下面本篇文章就来带大家详解了解一下Node.js 19的 6 大特性,希望对大家有所帮助!

聊聊Node.js中的 GC (垃圾回收)机制 聊聊Node.js中的 GC (垃圾回收)机制 Nov 29, 2022 pm 08:44 PM

Node.js 是如何做 GC (垃圾回收)的?下面本篇文章就来带大家了解一下。

一起聊聊Node中的事件循环 一起聊聊Node中的事件循环 Apr 11, 2023 pm 07:08 PM

事件循环是 Node.js 的基本组成部分,通过确保主线程不被阻塞来实现异步编程,了解事件循环对构建高效应用程序至关重要。下面本篇文章就来带大家深入了解Node中的事件循环 ,希望对大家有所帮助!

node无法用npm命令怎么办 node无法用npm命令怎么办 Feb 08, 2023 am 10:09 AM

node无法用npm命令是因为没有正确配置环境变量,其解决办法是:1、打开“系统属性”;2、找到“环境变量”->“系统变量”,然后编辑环境变量;3、找到nodejs所在的文件夹;4、点击“确定”即可。

See all articles