写模块

每个应用程序都是多个组件聚合的结果,随着应用程序的增长,我们连接这些组件的方式成为项目可维护性和成功的成败因素。

当组件 A 需要组件 B 来实现给定功能时,我们说 “A 依赖于 B”,或者相反,“B 是 A 的依赖项”。 为了理解这个概念,让我们举一个例子。

假设我们要为使用数据库存储数据的博客系统编写一个 API。 我们可以有一个实现数据库连接的通用模块 (db.js) 和一个博客模块,该模块公开从数据库创建和检索博客文章的主要功能 (blog.js)。

下图说明了数据库模块和博客模块的关系:

image 2024 05 07 11 58 37 012
Figure 1. 图7.1:博客模块和数据库模块之间的依赖关系图

在本节中,我们将了解如何使用两种不同的方法对这种依赖关系进行建模,一种基于单例模式,另一种使用依赖注入模式。

单例依赖

将两个模块连接在一起的最简单方法是利用 Node.js 的模块系统。 正如我们在上一节中讨论的那样,以这种方式连接的状态依赖实际上是单例。

为了了解其在实践中的工作原理,我们将使用数据库连接的单例实例来实现前面描述的简单博客应用程序。 让我们看看这种方法的可能实现(文件 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'))

在前面的代码中,我们使用 SQLite (nodejsdp.link/sqlite) 作为数据库来存储我们的帖子。 为了与 SQLite 交互,我们使用 npm 中的 sqlite3 模块(nodejsdp.link/sqlite3)。 SQLite 是一个数据库系统,它将所有数据保存在一个本地文件中。 在我们的数据库模块中,我们决定使用一个名为 data.sqlite 的文件,该文件保存在与模块相同的文件夹中。

前面的代码创建一个指向数据文件的新数据库实例,并将数据库连接对象导出为名为 db 的单例。

现在,让我们看看如何实现 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.js 模块导出一个名为 Blog 的类,其中包含三个方法:

  • initialize():如果 posts 表不存在,则创建该表。 该表将用于存储博客文章的数据。

  • createPost():获取创建帖子所需的所有必要参数。 它将执行 INSERT 语句将新帖子添加到数据库中。

  • getAllPosts():检索数据库中所有可用的帖子并将它们作为数组返回。

现在,让我们创建一个模块来尝试我们刚刚创建的博客模块(文件 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 post available. Run `node import-posts.js`' +
            ' to load some sample posts')
    }

    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)

前面的模块非常简单。 我们使用 blog.getAllPosts() 检索包含所有帖子的数组,然后循环遍历它并显示每个帖子的数据,并对其进行一些格式化。

您可以在运行 index.js 之前使用提供的 import-posts.js 模块将一些示例帖子加载到数据库中。 您可以在本书的代码存储库中找到 import-posts.js 以及本示例的其余文件。

作为一个有趣的练习,您可以尝试修改 index.js 模块来生成 HTML 文件; 一个用于博客索引,然后一个用于每篇博客文章的专用文件。 这样,您就可以构建自己的简约静态网站生成器!

从前面的代码可以看出,我们可以利用单例模式传递数据库实例来实现一个非常简单的命令行博客管理系统。 大多数时候,这就是我们在应用程序中管理有状态依赖关系的方式; 然而,在某些情况下这可能还不够。

正如我们在前面的示例中所做的那样,使用单例无疑是传递状态依赖项的最简单、最直接且可读的解决方案。 但是,如果我们想在测试期间模拟数据库,会发生什么? 如果我们想让博客 CLI 或博客 API 的用户选择另一个数据库后端,而不是我们默认提供的标准 SQLite 后端,我们该怎么办? 对于这些用例,单例可能会成为实现正确结构化解决方案的障碍。

我们可以在 db.js 模块中引入 if 语句,以根据某些环境条件或某些配置来选择不同的实现。 或者,我们可以摆弄 Node.js 模块系统来拦截数据库文件的导入并将其替换为其他文件。 但是,正如您可以想象的那样,这些解决方案远非优雅。

在下一节中,我们将了解另一种接线模块的策略,这可能是我们在此讨论的一些问题的理想解决方案。

依赖注入

Node.js 模块系统和 Singleton 模式可以作为组织和连接应用程序组件的绝佳工具。 然而,这些并不总是保证成功。 一方面,如果它们使用简单且非常实用,那么另一方面,它们可能会在组件之间引入更紧密的耦合。

在前面的示例中,我们可以看到 blog.js 模块与 db.js 模块紧密耦合。 事实上,我们的 blog.js 模块在设计上就无法在没有 database.js 模块的情况下工作,也不能在必要时使用不同的数据库模块。 我们可以利用依赖注入模式轻松修复两个模块之间的紧密耦合。

依赖注入 (DI) 是一种非常简单的模式,其中组件的依赖项由外部实体(通常称为注入器)作为输入提供。

注入器初始化不同的组件并将它们的依赖关系绑定在一起。 它可以是一个简单的初始化脚本,也可以是一个更复杂的全局容器,用于映射所有依赖项并集中系统所有模块的连接。 这种方法的主要优点是改进了解耦,特别是对于依赖有状态实例(例如数据库连接)的模块。 使用 DI,每个依赖项不是硬编码到模块中,而是从外部接收。 这意味着依赖模块可以配置为使用任何兼容的依赖关系,因此模块本身可以在不同的上下文中以最小的努力重用。

下图说明了这个想法:

image 2024 05 07 12 04 49 659
Figure 2. 图 7.2:依赖注入原理图

在图 7.2 中,我们可以看到通用服务需要与预定接口的依赖关系。 注入器有责任检索或创建实现此类接口的实际具体实例并将其传递(或 “注入”)到服务中。 换句话说,注入器的目标是提供一个满足服务依赖关系的实例。

为了在实践中演示这种模式,让我们通过使用 DI 连接其模块来重构我们在上一节中构建的简单博客系统。 让我们从重构 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 类构造函数将 db 作为参数 新的构造函数参数

新的构造函数参数 db 是预期的依赖关系,需要由 Blog 类的客户端组件在运行时提供。该客户端组件将是依赖关系的注入器。由于 JavaScript 没有任何方法来表示抽象接口,因此所提供的依赖项需要实现 db.run() 和 db.all() 方法。这就是本书前面提到的鸭子类型。

现在让我们重写 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 post available. Run `node import-posts.js`' +
            ' to load some sample posts')
    }
    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)

此代码也与之前的实现非常相似,除了两个重要的更改(在前面的代码中突出显示):

  1. 我们使用工厂函数 createDb() 创建数据库依赖项 (db)。

  2. 当我们实例化 Blog 类时,我们显式地 “注入” 数据库实例。

在我们博客系统的这个实现中,blog.js 模块与实际的数据库实现完全解耦,使其更具可组合性并且易于单独测试。

我们看到了如何将依赖项作为构造函数参数注入(构造函数注入),但是依赖项也可以在调用函数或方法时传递(函数注入)或通过分配对象的相关属性来显式注入(属性注入)。

不幸的是,依赖注入模式提供的解耦和可重用性方面的优势是要付出代价的。 一般来说,在编码时无法解决依赖关系使得理解系统各个组件之间的关系变得更加困难。 在大型应用程序中尤其如此,我们可能拥有大量具有复杂依赖关系图的服务。

另外,如果我们查看前面示例脚本中实例化数据库依赖项的方式,我们可以看到我们必须确保数据库实例已创建,然后才能从 Blog 实例调用任何函数。 这意味着,当以其原始形式使用时,依赖注入迫使我们手动构建整个应用程序的依赖关系图,确保我们以正确的顺序进行操作。 当要接线的模块数量过多时,这可能会变得难以管理。

另一种模式称为控制反转,它允许我们将连接应用程序模块的责任转移给第三方实体。 该实体可以是服务定位器(用于检索依赖项的简单组件,例如 serviceLocator.get('db'))或依赖项注入容器(根据中指定的某些元数据将依赖项注入到组件中的系统) 代码本身或配置文件中)。 您可以在 Martin Fowler 的博客(nodejsdp.link/ioc-containers)上找到有关这些组件的更多信息。 尽管这些技术与 Node.js 的工作方式有些脱轨,但其中一些技术最近获得了一些流行。 查看 inversify (nodejsdp.link/inversify) 和 awilix (nodejsdp.link/awilix) 以了解更多信息。