3

Node.js 设计模式笔记 —— 单例模式

 2 years ago
source link: https://rollingstarky.github.io/2022/05/09/node-js-design-patterns-singleton-pattern/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Node.js 设计模式笔记 —— 单例模式

发表于 2022-05-09

| 分类于 Program

| 0

| 阅读次数:

字数统计: 8.6k

|

阅读时长 ≈ 0:09

Singleton

单例(Singleton)模式是面向对象编程中最常见的设计模式之一,Node.js 已经有了很简单的实现。
使用单例模式的目的在于确保某个类只有一个实例存在,并对该实例的访问进行统一的控制。其主要运用场景如下:

  • 共享有状态的信息
  • 优化资源消耗
  • 同步对某个资源的访问

比如,一个标准的 Database 类会提供对数据库的访问:

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

在上述类的标准实现中,通常需要维护一个数据库连接池,毕竟为每一次数据库请求都分别创建一个新的 Database 实例显得不太现实。此外,Database 实例可能会保存部分有状态的数据,比如 pending 的事务列表。
因此,一般只在应用开始运行时初始化一个 Database 实例,此后其作为一个唯一的共享实例被所有其他组件使用。

Node.js 的新用户可能会思考该如何从逻辑层面实现单例模式,事实上远比想象中更简单。
将某个实例从模块中导入,即可实现单例模式的所有需求。

// 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),在当前的整个包中就可以认为只存在这一个 dbInstance 对象(单例),这得益于 Node.js 的模块系统。Node.js 会对模块进行缓存,保证不会在每次导入时都再执行一遍代码。

再通过如下一行代码即可简单地获取上面创建的共享的 dbInstance 实例:

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

Node.js 中缓存的模块以完整路径作为对其进行查找的 key,所以前面实现的 Singleton 只在当前的包中生效。每个包都有可能包含其私有的依赖,放置在它自己的 node_modules 路径下。因而就可能导致同一个模块存在多个实例,前面实现的 Singleton 不能再保证唯一性。

例如,前面的 Database.jsdbInstance.js 同属于 mydb 包,其 package.json 内容如下:

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

又假设有两个包(package-apackage-b)各自都拥有包含如下内容的 index.js 文件:

import {dbInstance} from 'mydb'

export function getDbInstance() {
return dbInstance
}

package-apackage-b 都依赖包 mydb,但 package-a 依赖版本 1.0.0,package-b 依赖版本 2.0.0。结果就会出现如下结构的依赖关系:

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

package-apackage-b 依赖两个不兼容版本的 mydb 模块时,包管理器不会将 mydb 放置在 node_modules 的根路径下,而是在 package-apackage-b 下面各自放一个私有的 mydb 副本,从而解决版本冲突。

此时假如 app/ 路径下有一个如下内容的 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'}`)

getDbFromA()getDbFromB() 并不会获得同一个 dbInstance 实例,打破了 Singleton 模式的假设。

当然了,大多数情况下我们并不需要一个 pure Singleton。事实上,通常也只会在应用的 main 包中创建和导入 Singleton。

Singleton dependencies

最简单地将两个模块组合在一起的方式,就是直接利用 Node.js 的模块系统。如前面所说,这样组合起来的有状态的依赖关系其实就是单例模式。

实现下面一个博客系统:
mkdir blog && cd blog
npm install sqlite3

blog/package.json:

{
"type": "module",
"dependencies": {
"sqlite3": "^5.0.8"
}
}

blog/db.js

import {dirname, join} from 'path'
import {fileURLToPath} from 'url'
import sqlite3 from 'sqlite3'

const __dirname = dirname(fileURLToPath(import.meta.url))
export const db = new sqlite3.Database(
join(__dirname, 'data.sqlite')
)

blog/blog.js

import {promisify} from 'util'
import {db} from './db.js'

const dbRun = promisify(db.run.bind(db))
const dbAll = promisify(db.all.bind(db))

export class Blog {
initialize() {
const initQuery = `CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`
return dbRun(initQuery)
}

createPost(id, title, content, createdAt) {
return dbRun('INSERT INTO posts VALUES (?, ?, ?, ?)',
id, title, content, createdAt)
}
getAllPosts() {
return dbAll('SELECT * FROM posts ORDER BY created_at DESC')
}
}

blog/index.js

import {Blog} from './blog.js'

async function main() {
const blog = new Blog()
await blog.initialize()
const posts = await blog.getAllPosts()

if (posts.length === 0) {
console.log('No posts available.')
}

for (const post of posts) {
console.log(post.title)
console.log('-'.repeat(post.title.length))
console.log(`Published on ${new Date(post.created_at).toISOString()}`)
console.log(post.content)
}
}

main().catch(console.error)

db.js 创建了一个 db 数据库实例并导出,blog.jsdb.js 中导入 db 实例并直接在代码中使用。形成了一种简单直观的 blog.js 依赖于 db.js 模块的关系。同时整个项目中的数据库连接都由唯一的 db 单例进行控制。

运行效果:

$ node index.js
No posts available.

可以运行下面的命令插入测试数据:

// import-posts.js
import {Blog} from './blog.js'

const posts = [
{
id: 'my-first-post',
title: 'My first post',
content: 'Hello World!\nThis is my first post',
created_at: new Date('2020-02-03')
},
{
id: 'iterator-patterns',
title: 'Node.js iterator patterns',
content: 'Let\'s talk about some iterator patterns in Node.js\n\n...',
created_at: new Date('2020-02-06')
},
{
id: 'dependency-injection',
title: 'Dependency injection in Node.js',
content: 'Today we will discuss about dependency injection in Node.js\n\n...',
created_at: new Date('2020-02-29')
}
// ...
]

async function main() {
const blog = new Blog()
await blog.initialize()

await Promise.all(
posts.map(
(post) => blog.createPost(
post.id,
post.title,
post.content,
post.created_at
)
)
)
console.log('All posts imported')
}

main().catch(console.error)
$ node import-posts.js
All posts imported
$ node index.js
Dependency injection in Node.js
-------------------------------
Published on 2020-02-29T00:00:00.000Z
Today we will discuss about dependency injection in Node.js

...
Node.js iterator patterns
-------------------------
Published on 2020-02-06T00:00:00.000Z
Let's talk about some iterator patterns in Node.js

...
My first post
-------------
Published on 2020-02-03T00:00:00.000Z
Hello World!
This is my first post

就如上面的代码所示,借助 Singleton 模式,将 db 实例自由地在文件之间传递,可以实现一个很简单的命令行博客管理系统。这也是大多数情况下我们管理有状态的依赖的方式。
使用 Singleton 诚然是最简单、即时,可读性最好的方案。但是,假如我们需要在测试过程中 mock 数据库,或者需要终端用户能够自主选择另一个数据库后端,而不是默认提供的 SQLite。
对于以上需求,Singleton 反而成为了一个设计更好结构的阻碍。可以在 db.js 中引入 if 语句根据某些条件来选择不同的实现,显然这种方式并不是很美观。

Dependency Injection

Node.js 的模块系统以及 Singleton 模式可以作为一个很好的管理和组合应用组件的工具,它们非常简单,容易上手。但另一方面,它们也可能会使各组件之间的耦合程度加深。
在前面的例子中,blog.jsdb.js 模块是耦合度很高的,blog.js 没有了 db.js 就无法工作,当然也无法使用另一个不同的数据库模块。
可以借助 Dependency Injection 来弱化模块之间的耦合度。

依赖注入表示将某个组件的依赖模块由外部实体(injector)作为输入提供。
DI 的主要优势在于能够降低耦合度,尤其当模块依赖于有状态的实例(比如数据库连接)时。每个依赖项并不是硬编码进主体代码,而是由外部传入,意味着这些依赖项可以被替换成任意相互兼容的实例。使得主体代码本身可以以最小的改动在不同的背景下重用。

Dependency injection schematic

修改 blog.js

import {promisify} from 'util'


export class Blog {
constructor(db) {
this.db = db
this.dbRun = promisify(db.run.bind(db))
this.dbAll = promisify(db.all.bind(db))
}
initialize() {
const initQuery = `CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`
return this.dbRun(initQuery)
}

createPost(id, title, content, createdAt) {
return this.dbRun('INSERT INTO posts VALUES (?, ?, ?, ?)',
id, title, content, createdAt)
}
getAllPosts() {
return this.dbAll('SELECT * FROM posts ORDER BY created_at DESC')
}
}

最主要的改动在于为 Blog 类添加了 constructor (db) 构造方法,该方法的参数 db 即为 Dependency,Blog 的依赖项,需要在运行时由 Blog 的客户端提供。

修改 db.js

import sqlite3 from 'sqlite3'

export function createDb(dbFile) {
return new sqlite3.Database(dbFile)
}

此版本的 db 模块提供了一个 createDB() 工厂函数,可以在运行时返回一个新的数据库实例。

修改 index.js

import {dirname, join} from 'path'
import {fileURLToPath} from 'url'
import {Blog} from './blog.js'
import {createDb} from './db.js'

const __dirname = dirname(fileURLToPath(import.meta.url))

async function main() {
const db = createDb(join(__dirname, 'data.sqlite'))
const blog = new Blog(db)
await blog.initialize()
const posts = await blog.getAllPosts()

if (posts.length === 0) {
console.log('No posts available.')
}

for (const post of posts) {
console.log(post.title)
console.log('-'.repeat(post.title.length))
console.log(`Published on ${new Date(post.created_at).toISOString()}`)
console.log(post.content)
}
}

main().catch(console.error)

使用 createDB() 工厂函数创建数据库实例 db,然后在初始化 Blog 实例时,将 db 作为 Blog 的依赖进行注入。
从而 blog.js 与具体的数据库实现进行了分离。

依赖注入可以提供松耦合和代码重用等优势,但也存在一定的代价。比如无法在编码时解析依赖项,使得理解模块之间的逻辑关系变得更加困难,尤其当应用很大很复杂的时候。
此外,我们还必须确保数据库实例(依赖)在 Blog 实例之前创建,从而迫使我们手动构建整个应用的依赖图,以保证顺序正确。

Node.js Design Patterns: Design and implement production-grade Node.js applications using proven patterns and techniques, 3rd Edition


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK