在大型的前端项目中,我们常常面临如何维护和通信状态的问题。当你的应用不断扩展,组件间状态共享与通信变得尤为复杂。此时,过度的直接通信可能导致组件间的高耦合,进而增加修改和维护的难度。发布-订阅模式作为解决这一问题的模式之一,提供了一种轻量而灵活的解决方案。
现实中的发布-订阅模式
无论是在程序师姐里还是在现实生活中,发布-订阅模式的应用都非常广泛。我们先看一个现实中的例子。
股市是一个动态的环境,其中股票、债券和其他证券的价格会随着供需、新闻、宏观经济数据和其他因素不断地波动。为了跟踪这些变化,投资者、经纪人、分析师和其他市场参与者需要实时的、准确的信息。这里的发布-订阅模式表现得尤为明显。
发布者:
- 交易所:公布每只股票的实时价格和交易量。
- 新闻机构:发布金融市场相关新闻。
- 数据供应商:提供各种金融数据,如股票价格和交易量。
订阅者:
- 个人投资者:使用应用或软件跟踪他们关注的股票价格。
- 经纪人:实时查看多个证券的价格以执行交易。
- 金融分析师:收集数据来进行股票分析和预测。
整个股票的工作流程如下:
- 当股票价格变动,交易所即刻发布这些数据。
- 订阅了这些数据的投资者、经纪人或分析师会实时接收到更新。
- 投资者可能设置警报,如股票价格达到某值时,他们会得到通知。
发布-订阅模式确保金融市场参与者实时接收关于股票价格和相关新闻的更新,以做出决策。
理解发布-订阅模式
发布-订阅模式是一种设计模式,用于实现分布式系统中的消息传递/事件发送。这种模式设计了发布者和订阅者两类实体。发布者发布消息而不需要知道哪些订阅者会接收它;同样,订阅者接收消息而不需要知道是哪个发布者发布的。这个模式提供了一个松散耦合的系统设计,其中消息的发送者和接收者可以独立改变,而不是依赖于彼此。
发布者(Publisher)
发布者负责生成和发送消息,它不会直接发送消息给订阅者。发布者的角色是生成数据,并把数据推送到中间的消息队列或者中介者中。
在具体的实现中,发布者会包含如下主要行为:
- 生成消息。
- 发送消息到中介者。
javascript
class Publisher {
constructor(mediator) {
this.mediator = mediator;
}
publish(topic, message) {
this.mediator.publish(topic, message);
}
}
订阅者(Subscriber)
订阅者订阅发布者的消息。它关心的只是从发布者那里接收到关于某一主题或类型的消息。
订阅者通常会有如下行为:
- 订阅主题。
- 处理接收到的消息。
javascript
class Subscriber {
constructor(mediator) {
this.mediator = mediator;
}
subscribe(topic, handler) {
this.mediator.subscribe(topic, handler);
}
}
中介者(Mediator)/ 事件通道(Channel)
中介者是发布者和订阅者之间的通信渠道。发布者发送消息到中介者,而订阅者从中介者订阅消息。中介者通常会有一个注册表,用于存储哪些订阅者订阅了哪些主题。
中介者会:
- 保存订阅者的订阅信息。
- 将发布者的消息转发给正确的订阅者。
javascript
class Mediator {
constructor() {
this.topics = {};
}
subscribe(topic, subscriber, handler) {
if (!this.topics[topic]) {
this.topics[topic] = [];
}
this.topics[topic].push({ subscriber, handler });
}
publish(topic, message) {
if (!this.topics[topic]) return;
this.topics[topic].forEach((sub) =>
sub.handler.call(sub.subscriber, message)
);
}
}
发布-订阅模式的优缺点
发布-订阅模式是一个常用的消息传递模式,它允许系统的多个组件之间进行解耦合的通信。以下是发布-订阅模式的优缺点:
优点:
-
解耦:发布者和订阅者之间是松耦合的,这意味着系统的一个部分可以更改或进化,而不必影响到其他部分。
- 一个在线购物平台可以独立更新其支付模块,而不影响到订单或库存模块。当支付完成时,它只需要发布一个消息。订单模块订阅这个消息并采取相应行动,而不必关心支付模块的内部工作原理。
-
动态性:新的订阅者可以随时加入,旧的订阅者可以随时离开,而不影响系统的其他部分或发布者的操作。
- 一个新的日志分析工具可以简单地订阅已存在的系统日志发布频道,开始收集数据,而不需要其他组件的修改或介入。
-
扩展性:由于发布者和订阅者是解耦的,所以可以容易地增加更多的发布者或订阅者,而不需要对现有系统进行大的修改。
- 随着社交媒体应用的用户增长,可以轻松地增加更多的通知服务实例来处理订阅者的通知需求,因为每个实例都可以独立地接收发布的消息。
-
灵活性:订阅者可以根据自己的需求选择订阅或取消订阅某个主题或频道。
- 在一个新闻应用中,用户可以选择订阅他们感兴趣的特定新闻类别,如科技、体育或政治,而取消对不感兴趣类别的订阅。
-
异步通信:发布-订阅模式通常支持异步消息传递,这可以提高系统的响应性和效率。
- 一个电商网站在顾客下单后,可以立即返回确认信息,而实际的库存检查、支付和发货操作是异步进行的,通过后台发布和订阅消息来完成。
缺点:
-
复杂性:随着订阅者数量的增加,管理和维护订阅关系可能会变得复杂。
- 在一个大型企业系统中,可能有数百个服务互相发布和订阅消息,管理这些交互关系可能变得十分复杂。
-
难以跟踪和调试:由于发布者和订阅者是解耦的,并且消息传递是异步的,所以在出现问题时可能很难找到问题的根源。
- 当一个用户没有收到他的订单确认邮件时,可能很难确定是邮件服务、订单服务还是消息系统中的哪个部分出了问题。
-
冗余和资源浪费:如果许多订阅者订阅了同一个主题,那么发布者可能需要发送多份相同的消息,这可能导致带宽和资源的浪费。
- 如果有成千上万的用户订阅了某个热门活动的实时更新,每次活动发生变化时,系统都需要向这么多的用户发送相同的消息,消耗大量的带宽和资源。
-
消息积压和延迟:如果订阅者无法及时处理收到的消息,可能会导致消息在某处积压,从而引发延迟或其他相关问题。
- 在大促销期间,一个电商平台可能会收到大量的订单。如果处理订单的服务不能及时处理这些消息,订单消息可能会在消息队列中积压,导致用户长时间等待确认。
-
安全性:由于发布者不直接与订阅者交互,所以可能需要额外的机制来确保消息的安全性和完整性。
- 如果一个金融系统的交易消息被未经授权的第三方订阅和接收,这可能导致严重的数据泄露问题。
-
依赖外部系统:许多发布-订阅实现依赖于外部的消息中间件或代理,这可能导致额外的管理和维护成本
- 例子:在 NestJS 中,它不会自己处理消息传递,而是依赖这些外部系统来确保消息的可靠传递和缓冲一个依赖于 Kafka 的消息系统在 Kafka 出现问题时,可能会影响整个应用的正常运行,增加了运维的复杂性。
通过上面这些例子应该有助于我们更好地理解发布-订阅模式的各种优缺点及其实际应用场景。
发布-订阅模式的实现
发布-订阅模式是一种消息通信方法,其中发送消息的实体(发布者)并不发送消息给特定的接收者(订阅者),而是无需知道有哪些订阅者。反过来,订阅者也无需知道有哪些发布者。这种解耦合可以帮助我们更容易地组织和扩展应用。
2.1 JavaScript 的基础实现
接下来我们使用 ts 实现一个发布-订阅的基础代码结构。
ts
interface Subscriber {
id: string;
update(messageType: string, message: any): void;
}
interface MessageTypeSubscriberMap {
[key: string]: Subscriber[];
}
class Publisher {
private subscribers: MessageTypeSubscriberMap = {};
// 订阅特定的消息类型
subscribe(messageType: string, subscriber: Subscriber): void {
if (!this.subscribers[messageType]) {
this.subscribers[messageType] = [];
}
this.subscribers[messageType].push(subscriber);
}
// 根据标识符和消息类型取消订阅
unsubscribe(messageType: string, subscriberId: string): void {
if (!this.subscribers[messageType]) return;
this.subscribers[messageType] = this.subscribers[messageType].filter(
(subscriber) => subscriber.id !== subscriberId
);
}
// 发布消息到指定的消息类型
publish(messageType: string, message: any): void {
if (!this.subscribers[messageType]) return;
for (const subscriber of this.subscribers[messageType]) {
try {
subscriber.update(messageType, message);
} catch (error) {
console.error(`Error notifying subscriber ${subscriber.id}: ${error}`);
}
}
}
}
class ConcreteSubscriber implements Subscriber {
id: string;
private name: string;
constructor(id: string, name: string) {
this.id = id;
this.name = name;
}
update(messageType: string, message: any): void {
console.log(
`[${this.name}] received message of type ${messageType}: ${JSON.stringify(
message
)}`
);
}
}
// 使用示例
const publisher = new Publisher();
const subscriber1 = new ConcreteSubscriber("1", "Subscriber1");
const subscriber2 = new ConcreteSubscriber("2", "Subscriber2");
// 订阅不同类型的消息
publisher.subscribe("TYPE_A", subscriber1);
publisher.subscribe("TYPE_B", subscriber2);
// 发布消息
publisher.publish("TYPE_A", { data: "moment" });
publisher.publish("TYPE_B", { data: "supper" });
// 移除订阅
publisher.unsubscribe("TYPE_A", "1");
// 再次发布消息
publisher.publish("TYPE_A", { data: "supper moment" });
publisher.publish("TYPE_B", { data: "supper moment" });
在上面的当中,主要实现思路如下所示:
-
定义接口:
Subscriber
: 订阅者应有一个唯一标识 (id
) 和一个处理消息的方法 (update
)。MessageTypeSubscriberMap
: 用于关联消息类型和对应的订阅者列表。
-
发布者(
Publisher
:subscribers
: 存储各种消息类型及其对应的订阅者列表。subscribe
: 允许订阅者订阅某种消息类型。unsubscribe
: 允许订阅者取消订阅某种消息类型。publish
: 向所有订阅某种消息类型的订阅者发送消息。
-
订阅者(
ConcreteSubscriber
:- 实现了
Subscriber
接口。 - 拥有标识 (
id
和name
)。 update
: 接收并处理来自发布者的消息。
- 实现了
当发布者将订阅者移除订阅之后,订阅者再也不能接收到发布者的消息。
最终代码输出结果如下图所示:
在 React 中的实现
假设我们要构建一个简单的通知系统,其中任何组件都可以发送通知,并且我们有一个专门的通知组件来显示它们。
jsx
import React, { createContext, useContext, useEffect } from "react";
const NotificationContext = createContext();
export const NotificationProvider = ({ children }) => {
const pubsub = new PubSub();
return (
<NotificationContext.Provider value={pubsub}>
{children}
</NotificationContext.Provider>
);
};
export const useNotification = () => {
return useContext(NotificationContext);
};
// Notifier Component
export const Notifier = () => {
const notificationService = useNotification();
useEffect(() => {
const handle = (data) => alert(data.message);
notificationService.subscribe("NOTIFY_USER", handle);
return () => {
notificationService.unsubscribe("NOTIFY_USER", handle);
};
}, [notificationService]);
return null;
};
在上面的代码中,我们使用了 useContext 实现了一个 发布-订阅模式。任何组件现在都可以使用 useNotification()
来获取发布-订阅服务,发送或订阅通知。
参考文献
- 书籍:JavaScript 设计模式与开发实践
总结
发布-订阅模式提供了一种组织和管理状态和消息通信的有力手段,能够帮助我们构建更加模块化和可维护的前端应用。
发布-订阅模式的有点非常明显,但也不是完全没有缺点。发布-订阅模式虽然可以弱化对象之间的联系,但如果过渡使用的话,对象和对象之间的必要联系也将被深埋在背后,会导致程序难以跟踪维护和理解。特别是有多个发布者和订阅者嵌套到一起的时候,要跟踪一个 bug 不是件轻松的事情。
发布-订阅模式还可以用来帮助实现一些别的设计模式,比如中介者模式。从架构上来看,无论是 MVC 还是 MVVM,都少不了发布-订阅模式的参与,而 JavaScript 本身也是一门基于事件驱动的语言。
最后分享两个我的两个开源项目,它们分别是:
这两个项目都会一直维护的,如果你也喜欢,欢迎 star 🚗🚗🚗