作者 | Emeni Oghenevwede
译者 | 明知山
策划 | 丁晓昀
关键要点
- 关注点分离是 Node.js 的一种架构,可以确保代码的可读性、易于重构和良好的代码协作。
- 通过遵循关注点分离原则,你可以确保最终的系统是稳定和可维护的。
- 关注点分离的原则确保了组件不会重复,从而使系统更加容易维护和重构。
- 关注点分离原则认为,业务逻辑应该与控制器分离。这简化了控制器的开发和测试用例的编写。
- 关注点分离原则有助于提升代码的可重用性。这样可以很容易地找到故障的来源以及如何将其从整个系统中隔离出来,从而降低维护成本和缩短维护时间。
创建好的 Node.js 项目架构
大多数时候,我们在大团队中工作,不同的人处理系统的不同部分,如果事情没有得到妥当安排,就会变得混乱。受疫情影响,越来越多的团队采用了远程工作的方式,拥有清晰和定义良好的代码结构从未像现在这么重要。
从本质上讲,项目结构是一个很重要的主题,因为如何引导应用程序决定了整个项目生命周期的整体开发体验。
Node.js 的惊人之处在于,你可以随心所欲地构造代码,没有所谓的“正确的方法”。你可以选择在一个 app.js 文件中编写所有代码,也可以创建多个文件并将它们放在不同的文件夹中。
然而,大多数开发人员会建议通过将相关数据分组在一起来组织项目结构,而不是将所有东西全部放在一起。当你想要修改模型时,最好可以直接通过浏览模型文件夹来修改,而不是在包含模型、控制器、加载器和服务的单个文件中找来找去。
为什么好的项目架构如此重要
如前所述,好的项目架构非常重要,而混乱的架构可能会造成问题。下面是好的架构的一些好处。
- 使代码更具可读性和整洁性。
- 更容易避免重复代码。
- 更容易扩展和修改。
- 简化了测试用例的编写。
关注点分离
关注点分离是一种将软件程序划分为多个片段的设计原则。每一个片段都试图解决一个不同的问题,包含了一组对程序代码有影响的细节。
这个概念本质上指的是一种架构模式,程序逻辑与程序内容和表示是分离的。这会让项目变得更加容易维护,并且不容易出现重复。它还简化了团队协作和变更的实现。
Node.js 项目可以有多种组织方式。每种组织方式都有各自的优点和缺点。开发人员的目标是创建可扩展和干净的代码。遵循这种架构模式的项目通常具有这样的结构:
└───app.js # 应用程序的入口└───api # 包含控制器、路由和中间件 └───config # 开发和生产环境的应用程序配置 └───loaders # 包含启动进程└───models # 数据库模型 └───services # 包含我们的业务逻辑└───jobs # 作业定义(如果你的程序中有cron作业,我们的没有)└───subscribers # 异步任务的事件处理器 └───test # 测试文件放在这里
为了解释文件夹结构和关注点分离的概念,我们将创建一个简单的身份验证 REST API。我们将构建一个可扩展的结构,以便促进团队协作。我们将使用 Node.js、Express.JS 和 MongoDB。请先确保安装了 Node.js 和 MongoDB。
我们的示例应用程序是一个简单的用于身份验证的 REST API。当用户注册时,他们的信息被保存在 MongoDB 数据库中。当用户登录时,我们将验证他们的信息,如果验证成功,就返回一个令牌。在构建这个应用程序的过程中,我们将实现一个可扩展的项目结构,并了解实现这个功能需要做些什么。
创建项目文件夹
我们的应用程序将按照以下的方式组织结构。
- 所有的文件和逻辑都保存在一个叫作 src 的文件夹中。
- 应用程序的入口和启动在 server.js 和 app.js 中。api 文件夹包含 controllers、middlewares、routes 和 repositories 子文件夹,这些子文件夹主要用于处理数据传输、请求处理和验证等任务。配置文件夹 config 包含与开发环境和生产环境相关的信息。
- loaders 文件夹包含程序第一次启动时执行的操作,包括数据库加载器(告诉数据库开始启动)和 Express 加载器(执行 Express 应用程序)。
- models 文件夹包含了用于描述写入数据库或从数据库读取的数据类型的文件。
- services 文件夹包含可重用的业务逻辑,用于处理数据处理、实现惟一性业务逻辑、调用数据库等任务。
- utils 文件夹包含辅助工具、验证器、错误处理器、常量等文件。应用程序中的其他文件可以调用它们来执行一些操作。
└───src └───app.js └───server.js └───api └───controllers └───middlewares └───routes └───config └───loaders └───models └───services └───utils
utils 文件夹——辅助文件
这些文件为应用程序的其他部分提供支持。它们被几个文件或模块调用,用于验证或修改请求或数据块,因为它们具有可重用的结构。例如,开发一个辅助函数来验证电子邮件的格式是否合法。这个功能可以用来验证用户在注册或登录时输入的电子邮件是否遵循正确的格式。
utils 文件夹包含四个文件:
- validator.js
- helpers.js
- error_handler.js
- error_response.js
validator.js
这个文件中有一个叫作 signupValidator 的方法,用于验证是否提供了所需的参数,以及参数是否正确。例如,我们验证用户提供了用户名和电子邮件,并且密码是我们想要的格式(至少 8 个字符,并且是字母数字和特殊字符的组合)。
import { celebrate, Joi, Segments } from 'celebrate';export default class Validator { static signupValidator = celebrate({ [Segments.BODY]: Joi.object().keys({ name: Joi.string().required(), email: Joi.string().email().required().trim().lowercase(), password: Joi.string().regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*d)[a-zA-ZdwW]{8,}$/).required().label('Password').messages({ "string.min": "{#label} Must have at least 8 characters", "string.pattern.base": "{#label} must include at least eight characters, one uppercase and lowercase letter and one number" }) }), });}
helpers.js
这个文件包含处理 JSON 响应格式、密码散列、随机字符串生成等功能的函数。它包含的函数可以被其他服务使用,因为与其在服务中构建这些函数,不如直接根据需要导入它们,保持代码整洁并加快开发速度。
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
const ENCRYPTION_KEY = "(some_r**n_5_str_$$8276_-yuiuj6]"; // 必须是256位 (32个字符)const IV_LENGTH = 16; // 对于AES来说通常是16
export class JsonResponse { constructor(statusCode = 200) { this.statusCode = statusCode; } error = (res, message, data) => { return res.status(this.statusCode).json({ status: false, message, data }) } success = (res, message, data) => { return res.status(this.statusCode).json({ status: true, message, data }) }}
export const randomString = (length) => { let numbers = "0123456789"; let chars = "acdefhiklmnoqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY";
let randomstring = ''; let randomstring2 = '';
for (let x = 0; x < Math.floor(length / 2); x ) { let rnum = Math.floor(Math.random() * chars.length); randomstring = chars.substring(rnum, rnum 1);
} for (let y = 0; y < Math.floor(length / 2); y ) {
let rnum2 = Math.floor(Math.random() * numbers.length); randomstring2 = numbers.substring(rnum2, rnum2 1);
} let finalString = (randomstring randomstring2).split(''); return shuffle(finalString).join('');}
// 比较散列值export const compareHash = (string, hash) => bcrypt.compare(string, hash);
export const hashString = async function (string) { const salt = await bcrypt.genSalt(10); return await bcrypt.hash(string, salt);}
export const encryptData = data => { let iv = crypto.randomBytes(IV_LENGTH); let cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY), iv); let encrypted = cipher.update(data);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return iv.toString('hex') ':' encrypted.toString('hex');}
export const decryptData = data => { let textParts = data.split(':'); let iv = Buffer.from(textParts.shift(), 'hex'); let encryptedText = Buffer.from(textParts.join(':'), 'hex'); let decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY), iv); let decrypted = decipher.update(encryptedText);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();}
error_handler.js
这个文件定义了错误响应结构。例如,当你试图构建一个 try-catch 事件时可以在 catch 部分调用它,并提供必要的参数(如状态、数据和消息)。你可以重用这些定义,而不是在所有地方声明它们。准确显示错误信息是非常重要的,因为这有助于 API 用户和开发人员了解问题的根源。
export default class ErrorResponse extends Error { constructor(message, status) { super(message); this.status = status; }}
error_response.js
我们可以从文件名推断,它包含了处理不同错误条件的函数。例如,它提供了处理 404 问题、数据库重复字段和服务器问题的函数。
import ErrorResponse from './error_response';import { isCelebrateError } from 'celebrate';
const errorHandler = (err, req, res, next) => { console.log(err) let error = { ...err }; error.message = err.message;
//Celebrate验证错误 if (isCelebrateError(err)) { if (!err) { error = new ErrorResponse("Unable to process request, try again", 400); } else { const errorBody = err.details.get('body'); if (errorBody) { const { details: [errorDetails] } = errorBody; console.log(errorDetails) const message = errorDetails.message; error = new ErrorResponse(message, 400); } else { error = new ErrorResponse("Invalid payload sent, review and try again", 400); } } }
// mongoose重复错误 if (err.code == 11000) { const message = "Field already exists or duplicate value encountered"; error = new ErrorResponse(message, 400); }
// mongoose验证错误 if (err.name == "CastError") { const message = "Invalid parameter passed"; error = new ErrorResponse(message, 400); }
// mongoose验证错误 if (err.name == "ValidationError") { const message = Object.values(err.errors).map(val => val.message); error = new ErrorResponse(message, 400); }
res.status(error.status || 500).json({ status: false, message: error.message || "Server error! request not completed", data: {} });}
export default errorHandler;
config 文件夹——环境管理
大多数时候,我们有不同的环境变量。例如,如果我们使用本地开发环境,那么 MongoDB URI 很可能以 localhost 开头,而在生产环境中可能是一个指向图集数据库的链接。因此,我们需要谨慎处理这些差异。我们的 config 文件夹将包含三个文件——dev.js(用于开发环境)、prod.js(用于生产环境)和 index.js 文件(导入前面两个文件)。此外,index.js 文件有一个开关,根据环境决定应该使用哪个文件。
不要忘记创建一个.env 文件,其中包含所需的所有变量。
dev.js
import '../.env'import dotenv from 'dotenv';dotenv.config()
export const config = { secrets: { jwt: process.env.JWT_SECRET_DEV, jwtExp: '100d' }, dbUrl: process.env.MONGO_URI_DEV,}
prod.js
import '../.env'import dotenv from 'dotenv';dotenv.config()
export const config = { secrets: { jwt: process.env.JWT_SECRET, jwtExp: '7d' }, dbUrl: process.env.MONGO_URI,
index.js
import { merge } from 'lodash';const env = process.env.NODE_ENV || 'development';const port = process.env.PORT || 4002;
const baseConfig = { env, isDev: env === 'development', port,}
let envConfig = {}
switch (env) { case 'dev': case 'development': envConfig = require('./dev').config break case 'prod': case 'production': envConfig = require('./prod').config break default: envConfig = require('./dev').config}export default merge(baseConfig, envConfig)
loaders 文件夹
loaders 文件夹包含特定函数初始化所需的文件。例如,我们有一个 Express 加载器和一个数据库加载器,分别用于启动 Express 应用程序和数据库。
背后的想法是将应用程序的启动过程拆成可测试的组件。各种加载器被导入到 loaders 文件夹的 index.js 文件中,让其他文件可以使用它们。
db-loader.js
import mongoose from 'mongoose';import dotenv from 'dotenv'; import options from '../config';
require('dotenv').config({path: __dirname '/.env' })
export default (url = options.dbUrl, opts = {}) => { let dbOptions = { ...opts, useNewUrlParser: true, useUnifiedTopology: true }; mongoose.connect(url, dbOptions); const conn = mongoose.connection; return conn;}
express-loader.js
import * as fs from 'fs';import morgan from 'morgan';import mongoSanitize from 'express-mongo-sanitize';import rateLimit from 'express-rate-limit';import helmet from 'helmet';import xss from 'xss-clean';import cors from 'cors';import ErrorResponse from '../utils/error_response';import errorHandler from '../utils/error_handler';
// 导入路由import apiRoutes from '../api/routes';
const apiLimiter = rateLimit({ windowMs: 20 * 60 * 1000, // 20 minutes max: 100, // 将IP限制在每`window`(这里是20分钟)100个请求 standardHeaders: true, // 通过`RateLimit-*`标头返回速率限定信息 legacyHeaders: false, // 禁用`X-RateLimit-*`标头 handler: (_request, res, _next) => res.status(429).json({ status: false, message: "Too many requests, please try again later." }),})
export default ({ app, express }) => {
app.disable('x-powered-by');
app.use(express.json()) app.use(express.urlencoded({ extended: true })) // 开发用的日志中间件 if (process.env.NODE_ENV === 'development') { app.use(morgan('dev')); }
app.enable('trust proxy'); app.use(cors())
app.use(mongoSanitize());
// 添加安全标头 app.use(helmet());
app.use(xss()); app.get('/ip', (request, response) => response.send(request.ip)) app.use('/api/v1', apiLimiter, apiRoutes);
app.use(errorHandler);
app.use((_req, _res, next) => next(new ErrorResponse('Route not found', 404))); app.use(errorHandler); return app;};
index.js
import dbConnect from './db-loader';import expressLoader from './express-loader';
export default async ({ app, express }) => { const connection = dbConnect(); console.log('MongoDB has been Initialized'); expressLoader({ app, express }); console.log('Express package has been Initialized');}
入口文件
我们应用程序的入口点是 app.js。通常的做法是在这里放置大量的代码,但关注点分离要确保所有逻辑是分离的。我们将创建两个入口点,即 server.js 和 app.js。在 server.js 文件中,我们将导入加载器和配置文件,并开始监听 PORT。app.js 文件只导入 server.js。因此,从技术上讲,当服务器试图启动应用程序时,它会读取 app.js 文件并试图启动 server.js 文件中指定的各种函数。
server.js
import express from 'express';import dotenv from 'dotenv';import appLoader from './loaders';import appConfig from './config';export const app = express();require('dotenv').config({path: __dirname '/.env' })
export const start = async () => { try { await appLoader({ app, express }); app.listen(appConfig.port, () => { console.log(`REST API on http://localhost:${appConfig.port}/api/v1`); console.log(process.env.MONGO_URI_DEV); console.log(appConfig.dbUrl); }); } catch (e) { console.error(e) }}
app.js
import { start } from './server'
start()process.on('unhandledRejection', (err, _) => { console.log(`Server error: ${err}`)})
到目前为止,在运行我们的应用程序时我们会得到一条消息,说我们的应用程序正运行在首选端口上,Express 服务器已启动,并已成功连接到数据库。
模 型
还有一些模型,它们是应用程序和数据库之间的接口。它们用于组织我们在应用程序中传递的数据。因此,我们将在模型文件夹中创建两个文件——user.model.js 和 index.js 文件,我们将把所有模型都导入到 index.js 文件中。
user.model.js
import mongoose from 'mongoose';import bcrypt from 'bcryptjs';import { sign } from 'jsonwebtoken';
import config from '../config';const UserSchema = new mongoose.Schema({ name: { type: String, trim: true, required: [true, "Name is required"] }, email: { type: String, trim: true, unique: true, match: [/^(([^<>()[]\.,;:s@"] (.[^<>()[]\.,;:s@"] )*)|(". "))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9] .) [a-zA-Z]{2,}))$/, "Please enter a valid email"], required: [true, "Email is required"] }, password: { type: String, select: false, }, created_at: { type: Date, default: Date.now }})
index.js
import User from './user.model';export { User};
服务文件
服务文件负责处理数据操作、数据库调用和其他业务逻辑。将应用程序服务与控制器分离是一种关注点分离技术。服务层包含与业务相关的逻辑,与 HTTP 层没有任何关联。这种技术让测试用例变得更容易编写,也更容易重构,控制器也变得更小。服务实现了应用程序的逻辑,并在向控制器返回必要的响应之前与数据库发生通信。我们创建了一个简单的认证服务文件,其中包含我们的登录和注册逻辑。
auth.js
import { User } from '../models';import ErrorResponse from '../utils/error_response';import { randomToken } from '../utils/helpers';
export default class AuthService { //用户注册 async signup(data) { try { const { email, password, name } = data; // 通过电子邮件地址查找用户 let query = { $or: [{ email: { $regex: email, $options: 'i' } }] }; const hasEmail = await User.find(query); console.log(hasEmail); // 如果找不到用户就抛出错误 if (hasEmail.length > 0) { throw new ErrorResponse('Email already exists', 400); } const user = await User.create({ email, password, name }); console.log(user) return user; } catch (e) { throw e; } }
async signin(data) { try { let { email, password } = data; let query = { $or: [ { email: { $regex: email, $options: 'i' } } ] };
// 通过电子邮件地址查找用户 const user = await User.findOne(query).select(' password'); // throw error if user not found if (!user) { throw new ErrorResponse('Invalid credentials', 401); }
// 检查用户密码 const isMatch = await user.comparePassword(password); if (!isMatch) { throw new ErrorResponse('Invalid credentials', 401); } return { user: user.toMap(), token: user.getJwtToken(), }; } catch (e) { throw e; } }}
api 文件夹
最后是我们的 api 文件夹,其中包含另外三个重要的子文件夹——controllers、routes 和 middlewares,我们将分别介绍它们。
middlewares
中间件负责处理应用程序中的各种验证或其他一般性检查。我们将创建两个文件,async_handler.js 和 auth_handler.js,来处理 res(响应)和 req(请求)对象,以及用户授权。
async_handler.js
export const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
auth_handler.js
import { verify } from 'jsonwebtoken';import ErrorResponse from '../../utils/error_response';import { asyncHandler } from './async_handler';import config from '../../config';
export const userAuth = asyncHandler( async (req, res, next) => { let authHeader = req.headers.authorization; let token = authHeader && authHeader.startsWith('Bearer') && authHeader.split(' ')[1];
if (!token) { return next(new ErrorResponse('Unauthorized access', 401)); } try { const decoded = verify(token, config.secrets.jwt); req.user = decoded.result; next(); } catch (e) { return next(new ErrorResponse('Unauthorized access', 401)); } });
controllers
控制器接收请求,调用所需的服务,通过数据访问层与数据库通信,然后将结果发送回服务,服务再将结果发送回控制器,控制器再将结果发送给客户端。我们将在 controllers 文件夹中创建一个叫作 index.js 的文件,其中包含我们的登录和注册控制器。这些控制器使用 handler.js 文件中的 res 和 req 对象向各种服务发送请求。
index.js
import { asyncHandler } from '../middlewares/async_handler';import { JsonResponse } from '../../utils/helpers';import AuthService from '../../services/auth';export default class IndexController { constructor() { this.authService = new AuthService(); } index = asyncHandler( async (req, res, _) => { res.json({ status: true, message: "Base API Endpoint." })
}); loginUser = asyncHandler( async (req, res, _) => {
const { user, token } = await this.authService.signin(req.body);
return new JsonResponse().success(res, "User logged in successfully", { user, token });
});
registerUser = asyncHandler( async (req, res, _) => {
await this.authService.signup(req.body);
return new JsonResponse(201).success(res, "User account created successfully", {});
});
}
routes
路由定义了我们的应用程序应该如何响应来自客户端的 HTTP 请求。它是程序中与 HTTP 谓词相关的部分。中间件可能会保护这些路由,也可能不会。路由的主要功能是在请求到达时处理请求。
例如,POST 请求创建路由并期望数据被发布或传递。
在 routes 文件夹中,我们创建了一个 index.js 文件,其中包含了访问平台各种服务所需的所有路由。路由接收一个请求,将其转发到控制器,然后控制器将其转发到数据库,并向控制器返回一个报告。
index.js
import { Router } from 'express';import Validator from '../../utils/validator';import IndexController from '../controllers';
const router = Router();// 导入所有的控制器let indexController = new IndexController();
// 注册所有的路由router.get('/', indexController.index);
router.post('/login', indexController.loginUser);router.post('/register', Validator.signupValidator, indexController.registerUser);
//导出基础路由器export default router;
结 论
每个开发人员都应该努力编写干净的、可读的和可重用的代码,这样可以让重构、协作、测试和少犯错误变得更加容易。设计 API 架构有多种方法,在选择架构时,无论如何确保可伸缩性和可读性都是你的首要考虑因素。
不过我们确实建议采用技术架构分离,因为正如你所看到的,它有许多优点。这项技术已被证明在构建项目时是非常有用的,无论项目的复杂性或团队规模如何。你肯定不希望在生产环境中出现任何错误!
作者简介
Oghenevwede Emeni 是一名拥有超过 6 年经验的软件开发人员,目前在 Bawse(一家音乐科技初创公司)担任全栈工程师,并经营着自己的第三方支付初创公司!
原文链接:
https://www.infoq.com/articles/separation-concerns-nodejs/
声明:本文为InfoQ翻译,未经许可禁止转载。
今日好文推荐
对话iPod之父:这不是互联网最坏的年代
“羊了个羊”背后公司清仓式分红10亿元;Meta元宇宙部门今年已亏94亿美元;微软称GitHub年收入10亿美元|Q资讯
全面审查Twitter代码、当场炒掉CEO等众多高管:马斯克正式入主Twitter
字节跳动开源BitSail:重构数据集成引擎,走向云原生化、实时化