异步编程
一旦开始接触异步I/O,我们肯定需要面对的是异步编程,node比起其他的异步编程看起来要简单得多,因为它是单线程的,在js层面而言, 无需过多地去关注多线程的管理,多线程之间如何配合等等复杂的动作,仅仅简单通过回调的方式来进行管理即可。 但随着越来越多的异步I/O被调用,以及一个异步I/O依赖与另外的一个异步I/O,不同的异步调用之间互相依赖,这个时候,就需要 很好地来管理我们所发起的所有的异步I/O调用以及他们的回调,毕竟代码是写给人看得,写出难以阅读或者无法维护的代码,其风险成本是巨大的。 因此,我们急需一个内部的自定义管控异步I/O的机制,来统一接管关于异步I/O动作, :thinking: 怎样才能做到这一点呢? 采用js中的函数式编程,利用最基本的函数来实现这个目的!!
函数式编程(Functional Programing)
居然一个简单的函数,就能够达到这个做到对异步I/O的管控,这未免有点夸张了吖?然而实时就确实是这样子的。 那么,什么是函数式编程?它与我们平时编写的函数有什么区别?为什么函数式编程能够解决异步I/O的管控问题?如何来使用函数式编程?
首先,函数是javascript中的一等公民
,也就是函数与其他的js成员一样,可以作为执行动作、变量、函数参数、函数返回值的角色来使用,原本静态coding的方式,一下子变成灵活性很强的编程模式了,
因为将函数作为参数或者函数结果返回值,意味着所有的逻辑都是可变的,灵活的,因此可以衍生出灵活性更高、更具可扩展性的函数。
什么是函数式编程
函数式编程是一种方法论,指导来如何编写程序,它属于结构化编程模式的一种,可以理解为与面向对象、面向过程编程平级的另外一种编程思维,其主要思想就是将运算过程尽量写成一系列具有抽象意义的函数调用。 函数式编程就像是pipe编程,从管道的一头输入,在另外一头输出,而且相同参数必定输出相同的结果
函数式编程的特性
- 函数必须是纯的,只要有相同的参数,输出的结果必须是一样的;
- 确定抽象,并为其构建函数;
- 利用已有的函数来构建更为复杂的抽象;
- 通过将现有的函数传给其他的函数来构建更加复杂的抽象;
- 利用与数据抽象相结合来实现更加强大的编程模式;
- 合成与柯里化
关于函数式编程,个人觉得应该得整一个完整的学习教程,才能够从根本上来理解这样子设计与编写的一个原理,真正意义上了解函数式编程的一个抽象机制,并利用这种抽象机制来服务于自己, 编写出更加灵活的程序。
异步编程的难点
异步编程一般适用于前端方面的coding,但是,将异步编程搬到的后台的话,其编程范式与原本的面向对象以及面向过程编程相去甚远,我们必须🉐️直面这些难点,才能更好的来理解node异步编程!!
异常处理
一般地,我们编写的程序,如果在执行的过程中可能出错,可以使用
try...catch
的方式来捕获异常 对于node异步编程而言,关于异步编程主要分为两个阶段:发起异步调用与异步回调执行,这个时候如果想要捕获整个异步调用的过程的话,则需要将两个阶段同时去捕获到,简单的理解,就是写两个try...catch
, 将两个阶段分别都包裹起来,而node异步编程它也给我们提供了一个业界统一的规范:异常优先返回,什么意思呢?就是所有的异步回调执行函数,第一个参数都是error对象,如果在异步I/O的过程中报错了, error对象将返回为非 ,在接收到这个回调的时候,仅需要简单判断这个error对象即可。这也给我们在平时的日常编码事项业务的过程中,需要将业务异步I/O的过程也遵循这个规则:
- 必须执行调用者传入的回调函数(触发调用者的on监听动作);
- 正确传递异常供调用者判断(也就是error优先的原则)
函数嵌套过深
假如 这样子的一个场景,一个异步I/O的发起,依赖于另外一个异步I/O,假如这种依赖关系一旦多的话,就会陷入这个回调地狱,可能完成一个动作,需要5层回调或者是更深的回调动作。
fs.readFile('xxx1.txt', (err, data) => {
fs.readFIle('xxx2.txt', (err, data) => {
// 完成两个文件的读取,并同时输出
});
});
阻塞代码
在以前编写java代码的时候,我们能够很经常性地使用一个
Thread.sleep()
的方式,来阻塞当前的一个线程,然而在node中,则没有这玩意,那么 我们应该怎样做,才能够达到跟Thread.sleep()
一致的效果呢? 可以先看 一个例子:
var start = new Date();
while(new Date() - start < 1000){
// 阻塞的代码,而且还浪费了CPU执行时间
}
// 1秒后执行的代码
从 我们可以知道,while这里就是直接浪费了js主线程的一个执行时间,完全 必要这样子来整,搞得代码质量不高。
那么我们应该怎样做,才能实现类似于sleep
的效果呢?关键在于根据node异步I/O的工作流程为基础,来实现同样的效果,比如,可以使用setTimeout
,
那么 的 代码可以调整为 的方式:
setTimeout(() => {
// 1秒后执行的代码
}, 1000);
唯有理解了关于node的基本异步流程,才能够理解为什么要直接使用setTimeout
来达到同样的目的效果!!
多线程编程
node中的js是一个单线程执行的机制,它并没有像java那些能够利用的多核CPU的一个能力,从来就只有一个线程在执行,也不像浏览器中的js那样,具有UI线程, 那么,假如需要使用比较耗时的计算逻辑的话,如何使用这个node来满足这个场景呢? 通过参考浏览器中的
web worker
,node提供了一个child_process
包或者更深层次的cluster
,来帮助我们实现将耗时较多的计算,交给"子线程"去处理, 并在"子线程"执行的过程中,将执行过程与js主线程进行中间以及结果状态值的一个通讯,切忌直接占据js主线程的执行时间
node异步编程的解决方案
从 整理并学习了关于node异步编程的难点,相应地,这边提供一系列解决方案,其实就是一种编程范式思维模式,更好地来组织自己编写的代码。
- 发布订阅模式;
- Promise/Deferred模式
- 流程控制库
发布订阅模式
事件监听器模式是一种广泛用于异步编程的模式,是回调函数的事件化,又称为"发布订阅模式"。 而node中提供的大部分模块都基于其内部模块
events
来实现的事件异步回调机制,如下所示:
//订阅
var emitter.on('event', (error, message) => {});
//发布
emitter.emit('event', null, 'I am a message');
继承events模块
发布订阅模式通常可用来进行业务的结耦,事件发布者无需关心订阅者是如何实现逻辑的,也不用在意 多少个订阅者在监听着,只需要将事件给丢出去就可以了。
在一些实际的业务场景中,可以通过事件发布订阅模式对业务进行组装,将不变的或者复杂逻辑封装在组件内部,对外暴露api(这里是通过事件来暴露),完全面向组件的接口进行编程, 这种场景的设计非常重要,也非常的灵活,可以编写出灵活多变的程序,假如再结合这个函数式编程的话,那么则可以达到更高灵活度的代码逻辑,将不变的固定,变的逻辑给抽象出来,只要保持输出一致即可!!!
比如 这种场景:
登录并获取用户信息! 针对 这种场景,可以设计一个用户对象,该对象对外提供了一个login方法,并接收回调动作,类似的伪代码如下所示:
import { EventEmitter } from 'node:events';
// 通过继承于事件对象,使其具有了事件对象的基础用户on/emit
export default class User extends EventEmitter{
// 对外提供的一登录动作api
login(callback){
this.on('login', (error, result => {
callback && callback(error, result);
}));
db.query('sql', (error, result) => {
this.emit('login', result);
});
}
}
// 以下是对应的调用方式
let user = new User();
user.login((error, result) => {
if(error){
// 公共的异常
}else{
// 登录成功
}
});
通过 这种方式,可以很方便地创建一个具有业务能力的用户对象,并对外提供的公共操作的api,通过监听的方式,来进行的一个个 的回调,采用与node模块生态一致的异步I/O方式来进行组合实际的业务代码!!!
利用事件队列解决雪崩问题
所谓的雪崩问题,就是在高访问量、大并发量的情况下缓存失效的场景,此时大量的请求同时涌入数据库I/O中,数据库无法同时承受这么大的数据 请求查询,进而导致整体网站的相应速度。 针对 这种情况,可以借助于
events
中的once()
方法,通过它添加的监听器只能执行一次,而且执行完毕后,将自动移除 关联的事件,这通常对于过滤一些重复性的请求很有用!
import { EventEmitter } from 'node:events';
const proxy = new EventEmitter();
let status = 'ready';
let select = function(callback) {
proxy.once('selected', callback);
if('ready' === status){
status = 'pending';
db.select('sql', function(result) {
proxy.emit('selected', result);
status = 'ready';
})
}
};
select(res => {});
这里我们使用了once方法,将所有的请求的回调都雅茹事件队列中,利用其执行一次就会将监听器移除的特点,保证每一个回调都只会 被执行一次,而对于相同的sql,则保证在统一一个查询动作中执行一次即可,然后将结果通知的所有的监听器,实现查询一次,结果共享的目的! 这里 一个点需要注意的是:假如设置的监听器过多,可能被node环境所警告,这是需要调用setMaxListeners(0)来移除掉警告, 或者设置更大的阀值!!
多异步之间的协作方案
在实际的场景中,对于客户端发起的一个请求动作,可能需要多个异步I/O的同时满足后才能够响应,那么这里可以使用
Promise
机制, 借助于Promise所提供的api方法,来实现对一个事件多个异步I/O操作的响应处理,比如 以下的一个场景:
获取一个文件的内容并使用模版文件渲染后返回!
const fs = require('node:fs');
Promise.all([
new Promise(resolve => {
fs.readFile('template.html', (error, result) => {
resolve(result);
})
}),
new Promise(resolve => {
db.query('sql', (error, result) => {
resolve(result);
})
})
]).then(result => {
const fileContent = result[0];
const dbContent = result[1];
});
通过Promise
可以解决关于这个闭包的回调地狱问题,将异步的操作,编程链式的操作!!
Promise/Deferred模式
理解了现有的
Promise
的原理,也就理解这个这种编程模式了
流程控制库
通过开源的一些技术🐂人所提供的三方异步控制的框架,也可以达到灵活地来控制异步编程!!
尾触发与next
node中关于尾触发与next的机制,最常用的一个模块就是:connect中间件
const connect = require('./connect.js');
const app = connect.createServer();
app.use(connect.staticCache());
app.use(connect.static(__dirname + '/public'));
app.use(connect.cookieParser());
app.listen(3000);
代码中创建了一个网络请求对象,并对其设置一系列的中间件(采用app.use()
方法来注册),而这里的中间件就是利用的尾触发的机制,每一个中间件其实就是一个参数雷同的函数,其格式如下:
function middleware(req, res, next){}
每个中间件都传递请求对象,响应对象,以及指向下一个中间件函数,形成如下的一个链式引用的效果:
:star_of_david: 中间件机制使得在处理网络请求的时候,可以像面向切面编程一样进行过滤、验证、日志等功能,而且不与具体的业务逻辑耦合,比如之前学习的关于Vue.use
,也是其中的一种方式,其原理都是
通过函数方法的调用,将目标函数的一系列动作捆绑
到目标对象上!!
// connect.js
const http = require('node:http');
function createServer(){
function app(req, res) {
app.handle(req, res);
}
app.stack = []; //存储着可用的中间件队列
// 这里将中间件放到队列中
app.use = function(route, fn) {
this.stack.push({
route,
handle: fn
});
}
app.listen = function(p) {
const server = http.createServer(this);
return server.listen.apply(server, arguments);
}
app.handle = function(req, res, next) {
// 此处进行一系列中间件的逻辑操作
next();
}
function next(error) {
layer = app.stack[index ++];
layer.handle(req, res, next);
}
return app;
}
这里创建了一个自定义网络请求处理对象函数app,然后在app中维护着一个stack堆栈列表对象,采用提供的use方法,将中间件的操作,存储到队列中,然后在处理的时候,通过递归调用自己的next方法,来遍历stack中的中间件, 实现了将业务进行比较明显的划分,这也就说明了为什么在日志监控、公共的验证、过滤等操作中比较常用到了!!!
从 我们可以模仿它的一个编程思维模式,来搭建自己的一套框架,利用中间件的思维,来编写自己的一套网络请求框架,不单单是客户端的,就连服务端也能够完全胜任于这样子的工作!!!
async
async
是最知名的流程控制模块,其提供了多个不同的api,可以帮助我们来维护日常项目过程中的异步编程,具体查看其官网链接。 关于async
模块的学习,这里不详细讲解,后续这边单独整一个学习知识点文档来详细学习学习!!!
异步并发控制
node是单线程的,而已只需要通过简单的异步调用即可完成一个异步执行的动作,但是如果我们随意地添加这个任意数量的异步调用的时候,将会导致 过多的异步调用,而且每一个异步I/O之间本来就是彼此阻塞的, 异步I/O的越多则代表会影响着宿主机器的CPU被消耗执行,因此,在尽量高效地压榨底层的I/O的同时,也需要预留一定的空间出来。 针对这种情况,需要按照一定的规范来设计整体的机制,确保程序的正常执行:
- 通过一个队列来控制并发量;
- 如果当前活跃的异步调用量小于限定值,则从队列中取出执行;
- 如果活跃调用达到限定值,则将调用暂存在队列中;
- 4、每个异步调用时,从队列中取出新的异步调用来执行!