我试图了解如何使用Node/Express/Mongo构建企业应用程序(实际上使用MEAN堆栈).
在阅读了2本书和一些谷歌搜索(包括类似的StackOverflow问题)之后,我找不到使用Express构建大型应用程序的任何好例子.我读过的所有资料都建议通过以下实体拆分应用程序:
路线
控制器
楷模
但我这个结构看主要问题是,控制器是神一样的物体,他们知道req
,res
对象,负责验证和有业务逻辑包含英寸
在另一方面,我认为路由过度工程,因为他们所做的只是将端点(路径)映射到控制器方法.
我有Scala/Java背景,所以我习惯将3层中的所有逻辑分开 - controller/service/dao.
对我来说,以下陈述是理想的:
控制器只负责与WEB部分交互,即编组/解组,一些简单的验证(必需,最小,最大,电子邮件正则表达等);
服务层(实际上我在NodeJS/Express应用程序中错过了)仅负责业务逻辑,一些业务验证.服务层对WEB部分一无所知(即可以从其他应用场所调用,不仅可以从Web上下文调用);
关于DAO层对我来说都很清楚.猫鼬模型实际上是DAO,所以这里对我来说最清楚.
我认为我看到的例子很简单,它们只显示了Node/Express的概念,但我想看看一些真实世界的例子,其中涉及大部分业务逻辑/验证.
编辑:
另一件事我不清楚是否缺少DTO对象.考虑这个例子:
const mongoose = require('mongoose'); const Article = mongoose.model('Article'); exports.create = function(req, res) { // Create a new article object const article = new Article(req.body); // saving article and other code }
将JSON对象req.body
作为参数传递给创建Mongo文档.它闻起来对我不好.我想使用具体的类,而不是原始的JSON
谢谢.
控制器是上帝的对象,直到你不希望它们为止......
- 你不说zurfyx(╯°□°)╯(┻━┻
只是对解决方案感兴趣? 跳到最新的"结果"部分.
┬──┬◡ノ(° - °ノ)
在开始回答之前,让我为使这种响应方式比通常的SO长度更长而道歉.控制器本身什么都不做,这完全是关于整个MVC模式.因此,我觉得通过所有关于路由器< - >控制器< - >服务< - >模型的重要细节是相关的,以便向您展示如何以最小的责任实现适当的隔离控制器.
假设的情况让我们从一个小的假设案例开始:
我想拥有一个通过AJAX为用户搜索的API.
我希望有一个API也通过Socket.io提供相同的用户搜索.
让我们从Express开始.这很容易,不是吗?
routes.js
import * as userControllers from 'controllers/users'; router.get('/users/:username', userControllers.getUser);
控制器/ user.js的
import User from '../models/User'; function getUser(req, res, next) { const username = req.params.username; if (username === '') { return res.status(500).json({ error: 'Username can\'t be blank' }); } try { const user = await User.find({ username }).exec(); return res.status(200).json(user); } catch (error) { return res.status(500).json(error); } }
现在让我们来做Socket.io部分:
由于那不是socket.io问题,我将跳过样板.
import User from '../models/User'; socket.on('RequestUser', (data, ack) => { const username = data.username; if (username === '') { ack ({ error: 'Username can\'t be blank' }); } try { const user = User.find({ username }).exec(); return ack(user); } catch (error) { return ack(error); } });
嗯,闻到这里的气味......
if (username === '')
.我们不得不两次编写控制器验证器.如果有n
控制器验证器怎么办?我们是否必须保留最新的两份(或更多份)?
User.find({ username })
重复两次.这可能是一项服务.
我们刚刚编写了两个控制器,分别附加到Express和Socket.io的确切定义.它们很可能永远不会在其生命周期中破坏,因为Express和Socket.io往往具有向后兼容性.但是,它们不可重复使用.改变Hapi的快递?您将不得不重做所有控制器.
另一种可能不那么明显的难闻气味......
控制器响应是手工制作的. .json({ error: whatever })
RL中的API不断变化.将来你可能希望你的回复是{ err: whatever }
或者更复杂(和有用)的东西,如:{ error: whatever, status: 500 }
我不能把它的解决方案,因为有解决方案的无尽的金额在那里.这取决于您的创造力和您的需求.以下是一个不错的解决方案; 我在一个相对较大的项目中使用它,它似乎运行良好,它修复了我之前指出的一切.
我将去模型 - >服务 - >控制器 - >路由器,让它保持有趣直到结束.
我不会详细介绍该模型,因为这不是问题的主题.
您应该具有类似的Mongoose Model结构,如下所示:
车型/用户/ validate.js
export function validateUsername(username) { return true; }
您可以在此处阅读有关mongoose 4.x验证器的相应结构的更多信息.
车型/用户/ index.js
import { validateUsername } from './validate'; const userSchema = new Schema({ username: { type: String, unique: true, validate: [{ validator: validateUsername, msg: 'Invalid username' }], }, }, { timestamps: true }); const User = mongoose.model('User', userSchema); export default User;
只是一个基本的用户模式,包含用户created
updated
名字段和mongoose控制的字段.
我在validate
这里包含该字段的原因是您注意到您应该在此处进行大多数模型验证,而不是在控制器中.
Mongoose Schema是到达数据库之前的最后一步,除非有人直接查询MongoDB,否则您将始终放心,每个人都会通过模型验证,这比将它们放在控制器上更安全.并不是说单元测试验证器就像它们在前面的例子中一样是微不足道的.
在这里和这里阅读更多相关信息.
该服务将充当处理器.给定可接受的参数,它将处理它们并返回一个值.
大多数时候(包括这一次),它将使用Mongoose Models并返回Promise(或回调;但如果你还没有这样做,我绝对会使用ES6和Promises).
服务/ user.js的
function getUser(username) { return User.find({ username}).exec(); // Just as a mongoose reminder, .exec() on find // returns a Promise instead of the standard callback. }
在这一点上你可能想知道,没有catch
阻止?不,因为我们稍后会做一个很酷的技巧,我们不需要为这种情况定制一个.
其他时候,一个简单的同步服务就足够了.确保您的同步服务从不包含I/O,否则您将阻止整个Node.js线程.
服务/ user.js的
function isChucknorris(username) { return ['Chuck Norris', 'Jon Skeet'].indexOf(username) !== -1; }
我们希望避免重复的控制器,因此我们每个操作只有一个控制器.
控制器/ user.js的
export function getUser(username) { }
这个签名现在怎么样?太漂亮了吧?因为我们只对username参数感兴趣,所以我们不需要使用无用的东西req, res, next
.
让我们添加缺少的验证器和服务:
控制器/ user.js的
import { getUser as getUserService } from '../services/user.js' function getUser(username) { if (username === '') { throw new Error('Username can\'t be blank'); } return getUserService(username); }
仍然看起来很整洁,但是......那throw new Error
不会让我的应用程序崩溃吗? - 嘘,等等.我们还没有完成.
所以在这一点上,我们的控制器文档看起来有点像:
/** * Get a user by username. * @param username a string value that represents user's username. * @returns A Promise, an exception or a value. */
什么是"价值" @returns
?请记住,之前我们说我们的服务可以同步或异步(使用Promise
)?getUserService
在这种情况下是异步,但isChucknorris
服务不会,所以它只返回一个值而不是一个Promise.
希望每个人都会阅读文档.因为他们需要处理一些与其他控制器不同的控制器,其中一些控制器需要一个try-catch
块.
由于我们不能信任开发人员(包括我)在首先尝试之前阅读文档,此时我们必须做出决定:
控制器强制Promise
返回
服务总是返回承诺
⬑这将解决不一致的控制器返回(不是我们可以省略try-catch块的事实).
IMO,我更喜欢第一种选择.因为控制器是大多数时候链接最多Promise的控制器.
return findUserByUsername .then((user) => getChat(user)) .then((chat) => doSomethingElse(chat))
如果我们使用ES6 Promise,我们可以使用Promise
这样做的好的属性:Promise
可以在其生命周期内处理非承诺并仍然继续返回Promise
:
return promise .then(() => nonPromise) .then(() => // I can keep on with a Promise.
如果我们调用的唯一服务不使用Promise
,我们可以自己制作一个.
return Promise.resolve() // Initialize Promise for the first time. .then(() => isChucknorris('someone'));
回到我们的例子,它将导致:
... return Promise.resolve() .then(() => getUserService(username));
Promise.resolve()
在这种情况下,我们实际上并不需要getUserService
返回Promise,但我们希望保持一致.
如果你想知道catch
块:我们不想在我们的控制器中使用它,除非我们想要进行定制处理.这样我们就可以利用两个已经内置的通信通道(错误和成功消息返回的例外)通过各个通道传递我们的消息.
.then
我们可以在控制器中使用更新的ES2017 async / await
(现为官方版),而不是ES6 Promise :
async function myController() { const user = await findUserByUsername(); const chat = await getChat(user); const somethingElse = doSomethingElse(chat); return somethingElse; }
async
在前面注意function
.
最后是路由器,耶!
所以我们还没有对用户做出任何回应,我们所拥有的只是一个控制器,我们知道它总是返回一个Promise
(希望有数据).哦!,如果throw new Error is called
或某些服务Promise
中断,这可能会引发异常.
路由器将以统一的方式控制请求并将数据返回给客户端,无论是某些现有数据,null
还是undefined
data
错误.
路由器将是唯一具有多个定义的路由器.其数量取决于我们的拦截器.在假设的情况下,这些是API(使用Express)和Socket(使用Socket.io).
让我们回顾一下我们要做的事情:
我们希望我们的路由器转换(req, res, next)
成(username)
.一个天真的版本将是这样的:
router.get('users/:username', (req, res, next) => { try { const result = await getUser(req.params.username); // Remember: getUser is the controller. return res.status(200).json(result); } catch (error) { return res.status(500).json(error); } });
虽然它可以正常工作,但如果我们在所有路由中复制粘贴此代码段,则会导致大量的代码重复.所以我们必须做出更好的抽象.
在这种情况下,我们可以创建一种假的路由器客户端,它接受一个promise和n
参数并执行其路由和return
任务,就像在每个路由中一样.
/** * Handles controller execution and responds to user (API Express version). * Web socket has a similar handler implementation. * @param promise Controller Promise. I.e. getUser. * @param params A function (req, res, next), all of which are optional * that maps our desired controller parameters. I.e. (req) => [req.params.username, ...]. */ const controllerHandler = (promise, params) => async (req, res, next) => { const boundParams = params ? params(req, res, next) : []; try { const result = await promise(...boundParams); return res.json(result || { message: 'OK' }); } catch (error) { return res.status(500).json(error); } }; const c = controllerHandler; // Just a name shortener.
如果您有兴趣了解有关此技巧的更多信息,可以在React-Redux和带有socket.io的Websockets("SocketClient.js"部分)的其他回复中阅读完整版本.
你的路线怎么样controllerHandler
?
router.get('users/:username', c(getUser, (req, res, next) => [req.params.username]));
干净的一行,就像开头一样.
更多可选步骤它仅适用于那些使用ES6 Promises的人.ES2017 async / await
版本对我来说已经很好了.
出于某种原因,我不喜欢使用Promise.resolve()
name来构建初始化Promise.目前还不清楚那里发生了什么.
我宁愿用更容易理解的东西替换它们:
const chain = Promise.resolve(); // Write this as an external imported variable or a global. chain .then(() => ...) .then(() => ...)
现在你知道这chain
标志着Promises链的开始.每个读取代码的人都是如此,或者如果没有,他们至少会认为它是一个服务功能链.
Express确实有一个默认的错误处理程序,您应该使用它来捕获至少最意外的错误.
router.use((err, req, res, next) => { // Expected errors always throw Error. // Unexpected errors will either throw unexpected stuff or crash the application. if (Object.prototype.isPrototypeOf.call(Error.prototype, err)) { return res.status(err.status || 500).json({ error: err.message }); } console.error('~~~ Unexpected error exception start ~~~'); console.error(req); console.error(err); console.error('~~~ Unexpected error exception end ~~~'); return res.status(500).json({ error: '?? ? (????????????) ?' }); });
更重要的是,你应该使用像debug或winston这样的东西,而不是console.error
更专业的处理日志的方法.
这就是我们如何将其插入controllerHandler
:
... } catch (error) { return res.status(500) && next(error); }
我们只是将任何捕获的错误重定向到Express'错误处理程序.
Error
被认为是在Javascript中抛出异常时封装错误的默认类.如果您真的只想跟踪自己的受控错误,我可能会将throw Error
Express和错误处理程序更改Error
为ApiError
,您甚至可以通过添加状态字段来更好地满足您的需求.
export class ApiError { constructor(message, status = 500) { this.message = message; this.status = status; } }附加信息
您可以通过throw new Error('whatever')
或使用任何点抛出任何自定义异常new Promise((resolve, reject) => reject('whatever'))
.你只需要玩Promise
.
这是非常自以为是的观点.IMO ES6(甚至ES2017,现在具有一套官方功能)是基于Node的大型项目的合适方式.
如果您还没有使用它,请尝试查看ES6功能以及ES2017和Babel转换器.
结果这只是完整的代码(之前已经显示过),没有注释或注释.您可以通过向上滚动到相应的部分来检查有关此代码的所有内容.
router.js
const controllerHandler = (promise, params) => async (req, res, next) => { const boundParams = params ? params(req, res, next) : []; try { const result = await promise(...boundParams); return res.json(result || { message: 'OK' }); } catch (error) { return res.status(500) && next(error); } }; const c = controllerHandler; router.get('/users/:username', c(getUser, (req, res, next) => [req.params.username]));
控制器/ user.js的
import { serviceFunction } from service/user.js export async function getUser(username) { const user = await findUserByUsername(); const chat = await getChat(user); const somethingElse = doSomethingElse(chat); return somethingElse; }
服务/ user.js的
import User from '../models/User'; export function getUser(username) { return User.find({}).exec(); }
车型/用户/ index.js
import { validateUsername } from './validate'; const userSchema = new Schema({ username: { type: String, unique: true, validate: [{ validator: validateUsername, msg: 'Invalid username' }], }, }, { timestamps: true }); const User = mongoose.model('User', userSchema); export default User;
车型/用户/ validate.js
export function validateUsername(username) { return true; }
每个人都有自己的将项目划分为特定文件夹的方式。我使用的结构是
配置
日志
路线
控制器
楷模
服务
实用程序
app.js / server.js / index.js(您喜欢的任何名称)
config文件夹包含用于开发的所有阶段(如“生产”,“开发”,“测试”)的配置文件(如数据库连接设置)
例
'use strict' var dbsettings = { "production": { //your test settings }, "test": { }, "development": { "database": "be", "username": "yourname", "password": "yourpassword", "host": "localhost", "connectionLimit": 100 } } module.exports = dbsettings
日志文件夹包含您的连接日志错误日志以进行调试
控制器用于验证您的需求数据和业务逻辑
例
const service = require("../../service") const async = require("async") exports.techverify = (data, callback) => { async.series([ (cb) => { let searchObject = { accessToken: data.accessToken } service.admin.get(searchObject, (err, result) => { if (err || result.length == 0) { callback(err, { message: "accessToken is invalid" }) } else { delete data.accessToken service.tech.update(data, { verified: true }, (err, affe, res) => { if (!err) callback(err, { message: "verification done" }) else callback(err, { message: "error occured" }) }) } }) } ]) }
模型用于定义数据库模式
示例mongoDb模式
'use strict' let mongoose = require('mongoose'); let schema = mongoose.Schema; let user = new schema({ accesstoken: { type: String }, firstname: { type: String }, lastname: { type: String }, email: { type: String, unique: true }, image: { type: String }, phoneNo: { type: String }, gender: { type: String }, deviceType: { type: String }, password: { type: String }, regAddress: { type: String }, pincode: { type: String }, fbId: { type: String, default: 0 }, created_at: { type: Date, default: Date.now }, updated_at: { type: Date, default: Date.now }, one_time_password: { type: String }, forgot_password_token: { type: String }, is_block: { type: Boolean, default: 0 }, skin_type: { type: String }, hair_length: { type: String }, hair_type: { type: String }, credits: { type: Number, default: 0 }, invite_code: { type: String }, refered_by: { type: String }, card_details: [{ card_type: { type: String }, card_no: { type: String }, card_cv_no: { type: String }, created_at: { type: Date } }] }); module.exports = mongoose.model('user', user);
服务用于编写数据库查询,避免在控制器中编写查询,尝试在此文件夹中编写查询并在控制器中调用它
用猫鼬查询
'use strict' const modelUser = require('../../models/user'); exports.insert = (data, callback) => { console.log('mongo log for insert function', data) new modelUser(data).save(callback) } exports.get = (data, callback) => { console.log('mongo log for get function', data) modelUser.find(data, callback) } exports.update = (data, updateData, callback) => { console.log('mongo log for update function', data) modelUser.update(data, updateData, callback); } exports.getWithProjection = (data, projection, callback) => { console.log('mongo log for get function', data) modelUser.find(data, projection, callback) }
utils用于您的项目中常用的通用实用程序功能,例如加密,解密密码等
例
exports.checkPassword = (text, psypherText) => { console.log("checkPassword executed") console.log(text, psypherText) return bcrypt.compareSync(text, psypherText) } exports.generateToken = (userEmail) => { return jwt.sign({ unique: userEmail, timeStamp: Date.now }, config.keys.jsonwebtoken) }