设计模式-发布订阅模式

505 阅读9分钟

简介

概念

在24种基本设计模式中,并没有发布订阅模式,前期发布订阅模式只是观察者模式得到一个别称,因为观察者模式自身的一些缺点,渐渐的衍生出了观察者模式;

发布订阅模式其实就是一种对象间一对多的依赖关系,当一个对象的状态发生变化时,所有依赖它的对象都将得到状态改变的通知;其包含订阅者 Subscriber发布者 Publisher事件调度中心 Event Channel

简单案例分析

电商-降价通知系统

淘宝就是一个调度中心 Event Channel,客户是订阅者 Subscriber,商家就是发布者 Publisher,当客户订阅了一件商品的降价通知时,这个事件会被保存到淘宝中,当商家降价了某个商品时,相当于发布了商品降价的这个事件,进而触发淘宝的降价对应逻辑,然后由淘宝通知客户对应的商品降价了;

订阅和发布的顺序问题

普通情况下一般都是先订阅一个消息,然后再通过发布者进行发布消息进而收到推送消息;但是会有发布者在发布推送消息时,并没有订阅者进行接收,后续有订阅者后又不想错过订阅前的消息推送列表,此时就需要进行单独处理了;

实现思路

需要单独建立一个存放离线事件的堆栈,当发布者进行事件推送时保存到存放离线事件的堆栈,其思路是按照时间节点为key进行相应的数组列表存储(需要设定对应的上限值,当超过上限后就添加新的字段表明还有更多的历史消息),等到有新的订阅者进行订阅时,就从缓存数据里进行相应读取执行推送的事件,离线事件也只执行一次,需要对用户订阅者进行是否读取缓存的字段标识(默认没有缓存数据时注册次字段为读取过);

参考文献

发布订阅模式的相关应用

ES6 的Proxy应用

实现思路

在vue中可以通过Proxy和发布订阅模式进行响应式的实现,proxy在传入目标对象后就会检测目标对象上的属性追变化,当属性值变化时就会触发set API方法,此时就可以在set中进行响应式通知;

代码实现

(发布者)封装变化通知推送函数

// 通过Proxy监听对象变化
function eventEmitter(data, callback) {
  return new Proxy(data, {
    set(target, prop, value) {
      data[prop] = value
      callback()
      return value
    }
  })
}

给目标对象(订阅者)添加检测功能

let Lbxin = {
    name: "Lbxin",
    age: 20
}
let LbxinInfo = Lbxin.name + Lbxin.age
Lbxin.age = 21;
console.log('检测前',LbxinInfo)
Lbxin = eventEmitter(Lbxin,()=>{LbxinInfo = Lbxin.name + Lbxin.age})

Lbxin.age = 21;
console.log('检测后',LbxinInfo)

// 检测前 Lbxin20
// 检测后 Lbxin21

会话过期刷token

当一个请求失败并返回了会话过期的状态码时,则需要判断是接口过期还是登录过期,如果是接口过期,则去请求新token,然后拿新token再次发起请求;

实现思路
  1. 在axios中添加response响应拦截,当返回会话过期的错误码时就去调用刷token的逻辑;
  2. 在刷token的逻辑里,需要先判断是否是登录过期(拿登录后保存的有效期与该接口的当前请求时间对比,当当前 时间减去接口请求时间小于有效期时表明不是登录过期,走刷token逻辑,否则走登录逻辑);
  3. 当为刷token逻辑时,为了避免多次请求获取token的接口需要添加一个标识,用于避免多次请求token接口;
  4. 在刷token的过程中,需要将新请求收集到订阅列表中,在token请求成功(发布者)后通知调度中心进行通知订阅者重新请求缓存的接口;
服务端处理方式
  1. 直接在服务端进行逻辑判断,当会话过期时,可以在ResponseHeader里添加标识,表明会话是否已经过期;
  2. 前端在axios中添加response响应拦截,当发现有约定的会话过期的字段时就进行刷token或者跳转登录页面的操作;

中央事件总线EventEmitter的实现

实现思路及对应代码
  1. 创建一个EventEmitter类,充当调度中心,在该类上创建对应的事件存储中心;
class EventEmitter {
  constructor() {
    //初始化事件列表
    this.cache = {}
  }
}
  1. 将订阅者注册到事件中心
//订阅事件
subscribe(name, fn) {
  if (this.cache[name]) {
    //可以采用对象的方式进行存储  删除的时候比数组的方式性能好一些
    this.cache[name].push(fn)
  } else {
    //初始化事件
    this.cache[name] = [fn]
  }
  //在订阅事件的时候可以返回唯一id的unsubscript事件进行定回调的取消订阅,代替简单通过函数对比进行卸载,订阅者在订阅的时候可以进行定向的接收,从而实现定向的回调卸载;
}
  1. 发布者的发布事件注册到调度中心,调度中心处理对应的发布逻辑
//发布事件
publish(name, once = false, ...args) {
  if (this.cache[name]) {
    //取出当前事件的所有回调函数 避免回调函数中再次注册回调函数造成死循环
    let copyCache = this.cache[name].slice();
    for (let fn of copyCache) {
      //发布时进行相关的传参
      fn(...args)
    }
    if (once) {
      //当是一次性函数时进行销毁事件
      delete this.cache[name]
    }
  } else {
    return console.error(name + "not found!")
  }
}
  1. 取消订阅事件
//卸载事件
unSubscribe(name, fn) {
  let target = this.cache[name]
  if (!fn) {
    delete this.cache[name];
    return consolr.log(name + '事件已全部卸载!')
  }
  if (target) {
    const index = target.findIndex(f => f === fn || f.callback === fn)
    if (index > -1) {
      target.splice(index, 1)
    }
  }
}
  1. 只订阅一次,调用完毕后删除在事件存储中心存储的事件
// ......
if (once) {
  //当是一次性函数时进行销毁事件
  delete this.cache[name]
}
完整代码
class EventEmitter {
  constructor() {
    //初始化事件列表
    this.cache = {}
  }
  //订阅事件
  subscribe(name, fn) {
    if (this.cache[name]) {
      //可以采用对象的方式进行存储  删除的时候比数组的方式性能好一些
      this.cache[name].push(fn)
    } else {
      //初始化事件
      this.cache[name] = [fn]
    }
    //在订阅事件的时候可以返回唯一id的unsubscript事件进行定回调的取消订阅,代替简单通过函数对比进行卸载,订阅者在订阅的时候可以进行定向的接收,从而实现定向的回调卸载;
  }
  //卸载事件
  unSubscribe(name, fn) {
    let target = this.cache[name]
    if (!fn) {
      delete this.cache[name];
      return consolr.log(name + '事件已全部卸载!')
    }
    if (target) {
      const index = target.findIndex(f => f === fn || f.callback === fn)
      if (index > -1) {
        target.splice(index, 1)
      }
    }
  }
  //发布事件
  publish(name, once = false, ...args) {
    if (this.cache[name]) {
      //取出当前事件的所有回调函数 避免回调函数中再次注册回调函数造成死循环
      let copyCache = this.cache[name].slice();
      for (let fn of copyCache) {
        //发布时进行相关的传参
        fn(...args)
      }
      if (once) {
        //当是一次性函数时进行销毁事件
        delete this.cache[name]
      }
    } else {
      return console.error(name + "not found!")
    }
  }
}

案例分析文献

案例分析

订阅报刊

按照发布订阅者模式,将该需求拆分成订阅者发布者调度中心三部分:
订阅者只关注暴露报刊的订阅事件和发布后的推送接收事件,内部通过调度中心进行订阅报刊、推送接收与对应数据维护;
发布者只关注报刊类型的注册和报刊更新的推送事件,内部还是通过调度中心进行相关处理;
调度中心负责检测发布者注册(维护报刊类数据)和推送事件(通知订阅者触发更新逻辑),订阅者订阅取消订阅报刊的事件和通知订阅者更新的逻辑;

// 报社
class Publisher {
  constructor(name, channel) {
    this.name = name;
    this.channel = channel;
  }
  // 注册报纸 - 内部通过 调度中心 进行注册
  addTopic(topicName) {
    this.channel.addTopic(topicName);
  }
  // 推送报纸 - 内部通过 调度中心 进行推送
  publish(topicName) {
    this.channel.publish(topicName);
  }
}
// 订阅者
class Subscriber {
  constructor(name, channel) {
    this.name = name;
    this.channel = channel;
  }
  //订阅报纸 - 内部通过 调度中心 进行订阅
  subscribe(topicName) {
    this.channel.subscribeTopic(topicName, this);
  }
  //取消订阅 - 内部通过 调度中心 进行取消订阅
  unSubscribe(topicName) {
    this.channel.unSubscribeTopic(topicName, this);
  }
  //接收推送 - 内部通过 调度中心 进行订阅更新的接收
  update(topic) {
    console.log(`${topic}已经送到${this.name}家了`);
  }
}
// 事件调度中心 
// 注册报社 - 添加订阅者 - 检测发布者对应事件 - 根据发布者触发的事件通知订阅者
class Channel {
  constructor() {
    this.topics = {};
  }
  //报社在平台注册报纸
  addTopic(topicName) {
    this.topics[topicName] = [];
  }
  //报社取消注册
  removeTopic(topicName) {
    delete this.topics[topicName];
  }
  //订阅者订阅报纸
  subscribeTopic(topicName, sub) {
    if (this.topics[topicName]) {
      this.topics[topicName].push(sub);
    }
  }
  //订阅者取消订阅
  unSubscribeTopic(topicName, sub) {
    this.topics[topicName].forEach((item, index) => {
      if (item === sub) {
        this.topics[topicName].splice(index, 1);
      }
    });
  }
  //平台通知某个报纸下所有订阅者
  publish(topicName) {
    this.topics[topicName].forEach((item) => {
      item.update(topicName);
    });
  }
}

const channel = new Channel();

// 报社只关注报社的注册逻辑(发布者),不需要关注订阅者的逻辑,也不需要维护注册后的列表维护
const pub1 = new Publisher("报社1", channel);
const pub2 = new Publisher("报社2", channel);

// 注册后通过调度中心进行注册报社
pub1.addTopic("晨报1");
pub1.addTopic("晚报1");
pub2.addTopic("晨报2");

订阅者同样不用关注报社的相关逻辑,只需要注册订阅者即可
const sub1 = new Subscriber("小明", channel);
const sub2 = new Subscriber("小红", channel);
const sub3 = new Subscriber("小张", channel);

// 注册后通过调度中心进行订阅报纸
sub1.subscribe("晨报1");
sub2.subscribe("晨报1");
sub2.subscribe("晨报2");
sub3.subscribe("晚报1");

sub3.subscribe("晨报2");
sub3.unSubscribe("晨报2");

//一切后续动作都由发布者进行触发,内部通过调度中心去通知订阅者进行后续逻辑操作,本身不用关注其他逻辑
pub1.publish("晨报1");
pub1.publish("晚报1");
pub2.publish("晨报2");

//晨报1已经送到小明家了
//晨报1已经送到小红家了
//晚报1已经送到小张家了
//晨报2已经送到小红家了

参考文献

优缺点分析

优点

  1. 解耦发布者和订阅者:发布者和订阅者不需要有任何联系,不需要知道对应的需求逻辑,只需要关注对应的在调度中心中得到事件即可;
  2. 广泛应用于异步编程中:在接口的异步请求中,不知道接口的返回顺序,通常是加入回调进行处理,但在发布订阅模式中,异步逻辑(发布者)只需要在外部的调度中心订阅一个ready事件,在对应异步结束后去调用调度中心订阅的ready事件,当 满足条件后再由调度中心去通知订阅者处理对应逻辑;

缺点

  1. 因为将发布者与订阅者进行解耦,两者无法通过简单的方式进行通信,当需要进行通信时就需要通过事件的方式进行通信了;
  2. 发布订阅者模式弱化了对象间的联系,当使用不当,对象间的关系将难以追踪和维护;
  3. 使用发布订阅者模式时会应用到数据存储和消息推送等逻辑,会造成一定的时间和内存的消耗;

推荐文献
基于"发布-订阅"的原生JS插件封装 - 三元
会话过期-刷token