工厂

我们将从 Node.js 中最常见的设计模式之一开始我们的旅程:工厂。 正如您将看到的,工厂模式非常通用,并且不仅仅有一个用途。 它的主要优点是能够将对象的创建与一个特定的实现解耦。 例如,这允许我们创建一个其类在运行时确定的对象。 Factory 还允许我们暴露比类小得多的 “表面积”; 类可以扩展或操作,而工厂只是一个函数,为用户提供的选项较少,使其更健壮且更易于理解。 最后,工厂还可以通过利用闭包来强制封装。

解耦对象的创建和实现

我们已经强调了这样一个事实:在 JavaScript 中,函数式范式通常因其简单性、可用性和较小的表面积而优于纯粹的面向对象设计。 在创建新对象实例时尤其如此。 事实上,调用工厂,而不是使用 new 运算符或 Object.create() 直接从类创建新对象,在多个方面更加方便和灵活。

首先也是最重要的,工厂允许我们将对象的创建与其实现分开。 本质上,工厂包装了新实例的创建,为我们提供了更多的灵活性和控制方式。 在工厂内部,我们可以选择使用 new 运算符创建类的新实例,或者利用闭包动态构建有状态对象文字,甚至根据特定条件返回不同的对象类型。 工厂的使用者完全不知道实例的创建是如何进行的。 事实是,通过使用 new,我们将代码绑定到创建对象的一种特定方式,而使用工厂,我们可以拥有更大的灵活性,几乎是免费的。 作为一个简单的例子,让我们考虑一个创建 Image 对象的简单工厂:

function createImage (name) {
    return new Image(name)
}
const image = createImage('photo.jpeg')

createImage() 工厂可能看起来完全没有必要; 为什么不直接使用 new 运算符实例化 Image 类? 为什么不写如下内容:

const image = new Image(name)

正如我们已经提到的,使用 new 将我们的代码绑定到一种特定类型的对象,在前面的情况下是 Image 对象类型。 另一方面,工厂给了我们更大的灵活性。 想象一下,我们想要重构 Image 类,将其分成更小的类,每个类对应我们支持的每种图像格式。

如果我们将工厂暴露为创建新图像的唯一方法,我们可以简单地重写它,如下所示,而不会破坏任何现有代码:

function createImage (name) {
    if (name.match(/\.jpe?g$/)) {
        return new ImageJpeg(name)
    } else if (name.match(/\.gif$/)) {
        return new ImageGif(name)
    } else if (name.match(/\.png$/)) {
        return new ImagePng(name)
    } else {
        throw new Error('Unsupported format')
    }
}

我们的工厂还允许我们隐藏类并防止它们被扩展或修改(还记得小表面积的原则吗?)。 在 JavaScript 中,这可以通过仅导出工厂并保持类私有来实现。

强制封装的机制

由于闭包,工厂还可以用作封装机制。

封装是指通过防止外部代码直接操作组件的某些内部细节来控制对它们的访问。 与组件的交互仅通过其公共接口进行,从而将外部代码与组件实现细节的更改隔离开来。 封装与继承、多态性和抽象一起是面向对象设计的基本原则。

在 JavaScript 中,强制封装的主要方法之一是通过函数作用域和闭包。 工厂使强制执行私有变量变得简单。 例如,考虑以下情况:

function createPerson (name) {
    const privateProperties = {}

    const person = {
        setName (name) {
            if (!name) {
                throw new Error('A person must have a name')
            }
            privateProperties.name = name
        },
        getName () {
            return privateProperties.name
        }
    }

    person.setName(name)
    return person
}

在上面的代码中,我们利用闭包创建了两个对象:一个 person 对象,它代表工厂返回的公共接口;以及一组 privateProperties,这些属性从外部无法访问,只能通过 人对象。 例如,在前面的代码中,我们确保一个人的名字永远不为空; 如果 name 只是 person 对象的普通属性,则不可能强制执行。

使用闭包并不是我们强制封装的唯一技术。 事实上,其他可能的方法有:

  • 使用 Node.js 12 中引入的私有类字段(hashbang # 前缀语法)。更多信息请参见 nodejsdp.link/tc39-private-fields。 这是最现代的方法,但在撰写本文时,该功能仍处于实验阶段,尚未包含在官方 ECMAScript 规范中。

  • 使用 WeakMap。 有关更多信息,请访问 nodejsdp.link/weakmaps-private。

  • 使用符号,如以下文章中所述:nodejsdp.link/symbol-private。

  • 在构造函数中定义私有变量(Douglas Crockford 建议:nodejsdp.link/crockford-private)。 这是传统的方法,也是最著名的方法。

  • 使用约定,例如,在属性名称前添加下划线 “_”。 然而,这在技术上并不能阻止从外部读取或修改成员。

构建一个简单的代码分析器

现在,让我们使用工厂来研究一个完整的示例。 让我们构建一个简单的代码分析器,一个具有以下属性的对象:

  • 触发分析会话开始的 start() 方法

  • 用于终止会话并将其执行时间记录到控制台的 end() 方法

让我们首先创建 名为 profiler.js 的文件,其中包含以下内容:

class Profiler {
    constructor (label) {
        this.label = label
        this.lastTime = null
    }

    start () {
        this.lastTime = process.hrtime()
    }

    end () {
        const diff = process.hrtime(this.lastTime)
        console.log(`Timer "${this.label}" took ${diff[0]} seconds ` +
            `and ${diff[1]} nanoseconds.`)
    }
}

我们刚刚定义的Profiler类使用Node.js默认的高分辨率计时器来保存调用start()时的当前时间,然后在执行end()时计算经过的时间,并将结果打印到控制台。

现在,如果我们要在现实应用程序中使用这样的分析器来计算不同例程的执行时间,我们可以轻松想象打印到控制台的大量分析信息,尤其是在生产环境中。 我们可能想要做的是将分析信息重定向到另一个源,例如专用日志文件,或者如果应用程序在生产模式下运行,则完全禁用分析器。 很明显,如果我们要使用 new 运算符直接实例化 Profiler 对象,则需要在客户端代码或 Profiler 对象本身中添加一些额外的逻辑,以便在不同的逻辑之间进行切换。

或者,我们可以使用工厂来抽象 Profiler 对象的创建,以便根据应用程序是在生产模式还是开发模式下运行,我们可以返回一个完全工作的 Profiler 实例或具有相同接口但具有空方法的模拟对象 。 这正是我们将在 profiler.js 模块中执行的操作。 我们将只导出我们的工厂,而不是导出 Profiler 类。 下面是它的代码:

const noopProfiler = {
    start () {},
    end () {}
}

export function createProfiler (label) {
    if (process.env.NODE_ENV === 'production') {
        return noopProfiler
    }

    return new Profiler(label)
}

createProfiler() 函数是我们的工厂,它的作用是从其实现中抽象出 Profiler 对象的创建。 如果应用程序在生产模式下运行,我们将返回 noopProfiler,它本质上不执行任何操作,从而有效地禁用任何分析。 如果应用程序未在生产模式下运行,那么我们将创建并返回一个新的、功能齐全的 Profiler 实例。

感谢 JavaScript 的动态类型,我们能够在一种情况下返回一个使用 new 运算符实例化的对象,在另一种情况下返回一个简单的对象文字(这也称为鸭子类型,您可以在 nodejsdp 中阅读更多相关信息。link/ 鸭子打字)。 这证实了我们如何在工厂函数中以任何我们喜欢的方式创建对象。 我们还可以执行额外的初始化步骤或根据特定条件返回不同类型的对象,同时将对象的使用者与所有这些细节隔离。 我们可以很容易地理解这个简单模式的力量。

现在,让我们来玩一下我们的分析器工厂。 让我们创建一个算法来计算给定数字的所有因子,并使用我们的分析器记录其运行时间:

// index.js
import { createProfiler } from './profiler.js'

function getAllFactors (intNumber) {
    const profiler = createProfiler(
        `Finding all factors of ${intNumber}`)
    profiler.start()
    const factors = []
    for (let factor = 2; factor <= intNumber; factor++) {
        while ((intNumber % factor) === 0) {
            factors.push(factor)
            intNumber = intNumber / factor
        }
    }
    profiler.end()
    return factors
}

const myNumber = process.argv[2]
const myFactors = getAllFactors(myNumber)
console.log(`Factors of ${myNumber} are: `, myFactors)

profiler 变量包含我们的 Profiler 对象,其实现将由运行时的 createProfiler() 工厂根据 NODE_ENV 环境变量决定。

例如,如果我们在生产模式下运行该模块,我们将不会获得任何分析信息:

NODE_ENV=production node index.js 2201307499

如果我们在开发模式下运行该模块,我们将看到打印到控制台的分析信息:

node index.js 2201307499

我们刚才给出的例子只是工厂函数模式的一个简单应用,但它清楚地展示了将对象的创建与其实现分离的优点。

在野外(In the wild)

正如我们所说,工厂在 Node.js 中非常常见。 我们可以在流行的 Knex (nodejsdp.link/knex) 包中找到一个示例。 Knex 是一个支持多个数据库的 SQL 查询生成器。 它的包只导出一个函数,即一个工厂。 工厂执行各种检查,根据数据库引擎选择要使用的正确方言对象,最后创建并返回 Knex 对象。 查看nodejsdp.link/knex-factory 上的代码。