node.js - 使用 Node/Express 构建企业应用

标签 node.js express architecture

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

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

  • 路由
  • Controller
  • 型号

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

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

    我有 Scala/Java 背景,所以我习惯将所有逻辑分成 3 层 - Controller /服务/道。

    对我来说,以下陈述是理想的:
  • Controller 只负责与 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

    谢谢。

    最佳答案

    Controller 是上帝的对象,直到您不希望它们如此......
    – 你不说zurfyx (╯°□°)╯︵ ┻━┻

    只对解决方案感兴趣? 跳转到 最新部分 "Result"

    ┬──┬◡ノ(° -°ノ)

    在开始回答之前,让我为使这种响应方式比通常的 SO 长度更长而道歉。 Controller 本身什么都不做,这都是关于整个 MVC 模式的。因此,我觉得有必要了解有关路由器 <-> Controller <-> 服务 <-> 模型的所有重要细节,以便向您展示如何以最小的责任实现适当的隔离 Controller 。

    假设案例

    让我们从一个小假设案例开始:

  • 我想要一个 API,通过 AJAX 为用户搜索提供服务。
  • 我想要一个 API,它也可以通过 Socket.io 为相同的用户搜索提供服务。

  • 让我们从 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 上更安全。并不是说前面示例中的单元测试验证器是微不足道的。

    阅读有关此 herehere 的更多信息。

    服务

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

    大多数情况下(包括这一次),它会使用 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 块。

    由于我们不能相信开发人员(包括我在内)在尝试之前阅读文档,此时我们必须做出决定:
  • Controller 强制 Promise 返回
  • 服务总是返回一个 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 中断,则可能会引发异常。

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

    尽管它会运行良好,但如果我们在所有路由中复制粘贴此代码段,则会导致大量代码重复。所以我们必须做一个更好的抽象。

    在这种情况下,我们可以创建一种伪路由器客户端,它接受一个 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: '⁽ƈ ͡ (ुŏ̥̥̥̥םŏ̥̥̥̥) ु' });
    });
    

    更重要的是,您可能应该使用 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.js 的大型项目的合适方法。

    如果您还没有使用它,请尝试查看 ES6 features 和 ES2017Babel 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/

    相关文章:

    javascript - 包含在 Express 500 错误 - .html 扩展 View 引擎中

    node.js - bcrypt.compare() 验证密码时始终返回 false

    javascript - 单元测试 express.js 路由

    ios - 避免多个 if 子句调用不同的方法

    ios - swift MVVM : Where to keep custom model data if we have an array of models

    javascript - String.match() 函数即使匹配也返回 null

    node.js - 特拉维斯 CI : Karma testing passing but always get errored build

    json - 通过唯一的响应从多个文件获取 JSON 数据

    javascript - 在 POST 请求中创建用于动态下载的文件

    web-services - DAO 微服务是微服务架构中的好方法吗?