前言
大家好 , 我是一名在校大二学生 , 最近有所感悟 ,想和大家一起分享 😁 。
在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);
});
上述代码中,控制器不仅处理了请求和响应,还包含了大量的业务逻辑,如数据验证、创建用户记录、关联公司记录、触发各种事件和服务等。这使得控制器变得复杂且难以维护,同时也增加了单元测试的难度。
如果是我 , 看了都要砸电脑 !
带来的负面影响
- 可维护性差:当业务逻辑复杂时,控制器代码会变得冗长,不易理解和修改。不同业务逻辑交织在一起,一旦某个部分出现问题,定位和修复难度较大。
- 测试困难:在进行单元测试时,由于控制器依赖于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 };
}
}
服务层的优势
- 提高可维护性:服务层的代码结构更加清晰,业务逻辑集中管理,易于理解和修改。不同的业务逻辑可以拆分成独立的方法,便于复用和扩展。
- 便于测试:由于服务层不依赖于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模式的优势
- 解耦性强:发布者和订阅者之间松散耦合,彼此不需要了解对方的实现细节。当业务逻辑发生变化时,只需要修改相应的订阅者逻辑,而不会影响到其他部分。
- 可扩展性好:可以方便地添加新的订阅者来处理新的业务逻辑,而不需要修改发布者代码。
依赖注入的应用
依赖注入的概念与优势
依赖注入(Dependency Injection,DI)是一种常见的设计模式,有助于代码的组织和灵活性。通过注入依赖关系,而不是在类内部直接实例化依赖对象,可以方便地在不同环境中使用服务,例如在单元测试中替换真实的依赖为模拟对象。
依赖注入的实现方式
- 手动依赖项注入
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');
});
优势分析
- 任务管理更方便:可以通过数据库查询和管理任务的状态、执行历史等信息。
- 可靠性更高:在任务失败时可以根据配置进行重试,提高任务执行的可靠性。
- 灵活性强:可以方便地调整任务的执行时间、频率等参数。
配置和密钥的管理
遵循的原则
遵循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,
}
}
};
重要性
- 安全性提高:防止敏感信息泄露,保护应用程序的安全。
- 便于部署和配置管理:在不同环境(开发、测试、生产等)下,可以通过修改.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目录下的文件中,可以分别定义不同的加载逻辑,如数据库连接加载器、中间件加载器等。
优势分析
- 代码结构清晰:每个加载器专注于一个特定的启动任务,代码结构更加清晰,易于理解和维护。
- 便于测试和扩展:可以单独测试每个加载器的功能,并且在需要添加新的启动任务时,可以方便地创建新的加载器。
最佳实践总结
架构设计原则
- 使用三层架构:明确表现层、业务逻辑层和数据访问层的职责,分离关注点,提高代码的可维护性和扩展性。
- 避免控制器中的业务逻辑:将业务逻辑移至服务层,使控制器专注于请求和响应处理。
- 应用Pub/Sub模式:处理复杂业务逻辑时,通过发布与订阅模式提高代码的解耦性和可维护性。
- 进行依赖注入:提高代码的灵活性和可测试性,便于在不同环境中使用服务。
- 妥善管理配置和密钥:遵循最佳实践,保障应用的安全性和配置管理的便利性。
- 使用Loaders拆分启动过程:使启动过程更清晰、可维护,便于测试和扩展。
总结
上面的项目架构 , 适用于后端 , 与语言无关 , 就和算法一致 , 只要算法思想到位 , 语言如果只是在应用层 , 学习的时间成本很低 。
所以 ,我认为从整体把握后端很重要 , 在项目这一块 , 苦练语言底层的同时 , 开发思想远比代码重要 。
点赞收藏 , 加关注 ,让我们一起学习 !
更新
以下是我做的小项目使用文章中的架构 , 欢迎大家点赞评论 ~
马上考英语六级 , 我还在这里做AI全栈小项目(实验版)🤡🤡🤡 -- 小白挑战 : node.js实战Ai爬虫 + Ai数据分析 + Mysql数据持久化 - 掘金 (juejin.cn)