上一篇文章中,主要是介绍了什么是异步编程,而这从这篇文章开始,我会介绍一些异步编程的一些解决方案。
目前异步编程的解决方案主要有一下几种:
1.事件发布/订阅模式
2.Promise/Deferred模式
3.流程控制库
而我们这一篇文章主要是介绍第一种,即事件发布/订阅模式,后续会介绍Promise/Deferred模式以及es6的Promise实现,至于第三种,我并不是太过于了解,所以,近期不会介绍。
好了,话不都说,下面就开始介绍事件发布/订阅模式是如何改善我们的异步编程的。
说到前端的事件,脑子第一想到的估计就是js给dom添加的那些事件,但是,这里的异步编程靠的并不是那些已经被js定义好的那些事件,真正用于异步编程的是事件发布/订阅的这种模式:
在这个模式中,会存在一个事件对象,他的作用在于发布事件,叫做发布者。
还有一个叫做观察者(或者订阅者)的用来订阅发布者所发布出来的事件。
当发布者中所发布的某一个事件发生时,发布者会通知(其实就是调用)所有订阅了这个事件的观察者。
其实他们的关系就好比杂志社和读者的关系:
杂志社发布了一种杂志,读者可以订阅这种杂志。而每当杂志社每个月发布最新的一期的杂志时,都会把最新的杂志寄给所有订阅了这种杂志的读者。就是这么个关系。
在这里或者说在前端中,事件对象(发布者)是一个对象,他可以发布事件,取消事件,触发事件,并能够通知所有订阅了这个事件的观察者。而观察者说白了就是一个函数、方法,当发布者触发了某个事件时,通知观察者其实就等价于调用这个函数罢了。
好,现在来说一说这种事件发布/订阅模式和异步编程的关系以及他为什么会适合异步编程的思想
异步编程在执行了某个异步操作时,直接返回,不会再去理会,而我们只需要为其添加一个回调函数,用于当异步操作结束时,再执行这个回调函数即可。
而事件模式和这种异步思维极其类似。我订阅某个事件,并把事件发生时的处理函数作为观察者添加上去,当事件发生时,发布者会执行观察者这个处理函数。他是回调函数的事件化,极其类似却更加灵活。
熟悉前端的都非常熟悉dom事件了,不过,其实专门用于异步编程的事件发布/订阅模式,可是比前端大量dom事件更为简单,因为用户异步编程的事件订阅/发布模式不存在事件捕获,冒泡,preventDefault()以及sotpPropagation()这些东西。所以,是不是听起来松了一口气。确实是这样,因为异步编程的事件发布/订阅模式根据上面所解释的,只需要以下几个函数:
on 用户注册观察者
removeListener 删除某一个事件的所有观察者(其实就等于删除这个事件了)
emit 触发某一个事件,从而调用这个事件的所有观察者。
// 其他
once 注册只执行一次的事件,只触发一次就删除的事件
removeAllListener 删除这个事件对象的所有事件。
我们来看一下事件订阅/发布模式的使用:
// 订阅
emitter.on("event1", function (message) {
console.log(message);
});
// 触发
emitter.emit('event1', "I am message!");
// 打印 I am message!
其中emitter.on方法中第一个参数是订阅的事件名称,第二个参数是事件发生时的处理函数,也就是观察者。而emitter.emit则是用来触发event1这个事件,"I am message!" 是传递的参数,这个参数会传递给观察者。
一个事件可以有多个观察者,也就是可以有多个处理函数,并且事件和观察者可以随意的删除和修改,非常的灵活,并且事件发生和处理函数之间可以很方便的解耦。我不管这个事件发生时怎么去处理,也不用管这个事件有多少个观察者,而且数据通过消息参数的方式可以很灵活的传递。
那么事件订阅/发布模式他对比普通的回调函数,可以解决异步编程的哪些问题呢?
1.利用事件解决雪崩问题
首先这一个是上述异步编程存在的问题中所没有提及的,因为他也不算是异步本身编程的问题吧。那到底是啥问题呢?
这个例子是典型,所以我直接从书中拿来用了啊:
在计算机中,通常缓存用于加速对同一数据的重复请求,所谓雪崩问题就是在高访问,大并发的情况下,缓存失效的情景。这时候,大量数据请求同时访问服务器,服务器无法同时处理这么多的处理请求,导致影响网站整体响应速度。
比如请求数据库:
var select = function (callback) {
db.select("SQL", function (results) {
callback(results);
});
};
如果这时候,站点刚好启动,那么缓存不存在,那么同一条sql会被反复在数据库中查询,影响服务的整体性能(这里查询出来的数据设定为都是相同的结果)。
那么我们应该在第一条数据请求时才真正执行查询数据库,而后续的请求如果发现如果已经有一条数据库在执行了,那么就应该等待第一条数据请求返回结果,然后让后续的请求都直接使用这个结果即可。这样想是不是比较合理,那么该如何实现?
这时候可以使用事件的once方法这种执行一次就删除的特点来很方便的实现(事件队列)。
var event= new events;
var status = "ready"; //状态锁 ready为准备中 pending为执行中
var select = function (callback) {
event.once("selected", callback);
if (status === "ready") {
status = "pending";
db.select("SQL", function (results) {
event.emit("selected", results);
status = "ready";
});
}
};
上述代码中,使用status来标识,判断是否有请求去访问数据库了,并且把所有请求的回调都作为观察者,观察selected事件的发生。当第一条请求A进入时,执行select方法,把回调添加至观察者,然后发现status为ready,也就是没有请求在查询数据库,那么就会去查询数据库,并把状态设置为正在请求“pending”。然后立马第二条请求进来,发现已经在查询数据库了,那么就不再查询,只是添加回调在观察者队列中。然后等待数据库查询出结果后,会触发selected事件,把查询出的结果传递给所有请求的回调函数,并执行回调,并更改status的状态。
这里针对相同的sql语句,保证查询开始到结束的过程永远只有一次。而其他请求只需要等待查询返回结果即可。这样,可以节省重复数据请求的开销,而且不止用于缓存失效的情况,还可以用于有些时候不太好设置缓存的情况。
上面这个例子是《深入浅出node.js》书籍中的异步编程解决方案中的。然后说一下我在实际中遇到的一个类似问题,感觉也可以使用这种方式解决:
在我写一个抽奖小程序时,后台大佬,为了跟随微信官方小程序的登陆标准,从而商量使用登陆态(一串字符串)而不是openid来作为用户的登陆状态。这个登陆态啊,和openid不一样,他对于用户来说不是唯一的,而且还设置了一天的时间限制,一天没有登陆就失效了。而且,每个接口都在请求时,都需要传递登陆态过去验证。其实吧,这本身也没什么大问题,不过是为了举例从而拿出来说而已:
1.接口A,B,C请求时都需要登录态。
2.而登录态没有传递,或者登录态过期时,都会返回一个300给我。
3.如果返回300了,这时候,我会请求wx.login获取code并且使用code向后台请求login接口获取到新的登陆态(这个接口不需要传登陆态)然后,再使用这个新的登陆态重新请求那个A,B,C,并设置登陆态缓存。
﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋我是华丽分割线﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊﹋﹊
┈━═┈━═┈━═┈━═┈━═☆、┈━═┈━═┈━═┈━═┈━═☆、┈━═┈━═┈━═┈━═┈━═☆、┈━═┈━═┈━═┈━═┈━═☆、
看着没毛病是吧,但是问题在这里:接口的请求,不是一起的,没有任何关联,也就是A,B,C三个接口是分开请求的,那么:
我第一次进入小程序时,或者缓存登陆态过期的时候,那么:
A接口的请求行为:请求A接口 -> 报300 -> wx.lonin并请求login接口获取新登陆态 -> 使用新登陆态再请求A接口并设置登陆态缓存。
然而麻烦的是,刚进入小程序页面时,A,B,C接口同时请求,各不相干,而且绝对会全部报300(因为刚进入小程序没有登陆态缓存),但他们三个互相不知道,是独立的,所以B,C也同时走了一遍和接口A一样的请求行为,也就是全部都重新请求了一遍新的登陆态。然而这会有一点点影响页面加载的速度的,虽然问题不大但例子却很典型。
这时候如果使用一般的解决办法:
那也无非就是用一个数组,然后报300时,判断如果正在请求新的登陆态了,那么就把这个请求接口函数给存到数组中先存着,然后等登陆态请求到了,在把这些请求从数组中取出来请求一下。
看到这,不就是上面数据库中使用事件订阅/发布模式的once方法解决的问题一样的套路吗:在A(或者其他请求)请求返回300时,把A请求添加到一个名为logining的once事件中,请求新的登陆态(这时候会删除掉缓存,并且有一个登陆态请求中的status字段),并等待新的登陆态返回,然后B,C如果也返回了300,这时候发现登陆态正在请求中,那么也把B,C请求添加到logining这个once事件中,等待新的登陆态请求返回。等待新的登陆态请求返回了,就直接触发logining事件,重新用新的登陆态请求A,B,C接口,并把status设置为准备中。这样就不用多次请求新的登陆态了。
此处可能还有一种情况,A在重新请求登陆态时,B,C可能等到A重新请求到新的登陆态了,B,C还没有返回状态,这时候,当B,C请求返回时,A已经使用了新的登陆态,并且,没有接口正在请求新的登陆态,那么B又会重新进行请求新的登陆态的行为,这明显不是想要的。所以,这时候可以在接口重新请求新登陆态之前加一个判断,判断缓存中的登陆和我这一次失败时所使用的登陆态是否是一样的,如果是一样的,那么就请求新的登陆态,如果不是,那么使用缓存中登陆态的再次重新请求这个接口。至于如何拿到我这次请求所使用的登陆态,可以根据你的代码编写情况看是否拿得到,或者和后端商量,在发现登陆态失效时,再把这个登陆态再返回给你即可。
这是事件发布/订阅模式可以解决的第一个问题:重复请求相同数据的问题。并且实现起来相当容易。
2.代码嵌套过深和多异步协同问题
代码嵌套过深和多异步协同问题我在我的关于异步编程的第一篇文章:何为异步编程 中已经提出来了,即如果每个操作依赖于另外一个异步操作的结果,那么可能多个异步操作可能会出现许多个异步回调的嵌套过深的问题,而多异步协同问题指:如果一个操作依赖于多个异步操作的返回值,那么如何既利用异步操作带来的性能提升,又能够避免嵌套,写出优美的代码呢?我们可以尝试使用事件发布/订阅模式来解决一下这些问题。
1.使用事件发布/订阅模式解决嵌套问题:以nodejs中读取文件为例,先读取test.txt文件内容,然后以test.txt文件中内容为路径,请求test2.txt文件的数据,并打印出来
test.txt文件的内容为:./test2.txt
test2.txt文件的内容为:我是test2文件的内容
const fs = require("fs"); // nodejs 文件模块(用于操作文件的模块)
const event = require("events"); // nodejs 的事件模块
const readFileEvent = new event.EventEmitter(); //创建事件对象
// 监听事件
readFileEvent.on("readText1Succeed",(data) => {
let file1Data = data;
// 根据读取到的test.txt文件的内容为地址,读取test2.txt文件的内容。
fs.readFile(file1Data.toString(),(err,data) => {
readFileEvent.emit("readText2Succeed",data)
})
})
// 绑定文件test2.txt读取成功时的处理函数
readFileEvent.on("readText2Succeed",(data) => {
console.log("文件2的内容为:"+data.toString());
})
// 读取test.txt的内容
fs.readFile("./test.txt",(err,data) => {
readFileEvent.emit("readText1Succeed",data)
})
// 打印:
我是test2文件的内容
以上就是使用事件发布/订阅模式来实现的文件读取操作。使用事件的订阅,来监听文件的一的读取成功,然后在文件一读取成功时,读取文件二,当文件二读取成功时触发文件二的成功事件,因为文件读取成功时的处理函数可以在别的地方通过on进行绑定,所以,就没有多回调的嵌套问题。
那么事件订阅/发布模式又是如何解决多异步协同问题呢?在此之前,我们先来回顾一下需要多异步协同的个api的调用过程:
1.wx.login登陆获取code
2.使用code向后台请求openid
3.使用openid获取用户绑定的门店,还需要通过openid获取这个用户的团购id(此处需要多异步协同)
4.再用通过门店和团购id,请求该门店的团购商品。
他的问题在于:请求门店和请求团购id不在同一个接口中,第3步中,获取门店和团购id他们都是两个不同的异步操作。但是,我的下一步4中的操作要依赖这两个异步的结果那么如何既可以使用异步请求带来的性能提升又可以比较优美且不那么麻烦的编写和处理好代码呢?
一般的方法为使用哨兵变量,即用来记录次数之类的变量:
(以下代码为了可理解性,使用了半伪代码表示)
var count = 0; //哨兵变量
var results = {}; //存储每个接口返回的结果的变量
var done = function (key, value) {
results[key] = value;
count++;
if (count === 2) {
// 当门店和团购id都获取到时,执行4。
}
}
使用openid获取门店
当获取成功执行done函数:done('store',data)
使用openid获取团购id
当获取成功执行done函数:done('bulkId',data)
上面代码中,获取门店和获取团购id成功返回时,都会判断其他请求是否也返回了,当门店和团购id都返回时,才执行后面的第4步
一般这么写没什么问题的。不嫌麻烦也还可以。那么看一下使用事件订阅/发布模式怎么写吧:
var events = require("events");
var emitter = new events.EventEmitter(); // 创建新的事件对象
var count = 0; //哨兵变量
var results = {}; //存储每个接口返回的结果的变量
var done = function (key, value) {
results[key] = value;
count++;
if (count === 2) {
// 当门店和团购id都获取到时,执行4。
}
}
emitter.on("done", done);
使用openid获取门店
当获取成功时执行触发done事件:emitter.emit("done", "store", data);
使用openid获取团购id
当获取成功时执行触发done事件:emitter.emit("done", "bulkId", data);
这种其实就是上一个例子的一种事件发布/订阅模式的写法罢了。不过真正在实际用途中,还是应该要封装起来再用的,不然就不怎么舒服了。
比如在附件中的event.js中的all方法可以这样使用:
event.all("store", "bulkId", function (store, bulkId) {
// TODO
});
这个all其实就是对上面代码的一种封装。只有当"store", "bulkId"这两个事件全部都触发一次时,all的第二个参数的处理函数才会被执行,并且,他的参数就是这两个事件触发时传递的参数。
还有一个和all不同的tail方法,他的区别在于all方法触发时,处理函数只会执行一次,而tail的方法如果触发了一次,监听的"store", "bulkId"中任一一个事件再次触发时,都会重新触发tail的处理函数,并且传递的参数是使用最新的参数。
还有其他方法:after用于注册,当一个事件触发固定次数时,才会执行给after所添加的回调函数。
以上就是事件发布/订阅模式针对嵌套回调和多异步协同这两个异步编程的典型问题所做出的改善。不过,我们不妨也来看看他对于异步编程的异常处理,采取了一种怎样的方式。
3.事件发布订阅的异常处理
异步方法中,异常处理还是占用了一定的精力的。回调函数的异常处理一般无非就是如微信小程序接口中的,传入一个成功时的回调,和一个异常时的回调函数。或者如node中的,只传入一个回调函数,第一个参数为err,如果有异常则err为异常对象,如果没有异常,err为undefined。但是他们其实针对的异常捕获都是针对于接口的调用时的异常捕获,接口调用的成功或者失败,而如果是我们写的回调函数中抛出的异常,那他是无法捕获到的,这一点要注意了。
而事件发布/订阅模式的异常处理通常为:
每一个事件对象中应该有一个error事件,我们给这个error事件添加处理函数,当发生异常时,应该触发这个error异常事件,并把错误对象传递给error事件的处理函数(所有在这个事件对象中的事件,发生异常都触发这个事件对象的error事件,不是每个单独的事件都有error事件,而是这个事件对象的所有事件共享error事件,不过你可以通过传递给error处理函数中的参数添加一个事件类型名称来标识是哪个事件触发的错误,并且,最好是根据这个来,这样,可以为不同事件处理不同的错误)。
一个异常处理的例子:如果读取test.txt文件时发生错误,那么会触发readFileEvent事件对象的error事件,进行错误处理。
fs.readFile("./test.txt",(err,data) => {
if(err) { //发生错误,触发error事件
readFileEvent.emit("error",err);
return;
}
//处理文件内容
})
// 监听error事件,进行错误处理
readFileEvent.on("error",(err) => {
// 这里进行错误处理
})
其实在使用时,可以进行一个代码约定,约定:当发生错误时,再传递一个发生错误的标示符,那么只有当某个标示符匹配时,才执行相应的处理函数,比如:
fs.readFile("./test.txt",(err,data) => {
if(err) {
readFileEvent.emit("error",err,"testError")
return;
}
//处理文件内容
})
fs.readFile("./test2.txt",(err,data) => {
if(err) {
readFileEvent.emit("error",err,"test2Error")
return;
}
//处理文件内容
})
// 针对读取文件test时发生的错误处理
readFileEvent.on("error",(err,errorType) => {
if(errorType == "testError") {
// 错误:testError处理
}
})
// 针对读取文件test2时发生的错误处理
readFileEvent.on("error",(err,errorType) => {
if(errorType == "test2Error") {
// 错误:test2Error处理
}
})
// 当不传递错误类型时的错误处理,即,通用的错误处理
readFileEvent.on("error",(err,errorType) => {
if(errorType === undefined) {
// 错误处理
}
})
以上大致就是事件发布/订阅模式的内容。其实事件的发布订阅模式相对于普通的异步回调函数来说,大致可以改善异步编程的嵌套问题,多异步协同以及还可以利用once方法解决队列的雪崩问题。
不过,他其实还是有一些缺点存在的,比如:在解决回调嵌套问题时,其实每一个异步请求都需要事先设定好,一步一步的按照异步接口的顺序进行事件的监听以及触发,在一开始接触时对于这种代码风格的改变还是有点不适应的,一旦再加上错误处理,而且想要精细的根据不同错误设置不同的处理方式,那就需要细心一点才能够处理好代码的关系了。
所以,事件发布/订阅模式还有许多值得研究的地方,对于如何将这种模式能够运用实际代码中,根据不同的需要使用他去解决实际的问题才是我们学习了解事件订阅/发布的根本目的。
附加上我自己对事件订阅/发布模式的一种实现,不过只是很基础的,并且仅仅用作学习练习之用(好像csnd资源不能设置免积分下载啊,):https://download.csdn.net/download/qq_33024515/10598482
但是在生产环境中,应该使用那些成熟的事件库类,比如:
eventproxy github地址:https://github.com/JacksonTian/eventproxy
EventEmitter github地址:https://github.com/Olical/EventEmitter
下一篇我将会着重介绍由社区提出制定的Promise/Deferred模式,看一下由社区的力量所制定出来的规范,如何靠他来写出优雅的异步编程代码。
敬请期待!