美文网首页
Node.js 设计模式笔记 —— 单例模式

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

作者: rollingstarky | 来源:发表于2022-05-09 22:12 被阅读0次

    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

    相关文章

      网友评论

          本文标题:Node.js 设计模式笔记 —— 单例模式

          本文链接:https://www.haomeiwen.com/subject/mybayrtx.html