一、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這些類,並不是很合適,在大量請求時,頻繁建立和銷燬這些類,造成記憶體抖動。而使用物件池的機制,對這種頻繁需要建立和銷燬的物件儲存在一個物件池中,從而避免重讀的初始化操作,從而提高框架的效能。