状态模式
状态模式是策略模式的特殊化,其中策略根据上下文的状态而变化。
我们在上一节中已经了解了如何根据不同的变量(例如配置属性或输入参数)来选择策略,并且一旦完成此选择,该策略在上下文对象的剩余生命周期中保持不变。 相反,在状态模式中,策略(在这种情况下也称为状态)是动态的,可以在上下文的生命周期中发生变化,从而允许其行为根据其内部状态进行调整。
下图向我们展示了该模式的表示:
/image-2024-05-07-15-09-55-150.png)
图 9.2 显示了上下文对象如何在三种状态(A、B 和 C)之间转换。 使用状态模式,在每个不同的上下文状态下,我们选择不同的策略。 这意味着上下文对象将根据其所处的状态采取不同的行为。
为了使这一点更容易理解,让我们考虑一个例子:假设我们有一个酒店预订系统和一个名为 Reservation 的对象,该对象对房间预订进行建模。 这是一种典型的情况,我们必须根据对象的状态来调整对象的行为。
考虑以下一系列事件:
-
最初创建预留时,用户可以确认(使用名为confirm() 的方法)预留。 当然,他们不能取消它(使用cancel()),因为它仍然没有确认(例如,调用者会收到异常)。 然而,如果他们在购买前改变主意,可以删除它(使用delete())。
-
一旦预订被确认,再次使用confirm()方法没有任何意义; 然而,现在应该可以取消预订,但不能再删除它,因为它必须保留作为记录。
-
预订日期前一天,不可再取消预订; 已经太晚了。
现在,想象一下我们必须在一个整体对象中实现我们刚刚描述的预订系统。 我们已经可以想象出所有的 if…else 或 switch 语句,我们必须编写这些语句来根据预订的状态启用/禁用每个操作。
/image-2024-05-07-15-11-26-229.png)
如图 9.3 所示,状态模式在这种情况下是完美的:存在三种策略,全部实现所描述的三种方法(confirm()、cancel() 和 delete()),并且每种策略仅实现一个 行为——对应于建模状态的行为。 通过使用这种模式,Reservation 对象应该很容易从一种行为切换到另一种行为; 这只需要在每次状态变化时激活不同的策略(状态对象)。
状态转换可以由上下文对象、客户端代码或状态对象本身发起和控制。 最后一个选项通常在灵活性和解耦方面提供最佳结果,因为上下文不必了解所有可能的状态以及如何在它们之间转换。 |
现在让我们研究一个更具体的示例,以便我们可以应用我们学到的有关状态模式的知识。
实现基本的故障安全套接字
让我们构建一个 TCP 客户端套接字,当与服务器的连接丢失时,该套接字不会失败; 相反,我们希望将服务器离线期间发送的所有数据排队,然后在重新建立连接后立即尝试再次发送。 我们希望在一个简单的监控系统中利用这个套接字,其中一组机器定期发送一些有关其资源利用率的统计数据。 如果收集这些资源的服务器出现故障,我们的套接字将继续在本地对数据进行排队,直到服务器重新上线。
让我们首先创建一个名为 failsafeSocket.js 的新模块来定义我们的上下文对象:
import { OfflineState } from './offlineState.js'
import { OnlineState } from './onlineState.js'
export class FailsafeSocket {
constructor (options) { // (1)
this.options = options
this.queue = []
this.currentState = null
this.socket = null
this.states = {
offline: new OfflineState(this),
online: new OnlineState(this)
}
this.changeState('offline')
}
changeState (state) { // (2)
console.log(`Activating state: ${state}`)
this.currentState = this.states[state]
this.currentState.activate()
}
send (data) { // (3)
this.currentState.send(data)
}
}
javascript
FailsafeSocket 类由三个主要元素组成:
-
构造函数初始化各种数据结构,包括包含套接字离线时发送的任何数据的队列。 此外,它还创建了一组两种状态:一种用于在套接字脱机时实现套接字的行为,另一种用于在套接字联机时实现套接字的行为。
-
changeState()方法负责从一种状态转换到另一种状态。 它只是更新 currentState 实例变量并在目标状态上调用 activate() 。
-
send() 方法包含 FailsafeSocket 类的主要功能。 这就是我们希望根据离线/在线状态有不同的行为的地方。 正如我们所看到的,这是通过将操作委托给当前活动状态来完成的。
现在让我们从 offlineState.js 模块开始看看这两种状态是什么样的:
import jsonOverTcp from 'json-over-tcp-2' // (1)
export class OfflineState {
constructor (failsafeSocket) {
this.failsafeSocket = failsafeSocket
}
send (data) { // (2)
this.failsafeSocket.queue.push(data)
}
activate () { // (3)
const retry = () => {
setTimeout(() => this.activate(), 1000)
}
console.log('Trying to connect...')
this.failsafeSocket.socket = jsonOverTcp.connect(
this.failsafeSocket.options,
() => {
console.log('Connection established')
this.failsafeSocket.socket.removeListener('error', retry)
this.failsafeSocket.changeState('online')
}
)
this.failsafeSocket.socket.once('error', retry)
}
}
javascript
我们刚刚创建的模块负责管理套接字离线时的行为。 它的工作原理如下:
-
我们将使用一个名为 jsonover-tcp-2 (nodejsdp.link/json-over-tcp-2) 的小库,而不是使用原始 TCP 套接字。 这将极大地简化我们的工作,因为该库将负责将通过套接字的数据解析和格式化为 JSON 对象。
-
send() 方法仅负责对其接收的任何数据进行排队。 我们假设我们处于离线状态,因此我们将保存这些数据对象供以后使用。 这就是我们在这里需要做的。
-
activate() 方法尝试使用 json-over-tcp-2 套接字与服务器建立连接。 如果操作失败,则会在一秒后重试。 它会继续尝试,直到建立有效的连接,在这种情况下,failsafeSocket 的状态将转换为联机。
接下来,让我们创建 onlineState.js 模块,我们将在其中实现 OnlineState 类:
export class OnlineState {
constructor (failsafeSocket) {
this.failsafeSocket = failsafeSocket
this.hasDisconnected = false
}
send (data) { // (1)
this.failsafeSocket.queue.push(data)
this._safeWrite(data)
}
_safeWrite (data) { // (2)
this.failsafeSocket.socket.write(data, (err) => {
if (!this.hasDisconnected && !err) {
this.failsafeSocket.queue.shift()
}
})
}
activate () { // (3)
this.hasDisconnected = false
for (const data of this.failsafeSocket.queue) {
this._safeWrite(data)
}
this.failsafeSocket.socket.once('error', () => {
this.hasDisconnected = true
this.failsafeSocket.changeState('offline')
})
}
}
javascript
OnlineState 类模拟与服务器存在活动连接时 FailsafeSocket 的行为。 它的工作原理如下:
-
send() 方法将数据排队,然后立即尝试将其直接写入套接字,因为我们假设我们在线。 它将使用内部 _safeWrite() 方法来执行此操作。
-
_safeWrite() 方法尝试将数据写入套接字可写流(请参阅官方文档:nodejsdp.link/writable-write),并等待数据写入底层资源。 如果没有返回错误并且套接字在此期间没有断开连接,则意味着数据已成功发送,因此我们将其从队列中删除。
-
activate() 方法刷新套接字离线时排队的所有数据,并且还开始侦听任何错误事件; 我们将此视为套接字离线的症状(为了简单起见)。 发生这种情况时,我们会转换到离线状态。
这就是我们的 FailsafeSocket。 现在我们准备构建一个示例客户端和一个服务器来进行尝试。 让我们将服务器代码放在名为 server.js 的模块中:
import jsonOverTcp from 'json-over-tcp-2'
const server = jsonOverTcp.createServer({ port: 5000 })
server.on('connection', socket => {
socket.on('data', data => {
console.log('Client data', data)
})
})
server.listen(5000, () => console.log('Server started'))
javascript
然后,我们真正感兴趣的客户端代码进入 client.js:
import { FailsafeSocket } from './failsafeSocket.js'
const failsafeSocket = new FailsafeSocket({ port: 5000 })
setInterval(() => {
// send current memory usage
failsafeSocket.send(process.memoryUsage())
}, 1000)
javascript
我们的服务器只是将其收到的任何 JSON 消息打印到控制台,而我们的客户端则利用 FailsafeSocket 对象每秒发送其内存利用率的测量结果。
为了尝试我们构建的小系统,我们应该同时运行客户端和服务器,然后我们可以通过停止然后重新启动服务器来测试 failsafeSocket 的功能。 我们应该看到客户端的状态在在线和离线之间变化,并且服务器离线时收集的任何内存测量都会排队,然后在服务器恢复在线后立即重新发送。
该示例应该清楚地演示状态模式如何帮助提高必须根据其状态调整其行为的组件的模块化和可读性。
我们在本节中构建的 FailsafeSocket 类仅用于演示状态模式,并不希望成为处理 TCP 套接字连接问题的完整且 100% 可靠的解决方案。 例如,我们不会验证服务器是否接收到写入套接字流的所有数据,这将需要更多与我们想要描述的模式不严格相关的代码。 对于生产替代方案,您可以依靠 ZeroMQ (nodejsdp.link/zeromq)。 我们将在本书后面的第 13 章 “消息传递和集成模式” 中讨论使用 ZeroMQ 的一些模式。 |