详解Nodejs Express.js项目架构
引言
在 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 路由中别离出去。
由于未来的某天,你大概会在 CLI 工具或是其他地方处置你的业务。当然,也有大概不会,但在项目中使用API调取的方式来处置本身的业务毕竟不是一个好主意...
不要在操纵器中直接处置业务逻辑!!
在你的利用中,你大概经为了图便当而直接的在操纵器处置业务。不幸的是,这么做的话很快你将面临相面条一样复杂的操纵器代码,“恶果”也会随之而来,比方在处置单元测试的时候不得不使用复杂的 request 或 response 模拟。
同时,在决议何时向客户端返回响应,或但愿在发送响应之后再停止一些处置的时候,将会变得很复杂。
请不要像下面例子这样做.
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