命令

Node.js 中另一个非常重要的设计模式是命令。 在其最通用的定义中,我们可以将命令视为封装了稍后执行操作所需的所有信息的任何对象。 因此,我们不是直接调用方法或函数,而是创建一个表示执行此类调用意图的对象。 然后,另一个组件将负责实现意图,将其转化为实际行动。 传统上,该模式围绕四个主要组件构建,如图 9.6 所示:

image 2024 05 07 17 10 12 084
Figure 1. 图 9.6:命令模式的组件

命令模式的典型配置可以描述如下:

  • 命令是封装调用方法或函数所需信息的对象。

  • 客户端是创建命令并将其提供给调用者的组件。

  • 调用程序是负责在目标上执行命令的组件。

  • 目标(或接收者)是调用的主体。 它可以是一个单独的函数或一个对象的方法。

正如我们将看到的,这四个组件可能会有很大差异,具体取决于我们想要实现该模式的方式。 在这一点上这听起来应该不是什么新鲜事。

使用命令模式而不是直接执行操作有多种应用:

  • 可以安排命令稍后执行。

  • 命令可以轻松地序列化并通过网络发送。 这个简单的属性允许我们跨远程机器分发作业、将命令从浏览器传输到服务器、创建远程过程调用(RPC)系统等等。

  • 命令可以轻松保存系统上执行的所有操作的历史记录。

  • 命令是某些数据同步和冲突解决算法的重要组成部分。

  • 如果计划执行的命令尚未执行,则可以取消该命令。 它还可以恢复(撤消),将应用程序的状态恢复到执行命令之前的状态。

  • 多个命令可以组合在一起。 这可用于创建原子事务或实现一次性执行组中所有操作的机制。

  • 可以对一组命令执行不同类型的转换,例如重复删除、连接和拆分,或应用更复杂的算法,例如操作转换 (OT),这是当今大多数实时协作软件的基础, 例如协作文本编辑。

有关 OT 工作原理的精彩解释,请访问 nodejsdp.link/operational-transformation。

前面的列表清楚地向我们展示了这种模式的重要性,尤其是在像 Node.js 这样的平台上,网络和异步执行是必不可少的参与者。

现在,我们将更详细地探讨命令模式的几种不同实现,只是为了让您了解其范围。

Task 模式

我们可以从命令模式最基本、最简单的实现开始:任务模式。 当然,在 JavaScript 中创建表示调用的对象的最简单方法是围绕函数定义或绑定函数创建闭包:

function createTask(target, ...args) {
    return () => {
        target(...args)
    }
}

这(大部分)相当于执行以下操作:

const task = target.bind(null, ...args)

这看起来根本就不是什么新鲜事。 事实上,我们在整本书中已经多次使用了这种模式,特别是在第 4 章 “带有回调的异步控制流模式” 中。 这种技术允许我们使用单独的组件来控制和调度任务的执行,这本质上相当于命令模式的调用者。

更复杂的命令

现在让我们研究一个利用命令模式的更清晰的示例。 这次,我们希望支持撤消和序列化。 让我们从命令的目标开始,这是一个负责向类似 Twitter 的服务发送状态更新的小对象。 为了简单起见,我们将使用此类服务的模型(statusUpdateService.js 文件):

const statusUpdates = new Map()
// The Target
export const statusUpdateService = {
    postUpdate (status) {
        const id = Math.floor(Math.random() * 1000000)
        statusUpdates.set(id, status)
        console.log(`Status posted: ${status}`)
        return id
    },
    destroyUpdate (id) => {
        statusUpdates.delete(id)
        console.log(`Status removed: ${id}`)
    }
}

我们刚刚创建的 statusUpdateService 代表了我们的命令模式的目标。 现在,让我们实现一个工厂函数,该函数创建一个命令来表示新状态更新的发布。 我们将在名为 createPostStatusCmd.js 的文件中执行此操作:

export function createPostStatusCmd (service, status) {
    let postId = null

    // The Command
    return {
        run () {
            postId = service.postUpdate(status)
        },
        undo () {
            if (postId) {
                service.destroyUpdate(postId)
                postId = null
            }
        },
        serialize () {
            return { type: 'status', action: 'post', status: status }
        }
    }
}

前面的函数是一个工厂,它生成命令来模拟 “帖子状态” 意图。 每个命令都实现以下三个功能:

  • run() 方法,在调用时将触发操作。 换句话说,它实现了我们之前见过的任务模式。 该命令在执行时将使用目标服务的方法发布新的状态更新。

  • undo() 方法可恢复后操作的效果。 在我们的例子中,我们只是在目标服务上调用 destroyUpdate() 方法。

  • 构建 JSON 对象的 serialize() 方法,该对象包含重建同一命令对象所需的所有必要信息。

之后,我们可以构建一个调用程序。 我们可以从实现它的构造函数和 run() 方法(invocar.js 文件)开始:

import superagent from 'superagent'
// The Invoker
export class Invoker {
    constructor () {
        this.history = []
    }
    run (cmd) {
        this.history.push(cmd)
        cmd.run()
        console.log('Command executed', cmd.serialize())
    }
// ...rest of the class

run() 方法是我们的 Invoker 的基本功能。 它负责将命令保存到历史实例变量中,然后触发命令本身的执行。

接下来,我们可以向 Invoker 添加一个延迟执行命令的新方法:

delay (cmd, delay) {
    setTimeout(() => {
        console.log('Executing delayed command', cmd.serialize())
        this.run(cmd)
    }, delay)
}

然后,我们可以实现一个 undo() 方法来恢复最后一个命令:

undo () {
    const cmd = this.history.pop()
    cmd.undo()
    console.log('Command undone', cmd.serialize())
}

最后,我们还希望能够在远程服务器上运行命令,方法是序列化命令,然后使用 Web 服务通过网络传输命令:

async runRemotely (cmd) {
    await superagent
        .post('http://localhost:3000/cmd')
        .send({ json: cmd.serialize() })

    console.log('Command executed remotely', cmd.serialize())
}

现在我们有了命令、调用程序和目标,唯一缺少的组件是客户端,我们将在名为 client.js 的文件中实现它。 让我们首先导入所有必要的依赖项并实例化 Invoker:

import { createPostStatusCmd } from './createPostStatusCmd.js'
import { statusUpdateService } from './statusUpdateService.js'
import { Invoker } from './invoker.js'

const invoker = new Invoker()

然后,我们可以使用以下代码行创建一个命令:

const command = createPostStatusCmd(statusUpdateService, 'HI!')

我们现在有一个代表状态消息发布的命令。 然后我们可以决定立即发送它:

invoker.run(command)

糟糕,我们犯了一个错误,让我们将时间线恢复到发布最后一条消息之前的状态:

invoker.undo()

我们还可以决定安排消息在 3 秒后发送:

invoker.delay(command, 1000 * 3)

或者,我们可以通过将任务迁移到另一台机器来分配应用程序的负载:

invoker.runRemotely(command)

我们刚刚实现的小例子展示了如何将操作包装在命令中可以打开一个充满可能性的世界,而这只是冰山一角。

作为最后的评论,值得注意的是,只有在绝对必要时才应使用成熟的命令模式。 事实上,我们看到我们必须编写多少额外的代码才能简单地调用 statusUpdateService 的方法。 如果我们只需要一个调用,那么复杂的命令就显得大材小用了。 但是,如果我们需要计划任务的执行或运行异步操作,那么更简单的任务模式提供了最佳的折衷方案。 相反,如果我们需要更高级的功能,例如撤消支持、转换、冲突解决或我们之前描述的其他奇特用例之一,则几乎有必要使用更复杂的命令表示形式。