我试图了解如何使用 Node/Express/Mongo(实际上使用 MEAN 堆栈)来构建企业应用程序。
在阅读了 2 本书和一些谷歌搜索(包括类似的 StackOverflow 问题)后,我找不到任何使用 Express 构建大型应用程序的好例子。我读过的所有来源都建议按以下实体拆分应用程序:
但我看到这种结构的主要问题是 Controller 就像上帝对象,他们知道
req
、 res
对象,负责验证并包含业务逻辑。另一方面,路由在我看来像是过度设计,因为它们所做的只是将端点(路径)映射到 Controller 方法。
我有 Scala/Java 背景,所以我习惯将所有逻辑分成 3 层 - Controller /服务/道。
对我来说,以下陈述是理想的:
我认为我看到的例子非常简单,它们只展示了 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
}
来自
req.body
的 JSON 对象作为参数传递以创建 Mongo 文档。对我来说闻起来很糟糕。我想使用具体类,而不是原始 JSON谢谢。
最佳答案
Controller 是上帝的对象,直到您不希望它们如此......
– 你不说zurfyx (╯°□°)╯︵ ┻━┻
只对解决方案感兴趣? 跳转到 最新部分 "Result"
┬──┬◡ノ(° -°ノ)
在开始回答之前,让我为使这种响应方式比通常的 SO 长度更长而道歉。 Controller 本身什么都不做,这都是关于整个 MVC 模式的。因此,我觉得有必要了解有关路由器 <-> Controller <-> 服务 <-> 模型的所有重要细节,以便向您展示如何以最小的责任实现适当的隔离 Controller 。
假设案例
让我们从一个小假设案例开始:
让我们从 Express 开始。这很容易,不是吗?
路由.js
import * as userControllers from 'controllers/users';
router.get('/users/:username', userControllers.getUser);
Controller /用户.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 === '')
.我们不得不两次编写 Controller 验证器。如果有 n
Controller 验证器怎么办?我们是否必须保持每个副本的两个(或更多)最新版本? User.find({ username })
重复两次。那可能是一项服务。 我们刚刚编写了两个 Controller ,分别附加到 Express 和 Socket.io 的确切定义。它们在其生命周期中很可能永远不会中断,因为 Express 和 Socket.io 都倾向于向后兼容。 但是 ,它们不可重复使用。更改 Express 为 Hapi ?您将不得不重做所有 Controller 。
另一种可能不那么明显的难闻的气味......
Controller 响应是手工制作的。
.json({ error: whatever })
RL 中的 API 不断变化。将来你可能希望你的响应是
{ err: whatever }
或者更复杂(和有用)的东西,比如:{ error: whatever, status: 500 }
让我们开始吧(一个可能的解决方案)
我不能称之为解决方案,因为那里有无数的解决方案。这取决于您的创造力和需求。以下是一个不错的解决方案;我在一个相对较大的项目中使用它,它似乎运行良好,它修复了我之前指出的所有问题。
我会去模型 -> 服务 -> Controller -> 路由器,让它有趣到最后。
模型
我不会详细介绍模型,因为这不是问题的主题。
你应该有一个类似的 Mongoose 模型结构如下:
模型/用户/validate.js
export function validateUsername(username) {
return true;
}
您可以阅读有关 mongoose 4.x 验证器 here 的适当结构的更多信息。
模型/用户/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
字段的原因是让您注意到您应该在这里进行大多数模型验证,而不是在 Controller 中。Mongoose Schema 是到达数据库之前的最后一步,除非有人直接查询 MongoDB,否则您将始终放心,每个人都经过您的模型验证,这比将它们放在 Controller 上更安全。并不是说前面示例中的单元测试验证器是微不足道的。
阅读有关此 here 和 here 的更多信息。
服务
该服务将充当处理器。给定可接受的参数,它将处理它们并返回一个值。
大多数情况下(包括这一次),它会使用 Mongoose Models 并返回 Promise(或回调;但是 I would definitely 使用带有 Promise 的 ES6,如果你还没有这样做的话)。
服务/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,否则您将阻塞 the whole Node.js thread 。
服务/user.js
function isChucknorris(username) {
return ['Chuck Norris', 'Jon Skeet'].indexOf(username) !== -1;
}
Controller
我们想避免重复的 Controller ,所以我们将只有 一个 Controller 用于每个 Action 。
Controller /用户.js
export function getUser(username) {
}
这个签名现在怎么样了?漂亮吧?因为我们只对 username 参数感兴趣,所以我们不需要取
req, res, next
等无用的东西。让我们添加缺少的验证器和服务:
Controller /用户.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
怎么样,这不会使我的应用程序崩溃吗? - 嘘,等等。我们还没有完成。所以在这一点上,我们的 Controller 文档看起来有点像:
/**
* Get a user by username.
* @param username a string value that represents user's username.
* @returns A Promise, an exception or a value.
*/
@returns
中规定的“值(value)”是多少?还记得之前我们说过我们的服务可以是同步的也可以是异步的(使用 Promise
)? getUserService
在这种情况下是异步的,但 isChucknorris
服务不会,所以它只会返回一个值而不是一个 Promise。希望每个人都能阅读文档。因为他们需要处理一些与其他 Controller 不同的 Controller ,其中一些需要
try-catch
块。由于我们不能相信开发人员(包括我在内)在尝试之前阅读文档,此时我们必须做出决定:
Promise
返回 ⬑ 这将解决不一致的 Controller 返回(不是我们可以省略我们的 try-catch 块的事实)。
IMO,我更喜欢第一个选项。因为在大多数情况下, Controller 会链接最多的 Promise。
return findUserByUsername
.then((user) => getChat(user))
.then((chat) => doSomethingElse(chat))
如果我们使用 ES6 Promise,我们也可以使用
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
块:我们不想在我们的 Controller 中使用它,除非我们想对其进行自定义处理。通过这种方式,我们可以利用两个已经内置的通信 channel (错误消息的异常(exception)和成功消息的返回)通过单独的 channel 传递我们的消息。我们可以在 Controller 中使用较新的 ES2017
.then
( now official ) 代替 ES6 Promise async / await
:async function myController() {
const user = await findUserByUsername();
const chat = await getChat(user);
const somethingElse = doSomethingElse(chat);
return somethingElse;
}
注意
async
前面的 function
。路由器
最后是路由器,耶!
所以我们还没有对用户做出任何回应,我们只有一个 Controller ,我们知道它总是返回
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 and Websockets with socket.io(“SocketClient.js”部分)中的其他回复中阅读有关此技巧的完整版本。
使用
controllerHandler
时,您的路线会如何?router.get('users/:username', c(getUser, (req, res, next) => [req.params.username]));
一条干净的线,就像一开始一样。
进一步的可选步骤
Controller promise
它只适用于那些使用 ES6 Promises 的人。 ES2017
async / await
版本对我来说已经很好了。出于某种原因,我不喜欢必须使用
Promise.resolve()
名称来构建初始化 Promise。目前还不清楚那里发生了什么。我宁愿用更容易理解的东西替换它们:
const chain = Promise.resolve(); // Write this as an external imported variable or a global.
chain
.then(() => ...)
.then(() => ...)
现在您知道
chain
标志着 Promise 链的开始。阅读您的代码的每个人也是如此,如果没有,他们至少会假设它是一个服务功能链。快速错误处理程序
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 的错误处理程序。
错误为 ApiError
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
。ES6 ES2017
这是非常有见地的观点。 IMO ES6(甚至 ES2017 ,现在有一组官方功能)是处理基于 Node.js 的大型项目的合适方法。
如果您还没有使用它,请尝试查看 ES6 features 和 ES2017 和 Babel transpiler。
结果
这只是完整的代码(之前已经展示过),没有注释或注释。您可以通过向上滚动到相应部分来检查有关此代码的所有内容。
路由器.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]));
Controller /用户.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;
}
关于node.js - 使用 Node/Express 构建企业应用,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/41875617/