前端Node.js面試題

xiangzhihong發表於2021-11-11

一、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 配合,開發長連線的實時互動應用程式。

具體的使用場景如下:

  1. 使用者表單收集系統、後臺管理系統、實時互動系統、考試系統、聯網軟體、高併發量的web應用程式。
  2. 基於web、canvas等多人聯網遊戲。
  3. 基於web的多人實時聊天客戶端、聊天室、圖文直播。
  4. 單頁面瀏覽器應用程式。
  5. 運算元據庫、為前端和移動端提供基於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這些類,並不是很合適,在大量請求時,頻繁建立和銷燬這些類,造成記憶體抖動。而使用物件池的機制,對這種頻繁需要建立和銷燬的物件儲存在一個物件池中,從而避免重讀的初始化操作,從而提高框架的效能。

相關文章