Node.js 有難度的面試題,你能答對幾個?

  • 2019 年 10 月 8 日
  • 筆記

點擊上方藍字關注,關注後還可加入「Node交流群」共同進步

作者:lio-mengxiang 地址:https://juejin.im/post/5d2fc598e51d4577596487a3

Node模組機制

請介紹一下node里的模組是什麼

Node中,每個文件模組都是一個對象,它的定義如下:

function Module(id, parent) {    this.id = id;    this.exports = {};    this.parent = parent;    this.filename = null;    this.loaded = false;    this.children = [];  }    module.exports = Module;    var module = new Module(filename, parent);

所有的模組都是 Module 的實例。可以看到,當前模組(module.js)也是 Module 的一個實例。

請介紹一下require的模組載入機制

這道題基本上就可以了解到面試者對Node模組機制的了解程度 基本上面試提到

  • 1、先計算模組路徑
  • 2、如果模組在快取裡面,取出快取
  • 3、載入模組
  • 4、輸出模組的exports屬性即可
// require 其實內部調用 Module._load 方法  Module._load = function(request, parent, isMain) {    //  計算絕對路徑    var filename = Module._resolveFilename(request, parent);      //  第一步:如果有快取,取出快取    var cachedModule = Module._cache[filename];    if (cachedModule) {      return cachedModule.exports;      // 第二步:是否為內置模組    if (NativeModule.exists(filename)) {      return NativeModule.require(filename);    }      /********************************這裡注意了**************************/    // 第三步:生成模組實例,存入快取    // 這裡的Module就是我們上面的1.1定義的Module    var module = new Module(filename, parent);    Module._cache[filename] = module;      /********************************這裡注意了**************************/    // 第四步:載入模組    // 下面的module.load實際上是Module原型上有一個方法叫Module.prototype.load    try {      module.load(filename);      hadException = false;    } finally {      if (hadException) {        delete Module._cache[filename];      }    }      // 第五步:輸出模組的exports屬性    return module.exports;  };

接著上一題繼續發問

載入模組時,為什麼每個模組都有__dirname,__filename屬性呢,new Module的時候我們看到1.1部分沒有這兩個屬性的,那麼這兩個屬性是從哪裡來的

// 上面(1.2部分)的第四步module.load(filename)  // 這一步,module模組相當於被包裝了,包裝形式如下  // 載入js模組,相當於下面的程式碼(載入node模組和json模組邏輯不一樣)  (function (exports, require, module, __filename, __dirname) {    // 模組源碼    // 假如模組程式碼如下    var math = require('math');    exports.area = function(radius){        return Math.PI * radius * radius    }  });

也就是說,每個module裡面都會傳入__filename, __dirname參數,這兩個參數並不是module本身就有的,是外界傳入的

我們知道node導出模組有兩種方式,一種是exports.xxx=xxx和Module.exports={}有什麼區別嗎

  • exports其實就是module.exports
  • 其實1.3問題的程式碼已經說明問題了,接著我引用廖雪峰大神的講解,希望能講的更清楚

module.exports vs exports  很多時候,你會看到,在Node環境中,有兩種方法可以在一個模組中輸出變數:    方法一:對module.exports賦值:    // hello.js    function hello() {      console.log('Hello, world!');  }    function greet(name) {      console.log('Hello, ' + name + '!');  }  module.exports = {      hello: hello,      greet: greet  };  方法二:直接使用exports:  // hello.js  function hello() {      console.log('Hello, world!');  }  function greet(name) {      console.log('Hello, ' + name + '!');  }  function hello() {      console.log('Hello, world!');  }  exports.hello = hello;  exports.greet = greet;  但是你不可以直接對exports賦值:  // 程式碼可以執行,但是模組並沒有輸出任何變數:  exports = {      hello: hello,      greet: greet  };  如果你對上面的寫法感到十分困惑,不要著急,我們來分析Node的載入機制:  首先,Node會把整個待載入的hello.js文件放入一個包裝函數load中執行。在執行這個load()函數前,Node準備好了module變數:    var module = {      id: 'hello',      exports: {}  };  load()函數最終返回module.exports:  var load = function (exports, module) {      // hello.js的文件內容      ...      // load函數返回:      return module.exports;  };    var exported = load(module.exports, module);  也就是說,默認情況下,Node準備的exports變數和module.exports變數實際上是同一個變數,並且初始化為空對象{},於是,我們可以寫:    exports.foo = function () { return 'foo'; };  exports.bar = function () { return 'bar'; };  也可以寫:    module.exports.foo = function () { return 'foo'; };  module.exports.bar = function () { return 'bar'; };  換句話說,Node默認給你準備了一個空對象{},這樣你可以直接往裡面加東西。    但是,如果我們要輸出的是一個函數或數組,那麼,只能給module.exports賦值:    module.exports = function () { return 'foo'; };  給exports賦值是無效的,因為賦值後,module.exports仍然是空對象{}。    結論  如果要輸出一個鍵值對象{},可以利用exports這個已存在的空對象{},並繼續在上面添加新的鍵值;    如果要輸出一個函數或數組,必須直接對module.exports對象賦值。    所以我們可以得出結論:直接對module.exports賦值,可以應對任何情況:    module.exports = {      foo: function () { return 'foo'; }  };  或者:    module.exports = function () { return 'foo'; };  最終,我們強烈建議使用module.exports = xxx的方式來輸出模組變數,這樣,你只需要記憶一種方法。

Node的非同步I/O

本章的答題思路大多借鑒於朴靈大神的《深入淺出的NodeJS》

請介紹一下Node事件循環的流程

  • 在進程啟動時,Node便會創建一個類似於while(true)的循環,每執行一次循環體的過程我們成為Tick。
  • 每個Tick的過程就是查看是否有事件待處理。如果有就取出事件及其相關的回調函數。然後進入下一個循環,如果不再有事件處理,就退出進程。

在每個tick的過程中,如何判斷是否有事件需要處理呢?

  • 每個事件循環中有一個或者多個觀察者,而判斷是否有事件需要處理的過程就是向這些觀察者詢問是否有要處理的事件。
  • 在Node中,事件主要來源於網路請求、文件的I/O等,這些事件對應的觀察者有文件I/O觀察者,網路I/O的觀察者。
  • 事件循環是一個典型的生產者/消費者模型。非同步I/O,網路請求等則是事件的生產者,源源不斷為Node提供不同類型的事件,這些事件被傳遞到對應的觀察者那裡,事件循環則從觀察者那裡取出事件並處理。
  • 在windows下,這個循環基於IOCP創建,在*nix下則基於多執行緒創建

請描述一下整個非同步I/O的流程

V8的垃圾回收機制

如何查看V8的記憶體使用情況

使用process.memoryUsage(),返回如下

{    rss: 4935680,    heapTotal: 1826816,    heapUsed: 650472,    external: 49879  }

heapTotal 和 heapUsed 代表V8的記憶體使用情況。 external代表V8管理的,綁定到Javascript的C++對象的記憶體使用情況。 rss, 駐留集大小, 是給這個進程分配了多少物理記憶體(佔總分配記憶體的一部分) 這些物理記憶體中包含堆,棧,和程式碼段。

V8的記憶體限制是多少,為什麼V8這樣設計

64位系統下是1.4GB, 32位系統下是0.7GB。因為1.5GB的垃圾回收堆記憶體,V8需要花費50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。這是垃圾回收中引起Javascript執行緒暫停執行的事件,在這樣的花銷下,應用的性能和影響力都會直線下降。

V8的記憶體分代和回收演算法請簡單講一講

在V8中,主要將記憶體分為新生代和老生代兩代。新生代中的對象存活時間較短的對象,老生代中的對象存活時間較長,或常駐記憶體的對象。

新生代

新生代中的對象主要通過Scavenge演算法進行垃圾回收。這是一種採用複製的方式實現的垃圾回收演算法。它將堆記憶體一份為二,每一部分空間成為semispace。在這兩個semispace空間中,只有一個處於使用中,另一個處於閑置狀態。處於使用狀態的semispace空間稱為From空間,處於閑置狀態的空間稱為To空間。

  • 當開始垃圾回收的時候,會檢查From空間中的存活對象,這些存活對象將被複制到To空間中,而非存活對象佔用的空間將會被釋放。完成複製後,From空間和To空間發生角色對換。
  • 應為新生代中對象的生命周期比較短,就比較適合這個演算法。
  • 當一個對象經過多次複製依然存活,它將會被認為是生命周期較長的對象。這種新生代中生命周期較長的對象隨後會被移到老生代中。

老生代

老生代主要採取的是標記清除的垃圾回收演算法。與Scavenge複製活著的對象不同,標記清除演算法在標記階段遍歷堆中的所有對象,並標記活著的對象,只清理死亡對象。活對象在新生代中只佔較小部分,死對象在老生代中只佔較小部分,這是為什麼採用標記清除演算法的原因。

標記清楚演算法的問題

主要問題是每一次進行標記清除回收後,記憶體空間會出現不連續的狀態

  • 這種記憶體碎片會對後續記憶體分配造成問題,很可能出現需要分配一個大對象的情況,這時所有的碎片空間都無法完成此次分配,就會提前觸發垃圾回收,而這次回收是不必要的。
  • 為了解決碎片問題,標記整理被提出來。就是在對象被標記死亡後,在整理的過程中,將活著的對象往一端移動,移動完成後,直接清理掉邊界外的記憶體。

哪些情況會造成V8無法立即回收記憶體

閉包和全局變數

請談一下記憶體泄漏是什麼,以及常見記憶體泄漏的原因,和排查的方法

什麼是記憶體泄漏

  • 記憶體泄漏(Memory Leak)指由於疏忽或錯誤造成程式未能釋放已經不再使用的記憶體的情況。
  • 如果記憶體泄漏的位置比較關鍵,那麼隨著處理的進行可能持有越來越多的無用記憶體,這些無用的記憶體變多會引起伺服器響應速度變慢。
  • 嚴重的情況下導致記憶體達到某個極限(可能是進程的上限,如 v8 的上限;也可能是系統可提供的記憶體上限)會使得應用程式崩潰。常見記憶體泄漏的原因 記憶體泄漏的幾種情況:

一、全局變數

a = 10;  //未聲明對象。  global.b = 11;  //全局變數引用  這種比較簡單的原因,全局變數直接掛在 root 對象上,不會被清除掉。

二、閉包

function out() {      const bigData = new Buffer(100);      inner = function () {        }  }

閉包會引用到父級函數中的變數,如果閉包未釋放,就會導致記憶體泄漏。上面例子是 inner 直接掛在了 root 上,那麼每次執行 out 函數所產生的 bigData 都不會釋放,從而導致記憶體泄漏。

需要注意的是,這裡舉得例子只是簡單的將引用掛在全局對象上,實際的業務情況可能是掛在某個可以從 root 追溯到的對象上導致的。

三、事件監聽

Node.js 的事件監聽也可能出現的記憶體泄漏。例如對同一個事件重複監聽,忘記移除(removeListener),將造成記憶體泄漏。這種情況很容易在復用對象上添加事件時出現,所以事件重複監聽可能收到如下警告:

emitter.setMaxListeners() to increase limit

例如,Node.js 中 Agent 的 keepAlive 為 true 時,可能造成的記憶體泄漏。當 Agent keepAlive 為 true 的時候,將會復用之前使用過的 socket,如果在 socket 上添加事件監聽,忘記清除的話,因為 socket 的復用,將導致事件重複監聽從而產生記憶體泄漏。

原理上與前一個添加事件監聽的時候忘了清除是一樣的。在使用 Node.js 的 http 模組時,不通過 keepAlive 復用是沒有問題的,復用了以後就會可能產生記憶體泄漏。所以,你需要了解添加事件監聽的對象的生命周期,並注意自行移除。

排查方法

想要定位記憶體泄漏,通常會有兩種情況:

  • 對於只要正常使用就可以重現的記憶體泄漏,這是很簡單的情況只要在測試環境模擬就可以排查了。
  • 對於偶然的記憶體泄漏,一般會與特殊的輸入有關係。想穩定重現這種輸入是很耗時的過程。如果不能通過程式碼的日誌定位到這個特殊的輸入,那麼推薦去生產環境列印記憶體快照了。
  • 需要注意的是,列印記憶體快照是很耗 CPU 的操作,可能會對線上業務造成影響。快照工具推薦使用 heapdump 用來保存記憶體快照,使用 devtool 來查看記憶體快照。
  • 使用 heapdump 保存記憶體快照時,只會有 Node.js 環境中的對象,不會受到干擾(如果使用 node-inspector 的話,快照中會有前端的變數干擾)。
  • PS:安裝 heapdump 在某些 Node.js 版本上可能出錯,建議使用 npm install heapdump -target=Node.js 版本來安裝。

Buffer模組

新建Buffer會佔用V8分配的記憶體嗎

不會,Buffer屬於堆外記憶體,不是V8分配的。

Buffer.alloc和Buffer.allocUnsafe的區別

Buffer.allocUnsafe創建的 Buffer 實例的底層記憶體是未初始化的。新創建的 Buffer 的內容是未知的,可能包含敏感數據。使用 Buffer.alloc() 可以創建以零初始化的 Buffer 實例。

Buffer的記憶體分配機制

為了高效的使用申請來的記憶體,Node採用了slab分配機制。slab是一種動態的記憶體管理機制。Node以8kb為界限來來區分Buffer為大對象還是小對象,如果是小於8kb就是小Buffer,大於8kb就是大Buffer。

例如第一次分配一個1024位元組的Buffer,Buffer.alloc(1024),那麼這次分配就會用到一個slab,接著如果繼續Buffer.alloc(1024),那麼上一次用的slab的空間還沒有用完,因為總共是8kb,1024+1024 = 2048個位元組,沒有8kb,所以就繼續用這個slab給Buffer分配空間。

如果超過8bk,那麼直接用C++底層地宮的SlowBuffer來給Buffer對象提供空間。

Buffer亂碼問題

例如一個份文件test.md里的內容如下:

床前明月光,疑是地上霜,舉頭望明月,低頭思故鄉

我們這樣讀取就會出現亂碼:

var rs = require('fs').createReadStream('test.md', {highWaterMark: 11});  // 床前明???光,疑???地上霜,舉頭???明月,???頭思故鄉

一般情況下,只需要設置rs.setEncoding('utf8')即可解決亂碼問題

實例的程式碼實現與分析

webSocket與傳統的http有什麼優勢

  • 客戶端與伺服器只需要一個TCP連接,比http長輪詢使用更少的連接
  • webSocket服務端可以推送數據到客戶端
  • 更輕量的協議頭,減少數據傳輸量

webSocket協議升級時什麼,能簡述一下嗎?

首先,WebSocket連接必須由瀏覽器發起,因為請求協議是一個標準的HTTP請求,格式如下:

GET ws://localhost:3000/ws/chat HTTP/1.1  Host: localhost  Upgrade: websocket  Connection: Upgrade  Origin: http://localhost:3000  Sec-WebSocket-Key: client-random-string  Sec-WebSocket-Version: 13

該請求和普通的HTTP請求有幾點不同:

  • GET請求的地址不是類似/path/,而是以ws://開頭的地址;
  • 請求頭Upgrade: websocket和Connection: Upgrade表示這個連接將要被轉換為WebSocket連接;
  • Sec-WebSocket-Key是用於標識這個連接,並非用於加密數據;
  • Sec-WebSocket-Version指定了WebSocket的協議版本。

隨後,伺服器如果接受該請求,就會返回如下響應:

HTTP/1.1 101 Switching Protocols  Upgrade: websocket  Connection: Upgrade  Sec-WebSocket-Accept: server-random-string

該響應程式碼101表示本次連接的HTTP協議即將被更改,更改後的協議就是Upgrade: websocket指定的WebSocket協議。

Https

https用哪些埠進行通訊,這些埠分別有什麼用

  • 443埠用來驗證伺服器端和客戶端的身份,比如驗證證書的合法性
  • 80埠用來傳輸數據(在驗證身份合法的情況下,用來數據傳輸)

身份驗證過程中會涉及到密鑰, 對稱加密,非對稱加密,摘要的概念,請解釋一下

  • 密鑰:密鑰是一種參數,它是在明文轉換為密文或將密文轉換為明文的演算法中輸入的參數。密鑰分為對稱密鑰與非對稱密鑰,分別應用在對稱加密和非對稱加密上。
  • 對稱加密:對稱加密又叫做私鑰加密,即資訊的發送方和接收方使用同一個密鑰去加密和解密數據。對稱加密的特點是演算法公開、加密和解密速度快,適合於對大數據量進行加密,常見的對稱加密演算法有DES、3DES、TDEA、Blowfish、RC5和IDEA。
  • 非對稱加密:非對稱加密也叫做公鑰加密。非對稱加密與對稱加密相比,其安全性更好。對稱加密的通訊雙方使用相同的密鑰,如果一方的密鑰遭泄露,那麼整個通訊就會被破解。而非對稱加密使用一對密鑰,即公鑰和私鑰,且二者成對出現。私鑰被自己保存,不能對外泄露。公鑰指的是公共的密鑰,任何人都可以獲得該密鑰。用公鑰或私鑰中的任何一個進行加密,用另一個進行解密。
  • 摘要:摘要演算法又稱哈希/散列演算法。它通過一個函數,把任意長度的數據轉換為一個長度固定的數據串(通常用16進位的字元串表示)。演算法不可逆。

為什麼需要CA機構對證書籤名

如果不簽名會存在中間人攻擊的風險,簽名之後保證了證書里的資訊,比如公鑰、伺服器資訊、企業資訊等不被篡改,能夠驗證客戶端和伺服器端的「合法性」。

https驗證身份也就是TSL/SSL身份驗證的過程

簡要圖解如下:

進程通訊

請簡述一下node的多進程架構

面對node單執行緒對多核CPU使用不足的情況,Node提供了child_process模組,來實現進程的複製,node的多進程架構是主從模式,如下所示:

var fork = require('child_process').fork;  var cpus = require('os').cpus();  for(var i = 0; i < cpus.length; i++){      fork('./worker.js');  }

在linux中,我們通過ps aux | grep worker.js查看進程

這就是著名的主從模式,Master-Worker

請問創建子進程的方法有哪些,簡單說一下它們的區別

創建子進程的方法大致有:

  • spawn():啟動一個子進程來執行命令
  • exec(): 啟動一個子進程來執行命令,與spawn()不同的是其介面不同,它有一個回調函數獲知子進程的狀況
  • execFlie(): 啟動一個子進程來執行可執行文件
  • fork(): 與spawn()類似,不同電在於它創建Node子進程需要執行js文件
  • spawn()與exec()、execFile()不同的是,後兩者創建時可以指定timeout屬性設置超時時間,一旦創建的進程超過設定的時間就會被殺死
  • exec()與execFile()不同的是,exec()適合執行已有命令,execFile()適合執行文件。

請問你知道spawn在創建子進程的時候,第三個參數有一個stdio選項嗎,這個選項的作用是什麼,默認的值是什麼。

  • 選項用於配置在父進程和子進程之間建立的管道。
  • 默認情況下,子進程的 stdin、 stdout 和 stderr 會被重定向到 ChildProcess 對象上相應的 subprocess.stdin、subprocess.stdout 和 subprocess.stderr 流。
  • 這相當於將 options.stdio 設置為 ['pipe', 'pipe', 'pipe']。

請問實現一個node子進程被殺死,然後自動重啟程式碼的思路

  • 在創建子進程的時候就讓子進程監聽exit事件,如果被殺死就重新fork一下
var createWorker = function(){      var worker = fork(__dirname + 'worker.js')      worker.on('exit', function(){          console.log('Worker' + worker.pid + 'exited');          // 如果退出就創建新的worker          createWorker()      })  }

在7.4的基礎上,實現限量重啟,比如我最多讓其在1分鐘內重啟5次,超過了就報警給運維

  • 思路大概是在創建worker的時候,就判斷創建的這個worker是否在1分鐘內重啟次數超過5次
  • 所以每一次創建worker的時候都要記錄這個worker 創建時間,放入一個數組隊列裡面,每次創建worker都去取隊列里前5條記錄
  • 如果這5條記錄的時間間隔小於1分鐘,就說明到了報警的時候了

如何實現進程間的狀態共享,或者數據共享

我自己沒用過Kafka這類消息隊列工具,問了java,可以用類似工具來實現進程間通訊,更好的方法歡迎留言

中間件

如果使用過koa、egg這兩個Node框架,請簡述其中的中間件原理,最好用程式碼表示一下

  • 上面是在網上找的一個示意圖,就是說中間件執行就像洋蔥一樣,最早use的中間件,就放在最外層。處理順序從左到右,左邊接收一個request,右邊輸出返回response
  • 一般的中間件都會執行兩次,調用next之前為第一次,調用next時把控制傳遞給下游的下一個中間件。當下游不再有中間件或者沒有執行next函數時,就將依次恢復上游中間件的行為,讓上游中間件執行next之後的程式碼
  • 例如下面這段程式碼
const Koa = require('koa')  const app = new Koa()  app.use((ctx, next) => {      console.log(1)      next()      console.log(3)  })  app.use((ctx) => {      console.log(2)  })  app.listen(3001)  執行結果是1=>2=>3

koa中間件實現源碼大致思路如下:

// 注意其中的compose函數,這個函數是實現中間件洋蔥模型的關鍵  // 場景模擬  // 非同步 promise 模擬  const delay = async () => {    return new Promise((resolve, reject) => {      setTimeout(() => {        resolve();      }, 2000);    });  }  // 中間間模擬  const fn1 = async (ctx, next) => {    console.log(1);    await next();    console.log(2);  }  const fn2 = async (ctx, next) => {    console.log(3);    await delay();    await next();    console.log(4);  }  const fn3 = async (ctx, next) => {    console.log(5);  }    const middlewares = [fn1, fn2, fn3];    // compose 實現洋蔥模型  const compose = (middlewares, ctx) => {    const dispatch = (i) => {      let fn = middlewares[i];      if(!fn){ return Promise.resolve() }      return Promise.resolve(fn(ctx, () => {        return dispatch(i+1);      }));    }    return dispatch(0);  }    compose(middlewares, 1);

其他

現在在重新過一遍node 12版本的主要API,有很多新發現,比如說

  • fs.watch這個模組,事件的回調函數有一個參數是觸發的事件是事件名稱,但是呢無論我增刪改,都是觸發rename事件(如果更改是update事件,刪除delete事件該,重命名是rename事件多好)。後來網上找到一個node-watch模組,完美解決此問題,此模組增刪改都有對應的事件,然後還高效的支援遞歸watch 文件

雜想

  • crypto模組,可以考察基礎的加密學知識,比如摘要演算法有哪些(md5, sha1, sha256,加鹽的md5,sha256等等),接著可以問如何用md5自己模擬一個加鹽的md5演算法, 接著可以問加密演算法(crypto.createCiphe)中的aes,eds演算法的區別,分組加密模式有哪些(比如ECB,CBC,為什麼ECB不推薦),node里的分組加密模式是哪種(CMM),這些加密演算法里的填充和向量是什麼意思,接著可以問數字簽名和https的流程(為什麼需要CA,為什麼要對稱加密來加密公鑰等等)
  • tcp/ip,可以問很多基礎問題,比如鏈路層通過什麼協議根據IP地址獲取物理地址(arp),網關是什麼,ip里的ICMP協議有什麼用,tcp的三次握手,四次分手的過程是什麼,tcp如何控制重發,網路堵塞TCP會怎麼辦等等,udp和tcp的區別,udp里的廣播和組播是什麼,組播在node里通過什麼模組實現。
  • os,作業系統相關基礎,io的流程是什麼(從硬碟里讀取數據到內核的記憶體中,然後內核的記憶體將數據傳入到調用io的應用程式的進程記憶體中),馮諾依曼體系是什麼,進程和執行緒的區別等等(我最近在看馬哥linux教程,因為自己不是科班出身,聽了很多基礎的電腦知識,受益匪淺,建議去bilibili看)
  • linux相關操作知識(node涉及到後台,雖然是做中台,不涉及資料庫,但是基本的linux操作還是要會的)
  • node性能監控
  • 測試,因為用的egg框架,有很完善的學習單元測試的文檔,省略這部分
  • 資料庫可以問一些比如事務的等級有哪些,mysql默認的事務等級是什麼,會產生什麼問題,然後考一些mysql查詢的筆試題。。。和常用優化技巧,node的mysql的orm工具使用過沒有…