影片教學推薦: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)
而且,這不僅僅只是程式碼的組織方式...
這個想法源自 關注點分離原則,把業務邏輯從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) });
在單獨的服務層處理業務邏輯是建議的做法。
這一層是遵循適用於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 }; } }
##使用發布/訂閱模式嚴格來講發布/訂閱模式並不屬於3 層結構的範疇,但卻很實用。 這裡有一個簡單的 node.js API 用來建立用戶,所以同時你可能還需要呼叫外部服務、分析資料、或發送一連串的郵件。很快,這個簡單的原本用於創建用戶的函數,由於充斥各種功能,程式碼已經超過了 1000 行。 現在是時候把這些功能都拆分為獨立功能了,這樣才能讓你的程式碼繼續保持可維護性。在Github 查看範例程式碼
https://github.com/santiq/bulletproof-nodejs
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中文網其他相關文章!