【问题标题】:Building enterprise app with Node/Express使用 Node/Express 构建企业应用程序
【发布时间】:2017-06-12 01:14:15
【问题描述】:

我正在尝试了解如何使用 Node/Express/Mongo(实际上使用 MEAN 堆栈)构建企业应用程序。

在阅读了 2 本书和一些谷歌搜索(包括类似的 StackOverflow 问题)之后,我找不到任何使用 Express 构建大型应用程序的好例子。我读过的所有资料都建议通过以下实体拆分应用程序:

  • 路线
  • 控制器
  • 型号

但我看到这种结构的主要问题是控制器就像上帝的对象,他们知道reqres 对象,负责验证并且包含业务逻辑

另一方面,在我看来,路由就像是过度设计,因为它们所做的只是将端点(路径)映射到控制器方法。

我有 Scala/Java 背景,所以我习惯将所有逻辑分成 3 层 - 控制器/服务/dao。

对我来说,以下陈述是理想的:

  • 控制器仅负责与 WEB 部分交互,即编组/解组,一些简单的验证(必需、最小值、最大值、电子邮件正则表达式等);

  • 服务层(实际上我在 NodeJS/Express 应用程序中错过了)只负责业务逻辑,一些业务验证。服务层对 WEB 部分一无所知(即它们可以从应用程序的其他地方调用,而不仅仅是从 Web 上下文中调用);

  • 关于 DAO 层我很清楚。 Mongoose 模型实际上是 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
}

req.body 中的 JSON 对象作为参数传递,用于创建 Mongo 文档。对我来说闻起来很糟糕。我想使用具体的类,而不是原始 JSON

谢谢。

【问题讨论】:

  • 你可能想看看这个问题:stackoverflow.com/questions/5178334/… 这个要点:gist.github.com/lancejpollard/1398757
  • 您可以按照您认为合适的方式构建它。无需直接在控制器下创建文章,您始终可以将其分离到不同目录下的另一层,例如 Service.createArticle 并要求它(就像 java 中的导入一样)以供使用
  • 关于req.body对象,如果你使用像body-parser这样的express-middleware,它应该已经被转换为Javascript Object。是的,即使它不再是原始 JSON,我们仍然不会将它直接传递给 DTO 构造函数。它需要通过一些验证和清理
  • 一个项目的结构没有对错之分,不同的企业项目有不同的结构,因为他们做的事情不同。有些需要支持像socket.io这样的实时框架,有些需要有后台守护进程来消费消息队列,以及一堆其他的东西。只需按照您熟悉的方式构建您的应用,随着应用规模的扩大,结构会逐渐发生变化

标签: node.js express architecture


【解决方案1】:

控制器是上帝的对象,直到你不希望它们如此......
   - 你不要说 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 的 Express?您将不得不重做所有控制器。

另一种可能不那么明显的难闻气味......

控制器响应是手工制作的。 .json({ error: whatever })

RL 中的 API 不断变化。将来您可能希望您的回复是 { err: whatever } 或者更复杂(且有用)的内容,例如:{ error: whatever, status: 500 }

让我们开始吧(一个可能的解决方案)

我不能称它为 解决方案,因为那里有无穷无尽的解决方案。这取决于您的创造力和您的需求。以下是一个不错的解决方案;我在一个相对较大的项目中使用它,它似乎运行良好,它修复了我之前指出的所有问题。

我会去模型 -> 服务 -> 控制器 -> 路由器,让它保持有趣直到结束。

型号

我不会详细介绍模型,因为这不是问题的主题。

您应该具有类似的 Mongoose 模型结构,如下所示:

models/User/validate.js

export function validateUsername(username) {
  return true;
}

您可以阅读更多关于 mongoose 4.x 验证器的适当结构here

models/User/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,否则您将始终放心,每个人都会通过您的模型验证,这比将它们放在控制器上为您提供更多安全性。并不是说像前面示例中那样对验证器进行单元测试是微不足道的。

阅读更多关于 herehere 的信息。

服务

该服务将充当处理器。给定可接受的参数,它会处理它们并返回一个值。

大多数时候(包括这个),它会使用Mongoose Models 并返回一个Promise(或一个回调;但I would definitely 使用带有Promises 的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;
}

控制器

我们希望避免重复的控制器,因此每个操作只有 一个 控制器。

控制器/user.js

export function getUser(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 返回
  • 始终返回 Promise 的服务

⬑ 这将解决不一致的控制器返回(不是我们可以省略 try-catch 块的事实)。

IMO,我更喜欢第一个选项。因为控制器是大多数时候会链接最多 Promise 的控制器。

return findUserByUsername
         .then((user) => getChat(user))
         .then((chat) => doSomethingElse(chat))

如果我们使用 ES6 Promise,我们也可以使用 Promise 的一个不错的属性来执行此操作:Promise 可以在其生命周期内处理 non-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 块:我们不想在控制器中使用它,除非我们想对其进行自定义处理。这样我们就可以利用两个已经内置的通信通道(错误异常和成功消息返回)通过单独的通道传递我们的消息。

我们可以在控制器中使用较新的 ES2017 async / await (now official),而不是 ES6 Promise .then

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 中断,这可能会引发异常。

路由器将以统一的方式控制请求并向客户端返回数据,无论是一些现有数据,nullundefined 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);
  }
});

虽然它会运行良好,但如果我们将这个 sn-p 复制粘贴到所有路由中,将会导致大量代码重复。所以我们必须做一个更好的抽象。

在这种情况下,我们可以创建一种假路由器客户端,它接受一个承诺和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]));

干净的一行,就像一开始一样。

更多可选步骤

控制器承诺

它只适用于那些使用 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: '⁽ƈ ͡ (ुŏ̥̥̥̥םŏ̥̥̥̥) ु' });
});

此外,您可能应该使用debugwinston 之类的东西,而不是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 的大型项目的合适方式。

如果您还没有使用它,请尝试查看ES6 功能和ES2017Babel 转译器。

结果

这只是完整的代码(之前已经显示),没有 cmets 或注释。您可以通过向上滚动到相应的部分来检查有关此代码的所有内容。

路由器.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();
}

models/User/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;

models/User/validate.js

export function validateUsername(username) {
  return true;
}

【讨论】:

  • 我喜欢你的 controllerHandler 把戏。但是如果我想使用模板引擎渲染一些视图,你能给我建议吗?我的意思是res.render('index', {title: 'Hello'})。在这种情况下,Controller 和 Route 应该如何看待?
  • @MyTitle 我会避免修改控制器。 {title: 'Hello'} 可以是已经发送到handleController 的当前对象值。 'index' 应该是handleController 函数上的一个额外参数,因为没有控制器对渲染结果的视图感兴趣。根据您的需要,您可能希望传递 2 个或更多视图来处理不同类型的结果或错误,但首先我会简单地传递一个唯一的视图文件名,然后在“Express Error Handler”中设置一个默认错误"(目前是res.json)。
  • 如何在控制器中获取应用实例。我需要使用app.emit('some event') 触发一些事件
  • 整洁的controllerHandler 实现
  • 您对如何处理非常特定于路由的事情有任何建议,例如设置响应标头。有些路由可能需要添加某个标头,有些可能不需要。我不想将整个 res 对象传递给控制器​​并在那里设置标题。我认为应该从路由完成,或者在 Promise 完成执行后从其他中间件完成。我喜欢这种方法,但在处理每条路线的独特场景时需要更多的灵活性。
【解决方案2】:

每个人都有自己将项目划分为特定文件夹的方式。 我使用的结构是

  • 配置
  • 日志
  • 路线
  • 控制器
  • 型号
  • 服务
  • 实用工具
  • 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

log 文件夹包含用于调试的连接日志错误日志

控制器用于验证您的请求数据和业务逻辑

例子

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" })
                    })
                }
            })
        }
    ])
}

models 用于定义您的数据库架构

示例 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)
}

【讨论】:

    【解决方案3】:

    rohit salaria 的回答基本上解释了您在 java 中使用的相同应用程序结构。

    • 控制器是 Java 中的控制器
    • 模型是数据访问层
    • 服务是服务层

    不过,我有几点意见。第一个也是最重要的一点是,这不是 Java。这听起来可能很明显,但只要看看您的问题,就会发现您正在寻找与 Java 世界中使用的相同概念相同的开发体验。我下面的评论只是对此的解释。

    缺少 DTO。在 Java 中,它们只是必需的,句号。在 Java Web 应用程序中,您将数据存储在关系数据库中并以 JSON 格式向前端发送和接收数据,很自然地将数据转换为 Java 对象。然而,在 Node 应用程序中,一切都是 javascript 和 JSON。这是平台的优势之一。由于 JSON 是常用的数据格式,因此无需编写代码或依赖库来在图层的数据格式之间进行转换。

    将数据对象直接从请求传递到模型。为什么不?将 JSON 作为从前端到数据库的通用数据格式使您能够轻松地在所有层之间同步应用程序的数据模型。当然不一定非要走这条路,但大部分时候就够用了,何乐而不为呢?至于验证,它是在模型中完成的,它属于根据 MVC 理论的地方(而不是在懒惰和实用主义经常提出的控制器中:))。

    对于我想补充的最后一点,在项目规模缩放方面,这不是最好的平台。它根本就是点头蝙蝠,但Java在这方面更好。

    【讨论】:

      【解决方案4】:

      简单而基本的规则

      1. 保持组件彼此靠近。

      2. 将页面分成组件并工作

      3. 所有依赖的组件应该在一起

      4. 共享的东西应该独立于所有其他组件。

      最后,每种语言都是甜蜜的。就是你对语言的熟悉程度。只有熟悉你的剑才能赢得战斗。

      我正在使用 NodeJS 开发 Angular2 应用程序,Angular2 我会帮助你了解我的目录结构。

      `主模块` 

       `子模块结构`

      `将共享文件夹保留为单独的模块`

      希望对你有帮助:)

      【讨论】:

      • 我们需要一个共享文件夹吗?
      • 整个项目共享且通用的组件、服务、指令。
      猜你喜欢
      • 1970-01-01
      • 2018-09-28
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2012-04-24
      • 2011-08-02
      • 1970-01-01
      相关资源
      最近更新 更多