[译] Node.js 中的设计模式:实用指南

2,681 阅读13分钟

Node.js中的设计模式:实用指南

原文链接-design-patterns-in-node-js

设计模式是从事软件开发人员所必备技能的一部分,不管他们是否意识到这一点。

在本文中,我们将了解如何在项目中识别这些模式,并了解如何在自己的项目中开始使用它们。

什么是设计模式?

简单地说,设计模式是一种以一种允许您获得某种好处的方式构建解决方案代码的方式。如更快的开发速度,代码可重用性等。

所有模式都很容易适用于 OOP(Object Oriented Programming) 范例。虽然JavaScript具有灵活性,但您也可以在非OOP项目中实现这些概念。

在设计模式方面,有太多的方法只能在一篇文章中介绍,事实上,小型化书籍专门针对这个主题编写,每年都会创建新的模式,留下不完整的列表。

模式的一个非常常见的分类是在GoF book(The Gang of Four Book)书中使用的分类,由于我将只回顾其中的一小部分,所以我将忽略该分类,只需向您提供一个您可以看到的模式列表,然后立即开始在代码中使用。

立即调用的函数表达式(IIFE)

我将向您展示的第一个模式是允许您同时定义和调用函数的模式。由于JavaScript作用域的工作方式,使用IIFE可以很好地模拟类中私有属性。事实上,这种特殊模式有时被用作其他更复杂的模式的一部分。我们稍后会看到。

IIFE 是什么样的?

但在我们深入研究用例及其背后的机制之前,让我快速向您展示它的样式:

(function() {
   var x = 20;
   var y = 20;
   var answer = x + y;
   console.log(answer);
})();

将上面的代码粘贴到 Node.js REPL 或者是你的浏览器控制台运行,你会立即得到函数的执行结果,顾名思义,只要定义了函数,就可以执行它。

IIFE 的模板包含一个匿名函数声明,在一组括号内(将定义转换为函数表达式,a.k.a赋值),然后在它的尾部有一组调用括号。像以下这样的代码:

(function(/*received parameters*/) {
//your code here
})(/*parameters*/)

用例

虽然听起来可能很疯狂,但实际上有一些好处和使用IIFE的用例可能是一件好事,例如:

模拟静态变量

还记得静态变量吗?例如来自其他语言,如CC#。如果您不熟悉它们,静态变量将在第一次使用时初始化,然后获取上次设置的值。这样做的好处是,如果在函数中定义了一个静态变量,则该变量对函数的所有实例都是通用的,无论您调用它多少次,因此它大大简化了如下情况:

function autoIncrement() {
    static let number = 0
    number++
    return number
}

上面的函数将在每次调用时返回一个新的数字(当然,假设静态关键字在 js 中是可用的)。我们可以用 js 中的生成器来实现这一点,这是真的,但是假设我们没有访问它们的权限,您可以模拟如下静态变量:

let autoIncrement = (function() {
    let number = 0

    return function () {
     number++
     return number
    }
})()

[注]如果了解了闭包,就能更好的理解上面这个函数。

你在里面看到的,是封装在一个生命体中的魔力。纯粹的魔法。您基本上返回了一个新的函数,该函数将被分配给 autoincrement 变量(感谢iife的实际执行)。使用js的作用域机制,您的函数将始终能够访问数字变量(就像它是一个全局变量一样)。

模拟私有变量

正如您可能(或者可能不是,我猜)已经知道的那样,ES6课程将每个成员视为公共成员,这意味着没有私有属性或方法。这是不可能的,但多亏了IIFE,如果你愿意,你可以模拟它。

const autoIncrementer = (function() {
  let value = 0;

  return {
    incr() {
        value++
    },

    get value() {
        return value
    }
  };
})();
> autoIncrementer.incr()
undefined
> autoIncrementer.incr()
undefined
> autoIncrementer.value
2
> autoIncrementer.value = 3
3
> autoIncrementer.value
2

上面的代码向您展示了一种方法。尽管您没有特别定义一个类,可以在以后实例化,但请注意,您定义的是一个结构、一组属性和方法,这些属性和方法可以使用您正在创建的对象所共有的变量,但这些变量是不可访问的(通过失败的外派)。

工厂方法模式

这个模式是我最喜欢的模式之一,因为它充当了一个工具,您可以通过它来清理代码。

实际上,工厂方法允许您在一个地方集中创建对象的逻辑(意思是,创建哪个对象以及为什么)。 这使您可以忘记该部分,并专注于简单地请求您需要的对象然后使用它。

这似乎是一个小小的好处,但请容忍我一下,相信我,这是有道理的。

工厂方法模式是什么样的?

如果您首先查看其用法,然后再查看其实现,则此特定模式将更容易理解。

这是一个例子:

( _ => {

    let factory = new MyEmployeeFactory()

    let types = ["fulltime", "parttime", "contractor"]
    let employees = [];
    for(let i = 0; i < 100; i++) {
     employees.push(factory.createEmployee({type: types[Math.floor( (Math.random(2) * 2) )]})    )}
    
    //....
    employees.forEach( e => {
     console.log(e.speak())
    })

})()

上面代码的主要优点是,您将对象添加到同一个数组中,所有这些对象共享同一个接口(从某种意义上说,它们具有相同的方法集),但实际上不需要关心要创建哪个对象以及何时创建。

现在您可以看看实际的实现,正如您所看到的,有很多东西要看,但是非常简单:

class Employee {

    speak() {
     return "Hi, I'm a " + this.type + " employee"
    }

}

class FullTimeEmployee extends Employee{
    constructor(data) {
     super()
     this.type = "full time"
     //....
    }
}

class PartTimeEmployee extends Employee{
    constructor(data) {
     super()
     this.type = "part time"
     //....
    }
}


class ContractorEmployee extends Employee{
    constructor(data) {
     super()
     this.type = "contractor"
     //....
    }
}

class MyEmployeeFactory {

    createEmployee(data) {
     if(data.type == 'fulltime') return new FullTimeEmployee(data)
     if(data.type == 'parttime') return new PartTimeEmployee(data)
     if(data.type == 'contractor') return new ContractorEmployee(data)
    }
}

用例

前面的代码已经显示了一个通用的用例,但是如果我们想要更具体,我喜欢使用这个模式的一个特定用例是处理错误对象创建。

想象一个有大约 10 个端点的Express应用程序,其中每个端点需要根据用户输入返回两到三个错误。我们正在讨论如下 30 条句子:

if(err) {
  res.json({error: true, message: “Error message here”})
}

现在,这不会是一个问题,除非当然,直到下次你不得不突然向错误对象添加一个新属性。现在你必须检查你的整个项目,修改所有30个地方。这可以通过将错误对象的定义移到类中来解决。当然,除非你有不止一个错误对象,而且,你必须根据一些只有你知道的逻辑来决定实例化哪个对象。看看我想去哪?

如果你要集中创建错误对象的逻辑,那么你在整个代码中所要做的就是:

if(err) {
  res.json(ErrorFactory.getError(err))
}

就是这样,你已经完成了,你再也不用改变那条线了。

单例模式

这是另一个古老的模式但它真的很好。请注意,这是一个非常简单的模式,但它可以帮助您跟踪要实例化的类的实例数。实际上,它可以帮助你一直保持这个数字只有一个。主要是,单例模式允许您实例化一次对象,然后在每次需要时使用它,而不是创建一个新的对象,而不必跟踪对它的引用,无论是全局的还是作为依赖项到处传递。

单例模式是什么样的?

通常,其他语言使用一个静态属性来实现这个模式,一旦实例存在,它们就在其中存储实例。这里的问题是,正如我前面提到的,我们不能访问 JS 中的静态变量。所以我们可以用两种方法来实现,一种是使用IIFES而不是类。

另一种方法是使用ES6模块,并使用本地全局变量使用我们的单例类,在其中存储我们的实例。通过这样做,类本身将从模块中导出,但全局变量仍然是模块的本地变量。

我知道,但相信我,听起来比看起来要复杂得多:

let instance = null

class SingletonClass {

    constructor() {
     this.value = Math.random(100)
    }

    printValue() {
     console.log(this.value)
    }

    static getInstance() {
     if(!instance) {
         instance = new SingletonClass()
     }

     return instance
    }
}

module.exports = SingletonClass

你可以像这样使用它:

const Singleton = require(“./singleton”)

const obj = Singleton.getInstance()
const obj2 = Singleton.getInstance()

obj.printValue()
obj2.printValue()

console.log("Equals:: ", obj === obj2)

输出当然是:

0.5035326348000628
0.5035326348000628
Equals::  true

确认了这一点,我们只实例化对象一次,并返回现有实例。

用例

当试图决定是否需要类似于单例的实现时,您需要考虑一些事情:您真正需要多少类实例?如果答案是2或更多,那么这不是你的模式。

但有时需要处理数据库连接时,您可能需要考虑它。

考虑一下,一旦连接到数据库,最好在整个代码中保持连接的活动和可访问性。请注意,这可以通过许多不同的方式解决,是的,但这种模式确实是其中之一。

使用上面的例子,我们可以将它推断成如下:

const driver = require("...")

let instance = null


class DBClass {

    constructor(props) {
     this.properties = props
     this._conn = null
    }

    connect() {
     this._conn = driver.connect(this.props)
    }

    get conn() {
     return this._conn
    }

    static getInstance() {
     if(!instance) {
         instance = new DBClass()
     }

     return instance
    }
}

module.exports = DBClass

而且现在,无论你在哪里,如果你使用的是getInstance方法,你都会确定,你将返回唯一的有效连接(如果有的话)。

观察者模式

这是一个非常有趣的模式,从某种意义上说,它允许您通过对某些输入做出响应,而不是主动检查是否提供了输入。换言之,使用此模式,您可以指定等待的输入类型,并被动地等待直到提供该输入,以便执行代码。如果你愿意的话,这是一套让人忘记的交易。

在这里,观察者是你的对象,它们知道他们想要接收的输入类型和要响应的动作,这些对象意味着“观察”另一个对象并等待它们与它们通信。

另一方面,可观测数据会让观察者知道何时有新的输入,这样他们就可以对其做出反应(如果适用)。如果这听起来很熟悉,那是因为在Node中处理事件的任何东西都在实现这个模式。

观察者模式是什么样的?

你有没有编写自己的HTTP服务器?像这样的东西:

const http = require('http');


const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Your own server here');
});

server.on('error', err => {
    console.log(“Error:: “, err)
})

server.listen(3000, '127.0.0.1', () => {
  console.log('Server up and running');
});

在上面的代码中隐藏着,您正在学习的观察者模式。至少是它的实现。您的服务器对象将充当 observable,而您的回调函数是实际的观察者。这里类似事件的接口(参见下面代码),on方法和事件名称可能会使视图模糊不清,但请考虑以下实现:

class Observable {

    constructor() {
     this.observers = {}
    }

    on(input, observer) {
     if(!this.observers[input]) this.observers[input] = []
     this.observers[input].push(observer)
    }

    triggerInput(input, params) {
     this.observers[input].forEach( o => {
         o.apply(null, params)    
     })
    }
}

class Server extends Observable {

    constructor() {
     super()
    }


    triggerError() {
     let errorObj = {
         errorCode: 500,
         message: 'Port already in use'
     }
     this.triggerInput('error', [errorObj])
    }
}

您现在可以再次以完全相同的方式设置相同的观察者:

server.on('error', err => {
    console.log(“Error:: “, err)
})

如果你要调用 triggerError 方法(这是为了告诉你如何让你的观察者知道它们有新的输入),你将获得完全相同的输出:

Error:: { errorCode: 500, message: 'Port already in use' }

如果要考虑在Node.js中使用此模式,请首先查看EventEmitter 对象,因为它是 Node.js自己实现的此模式,可能会节省一些时间。

用例

正如您可能已经猜到的那样,这种模式非常适合处理异步调用,因为从外部请求获取响应可以被视为新输入。如果不是不断涌入异步代码到我们的项目中,node.js中有什么呢?因此,下次必须处理异步场景时,请考虑研究此模式。

正如您所看到的,这个模式的另一个广泛分布的用例是触发特定事件的用例。此模式可以在任何易于异步触发事件(如错误或状态更新)的模块上找到。一些例子包括 HTTP module、任何数据库驱动程序,甚至 socket.io,它允许您对从自己的代码外部触发的特定事件设置观察者。

职责链模式

职责链模式是Node.js世界中许多使用的模式,甚至没有意识到它。

它包括以一种允许您将请求的发送者与能够实现它的对象分离的方式构造代码。换言之,如果有对象 A 发送请求 R,则可能有三个不同的接收对象 R1R2R3A 如何知道应该将 R 发送给哪个对象?应该关心这个吗?

最后一个问题的答案是:不,它不应该。所以相反,如果 A 不应该关心谁将要处理这个请求,为什么我们不让 R1R2R3 自己决定呢?

这里是职责链发挥作用的地方,我们正在创建一个接收对象链,它将尝试满足请求,如果不能,它们只会传递它。这听起来很熟悉吗?

职责链模式是什么样的?

下面是这个模式的一个非常基本的实现,正如您在下面代码看到的,我们有四个可能的值(或请求)需要处理,但是我们不关心谁来处理它们,我们只需要,至少,一个函数来使用它们,因此我们只是将它发送到链,让每个函数决定他们是应该使用它还是忽略它。

function processRequest(r, chain) {

    let lastResult = null
    let i = 0
    do {
     lastResult = chain[i](r)
     i++
    } while(lastResult != null && i < chain.length)
    if(lastResult != null) {
     console.log("Error: request could not be fulfilled")
    }
}

let chain = [
    function (r) {
     if(typeof r == 'number') {
         console.log("It's a number: ", r)
         return null
     }
     return r
    },
    function (r) {
     if(typeof r == 'string') {
         console.log("It's a string: ", r)
         return null
     }
     return r
    },
    function (r) {
     if(Array.isArray(r)) {
         console.log("It's an array of length: ", r.length)
         return null
     }
     return r
    }
]

processRequest(1, chain)
processRequest([1,2,3], chain)
processRequest('[1,2,3]', chain)
processRequest({}, chain)

输出是:

It's a number:  1
It's an array of length:  3
It's a string:  [1,2,3]
Error: request could not be fulfilled

用例

在我们的生态系统中,这种模式最明显的例子是 ExpressJS的中间件。使用该模式,您实际上是在建立一系列功能(中间件),用于评估请求对象并决定对其执行操作或忽略它。您可以将该模式视为上述示例的异步版本,而不是检查函数是否返回值,而是检查传递给它们调用的下一个回调的值。

var app = express();

app.use(function (req, res, next) {
  console.log('Time:', Date.now());
  next(); //call the next function on the chain
});

Middlewares 是这种模式的一个特殊实现,因为不只是满足请求的链中的一个成员,而是所有成员都可以做到这一点。然而,其背后的理由是相同的。

最后的想法

这些只是一些你可能每天都会遇到甚至没有意识到的模式。我鼓励你去看看其他的,即使你没有找到一个即时的用例,现在我已经向你展示了他们中的一些人在野外的样子,你可能会自己开始看到他们!希望本文能够对设计模式这一主题有所了解,并帮助您比以往更快地提高编码效率。下次见!

最后个人推荐 JavaScript 设计模式文章,欢迎补充