共计 20718 个字符,预计需要花费 52 分钟才能阅读完成。
一、Node 根底概念
1.1 Node 是什么
Node.js 是一个开源与跨平台的 JavaScript 运行时环境。在浏览器外运行 V8 JavaScript 引擎(Google Chrome 的内核),利用事件驱动、非阻塞和异步输入输出模型等技术进步性能。咱们能够了解为:Node.js 就是一个服务器端的、非阻塞式 I / O 的、事件驱动的 JavaScript 运行环境。
了解 Node,有几个根底的概念:非阻塞异步和事件驱动。
- 非阻塞异步: Nodejs 采纳了非阻塞型 I / O 机制,在做 I / O 操作的时候不会造成任何的阻塞,当实现之后,以工夫的模式告诉执行操作。例如,在执行了拜访数据库的代码之后,将立刻转而执行其前面的代码,把数据库返回后果的解决代码放在回调函数中,从而进步了程序的执行效率。
- 事件驱动: 事件驱动就是当进来一个新的申请的时,申请将会被压入一个事件队列中,而后通过一个循环来检测队列中的事件状态变动,如果检测到有状态变动的事件,那么就执行该事件对应的解决代码,个别都是回调函数。比方,读取一个文件,文件读取结束后,就会触发对应的状态,而后通过对应的回调函数来进行解决。
1.2 Node 的利用场景及存在的毛病
1.2.1 优缺点
Node.js 适宜用于 I / O 密集型利用,值的是利用在运行极限时,CPU 占用率依然比拟低,大部分工夫是在做 I/ O 硬盘内存读写操作。毛病如下:
- 不适宜 CPU 密集型利用
- 只反对单核 CPU,不能充分利用 CPU
- 可靠性低,一旦代码某个环节解体,整个零碎都解体
对于第三点,罕用的解决方案是,应用 Nnigx 反向代理,开多个过程绑定多个端口,或者开多个过程监听同一个端口。
1.2.1 利用场景
在相熟了 Nodejs 的长处和弊病后,咱们能够看到它适宜以下的利用场景:
- 长于 I /O,不长于计算。因为 Nodejs 是一个单线程,如果计算(同步)太多,则会阻塞这个线程。
- 大量并发的 I /O,应用程序外部并不需要进行非常复杂的解决。
- 与 WeSocket 配合,开发长连贯的实时交互应用程序。
具体的应用场景如下:
- 用户表单收集零碎、后盾管理系统、实时交互零碎、考试零碎、联网软件、高并发量的 web 应用程序。
- 基于 web、canvas 等多人联网游戏。
- 基于 web 的多人实时聊天客户端、聊天室、图文直播。
- 单页面浏览器应用程序。
- 操作数据库、为前端和挪动端提供基于 json 的 API。
二、Node 全副对象
在浏览器 JavaScript 中,window 是全局对象,而 Nodejs 中的全局对象则是 global。
在 NodeJS 里,是不可能在最外层定义一个变量,因为所有的用户代码都是以后模块的,只在以后模块里可用,但能够通过 exports 对象的应用将其传递给模块内部。所以,在 NodeJS 中,用 var 申明的变量并不属于全局的变量,只在以后模块失效。像上述的 global 全局对象则在全局作用域中,任何全局变量、函数、对象都是该对象的一个属性值。
2.1 常见全局对象
Node 常见的全局对象有如下一些:
- Class:Buffer
- process
- console
- clearInterval、setInterval
- clearTimeout、setTimeout
- global
Class:Buffer
Class:Buffer 能够用来解决二进制以及非 Unicode 编码的数据,在 Buffer 类实例化中存储了原始数据。Buffer 相似于一个整数数组,在 V8 堆原始存储空间给它调配了内存,一旦创立了 Buffer 实例,则无奈扭转大小。
process
process 示意过程对象,提供无关以后过程的信息和管制。包含在执行 node 程序的过程中,如果须要传递参数,咱们想要获取这个参数须要在 process 内置对象中。比方,咱们有如下一个文件:
process.argv.forEach((val, index) => {console.log(`${index}: ${val}`);
});
当咱们须要启动一个过程时,能够应用上面的命令:
node index.js 参数...
console
console 次要用来打印 stdout 和 stderr,最罕用的比方日志输入:console.log
。清空控制台的命令为:console.clear
。如果须要打印函数的调用栈,能够应用命令console.trace
。
clearInterval、setInterval
setInterval 用于设置定时器,语法格局如下:
setInterval(callback, delay[, ...args])
clearInterval 则用于革除定时器,callback 每 delay 毫秒反复执行一次。
clearTimeout、setTimeout
和 setInterval 一样,setTimeout 次要用于设置延时器,而 clearTimeout 则用于革除设置的延时器。
global
global 是一个全局命名空间对象,后面讲到的 process、console、setTimeout 等能够放到 global 中,例如:
console.log(process === global.process) // 输入 true
2.2 模块中的全局对象
除了零碎提供的全局对象外,还有一些只是在模块中呈现,看起来像全局变量,如下所示:
- __dirname
- __filename
- exports
- module
- require
__dirname
__dirname 次要用于获取以后文件所在的门路,不包含前面的文件名。比方,在/Users/mjr
中运行 node example.js
,打印后果如下:
console.log(__dirname); // 打印: /Users/mjr
__filename
__filename 用于获取以后文件所在的门路和文件名称,包含前面的文件名称。比方,在/Users/mjr
中运行 node example.js
,打印的后果如下:
console.log(__filename);// 打印: /Users/mjr/example.js
exports
module.exports 用于导出一个指定模块所的内容,而后也能够应用 require() 拜访外面的内容。
exports.name = name;exports.age = age;
exports.sayHello = sayHello;
require
require 次要用于引入模块、JSON、或本地文件,能够从 node_modules 引入模块。能够应用相对路径引入本地模块或 JSON 文件,门路会依据__dirname 定义的目录名或当前工作目录进行解决。
三、谈谈对 process 的了解
3.1 基本概念
咱们晓得,过程计算机系统进行资源分配和调度的根本单位,是操作系统构造的根底,是线程的容器。当咱们启动一个 js 文件,理论就是开启了一个服务过程,每个过程都领有本人的独立空间地址、数据栈,像另一个过程无法访问以后过程的变量、数据结构,只有数据通信后,过程之间才能够数据共享。
process 对象是 Node 的一个全局变量,提供了无关以后 Node.js 过程的信息并对其进行管制。
因为 JavaScript 是一个单线程语言,所以通过 node xxx 启动一个文件后,只有一条主线程。
3.2 罕用属性和办法
process 的常见属性如下:
- process.env:环境变量,例如通过 `process.env.NODE_ENV 获取不同环境我的项目配置信息
- process.nextTick:这个在谈及 EventLoop 时常常为会提到
- process.pid:获取以后过程 id
- process.ppid:以后过程对应的父过程
- process.cwd():获取以后过程工作目录
- process.platform:获取以后过程运行的操作系统平台
- process.uptime():以后过程已运行工夫,例如:pm2 守护过程的 uptime 值
过程事件:process.on(‘uncaughtException’,cb) 捕捉异样信息、process.on(‘exit’,cb)过程推出监听 - 三个规范流:process.stdout 规范输入、process.stdin 规范输出、process.stderr 规范谬误输入
- process.title:用于指定过程名称,有的时候须要给过程指定一个名称
四、谈谈你对 fs 模块的了解
4.1 fs 是什么
fs(filesystem)是文件系统模块,该模块提供本地文件的读写能力,基本上是 POSIX 文件操作命令的简略包装。能够说,所有与文件的操作都是通过 fs 外围模块来实现的。
应用之前,须要先导入 fs 模块,如下:
const fs = require('fs');
4.2 文件基础知识
在计算机中,有对于文件的基础知识有如下一些:
- 权限位 mode
- 标识位 flag
- 文件形容为 fd
4.2.1 权限位 mode
针对文件所有者、文件所属组、其余用户进行权限调配,其中类型又分成读、写和执行,具备权限位 4、2、1,不具备权限为 0。如在 linux 查看文件权限位的命令如下:
drwxr-xr-x 1 PandaShen 197121 0 Jun 28 14:41 core
-rw-r--r-- 1 PandaShen 197121 293 Jun 23 17:44 index.md
在结尾前十位中,d 为文件夹,- 为文件,后九位就代表以后用户、用户所属组和其余用户的权限位,按每三位划分,别离代表读(r)、写(w)和执行(x),- 代表没有以后位对应的权限。
4.2.2 标识位
标识位代表着对文件的操作形式,如可读、可写、即可读又可写等等,如下表所示:
4.2.3 文件形容 fd
操作系统会为每个关上的文件调配一个名为文件描述符的数值标识,文件操作应用这些文件描述符来辨认与追踪每个特定的文件。
Window 零碎应用了一个不同但概念相似的机制来追踪资源,为不便用户,NodeJS 形象了不同操作系统间的差别,为所有关上的文件调配了数值的文件描述符。
在 NodeJS 中,每操作一个文件,文件描述符是递增的,文件描述符个别从 3 开始,因为后面有 0、1、2 三个比拟非凡的描述符,别离代表 process.stdin(规范输出)、process.stdout(规范输入)和 process.stderr(谬误输入)。
4.3 罕用办法
因为 fs 模块次要是操作文件的,所以常见的文件操作方法有如下一些:
- 文件读取
- 文件写入
- 文件追加写入
- 文件拷贝
- 创立目录
4.3.1 文件读取
罕用的文件读取有 readFileSync 和 readFile 两个办法。其中,readFileSync 示意同步读取,如下:
const fs = require("fs");
let buf = fs.readFileSync("1.txt");
let data = fs.readFileSync("1.txt", "utf8");
console.log(buf); // <Buffer 48 65 6c 6c 6f>
console.log(data); // Hello
- 第一个参数为读取文件的门路或文件描述符。
- 第二个参数为 options,默认值为 null,其中有 encoding(编码,默认为 null)和 flag(标识位,默认为 r),也可间接传入 encoding。
readFile 为异步读取办法,readFile 与 readFileSync 的前两个参数雷同,最初一个参数为回调函数,函数内有两个参数 err(谬误)和 data(数据),该办法没有返回值,回调函数在读取文件胜利后执行。
const fs = require("fs");
fs.readFile("1.txt", "utf8", (err, data) => {if(!err){console.log(data); // Hello
}
});
4.3.2 文件写入
文件写入须要用到 writeFileSync 和 writeFile 两个办法。writeFileSync 示意同步写入,如下所示。
const fs = require("fs");
fs.writeFileSync("2.txt", "Hello world");
let data = fs.readFileSync("2.txt", "utf8");
console.log(data); // Hello world
- 第一个参数为写入文件的门路或文件描述符。
- 第二个参数为写入的数据,类型为 String 或 Buffer。
- 第三个参数为 options,默认值为 null,其中有 encoding(编码,默认为 utf8)、flag(标识位,默认为 w)和 mode(权限位,默认为 0o666),也可间接传入 encoding。
writeFile 示意异步写入,writeFile 与 writeFileSync 的前三个参数雷同,最初一个参数为回调函数,函数内有一个参数 err(谬误),回调函数在文件写入数据胜利后执行。
const fs = require("fs");
fs.writeFile("2.txt", "Hello world", err => {if (!err) {fs.readFile("2.txt", "utf8", (err, data) => {console.log(data); // Hello world
});
}
});
4.3.3 文件追加写入
文件追加写入须要用到 appendFileSync 和 appendFile 两个办法。appendFileSync 示意同步写入,如下。
const fs = require("fs");
fs.appendFileSync("3.txt", "world");
let data = fs.readFileSync("3.txt", "utf8");
- 第一个参数为写入文件的门路或文件描述符。
- 第二个参数为写入的数据,类型为 String 或 Buffer。
- 第三个参数为 options,默认值为 null,其中有 encoding(编码,默认为 utf8)、flag(标识位,默认为 a)和 mode(权限位,默认为 0o666),也可间接传入 encoding。
appendFile 示意异步追加写入,办法 appendFile 与 appendFileSync 的前三个参数雷同,最初一个参数为回调函数,函数内有一个参数 err(谬误),回调函数在文件追加写入数据胜利后执行,如下所示。
const fs = require("fs");
fs.appendFile("3.txt", "world", err => {if (!err) {fs.readFile("3.txt", "utf8", (err, data) => {console.log(data); // Hello world
});
}
});
4.3.4 创立目录
创立目录次要有 mkdirSync 和 mkdir 两个办法。其中,mkdirSync 为同步创立,参数为一个目录的门路,没有返回值,在创立目录的过程中,必须保障传入的门路后面的文件目录都存在,否则会抛出异样。
// 假如曾经有了 a 文件夹和 a 下的 b 文件夹
fs.mkdirSync("a/b/c")
mkdir 为异步创立,第二个参数为回调函数,如下所示。
fs.mkdir("a/b/c", err => {if (!err) console.log("创立胜利");
});
五、谈谈你对 Stream 的了解
5.1 基本概念
流(Stream)是一种数据传输的伎俩,是一种端到端信息替换的形式,而且是有程序的,是逐块读取数据、解决内容,用于程序读取输出或写入输入。在 Node 中,Stream 分成三局部:source、dest、pipe。
其中,在 source 和 dest 之间有一个连贯的管道 pipe,它的根本语法是 source.pipe(dest),source 和 dest 就是通过 pipe 连贯,让数据从 source 流向 dest,如下图所示:
5.2 流的分类
在 Node,流能够分成四个品种:
- 可写流:可写入数据的流,例如 fs.createWriteStream() 能够应用流将数据写入文件。
- 可读流:可读取数据的流,例如 fs.createReadStream() 能够从文件读取内容。
- 双工流:既可读又可写的流,例如 net.Socket。
- 转换流:能够在数据写入和读取时批改或转换数据的流。例如,在文件压缩操作中,能够向文件写入压缩数据,并从文件中读取解压数据。
在 Node 的 HTTP 服务器模块中,request 是可读流,response 是可写流。对于 fs 模块来说,能同时解决可读和可写文件流可读流和可写流都是单向的,比拟容易了解。而 Socket 是双向的,可读可写。
5.2.1 双工流
在 Node 中,比拟的常见的全双工通信就是 websocket,因为发送方和接受方都是各自独立的办法,发送和接管都没有任何关系。
根本的应用办法如下:
const {Duplex} = require('stream');
const myDuplex = new Duplex({read(size) {// ...},
write(chunk, encoding, callback) {// ...}
});
5.3 应用场景
流的常见应用场景有:
- get 申请返回文件给客户端
- 文件操作
- 一些打包工具的底层操作
5.3.1 网络申请
流一个常见的应用场景就是网络申请,比方应用 stream 流返回文件,res 也是一个 stream 对象,通过 pipe 管道将文件数据返回。
const server = http.createServer(function (req, res) {
const method = req.method;
// get 申请
if (method === 'GET') {const fileName = path.resolve(__dirname, 'data.txt');
let stream = fs.createReadStream(fileName);
stream.pipe(res);
}
});
server.listen(8080);
5.3.2 文件操作
文件的读取也是流操作,创立一个可读数据流 readStream,一个可写数据流 writeStream,通过 pipe 管道把数据流转过来。
const fs = require('fs')
const path = require('path')
// 两个文件名
const fileName1 = path.resolve(__dirname, 'data.txt')
const fileName2 = path.resolve(__dirname, 'data-bak.txt')
// 读取文件的 stream 对象
const readStream = fs.createReadStream(fileName1)
// 写入文件的 stream 对象
const writeStream = fs.createWriteStream(fileName2)
// 通过 pipe 执行拷贝,数据流转
readStream.pipe(writeStream)
// 数据读取实现监听,即拷贝实现
readStream.on('end', function () {console.log('拷贝实现')
})
另外,一些打包工具,Webpack 和 Vite 等都波及很多流的操作。
六、事件循环机制
6.1 什么是浏览器事件循环
Node.js 在主线程里保护了一个事件队列,当接到申请后,就将该申请作为一个事件放入这个队列中,而后持续接管其余申请。当主线程闲暇时(没有申请接入时),就开始循环事件队列,查看队列中是否有要解决的事件,这时要分两种状况:如果是非 I/O 工作,就亲自解决,并通过回调函数返回到下层调用;如果是 I/O 工作,就从 线程池 中拿出一个线程来解决这个事件,并指定回调函数,而后持续循环队列中的其余事件。
当线程中的 I/O 工作实现当前,就执行指定的回调函数,并把这个实现的事件放到事件队列的尾部,期待事件循环,当主线程再次循环到该事件时,就间接解决并返回给下层调用。这个过程就叫 事件循环 (Event Loop),其运行原理如下图所示。
从左到右,从上到下,Node.js 被分为了四层,别离是 应用层、V8 引擎层、Node API 层 和 LIBUV 层。
- 应用层:即 JavaScript 交互层,常见的就是 Node.js 的模块,比方 http,fs
- V8 引擎层:即利用 V8 引擎来解析 JavaScript 语法,进而和上层 API 交互
- Node API 层:为下层模块提供零碎调用,个别是由 C 语言来实现,和操作系统进行交互。
- LIBUV 层:是跨平台的底层封装,实现了 事件循环、文件操作等,是 Node.js 实现异步的外围。
在 Node 中,咱们所说的事件循环是基于 libuv 实现的,libuv 是一个多平台的专一于异步 IO 的库。上图的 EVENT_QUEUE 给人看起来只有一个队列,但事实上 EventLoop 存在 6 个阶段,每个阶段都有对应的一个先进先出的回调队列。
6.2 事件循环的六个阶段
事件循环一共能够分成了六个阶段,如下图所示。
- timers 阶段:此阶段次要执行 timer(setTimeout、setInterval)的回调。
- I/ O 事件回调阶段(I/O callbacks):执行提早到下一个循环迭代的 I/O 回调,即上一轮循环中未被执行的一些 I / O 回调。
- 闲置阶段(idle、prepare):仅零碎外部应用。
- 轮询阶段(poll):检索新的 I/O 事件; 执行与 I/O 相干的回调(简直所有状况下,除了敞开的回调函数,那些由计时器和 setImmediate() 调度的之外),其余状况 node 将在适当的时候在此阻塞。
- 查看阶段(check):setImmediate() 回调函数在这里执行
- 敞开事件回调阶段(close callback):一些敞开的回调函数,如:socket.on(‘close’, …)
每个阶段对应一个队列,当事件循环进入某个阶段时, 将会在该阶段内执行回调,直到队列耗尽或者回调的最大数量已执行, 那么将进入下一个解决阶段,如下图所示。
七、EventEmitter
7.1 基本概念
前文说过,Node 采纳了事件驱动机制,而 EventEmitter 就是 Node 实现事件驱动的根底。在 EventEmitter 的根底上,Node 简直所有的模块都继承了这个类,这些模块领有了本人的事件,能够绑定、触发监听器,实现了异步操作。
Node.js 外面的许多对象都会散发事件,比方 fs.readStream 对象会在文件被关上的时候触发一个事件,这些产生事件的对象都是 events.EventEmitter 的实例,用于将一个或多个函数绑定到命名事件上。
7.2 根本应用
Node 的 events 模块只提供了一个 EventEmitter 类,这个类实现了 Node 异步事件驱动架构的基本模式:观察者模式。
在这种模式中,被观察者 (主体) 保护着一组其余对象派来 (注册) 的观察者,有新的对象对主体感兴趣就注册观察者,不感兴趣就勾销订阅,主体有更新会顺次告诉观察者,应用形式如下。
const EventEmitter = require('events')
class MyEmitter extends EventEmitter {}
const myEmitter = new MyEmitter()
function callback() {console.log('触发了 event 事件!')
}
myEmitter.on('event', callback)
myEmitter.emit('event')
myEmitter.removeListener('event', callback);
在下面的代码中,咱们通过实例对象的 on 办法注册一个名为 event 的事件,通过 emit 办法触发该事件,而 removeListener 用于勾销事件的监听。
除了下面介绍的一些办法外,其余罕用的办法还有如下一些:
- emitter.addListener/on(eventName, listener):增加类型为 eventName 的监听事件到事件数组尾部。
- emitter.prependListener(eventName, listener):增加类型为 eventName 的监听事件到事件数组头部。
- emitter.emit(eventName[, …args]):触发类型为 eventName 的监听事件。
- emitter.removeListener/off(eventName, listener):移除类型为 eventName 的监听事件。
- emitter.once(eventName, listener):增加类型为 eventName 的监听事件,当前只能执行一次并删除。
- emitter.removeAllListeners([eventName]):移除全副类型为 eventName 的监听事件。
7.3 实现原理
EventEmitter 其实是一个构造函数,外部存在一个蕴含所有事件的对象。
class EventEmitter {constructor() {this.events = {};
}
}
其中,events 寄存的监听事件的函数的构造如下:
{"event1": [f1,f2,f3],"event2": [f4,f5],...
}
而后,开始一步步实现实例办法,首先是 emit,第一个参数为事件的类型,第二个参数开始为触发事件函数的参数,实现如下:
emit(type, ...args) {this.events[type].forEach((item) => {Reflect.apply(item, this, args);
});
}
实现了 emit 办法之后,而后顺次实现 on、addListener、prependListener 这三个实例办法,它们都是增加事件监听触发函数的。
on(type, handler) {if (!this.events[type]) {this.events[type] = [];}
this.events[type].push(handler);
}
addListener(type,handler){this.on(type,handler)
}
prependListener(type, handler) {if (!this.events[type]) {this.events[type] = [];}
this.events[type].unshift(handler);
}
移除事件监听,能够应用办法 removeListener/on。
removeListener(type, handler) {if (!this.events[type]) {return;}
this.events[type] = this.events[type].filter(item => item !== handler);
}
off(type,handler){this.removeListener(type,handler)
}
实现 once 办法,再传入事件监听处理函数的时候进行封装,利用闭包的个性保护以后状态,通过 fired 属性值判断事件函数是否执行过。
once(type, handler) {this.on(type, this._onceWrap(type, handler, this));
}
_onceWrap(type, handler, target) {const state = { fired: false, handler, type , target};
const wrapFn = this._onceWrapper.bind(state);
state.wrapFn = wrapFn;
return wrapFn;
}
_onceWrapper(...args) {if (!this.fired) {
this.fired = true;
Reflect.apply(this.handler, this.target, args);
this.target.off(this.type, this.wrapFn);
}
}
上面是实现的测试代码:
class EventEmitter {constructor() {this.events = {};
}
on(type, handler) {if (!this.events[type]) {this.events[type] = [];}
this.events[type].push(handler);
}
addListener(type,handler){this.on(type,handler)
}
prependListener(type, handler) {if (!this.events[type]) {this.events[type] = [];}
this.events[type].unshift(handler);
}
removeListener(type, handler) {if (!this.events[type]) {return;}
this.events[type] = this.events[type].filter(item => item !== handler);
}
off(type,handler){this.removeListener(type,handler)
}
emit(type, ...args) {this.events[type].forEach((item) => {Reflect.apply(item, this, args);
});
}
once(type, handler) {this.on(type, this._onceWrap(type, handler, this));
}
_onceWrap(type, handler, target) {const state = { fired: false, handler, type , target};
const wrapFn = this._onceWrapper.bind(state);
state.wrapFn = wrapFn;
return wrapFn;
}
_onceWrapper(...args) {if (!this.fired) {
this.fired = true;
Reflect.apply(this.handler, this.target, args);
this.target.off(this.type, this.wrapFn);
}
}
}
八、中间件
8.1 基本概念
中间件(Middleware)是介于利用零碎和系统软件之间的一类软件,它应用系统软件所提供的根底服务(性能),连接网络上利用零碎的各个局部或不同的利用,可能达到资源共享、性能共享的目标。
在 Node 中,中间件次要是指封装 http 申请细节解决的办法。例如,在 express、koa 等 web 框架中,中间件的实质为一个回调函数,参数蕴含申请对象、响应对象和执行下一个中间件的函数,架构示意图如下。
通常,在这些中间件函数中,咱们能够执行业务逻辑代码,批改申请和响应对象、返回响应数据等操作。
8.2 koa
Koa 是基于 Node 以后比拟风行的 web 框架,自身反对的性能并不多,性能都能够通过中间件拓展实现。Koa 并没有捆绑任何中间件,而是提供了一套优雅的办法,帮忙开发者疾速而欢快地编写服务端应用程序。
Koa 中间件采纳的是洋葱圈模型,每次执行下一个中间件都传入两个参数:
- ctx:封装了 request 和 response 的变量
- next:进入下一个要执行的中间件的函数
通过后面的介绍,咱们晓得了 Koa 中间件实质上就是一个函数,能够是 async 函数,也能够是一般函数。上面就针对 koa 进行中间件的封装:
// async 函数
app.use(async (ctx, next) => {const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
// 一般函数
app.use((ctx, next) => {const start = Date.now();
return next().then(() => {const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
});
当然,咱们还能够通过中间件封装 http 申请过程中几个罕用的性能:
token 校验
module.exports = (options) => async (ctx, next) {
try {
// 获取 token
const token = ctx.header.authorization
if (token) {
try {
// verify 函数验证 token,并获取用户相干信息
await verify(token)
} catch (err) {console.log(err)
}
}
// 进入下一个中间件
await next()} catch (err) {console.log(err)
}
}
日志模块
const fs = require('fs')
module.exports = (options) => async (ctx, next) => {const startTime = Date.now()
const requestTime = new Date()
await next()
const ms = Date.now() - startTime;
let logout = `${ctx.request.ip} -- ${requestTime} -- ${ctx.method} -- ${ctx.url} -- ${ms}ms`;
// 输入日志文件
fs.appendFileSync('./log.txt', logout + '\n')
}
Koa 存在很多第三方的中间件,如 koa-bodyparser、koa-static 等。
8.3 Koa 中间件
koa-bodyparser
koa-bodyparser 中间件是将咱们的 post 申请和表单提交的查问字符串转换成对象,并挂在 ctx.request.body 上,不便咱们在其余中间件或接口处取值。
// 文件:my-koa-bodyparser.js
const querystring = require("querystring");
module.exports = function bodyParser() {return async (ctx, next) => {await new Promise((resolve, reject) => {
// 存储数据的数组
let dataArr = [];
// 接收数据
ctx.req.on("data", data => dataArr.push(data));
// 整合数据并应用 Promise 胜利
ctx.req.on("end", () => {
// 获取申请数据的类型 json 或表单
let contentType = ctx.get("Content-Type");
// 获取数据 Buffer 格局
let data = Buffer.concat(dataArr).toString();
if (contentType === "application/x-www-form-urlencoded") {
// 如果是表单提交,则将查问字符串转换成对象赋值给 ctx.request.body
ctx.request.body = querystring.parse(data);
} else if (contentType === "applaction/json") {
// 如果是 json,则将字符串格局的对象转换成对象赋值给 ctx.request.body
ctx.request.body = JSON.parse(data);
}
// 执行胜利的回调
resolve();});
});
// 持续向下执行
await next();};
};
koa-static
koa-static 中间件的作用是在服务器接到申请时,帮咱们解决动态文件,比方。
const fs = require("fs");
const path = require("path");
const mime = require("mime");
const {promisify} = require("util");
// 将 stat 和 access 转换成 Promise
const stat = promisify(fs.stat);
const access = promisify(fs.access)
module.exports = function (dir) {return async (ctx, next) => {
// 将拜访的路由解决成绝对路径,这里要应用 join 因为有可能是 /
let realPath = path.join(dir, ctx.path);
try {
// 获取 stat 对象
let statObj = await stat(realPath);
// 如果是文件,则设置文件类型并间接响应内容,否则当作文件夹寻找 index.html
if (statObj.isFile()) {ctx.set("Content-Type", `${mime.getType()};charset=utf8`);
ctx.body = fs.createReadStream(realPath);
} else {let filename = path.join(realPath, "index.html");
// 如果不存在该文件则执行 catch 中的 next 交给其余中间件解决
await access(filename);
// 存在设置文件类型并响应内容
ctx.set("Content-Type", "text/html;charset=utf8");
ctx.body = fs.createReadStream(filename);
}
} catch (e) {await next();
}
}
}
总的来说,在实现中间件时候,单个中间件应该足够简略,职责繁多,中间件的代码编写应该高效,必要的时候通过缓存反复获取数据。
九、如何设计并实现 JWT 鉴权
9.1 JWT 是什么
JWT(JSON Web Token),实质就是一个字符串书写标准,作用是用来在用户和服务器之间传递安全可靠的,如下图。
在目前前后端拆散的开发过程中,应用 token 鉴权机制用于身份验证是最常见的计划,流程如下:
- 服务器当验证用户账号和明码正确的时候,给用户颁发一个令牌,这个令牌作为后续用户拜访一些接口的凭证。
-
后续拜访会依据这个令牌判断用户时候有权限进行拜访。
Token,分成了三局部,头部(Header)、载荷(Payload)、签名(Signature),并以
.
进行拼接。其中头部和载荷都是以 JSON 格局存放数据,只是进行了编码,示意图如下。9.1.1 header
每个 JWT 都会带有头部信息,这里次要申明应用的算法。申明算法的字段名为 alg,同时还有一个 typ 的字段,默认 JWT 即可。以下示例中算法为 HS256:
{"alg": "HS256", "typ": "JWT"}
因为 JWT 是字符串,所以咱们还须要对以上内容进行 Base64 编码,编码后字符串如下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
9.1.2 payload
载荷即音讯体,这里会寄存理论的内容,也就是 Token 的数据申明,例如用户的 id 和 name,默认状况下也会携带令牌的签发工夫 iat,通过还能够设置过期工夫,如下:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
同样进行 Base64 编码后,字符串如下:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
9.1.3 Signature
签名是对头部和载荷内容进行签名,个别状况,设置一个 secretKey,对前两个的后果进行 HMACSHA25 算法,公式如下:
Signature = HMACSHA256(base64Url(header)+.+base64Url(payload),secretKey)
因而,就算后面两局部数据被篡改,只有服务器加密用的密钥没有泄露,失去的签名必定和之前的签名也是不统一的。
9.2 设计实现
通常,Token 的应用分成了两局部:生成 token 和校验 token。
- 生成 token:登录胜利的时候,颁发 token。
- 验证 token:拜访某些资源或者接口时,验证 token。
9.2.1 生成 token
借助第三方库 jsonwebtoken,通过 jsonwebtoken 的 sign 办法生成一个 token。sign 有三个参数:
- 第一个参数指的是 Payload。
- 第二个是秘钥,服务端特有。
- 第三个参数是 option,能够定义 token 过期工夫。
上面是一个前端生成 token 的例子:
const crypto = require("crypto"),
jwt = require("jsonwebtoken");
// TODO: 应用数据库
// 这里应该是用数据库存储,这里只是演示用
let userList = [];
class UserController {
// 用户登录
static async login(ctx) {
const data = ctx.request.body;
if (!data.name || !data.password) {
return ctx.body = {
code: "000002",
message: "参数不非法"
}
}
const result = userList.find(item => item.name === data.name && item.password === crypto.createHash('md5').update(data.password).digest('hex'))
if (result) {
// 生成 token
const token = jwt.sign(
{name: result.name},
"test_token", // secret
{expiresIn: 60 * 60} // 过期工夫:60 * 60 s
);
return ctx.body = {
code: "0",
message: "登录胜利",
data: {token}
};
} else {
return ctx.body = {
code: "000002",
message: "用户名或明码谬误"
};
}
}
}
module.exports = UserController;
在前端接管到 token 后,个别状况会通过 localStorage 进行缓存,而后将 token 放到 HTTP 申请头 Authorization 中,对于 Authorization 的设置,后面须要加上 Bearer,留神前面带有空格,如下。
axios.interceptors.request.use(config => {const token = localStorage.getItem('token');
config.headers.common['Authorization'] = 'Bearer' + token; // 注意这里的 Authorization
return config;
})
9.2.2 校验 token
首先,咱们须要应用 koa-jwt 中间件进行验证,形式比较简单,在路由跳转前校验即可,如下。
app.use(koajwt({secret: 'test_token'}).unless({
// 配置白名单
path: [/\/api\/register/, /\/api\/login/]
}))
应用 koa-jwt 中间件进行校验时,须要留神以下几点:
- secret 必须和 sign 时候保持一致。
- 能够通过 unless 配置接口白名单,也就是哪些 URL 能够不必通过校验,像登陆 / 注册都能够不必校验。
- 校验的中间件须要放在须要校验的路由后面,无奈对后面的 URL 进行校验。
获取用户 token 信息的办法如下:
router.get('/api/userInfo',async (ctx,next) =>{
const authorization = ctx.header.authorization // 获取 jwt
const token = authorization.replace('Beraer','')
const result = jwt.verify(token,'test_token')
ctx.body = result
}
留神:上述的 HMA256 加密算法为单秘钥的模式,一旦泄露结果十分的危险。
在分布式系统中,每个子系统都要获取到秘钥,那么这个子系统依据该秘钥能够公布和验证令牌,但有些服务器只须要验证令牌。这时候能够采纳非对称加密,利用私钥公布令牌,公钥验证令牌,加密算法能够抉择 RS256 等非对称算法。
除此之外,JWT 鉴权还须要留神以下几点:
- payload 局部仅仅是进行简略编码,所以只能用于存储逻辑必须的非敏感信息。
- 须要爱护好加密密钥,一旦泄露结果不堪设想。
- 为防止 token 被劫持,最好应用 https 协定。
十、Node 性能监控与优化
10.1 Node 优化点
Node 作为一门服务端语言,性能方面尤为重要,其掂量指标个别有如下几点:
- CPU
- 内存
- I/O
- 网络
10.1.1 CPU
对于 CPU 的指标,次要关注如下两点:
- CPU 负载:在某个时间段内,占用以及期待 CPU 的过程总数。
- CPU 使用率:CPU 工夫占用情况,等于 1 – 闲暇 CPU 工夫(idle time) / CPU 总工夫。
这两个指标都是用来评估零碎以后 CPU 的忙碌水平的量化指标。Node 利用个别不会耗费很多的 CPU,如果 CPU 占用率高,则表明利用存在很多同步操作,导致异步工作回调被阻塞。
10.1.2 内存指标
内存是一个非常容易量化的指标。内存占用率是评判一个零碎的内存瓶颈的常见指标。对于 Node 来说,外部内存堆栈的应用状态也是一个能够量化的指标,能够应用上面的代码来获取内存的相干数据:
// /app/lib/memory.js
const os = require('os');
// 获取以后 Node 内存堆栈状况
const {rss, heapUsed, heapTotal} = process.memoryUsage();
// 获取零碎闲暇内存
const sysFree = os.freemem();
// 获取零碎总内存
const sysTotal = os.totalmem();
module.exports = {memory: () => {
return {
sys: 1 - sysFree / sysTotal, // 零碎内存占用率
heap: heapUsed / headTotal, // Node 堆内存占用率
node: rss / sysTotal, // Node 占用零碎内存的比例
}
}
}
- rss:示意 node 过程占用的内存总量。
- heapTotal:示意堆内存的总量。
- heapUsed:理论堆内存的使用量。
- external:内部程序的内存使用量,蕴含 Node 外围的 C ++ 程序的内存使用量。
在 Node 中,一个过程的最大内存容量为 1.5GB,因而在理论应用时请正当管制内存的应用。
10.13 磁盘 I/O
硬盘的 IO 开销是十分低廉的,硬盘 IO 破费的 CPU 时钟周期是内存的 164000 倍。内存 IO 比磁盘 IO 快十分多,所以应用内存缓存数据是无效的优化办法。罕用的工具如 redis、memcached 等。
并且,并不是所有数据都须要缓存,拜访频率高,生成代价比拟高的才思考是否缓存,也就是说影响你性能瓶颈的思考去缓存,并且而且缓存还有缓存雪崩、缓存穿透等问题要解决。
10.2 如何监控
对于性能方面的监控,个别状况都须要借助工具来实现,比方 Easy-Monitor、阿里 Node 性能平台等。
这里采纳 Easy-Monitor 2.0,其是轻量级的 Node.js 我的项目内核性能监控 + 剖析工具,在默认模式下,只须要在我的项目入口文件 require 一次,无需改变任何业务代码即可开启内核级别的性能监控剖析。
Easy-Monitor 的应用也比较简单,在我的项目入口文件中依照如下形式引入。
const easyMonitor = require('easy-monitor');
easyMonitor('项目名称');
关上你的浏览器,拜访 http://localhost:12333,即可看到过程界面,更具体的内容请参考官网
10.3 Node 性能优化
对于 Node 的性能优化的形式有如下几个:
- 应用最新版本 Node.js
- 正确应用流 Stream
- 代码层面优化
- 内存治理优化
10.3.1 应用最新版本 Node.js
每个版本的性能晋升次要来自于两个方面:
- V8 的版本更新
- Node.js 外部代码的更新优化
10.3.2 正确应用流
在 Node 中,很多对象都实现了流,对于一个大文件能够通过流的模式发送,不须要将其齐全读入内存。
const http = require('http');
const fs = require('fs');
// 谬误形式
http.createServer(function (req, res) {fs.readFile(__dirname + '/data.txt', function (err, data) {res.end(data);
});
});
// 正确形式
http.createServer(function (req, res) {const stream = fs.createReadStream(__dirname + '/data.txt');
stream.pipe(res);
});
10.3.3 代码层面优化
合并查问,将屡次查问合并一次,缩小数据库的查问次数。
// 谬误形式
for user_id in userIds
let account = user_account.findOne(user_id)
// 正确形式
const user_account_map = {}
// 留神这个对象将会耗费大量内存。user_account.find(user_id in user_ids).forEach(account){user_account_map[account.user_id] = account
}
for user_id in userIds
var account = user_account_map[user_id]
10.3.4 内存治理优化
在 V8 中,次要将内存分为新生代和老生代两代:
- 新生代:对象的存活工夫较短。新生对象或只通过一次垃圾回收的对象。
- 老生代:对象存活工夫较长。经验过一次或屡次垃圾回收的对象。
若新生代内存空间不够,间接调配到老生代。通过缩小内存占用,能够进步服务器的性能。如果有内存泄露,也会导致大量的对象存储到老生代中,服务器性能会大大降低,比方上面的例子。
const buffer = fs.readFileSync(__dirname + '/source/index.htm');
app.use(mount('/', async (ctx) => {
ctx.status = 200;
ctx.type = 'html';
ctx.body = buffer;
leak.push(fs.readFileSync(__dirname + '/source/index.htm'));
})
);
const leak = [];
当 leak 的内存十分大的时候,就有可能造成内存泄露,该当防止这样的操作。
缩小内存应用,能够显著的进步服务性能。而节俭内存最好的形式是应用池,其将频用、可复用对象存储起来,缩小创立和销毁操作。例如有个图片申请接口,每次申请,都须要用到类。若每次都须要从新 new 这些类,并不是很适合,在大量申请时,频繁创立和销毁这些类,造成内存抖动。而应用对象池的机制,对这种频繁须要创立和销毁的对象保留在一个对象池中,从而防止重读的初始化操作,从而进步框架的性能。