深入浅出观察者模式和发布订阅模式

观察者模式和发布订阅模式


前言

程序的设计模式有很多种,想必大家已经不陌生了。

最近有小伙伴问我观察者模式和发布订阅模式有什么区别?

我内心OS:观察者模式和发布订阅不就是换了个说法吗?引用一下《Head First设计模式》里讲的:

Publishers + Subscribers = Observer Pattern

然后小伙伴说,这不对呀,好多帖子都说是不一样的!!

我也就带着这样的好奇心去查阅了一些文档资料,总算是彻底搞明白了这两个模式到底有什么区别,废话不多说,看官老爷听我娓娓道来,保证看完你会明白。

观察者模式

首先,本着解释一件复杂事物的时候一定不能引入新的概念的原则,我先来举个生活中的例子告诉大家什么是观察者模式。

我和小张同时写了一个游戏项目,但是分工不同,我独自完成了Map,Sound两个模块,小张写的是一个image加载模块。

需求是要在图片加载完成后,才进行加载Map和Sound,也就是执行以下Map.init()Sound.init()这两个函数,于是我需要告诉小张把这两个函数加进去。

小张此时 :

imageLoad(images, function(){ 
  Map.init()
  Sound.init()
}) 

功能一切正常,有一天新加了个需求,需要在图片加载完,加载一些活动充值的展示,于是我又开发了一个 Activity.init(),告诉小张,发现小张请假了。。。

此时我得找到小张的代码,看到上面的图片加载函数,但是我不敢轻举妄动啊,万一出问题呢,于是我就打电话给小张,说这个Activity.init()能放在之前的加载函数内吗,小张(内心OS:淦!)犹豫了一下,说我看看啊,然后小张在度假村掏出电脑,噼里啪啦半天告诉我,我来给你加吧,最后还是得靠小张。

上面这种就属于一种代码的耦合带来的维护的不便利性。

那我们怎么避免一有事就找小张呢?此时就需要运用到行为模式让程序自己说话,就不需要小张了,程序往外用大喇叭告诉我,我这ok啦,你们可以接班啦。

我们只需要接收这一个信号就可以了。

此时代码可以写成这样,imageLoad().then(() => {}) 没错,你天天用的Promise.then也是一种发布订阅的方式,只不过他是实现了一个nextTick去通知回调执行,这个我们这里先不做展开。

或者写成这样:
   imageLoad.on('success', () => { Map.init });
   imageLoad.on('success', () => { Sound.init });
   imageLoad.on('success', () => { Activity.init });

哇,这样太好了,我们终于不用担心小张在不在了,因为小张写的代码已经告诉我们执行的状态,小张也不用担心后面要加什么功能,只聚焦到自己的模块上。

这就是观察者模式或者发布订阅模式,让程序有一些行为,小张此时就是发布者,我在订阅小张的消息*。

其实生活中很多这样的例子:比如你去面试,HR告诉你说回去等通知吧,此时你给HR留下了自己的联系方式就可以回去等待HR通知你面试的结果,这个时候你不用有事没事就来问面试官。
你:结果咋样啦?结果咋样啦?结果咋样啦?这个叫轮询,是在HR不知道你的联系方式时你去主动联系的,就好比服务器不知道每一个客户端的身份,但是客户端是可以知道服务器在哪的(好吧扯远了,轮询我们以后再讲)。

此时我们不需要去一直问面试官,只需要等HR打电话告诉你,这个时候你就是扮演着订阅消息的观察者面试官扮演着发布消息的被观察者,面试官管理者一大批的观察者,等到出了面试结果,他统一去发通知给所有正在观察或者订阅面试是否成功这个消息的观察者。

你只要给面试官一个联系方式,发消息的权利在面试官身上。

上面应该很好的解释了什么是观察者模式,那我们也能很清楚的分析得知,观察者模式里面,notifyAllObservers()方法所在的实例对象,就是被观察者(Subject,或者叫Observable),它只需维护一套观察者(Observer)的集合,这些Observer实现相同的接口,Subject只需要知道,通知Observer时,需要调用哪个统一方法就好了。

观察者模式.png

看完大体的设计架构,我们来通过程序看一下如何实现一个观察者。
const observer = function () {
    const events = {}
    return {
        on(callbackName, callback) {
            if (events[callbackName]) {
                events[callbackName].push(callback)
            } else {
                events[callbackName] = [callback]
            }
        },
        emit(callbackName) {
            events[callbackName].forEach(callback => callback())
        },
        remove(callbackName, callback) {
            if (events[callbackName] && callback) {
                events[callbackName] = events[callbackName].filter(preCallback => preCallback !== callback)
            } else if (events[callbackName]) {
                events[callbackName] = []
            }
        }
    }
}

const ob = observer()
ob.on('hello', function () {
    console.log('h')
})
ob.on('hello', function () {
    console.log('e')
})
ob.on('hello', function () {
    console.log('l')
})
ob.on('hello', function () {
    console.log('l')
})
ob.on('hello', function () {
    console.log('o')
})
ob.emit('hello')

我们可以看到,在上述代码中,观察者有序地往行为对象内部注册相同的"hello"事件,这些事件都被行为对象管理了起来,当我们需要调用时,行为对象就可以通过ob.emit('hello')触发注册的事件。

现在我们可随意地添加发布订阅的函数到行为对象(Subject也就是上述的ob)身上,此时发布和订阅的通道是松耦合的,但是依然在ob内部进行管理,无法实现解耦。

上面的代码实现了对事件的观察者对象,那我们想像Vue或者React那样,在状态改变的同时也能实时通知依赖组件,这又该怎么做呢?


class PubSub {
    constructor() {
        this.state = 0;
        this.observers = []
    }
    setState(state) {
        this.state = state
        this.notifyAllObservers()
    }
    getState() {
        return this.state
    }
    attach(ob) {
        this.observers.push(ob)
    }
    notifyAllObservers() {
        this.observers.forEach(ob => ob.update())
    }
}

class Observer {
    constructor(obName, sub) {
        this.obName = obName
        this.sub = sub
        this.sub.attach(this)
    }
    update() {
        console.log(`name: ${this.obName}, subState: ${this.sub.getState()}`)
    }
}

const ss = new PubSub()
const obse1 = new Observer('ob1', ss)
const obse2 = new Observer('ob2', ss)
const obse3 = new Observer('ob3', ss);
(function () {
    let stateNum = ss.getState()
    let timer = setInterval(() => {
        ss.setState(stateNum++)
        if (stateNum > 10) {
            clearInterval(timer)
            timer = null
        }
    }, 1000)
})()

/*
所有的观察者都会注入我们的PubSub实例,每生成实例的同时,就会注册一个observer交由Subject管理,从而实现状态改变通知全部的观察者对象。
*/
同学们不需要死记硬背,下面由我来用大白话讲一下这套模式的内功心法
  1. 发布者更改状态要通知到所有的订阅者身上,或者说是通知到所有的观察对象身上

  2. 为什么可以通知到,是因为我们修改状态的时候,调用了订阅者的函数,发布者那里必然留下了订阅者的联系方式,也就是这个订阅者的函数

  3. 而订阅者那里,什么都不需要管,那为什么只要发布就会通知到订阅者那里呢,因为订阅者一直惦记着发布者,所以订阅者心里一定住着一个发布者,并且一定会给发布者留下自己的联系方式,这是我们的思维核心

  4. 至于为什么像Vue那样不需要setState就可以通知到组件,是因为Vue2用了Object.defineProperty() 进行观察对象的节点变化进行数据拦截,从而在内部去执行了setState()的相关操作

以上请仔细阅读,理解了必然会弄清楚观察者模式的行为逻辑。

发布订阅模式

其实我认为,发布订阅模式不应该拎出来说是一种设计模式。

模式都是按封装目的归类的,按意图区分。
发布订阅是一种广义上的观察者模式,其实不能说他和观察者模式有什么不同,而是他作为观察者模式的一个变种或者解耦方案,新增了一个消息中间件,为观察者模式中的发布订阅增加了一条消息的中间通道

在观察者模式中,观察者或者说订阅者需要直接订阅目标事件,而发布者可以直接发布一条观察者或者订阅者可以接受的消息。

观察者模式:数据源直接通知订阅者发生改变。
发布订阅模式:数据源告诉第三方(事件频道)发生了改变,第三方再通知订阅者发生了改变。

在设计模式结构上,发布订阅模式继承自观察者模式,是观察者模式的一种实现的变体

在设计模式意图上,两者关注点不同,一个关心数据源,一个关心的是事件消息

发布订阅模式.png
  1. 我们知道不管是观察者还是发布订阅,其实都是一种行为,这也是设计模式中的行为模式。这两个模式的目的就是给模块建立一条信息通道,方便模块间的信息传输,只是手段和重点不一样。

  2. 他们都解决了一个问题,对于多个不同对象基于同一个对象变化时需要同步自身状态或者做一些操作时,怎么能够降低代码的耦合程度。

  3. 举个例子

    就好比我们在网上购物,之前是快递员上门送货,后来快递太多了,为了增加效率,分工更明确一点,现在多了个中间站,菜鸟驿站,快递员方便了,这是在规模起来以后自然而然的选择。

    现在是人主动去拿快递,如果以后连这也嫌弃效率不高,怎么办?

    再加一层,菜鸟驿站派出无人机送,你品,这就是解耦以后可以干的事情

    例子我们就不多说了,我们关注一下代码层面,发布订阅的优势。


// 发布者只管发布消息,不管消息被谁获取了,通常将消息发给平台(消息中间件),让平台去分发消息
class PubLisher {
    constructor(TopicChannel) {
        this.channel = {}
        this.channelList = []
        this.addChannel(TopicChannel)
    }
    addChannel(channel) {
        this.channelList.push(channel)
    }
    doA() {
        this.publish('doA')
    }
    doB() {
        this.publish('doB')
    }
    doC() {
        this.publish('doC')
    }
    publish(msg) {
        this.channelList.forEach(channel => channel.notifyMsg(msg))
    }
}

// 消息中间件只管将发布者的消息处理成不同的channel供订阅者去订阅,维护的是订阅不同topic的订阅者列表,等待发布者一声令下通知订阅者们
class TopicChannel {
    constructor(channelName) {
        this.channelName = channelName
        this.SubjectList = []
    }
    addSubject(subject) {
        this.SubjectList.push(subject)
    }
    notifyMsg(msg) {
        this.interceptPublishMsg(msg)
    }
    interceptPublishMsg(pubMsg) {
        const msgBbj = {
            'doA': 'doA',
            'doB': 'doA',
            'doC': 'doC'
        }
        this.notifyAllSubject(msgBbj[pubMsg])
    }
    notifyAllSubject(pubTopic) {
        this.SubjectList.forEach(subject => subject[pubTopic]())
    }

}


// 订阅者只关心自己要做什么(事件),不关心是谁发布的 ,通常要在平台注册某个事件
class Subject {
    constructor(subName, TopicChannel) {
        this.subName = subName
        this.TopicChannel = TopicChannel
        this.TopicChannel.addSubject(this)
    }
    doA() {
        console.log(this.subName + 'doA')
    }
    doB() {
        console.log(this.subName + 'doB')
    }
    doC() {
        console.log(this.subName + 'doC')
    }
}


// 一个频道T1
const T1 = new TopicChannel('T1')

// 三个订阅者订阅了T1频道的消息
const S1 = new Subject('S1', T1)
const S2 = new Subject('S2', T1)
const S3 = new Subject('S3', T1)

// 一个发布者 发布消息到T1频道
const P1 = new PubLisher(T1)

P1.doA()
P1.doB()
P1.doC()

你以为订阅者完全按照你的指令去做事了,其实他们被中间商篡改了你发布的指令:

屏幕快照 2021-03-04 23.47.34.png

我们惊讶的发现,每一个订阅者S完全不需要认识发布者P,发布者P也不需要认识订阅者S,发布者P只需要知道T可以帮他传递消息就可以,订阅者S也只要知道T可以通知到自己办事就行。

像不像生活中的租房者,房屋中介,房东,彼此不需要跃层认识,就可以完成房子租约。
你就是订阅者不需要找到房东你只要说出自己需要多大的房子朝向如何多少租金等需求。
中介会在自己的体系中为你匹配,你只需要等中介通知你就行。
中介作为消息中间件只需要维护租房者和房东们,与房东签署了协议房东说这个可以租你租3000吧,中介为了赚中介费就通知到了租房者们说4000。
房东作为发布者,只要给平台和中介发自己的房子的基本信息,不需要找到租房者,中介给他钱就行。

上面的例子我们可以拓展出很多租房者,发布者,多平台,这就是你平时看程序设计说的松散耦合带来的程序拓展性可插拔性的优势。
其实像这种中介一样的模式,在程序里叫做经纪人模式或者代理模式,ES6中有Proxy实现了代理,Vue3中也是运用了Proxy替换了之前使用的Object.defineProperty,我们来看一下Proxy。

Proxy

关于他的定义我们就不多说了,网上真的太多了,可以去MDN上看。
我们这里只用一个例子来解释一下什么是代理,以及代理能干什么。


// 以下纯属虚构

// 这是古代皇帝要赈灾的实际款项
const Project = {
    wood: '10w两白银',
    rice: '20w两白银',
    silks: '30w两白银'
}

// 李县令是一个清官,向上级报告说这里有难民要救济,请上面拨款,于是找了王太尉,并拿出了自己的3w两白银救助灾民,以私济公
const Project1 = {
    wood: '9w两白银',
    rice: '19w两白银',
    silks: '29w两白银'
}


// 王太尉作为一个贪官十分腐败,皇帝问他的时候汇报如下
const Project2 = {
    wood: '20w两白银',
    rice: '40w两白银',
    silks: '60w两白银'
}

// 我们怎么来实现这种需求呢,通过上面的发布订阅是做一个消息中间件是可以实现的,现在我们用代理试试

const needMoney = {
    wood: 10,
    rice: 20,
    silks: 30,
}
const resultMoney = {
    wood: 0,
    rice: 0,
    silks: 0,
}

//首先是李县令
const LiJob = new Proxy(needMoney, {
    get(target, key) {
        console.log('王太尉问了一下李县令' + key + '的款项情况')
        return (target[key] - 1) 
    },
    set(target, key, value) {
        console.log('李县令给灾民放款')
        Reflect.set(target, key, value + 1)
        resultMoney[key] = value + 1
    }
})

// 然后是王太尉
const WangJob = new Proxy(LiJob, {
    get(target, key) {
        console.log('皇帝一下王太尉' + key + '的款项情况')
        return (target[key] + 1) * 2
    },
    set(target, key, value) {
        console.log('王太尉给李县令拨款')
        Reflect.set(target, key, (value/2) - 1)
    }
})

// 最后是蒙在鼓里的皇帝
const Emperor = new Proxy(WangJob, {
    get(target, key) {
        console.log('你问了一下皇帝' + key + '的款项情况')
        return target[key]

    },
    set(target, key, value) {
        console.log(`皇帝拨款${key}为${value}`)
        Reflect.set(target, key, value)
    }
})

屏幕快照 2021-03-04 23.57.57.png

我们发现,你眼见不一定为实,知人知面不知心,这就是代理模式,每一层都不知道其它层干了什么,可以拦截数据对其包装后发布出去。

中介隔离作用:在某些情况下,一个客户类不想或者不能直接引用一个委托对象,而代理类对象可以在客户类和委托对象之间起到中介的作用,其特征是代理类和委托类实现相同的接口。
开闭原则,增加功能:代理类除了是客户类和委托类的中介之外,我们还可以通过给代理类增加额外的功能来扩展委托类的功能,这样做我们只需要修改代理类而不需要再修改委托类,符合代码设计的开闭原则。代理类主要负责为委托类预处理消息、过滤消息、把消息转发给委托类,以及事后对返回结果的处理等。

在这个例子中

我们可以看到李县令是清官,自己拿出3w让上面少拨款。
王太尉是个贪官,贪了双倍还多。
皇帝被蒙在鼓里听信了王太尉。

然后皇帝开始放款,王太尉从中捞一笔,最后李县令放款了,灾民收到了救济款。

我们不难发现,每一层都不知道别人到底干了什么,每一层都可以拦截数据,再包装递推给下一层,层层代理,每一层都不用关心跃层的事情,也做到了松散耦合

最后

看到这里,大家是不是已经明白了观察者模式和发布订阅的区别和相同点,并且也能知道为什么我们要在这里提到JS中的Proxy。我也终于可以去给小伙伴回去科普了!!

顺便给大家留个作业,看看Vue的双向绑定机制是如何实现的。如果你仔细阅读了这篇文章,阅读源码的时候一定会豁然开朗。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,681评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,710评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,623评论 0 334
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,202评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,232评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,368评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,795评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,461评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,647评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,476评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,525评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,226评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,785评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,857评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,090评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,647评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,215评论 2 341

推荐阅读更多精彩内容