Node.js项目架构优化 -- 小白开始项目前必知必会

611 阅读13分钟

前言

大家好 , 我是一名在校大二学生 , 最近有所感悟 ,想和大家一起分享 😁 。

在Node.js项目开发中,合理的架构设计十分重要 。

虽然我接触node.js不是很久 , 但之前有过几个java单体架构的项目经验(自己瞎搞的) , 在转node.js过程中 , 我发现很多思想都是一致的 。

也难怪很久之前在牛客上看到一个哥们去实习只会java , node.js照样做,那时还在学C语言的我很是费解 , 现在看来如果只是简单开发 ,的确很好入门 , 首先是后端思想相通 , 其次是框架封装的特别好 , 而要深入学习,肯定要吃透语言的底层 , 毕竟现在都给你封装的好好的 ,一旦报错 , 不了解底层就很难搞 .

还有就是不同语言有不同优势

就比如node.js单线程 ,非阻塞I/O支持高并发 ; 而对于多线程的java而言, 还要考虑锁的问题 , 不同语言有个性 , 这些感觉都有很深的门道值得学习 ,所以我会永远保持对知识的敬畏之心

以下是我前期学习的总结 , 我觉得一个项目的规范化十分重要 , 很多思想都是通用的.

项目结构

常见目录结构解析

一个清晰合理的项目目录结构有助于提高代码的可读性、可维护性和可扩展性。以下是一种常见的项目目录结构及其功能解释:

以下是我觉得比较规范的一个项目结构 , 供各位参考 :

src
 │ app.js 
 └───api 
 └───config 
 └───jobs 
 └───loaders 
 └───models
 └───services 
 └───subscribers 
 └───types 
  • src目录:存放项目的所有源代码,使代码组织更加有序。
  • app.js:作为整个应用程序的入口文件,是项目启动的起点。
  • api目录:用于放置Express路由控制器,专门处理API端点相关逻辑,将不同功能的API进行分类管理,便于维护和扩展。
  • config目录:存放环境变量和各种配置相关文件,如数据库连接字符串、API密钥等。通过将配置信息集中管理,方便在不同环境下进行切换和部署。
  • jobs目录:针对任务调度相关的定义,例如使用agenda.js进行任务调度时,可将任务相关逻辑放置在此目录。
  • loaders目录:将Node.js的启动过程拆分为多个可测试的模块,极大提高了启动过程的可维护性。
  • models目录:存放数据库模型文件,负责与数据库进行交互,包括数据的查询、插入、更新和删除操作。
  • services目录:作为业务逻辑层,包含应用的核心业务逻辑,不涉及具体的数据访问细节,遵循SOLID原则,提高代码的可维护性和复用性。
  • subscribers目录:用于存放异步任务的事件处理程序,在处理发布与订阅模式中的事件时发挥关键作用。
  • types目录(对于使用TypeScript的项目) :存放类型声明文件(.d.ts),增强代码的类型安全性和可读性。

上述结构需要根据各自的需要定制结构 , 对于我我可能有一些自己特定的命名习惯 , 只要思想符合上述结构 , 做到项目结构清晰合理即可 。

例如,对于小型项目可能不需要如此复杂的目录结构,而大型项目可能需要进一步细分模块或添加特定功能目录。

三层架构详解

我画了一个图 , 方便讲清楚三层架构的联系 , 其实我们的项目结构也是按照这个设计的 .

下面详细解析 :

表现层(Controller)

表现层在Node.js应用中通常负责处理HTTP请求和响应。以Express.js为例,路由控制器就是表现层的重要组成部分。它接收客户端请求,调用服务层的方法获取业务数据,并将处理结果返回给客户端。例如:

const express = require('express');
const router = express.Router();

// 假设userService是注入的服务层实例
router.get('/users', async (req, res) => {
  const users = await userService.getUsers();
  res.json(users);
});

module.exports = router;

业务逻辑层(Service)

业务逻辑层包含应用的核心业务逻辑,是处理业务规则和业务流程的关键部分。它应该是一个具有明确目的的类的集合,遵循SOLID原则,不涉及具体的数据访问细节,提高代码的可维护性和复用性。例如:

class UserService {
  constructor(userRepository) {
    this.userRepository = userRepository;
  }

  async getUsers() {
    // 可能包含一些业务逻辑处理,如数据过滤、转换等
    return this.userRepository.findAll();
  }
}

数据访问层(Mapper或Repository)

数据访问层负责与数据库或其他数据源进行交互,执行数据的查询、插入、更新和删除操作。它将数据访问逻辑封装起来,使业务逻辑层与数据源解耦。例如:

class UserRepository {
  constructor(dbConnection) {
    this.dbConnection = dbConnection;
  }

  async findAll() {
    const query = 'SELECT * FROM users';
    const result = await this.dbConnection.query(query);
    return result.rows;
  }
}

三层架构的优势

采用三层架构模式可以使各模块分层解耦,降低代码的复杂性。当业务需求发生变化时,只需要在相应的层进行修改,而不会影响到其他层。同时,也便于团队成员分工协作,提高开发效率。

避免控制器中的业务逻辑

问题示例分析

在实际开发中,将业务逻辑直接放在控制器中是一种不良实践。例如:

route.post('/', async (req, res, next) => {
  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);
  res.json({ user: userRecord, company: companyRecord });
  const salaryRecord = await SalaryModel.create(userRecord, companyRecord);
  eventTracker.track('user_signup', userRecord, companyRecord, salaryRecord);
  gaAnalytics.event('user_signup', userRecord);
  intercom.createUser(userRecord);
  await EmailService.startSignupSequence(userRecord);
});

上述代码中,控制器不仅处理了请求和响应,还包含了大量的业务逻辑,如数据验证、创建用户记录、关联公司记录、触发各种事件和服务等。这使得控制器变得复杂且难以维护,同时也增加了单元测试的难度。

如果是我 , 看了都要砸电脑 !

带来的负面影响

  1. 可维护性差:当业务逻辑复杂时,控制器代码会变得冗长,不易理解和修改。不同业务逻辑交织在一起,一旦某个部分出现问题,定位和修复难度较大。
  2. 测试困难:在进行单元测试时,由于控制器依赖于Express.js的req和res对象,模拟这些对象会变得复杂,增加了测试的复杂性和成本。

将业务逻辑移至服务层

服务层的职责

将业务逻辑移到服务层可以有效解决上述问题。服务层专注于业务逻辑的处理,不涉及HTTP传输层相关的信息。它接收来自控制器的请求数据,执行相应的业务逻辑,并返回处理结果。例如:

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);
    await EmailService.startSignupSequence(userRecord);
    return { user: userRecord, company: companyRecord };
  }
}

服务层的优势

  1. 提高可维护性:服务层的代码结构更加清晰,业务逻辑集中管理,易于理解和修改。不同的业务逻辑可以拆分成独立的方法,便于复用和扩展。
  2. 便于测试:由于服务层不依赖于HTTP相关对象,在进行单元测试时可以更方便地模拟输入和验证输出,降低了测试难度,提高了测试覆盖率。

发布与订阅层的应用

处理复杂业务逻辑的挑战

在一些复杂业务场景中,如创建用户可能涉及调用多个第三方服务、进行数据分析或开启电子邮件序列等操作。如果将所有这些逻辑都放在一个地方,代码会变得复杂且难以维护。

Pub/Sub模式的解决方案

使用发布与订阅(Pub/Sub)模式可以将职责划分,提高代码的可维护性。例如:

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

export default class UserService {
  constructor() {
    this.eventEmitter.emit('user_signup', { user: userRecord, company: companyRecord });
  }
  async Signup(user) {
    const userRecord = await this.userModel.create(user);
    const companyRecord = await this.companyModel.create(user);
    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);
});

Pub/Sub模式的优势

  1. 解耦性强:发布者和订阅者之间松散耦合,彼此不需要了解对方的实现细节。当业务逻辑发生变化时,只需要修改相应的订阅者逻辑,而不会影响到其他部分。
  2. 可扩展性好:可以方便地添加新的订阅者来处理新的业务逻辑,而不需要修改发布者代码。

依赖注入的应用

依赖注入的概念与优势

依赖注入(Dependency Injection,DI)是一种常见的设计模式,有助于代码的组织和灵活性。通过注入依赖关系,而不是在类内部直接实例化依赖对象,可以方便地在不同环境中使用服务,例如在单元测试中替换真实的依赖为模拟对象。

依赖注入的实现方式

  1. 手动依赖项注入
export default class UserService {
  constructor(userModel, companyModel, salaryModel) {
    this.userModel = userModel;
    this.companyModel = companyModel;
    this.salaryModel = salaryModel;
  }
  getMyUser(userId) {
    const user = this.userModel.findById(userId);
    return user;
  }
}

2. 使用依赖注入框架(如TypeDI)

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;
  }
}

在Express.js中的应用

在Express.js中结合依赖注入,可以使项目结构更加清晰。例如:

route.post('/', 
  async (req, res, next) => {
    const userDTO = req.body;
    const userServiceInstance = Container.get(UserService);
    const { user, company } = userServiceInstance.Signup(userDTO);
    return res.json({ user, company });
  });

对测试的帮助

依赖注入使得单元测试更加简单。在测试时,可以轻松地注入模拟的依赖对象,控制依赖对象的行为,从而更准确地测试目标代码的逻辑。例如:

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: 'mok-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();
    });
  });
});

Cron Jobs和重复任务的处理

原始方法的问题

对于定时任务或重复任务,如果使用setTimeout等原始方法来实现,会存在一些问题。例如,难以控制失败的Jobs,无法方便地获取执行结果的反馈,并且在任务管理和调度方面缺乏灵活性。

推荐的解决方案

应依赖于将Jobs及其执行持久化到数据库中的框架,如agenda.js。这样可以更好地管理定时任务,包括任务的调度、执行、失败重试等。例如:

const Agenda = require('agenda');

const agenda = new Agenda({
  db: { address: 'localhost:27017/agenda-jobs' }
});

agenda.define('send daily report', async (job) => {
  // 执行发送日报的任务逻辑
});

agenda.on('ready', () => {
  agenda.every('1 day', 'send daily report');
});

优势分析

  1. 任务管理更方便:可以通过数据库查询和管理任务的状态、执行历史等信息。
  2. 可靠性更高:在任务失败时可以根据配置进行重试,提高任务执行的可靠性。
  3. 灵活性强:可以方便地调整任务的执行时间、频率等参数。

配置和密钥的管理

遵循的原则

遵循Twelve-Factor App概念,使用dotenv来管理配置和密钥是一种最佳实践。将敏感信息如数据库连接字符串、API密钥等存储在.env文件中,避免在代码中直接硬编码。

具体实现示例

在config/index.ts文件中加载和管理这些配置变量:

const dotenv = require('dotenv');
dotenv.config();

export default {
  port: process.env.PORT,
  paypal: {
    databaseURL: process.env.DATABASE_URI,
    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,
    }
  }
};

重要性

  1. 安全性提高:防止敏感信息泄露,保护应用程序的安全。
  2. 便于部署和配置管理:在不同环境(开发、测试、生产等)下,可以通过修改.env文件轻松切换配置,而不需要修改代码。

Loaders的使用

启动过程的问题

Node.js应用的启动过程可能涉及多个步骤,如配置加载、数据库连接、中间件注册等。如果将这些步骤都写在一个文件中,代码会变得冗长且难以维护。

Loaders的解决方案

Loaders的思想是将启动过程拆分为可测试的模块,提高启动过程的可维护性。例如:

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目录下的文件中,可以分别定义不同的加载逻辑,如数据库连接加载器、中间件加载器等。

优势分析

  1. 代码结构清晰:每个加载器专注于一个特定的启动任务,代码结构更加清晰,易于理解和维护。
  2. 便于测试和扩展:可以单独测试每个加载器的功能,并且在需要添加新的启动任务时,可以方便地创建新的加载器。

最佳实践总结

架构设计原则

  1. 使用三层架构:明确表现层、业务逻辑层和数据访问层的职责,分离关注点,提高代码的可维护性和扩展性。
  2. 避免控制器中的业务逻辑:将业务逻辑移至服务层,使控制器专注于请求和响应处理。
  3. 应用Pub/Sub模式:处理复杂业务逻辑时,通过发布与订阅模式提高代码的解耦性和可维护性。
  4. 进行依赖注入:提高代码的灵活性和可测试性,便于在不同环境中使用服务。
  5. 妥善管理配置和密钥:遵循最佳实践,保障应用的安全性和配置管理的便利性。
  6. 使用Loaders拆分启动过程:使启动过程更清晰、可维护,便于测试和扩展。

总结

上面的项目架构 , 适用于后端 , 与语言无关 , 就和算法一致 , 只要算法思想到位 , 语言如果只是在应用层 , 学习的时间成本很低 。

所以 ,我认为从整体把握后端很重要 , 在项目这一块 , 苦练语言底层的同时 , 开发思想远比代码重要 。

北极熊和黄色的猫.gif

点赞收藏 , 加关注 ,让我们一起学习 !

更新


以下是我做的小项目使用文章中的架构 , 欢迎大家点赞评论 ~

马上考英语六级 , 我还在这里做AI全栈小项目(实验版)🤡🤡🤡 -- 小白挑战 : node.js实战Ai爬虫 + Ai数据分析 + Mysql数据持久化 - 掘金 (juejin.cn)