Node.js 中的设计模式:第 2 部分
作者:费尔南多·多格里奥✏️
欢迎回到Node.js 设计模式的另一部分,这是第二部分,但如果您想回顾第一部分,其中我介绍了IIFE、工厂方法、单例、观察者和责任链模式,请随时查看,我会在这里等您。
但如果您不感兴趣或者可能已经了解它们,请继续阅读,因为我今天将介绍另外四种模式。
我会尝试提供尽可能多的实际用例,并将理论的花招保持在最低限度(维基百科上总有相关内容)。
让我们来有趣地回顾一下模式,好吗?
模块模式
模块模式绝对是最常见的模式之一,因为它似乎是出于控制模块中共享什么和隐藏什么的需要而诞生的。
让我解释一下。在 Node.js(以及 JavaScript 的通用功能)中,一个非常常见的做法是将代码组织成模块(即将一组相互关联的函数组合成一个文件并导出)。默认情况下,Node 的模块允许您选择要共享的内容和要隐藏的内容,所以这没有问题。
但是如果您使用的是普通的旧 JavaScript 或者在同一个文件中有多个模块,则此模式可以帮助您隐藏部分内容,同时让您选择要共享的内容。
它看起来像什么?
该模块严重依赖于 IIFE 模式,因此如果您不确定该模块如何工作,请查看我之前的文章。
创建模块的方式是创建一个 IIFE,如下所示:
const myLogger = ( _ => {
const FILE_PATH = "./logfile.log"
const fs = require("fs")
const os = require("os")
function writeLog(txt) {
fs.appendFile(FILE_PATH, txt + os.EOL, err => {
if(err) console.error(err)
})
}
function info(txt) {
writeLog("[INFO]: " + txt)
}
function error(txt) {
writeLog("[ERROR]: " + txt)
}
return {
info,
error
}
})()
myLogger.info("Hey there! This is an info message!")
myLogger.error("Damn, something happened!")
现在,通过上面的代码,您实际上是在模拟一个仅导出info
和error
函数的模块(当然,如果您使用的是 Node.js)。
代码示例非常简单,但您仍然明白,您可以通过创建一个类来获得类似的结果,是的,但您失去了隐藏方法的能力writeLog
,甚至是我在这里使用的常量。
模块模式的用例
这是一个非常简单的模式,代码本身就说明了一切。话虽如此,我可以介绍一下在代码中使用此模式的一些直接好处。
更清洁的命名空间
通过使用模块模式,您可以确保导出函数所需的全局变量、常量或函数不会被所有用户代码访问。我所说的用户代码是指任何使用您模块的代码。
这可以帮助您保持井然有序,避免命名冲突,甚至避免用户代码通过修改任何可能的全局变量来影响函数的行为。
免责声明:我并不赞同全局变量是一种好的编码标准,或者说你应该尝试使用它,但考虑到你将它们封装在模块作用域内,它们不再是全局变量了。所以在使用此模式之前请务必三思,同时也要考虑它带来的好处!
避免导入名称冲突
让我解释一下。如果你碰巧使用了多个外部库(尤其是在浏览器端使用纯 JavaScript 时),它们可能会将代码导出到同一个变量中(名称冲突)。所以,如果你不像我将要展示的那样使用模块模式,你可能会遇到一些不必要的行为。
你用过 jQuery 吗?还记得吗,一旦将它引入到代码中,除了jQuery
对象之外,还可以$
在全局范围内使用变量?嗯,以前也有一些库可以做到同样的事情。所以,如果你想让你的代码通过$
anyways 来兼容 jQuery,你需要这样做:
( $ => {
var hiddenBox = $( "#banner-message" );
$( "#button-container button" ).on( "click", function( event ) {
hiddenBox.show();
});
})(jQuery);
这样,您的模块就安全了,即使包含在其他已经使用该$
变量的代码库中,也不会有发生命名冲突的风险。最后一点至关重要,如果您正在开发的代码将被其他人使用,则需要确保其兼容性,因此使用模块模式可以帮您清理命名空间,避免名称冲突。
适配器模式
适配器模式是另一个非常简单但功能强大的模式。本质上,它帮助你将一个 API(这里我指的 API 是指特定对象拥有的一组方法)适配到另一个 API 中。
我的意思是,适配器基本上是特定类或对象的包装器,它提供不同的 API 并在后台利用对象的原始 API。
它看起来像什么?
假设记录器类如下所示:
const fs = require("fs")
class OldLogger {
constructor(fname) {
this.file_name = fname
}
info(text) {
fs.appendFile(this.file_name, `[INFO] ${text}`, err => {
if(err) console.error(err)
})
}
error(text) {
fs.appendFile(this.file_name, `[ERROR] ${text}`, err => {
if(err) console.error(err)
})
}
}
您已经有使用它的代码,如下所示:
let myLogger = new OldLogger("./file.log")
myLogger.info("Log message!")
如果突然,记录器将其 API 更改为:
class NewLogger {
constructor(fname) {
this.file_name = fname
}
writeLog(level, text) {
fs.appendFile(this.file_name, `[${level}] ${text}`, err => {
if(err) console.error(err)
})
}
}
然后,您的代码将停止工作,除非您为记录器创建一个适配器,如下所示:
class LoggerAdapter {
constructor(fname) {
super(fname)
}
info(txt) {
this.writeLog("INFO", txt)
}
error(txt) {
this.writeLog("ERROR", txt)
}
}
这样,您就为不再符合旧 API 的新记录器创建了一个适配器(或包装器)。
适配器模式的用例
这种模式非常简单,但我提到的用例非常强大,因为它们有助于隔离代码修改并减轻可能出现的问题。
一方面,您可以通过为现有模块提供适配器来为其提供额外的兼容性。
举个例子,request-promise-native包为请求包提供了一个适配器,允许您使用基于承诺的 API,而不是请求提供的默认 API。
因此,使用承诺适配器,您可以执行以下操作:
const request = require("request")
const rp = require("request-promise-native")
request //default API for request
.get('http://www.google.com/', function(err, response, body) {
console.log("[CALLBACK]", body.length, "bytes")
})
rp("http://www.google.com") //promise based API
.then( resp => {
console.log("[PROMISE]", resp.length, "bytes")
})
另一方面,你也可以使用适配器模式来包装一个你已知将来可能会更改其 API 的组件,并编写与适配器 API 兼容的代码。这将帮助你避免将来组件 API 更改或需要完全替换时出现的问题。
一个例子就是存储组件,您可以编写一个包装MySQL驱动程序并提供通用存储方法的组件。如果将来您需要将 MySQL 数据库更改为AWS RDS,您只需重写适配器,使用该模块代替旧的驱动程序,其余代码将不受影响。
装饰器模式
装饰器模式绝对是我最喜欢的五种设计模式之一,因为它能够以非常优雅的方式扩展对象的功能。该模式用于在运行时动态扩展甚至更改对象的行为。其效果可能看起来很像类继承,但该模式允许在同一次执行过程中切换行为,而继承则无法做到这一点。
这是一个非常有趣且实用的模式,以至于已经有正式的提案将其纳入到语言中。如果您想了解详情,可以在这里找到。
这个图案是什么样的?
得益于 JavaScript 灵活的语法和解析规则,我们可以非常轻松地实现此模式。本质上,我们要做的就是创建一个装饰器函数,它接收一个对象并返回装饰后的版本,其中包含新的方法和属性,或者更改的版本。
例如:
class IceCream {
constructor(flavor) {
this.flavor = flavor
}
describe() {
console.log("Normal ice cream,", this.flavor, " flavored")
}
}
function decorateWith(object, decoration) {
object.decoration = decoration
let oldDescr = object.describe //saving the reference to the method so we can use it later
object.describe = function() {
oldDescr.apply(object)
console.log("With extra", this.decoration)
}
return object
}
let oIce = new IceCream("vanilla") //A normal vanilla flavored ice cream...
oIce.describe()
let vanillaWithNuts = decorateWith(oIce, "nuts") //... and now we add some nuts on top of it
vanillaWithNuts.describe()
如你所见,这个例子实际上是在装饰一个对象(在本例中是香草冰淇淋)。装饰器会添加一个属性并重写一个方法。注意,我们仍然调用的是该方法的原始版本,这要归功于我们在执行重写之前保存了对该方法的引用。
我们还可以同样轻松地为其添加额外的方法。
装饰器模式的用例
实践上,此模式的重点在于将新行为封装到不同的函数或额外的类中,用于修饰原始对象。这样,你就能以最小的代价单独添加额外的函数或修改现有的函数,而无需影响所有相关的代码。
话虽如此,下面的例子试图准确地表明,从披萨公司的后端的想法来看,试图计算单个披萨的价格,而根据添加的配料,披萨的价格可能会有所不同:
class Pizza {
constructor() {
this.base_price = 10
}
calculatePrice() {
return this.base_price
}
}
function addTopping(pizza, topping, price) {
let prevMethod = pizza.calculatePrice
pizza.toppings = [...(pizza.toppings || []), topping]
pizza.calculatePrice = function() {
return price + prevMethod.apply(pizza)
}
return pizza
}
let oPizza = new Pizza()
oPizza = addTopping(
addTopping(
oPizza, "muzzarella", 10
), "anana", 100
)
console.log("Toppings: ", oPizza.toppings.join(", "))
console.log("Total price: ", oPizza.calculatePrice())
我们在这里做的事情与前面的例子类似,但采用了一种更贴近实际的方法。每次调用addTopping
都会以某种方式从前端传入后端,而且由于我们添加了额外的配料,我们将对 的调用一直链接calculatePrice
到原始方法,该方法只会返回披萨的原始价格。
再举一个更贴切的例子——文本格式化。这里我在 Bash 控制台中格式化文本,但你也可以将它应用于所有 UI 格式,添加一些细微变化的组件以及其他类似的情况。
const chalk = require("chalk")
class Text {
constructor(txt) {
this.string = txt
}
toString() {
return this.string
}
}
function bold(text) {
let oldToString = text.toString
text.toString = function() {
return chalk.bold(oldToString.apply(text))
}
return text
}
function underlined(text) {
let oldToString = text.toString
text.toString = function() {
return chalk.underline(oldToString.apply(text))
}
return text
}
function color(text, color) {
let oldToString = text.toString
text.toString = function() {
if(typeof chalk[color] == "function") {
return chalk\[color\](oldToString.apply(text))
}
}
return text
}
console.log(bold(color(new Text("This is Red and bold"), "red")).toString())
console.log(color(new Text("This is blue"), "blue").toString())
console.log(underlined(bold(color(new Text("This is blue, underlined and bold"), "blue"))).toString())
顺便提一下, Chalk是一个小型实用的库,用于在终端上格式化文本。在这个例子中,我创建了三个不同的装饰器,你可以像使用 topping 一样使用它们,只需将每个装饰器的调用组合起来即可获得最终结果。
上述代码的输出为:
命令模式
最后,今天要回顾的最后一个模式是我最喜欢的模式——命令模式。这个小家伙允许你将复杂的行为封装在一个模块(或者类)中,外部人员可以通过非常简单的 API 来调用它。
这种模式的主要好处是,通过将业务逻辑拆分为单独的命令类,所有命令类都具有相同的 API,您可以执行诸如添加新命令或修改现有代码等操作,而对项目其余部分的影响最小。
它看起来像什么?
实现这个模式非常简单,你只需要记住为你的命令提供一个通用的 API。可惜的是,由于 JavaScript 没有 的概念Interface
,我们无法在这里使用这个结构。
class BaseCommand {
constructor(opts) {
if(!opts) {
throw new Error("Missing options object")
}
}
run() {
throw new Error("Method not implemented")
}
}
class LogCommand extends BaseCommand{
constructor(opts) {
super(opts)
this.msg = opts.msg,
this.level = opts.level
}
run() {
console.log("Log(", this.level, "): ", this.msg)
}
}
class WelcomeCommand extends BaseCommand {
constructor(opts) {
super(opts)
this.username = opts.usr
}
run() {
console.log("Hello ", this.username, " welcome to the world!")
}
}
let commands = [
new WelcomeCommand({usr: "Fernando"}),
new WelcomeCommand({usr: "reader"}),
new LogCommand({
msg: "This is a log message, careful now...",
level: "info"
}),
new LogCommand({
msg: "Something went terribly wrong! We're doomed!",
level: "error"
})
]
commands.forEach( c => {
c.run()
})
这个示例展示了如何创建具有非常基本run
方法的不同命令,而复杂的业务逻辑可以放在这些方法中。请注意,我是如何尝试使用继承来强制实现一些所需方法的。
命令模式的用例
这种模式非常灵活,如果您正确使用,可以为您的代码提供很大的可扩展性。
我特别喜欢将它与require-dir模块结合使用,因为它可以 require 文件夹中的所有模块,这样您就可以保留一个特定于命令的文件夹,并以命令命名每个文件。此模块只需一行代码即可 require 所有这些文件,并返回一个以文件名(即命令名称)为键的对象。这样一来,您就可以继续添加命令而无需添加任何代码,只需创建文件并将其放入文件夹中,您的代码就会 require 并自动使用它。
标准 API 会确保你调用正确的方法,所以同样,无需进行任何更改。以下代码可以帮助你实现这一点:
function executeCommand(commandId) {
let commands = require-dir("./commands")
if(commands[commandId]) {
commands[commandId].run()
} else {
throw new Error("Invalid command!")
}
}
有了这个简单的功能,你就可以自由地扩展你的命令库,而无需进行任何更改!这就是精心设计的架构的魔力!
在实践中,这种模式非常适合以下情况:
- 处理与菜单栏相关的操作
- 接收来自客户端应用程序的命令,例如游戏的情况,客户端应用程序不断向后端服务器发送命令消息,以供其处理、运行并返回结果
- 聊天服务器接收来自不同客户端的事件并需要单独处理它们
这个列表还可以继续列下去,因为几乎任何对某种输入形式做出响应的功能都可以用基于命令的方式实现。但重点在于,实现这种逻辑(无论它对你来说是什么)所带来的巨大价值。这样,你就可以获得惊人的灵活性,以及在对其余代码影响最小的情况下进行扩展或重构的能力。
结论
我希望本文能帮助您理解这四种新模式、它们的实现和用例。了解何时使用它们,以及最重要的,了解为什么要使用它们,有助于您获得它们的优势并提高代码质量。
如果您对我展示的代码有任何问题或意见,请在评论中留言!
否则,下次再见!
编者注:觉得这篇文章有什么问题?您可以在这里找到正确版本。
插件:LogRocket,一个用于 Web 应用的 DVR
LogRocket是一款前端日志工具,可让您重播问题,就像它们发生在您自己的浏览器中一样。无需猜测错误发生的原因,也无需要求用户提供屏幕截图和日志转储,LogRocket 让您重播会话,快速了解问题所在。它可与任何应用程序完美兼容,不受框架限制,并且提供插件来记录来自 Redux、Vuex 和 @ngrx/store 的额外上下文。
除了记录 Redux 操作和状态外,LogRocket 还记录控制台日志、JavaScript 错误、堆栈跟踪、带有标头 + 正文的网络请求/响应、浏览器元数据以及自定义日志。它还会对 DOM 进行插桩,以记录页面上的 HTML 和 CSS,即使是最复杂的单页应用程序,也能重现像素完美的视频。
免费试用。
Node.js 中的设计模式:第 2 部分一文最先出现在LogRocket 博客上。
文章来源:https://dev.to/bnevilleoneill/design-patterns-in-node-js-part-2-2mf