适配器
适配器模式允许我们使用不同的接口访问对象的功能。
适配器的一个现实示例是允许您将 USB Type-A 电缆插入 USB Type-C 端口的设备。 从一般意义上讲,适配器转换具有给定接口的对象,以便它可以在需要不同接口的上下文中使用。
在软件中,适配器模式用于获取对象(适配器)的接口,并使其与给定客户端期望的另一个接口兼容。 让我们看一下图 8.3 来阐明这个想法:
/image-2024-05-07-14-49-38-602.png)
在图 8.3 中,我们可以看到适配器本质上是适配器的包装器,暴露了不同的接口。 该图还强调了这样一个事实:适配器的操作也可以是适配器上的一个或多个方法调用的组合。 从实现的角度来看,最常见的技术是组合,其中适配器的方法为被适配者的方法提供了桥梁。 这种模式非常简单,所以让我们立即来看一个示例。
通过文件系统 API 使用 LevelUP
我们现在将围绕 LevelUP API 构建一个适配器,将其转换为与核心 fs 模块兼容的接口。 特别是,我们将确保对 readFile() 和 writeFile() 的每次调用都会转换为对 db.get() 和 db.put() 的调用。 这样我们就能够使用 LevelUP 数据库作为简单文件系统操作的存储后端。
让我们首先创建一个名为 fs-adapter.js 的新模块。 我们将首先加载依赖项并导出将用于构建适配器的 createFsAdapter() 工厂:
import { resolve } from 'path'
export function createFSAdapter (db) {
return ({
readFile (filename, options, callback) {
// ...
},
writeFile (filename, contents, options, callback) {
// ...
}
})
}
接下来,我们将在工厂内部实现 readFile() 函数,并确保其接口与 fs 模块中的原始函数之一兼容:
readFile (filename, options, callback) {
if (typeof options === 'function') {
callback = options
options = {}
} else if (typeof options === 'string') {
options = { encoding: options }
}
db.get(resolve(filename), { // (1)
valueEncoding: options.encoding
},
(err, value) => {
if (err) {
if (err.type === 'NotFoundError') { // (2)
err = new Error(`ENOENT, open "${filename}"`)
err.code = 'ENOENT'
err.errno = 34
err.path = filename
}
return callback && callback(err)
}
callback && callback(null, value) // (3)
})
}
在前面的代码中,我们必须做一些额外的工作,以确保新函数的行为尽可能接近原始的 fs.readFile() 函数。 该函数执行的步骤描述如下:
-
要从数据库实例检索文件,我们使用文件名作为键调用 db.get(),并确保始终使用其完整路径(使用 resolve()) )。 我们将数据库使用的 valueEncoding 选项的值设置为等于作为输入接收的任何最终编码选项。
-
如果在数据库中找不到该键,我们会创建一个错误,错误代码为 ENOENT,该代码是原始 fs 模块用来指示文件丢失的代码。 任何其他类型的错误都会转发到回调(对于本示例的范围,我们仅调整最常见的错误条件)。
-
如果从数据库中成功检索到键值对,我们将使用回调将值返回给调用者。
我们创建的函数并不想成为 fs.readFile() 函数的完美替代品,但它确实在最常见的情况下发挥了作用。
为了完成我们的小适配器,现在让我们看看如何实现 writeFile() 函数:
writeFile (filename, contents, options, callback) {
if (typeof options === 'function') {
callback = options
options = {}
} else if (typeof options === 'string') {
options = { encoding: options }
}
db.put(resolve(filename), contents, {
valueEncoding: options.encoding
}, callback)
}
正如我们所看到的,在这种情况下我们也没有完美的包装器。 我们忽略一些选项,例如文件权限(options.mode),并且按原样转发从数据库收到的任何错误。
我们的新适配器现已准备就绪。 如果我们现在编写一个小的测试模块,我们可以尝试使用它:
import fs from 'fs'
fs.writeFile('file.txt', 'Hello!', () => {
fs.readFile('file.txt', { encoding: 'utf8' }, (err, res) => {
if (err) {
return console.error(err)
}
console.log(res)
})
})
// try to read a missing file
fs.readFile('missing.txt', { encoding: 'utf8' }, (err, res) => {
console.error(err)
})
前面的代码使用原始的 fs API 在文件系统上执行一些读写操作,并且应该在控制台上打印如下内容:
Error: ENOENT, open "missing.txt"
Hello!
现在,我们可以尝试用我们的适配器替换 fs 模块,如下所示:
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import level from 'level'
import { createFSAdapter } from './fs-adapter.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const db = level(join(__dirname, 'db'), {
valueEncoding: 'binary'
})
const fs = createFSAdapter(db)
// ...
再次运行我们的程序应该会产生相同的输出,除了我们指定的文件的任何部分都不是直接使用文件系统 API 读取或写入这一事实之外。 相反,使用我们的适配器执行的任何操作都将转换为在 LevelUP 数据库上执行的操作。
我们刚刚创建的适配器可能看起来很傻; 使用数据库代替真正的文件系统的目的是什么? 但是,我们应该记住,LevelUP 本身具有使数据库也可以在浏览器中运行的适配器。 这些适配器之一是 level-js (nodejsdp.link/level-js)。 现在我们的适配器完全有意义了。 我们可以使用类似的方法来允许利用 fs 模块的代码在 Node.js 和浏览器上运行。 我们很快就会意识到,在与浏览器共享代码时,适配器是一种极其重要的模式,我们将在第 10 章 “Web 应用程序的通用 JavaScript” 中详细了解这一点。
in the wild
有很多适配器模式的实际示例。 我们在这里列出了一些最著名的示例供您探索和分析:
-
我们已经知道LevelUP 能够与不同的存储后端运行,从默认的LevelDB 到浏览器中的IndexedDB。 这是通过创建用于复制内部(私有)LevelUP API 的各种适配器来实现的。 在nodejsdp.link/level-stores 上查看其中一些,了解它们是如何实现的。
-
JugglingDB 是一个多数据库ORM,当然,使用多个适配器来使其兼容不同的数据库。 请访问 nodejsdp.link/jugglingdb-adapters 查看其中的一些内容。
-
nanoSQL (nodejsdp.link/nanosql) 是一个现代多模型数据库抽象库,它大量使用适配器模式来支持多种数据库。
-
对我们创建的示例的完美补充是 level-filesystem (nodejsdp.link/level-filesystem),它是 LevelUP 之上 fs API 的正确实现。