单例

现在,我们将花几句话介绍面向对象编程中最常用的模式,即单例模式。 正如我们将看到的,Singleton 是在 Node.js 中实现微不足道的模式之一,几乎不值得讨论。 然而,每个优秀的 Node.js 开发人员都必须了解一些注意事项和限制。

单例模式的目的是强制一个类只存在一个实例并集中其访问。 在应用程序的所有组件中使用单个实例有几个原因:

  • 用于共享状态信息

  • 用于优化资源使用

  • 同步对资源的访问

正如您可以想象的那样,这些都是非常常见的场景。 以一个典型的 Database 类为例,它提供对数据库的访问:

// 'Database.js'
export class Database {
    constructor (dbName, connectionDetails) {
        // ...
    }
    // ...
}

此类的典型实现通常会保留一个数据库连接池,因此为每个请求创建一个新的数据库实例是没有意义的。 另外,数据库实例可以存储一些状态信息,例如待处理事务的列表。 因此,我们的数据库类满足证明单例模式合理性的两个标准。 因此,我们通常想要的是在应用程序启动时配置和实例化一个数据库实例,并让每个组件使用该单个共享数据库实例。

许多 Node.js 新手对如何正确实现 Singleton 模式感到困惑; 然而,答案比我们想象的要容易。 简单地从模块导出一个实例就足以获得与单例模式非常相似的东西。 例如,考虑以下代码行:

// file 'dbInstance.js'
import { Database } from './Database.js'

export const dbInstance = new Database('my-app-db', {
    url: 'localhost:5432',
    username: 'user',
    password: 'password'
})

通过简单地导出 Database 类的一个新实例,我们就可以假设在当前包(很容易成为我们应用程序的整个代码)中,我们将只有一个 dbInstance 模块的实例。 这是可能的,因为正如我们从第 2 章模块系统中知道的那样,Node.js 会缓存模块,确保不会在每次导入时执行其代码。

例如,我们可以使用以下代码行轻松获取我们之前定义的 dbInstance 模块的共享实例:

import { dbInstance } from './dbInstance.js'

但有一个警告。 该模块使用其完整路径作为查找键进行缓存,因此只能保证它是当前包中的单例。 事实上,每个包在其 node_modules 目录中可能有自己的一组私有依赖项,这可能会导致同一包的多个实例,因此同一模块的多个实例,导致我们的单例不再是唯一的! 当然,这种情况很少见,但了解其后果很重要。

例如,考虑一下我们之前看到的 Database.js 和 dbInstance.js 文件被包装到名为 mydb 的包中的情况。 以下代码行将位于其 package.json 文件中:

{
    "name": "mydb",
    "version": "2.0.0",
    "type": "module",
    "main": "dbInstance.js"
}

接下来,考虑两个包(package-a 和 package-b),它们都有一个名为 index.js 的文件,其中包含以下代码:

import { dbInstance } from 'mydb'

export function getDbInstance () {
    return dbInstance
}

package-a 和 package-b 都依赖于 mydb 包。 但是,package-a 依赖于 mydb 包的版本 1.0.0,而 package-b 依赖于同一个包的版本 2.0.0(对于我们的示例,它具有相同的实现,但只是在其包中指定了不同的版本) package.json 文件)。

鉴于我们刚刚描述的结构,我们最终会得到以下包依赖关系树:

app/
`-- node_modules
    |-- package-a
    |     `-- node_modules
    |           `-- mydb
    `-- package-b
        `-- node_modules
            `-- mydb

我们最终得到像这里这样的目录结构,因为 package-a 和 package-b 需要 mydb 模块的两个不同的不兼容版本(例如,1.0.0 与 2.0.0)。 在这种情况下,典型的包管理器(例如npm或yarn)不会将依赖项“提升”到顶级node_modules目录,而是会安装每个包的私有副本以尝试修复版本不兼容性。

根据我们刚刚看到的目录结构,package-a 和 package-b 都依赖于 mydb 包; 反过来,app 包(即我们的根包)依赖于 package-a 和 package-b。

我们刚刚描述的场景将打破关于数据库实例唯一性的假设。 事实上,请考虑位于应用程序包根文件夹中的以下文件 (index.js):

import { getDbInstance as getDbFromA } from 'package-a'
import { getDbInstance as getDbFromB } from 'package-b'
const isSame = getDbFromA() === getDbFromB()
console.log('Is the db instance in package-a the same ' +
    `as package-b? ${isSame ? 'YES' : 'NO'}`)

如果您运行前面的文件,您会注意到 Is the db instance in package-a the same as package-b? 的答案 没有。 事实上,package-a 和 package-b 实际上会加载 dbInstance 对象的两个不同实例,因为 mydb 模块将解析到不同的目录,具体取决于所需的包。 这显然打破了单例模式的假设。

相反,如果 package-a 和 package-b 都需要两个相互兼容的 mydb 包版本,例如 ^2.0.1 和 ^2.0.7,那么包管理器会将 mydb 包安装到顶层 node_modules 目录(一种称为依赖提升的做法),有效地与 package-a、package-b 和根包共享同一实例。

至此,我们可以很容易地说,文献中描述的单例模式在 Node.js 中并不存在,除非我们使用真正的全局变量来存储它,例如以下:

global.dbInstance = new Database('my-app-db', {/*...*/})

这保证了该实例是整个应用程序中唯一共享的实例,而不仅仅是同一包。 然而,请考虑到大多数时候,我们并不真正需要一个纯粹的单例。 事实上,我们通常在应用程序的主包中创建和导入单例,或者最坏的情况是在已模块化为依赖项的应用程序的子组件中创建和导入单例。

如果您要创建一个将由第三方使用的包,请尝试使其保持无状态,以避免出现我们在本节中讨论的问题。

在本书中,为了简单起见,我们将使用术语 “单例” 来描述由模块导出的类实例或有状态对象,即使这在该术语的严格定义中并不代表真正的单例。

接下来,我们将看到处理模块之间依赖关系的两种主要方法,一种基于单例模式,另一种利用依赖注入模式。