一年Node.js開發開發經驗總結

CoyPan發表於2020-08-10
本文首發於公眾號:符合預期的CoyPan

寫在前面

不知不覺的,寫Node.js已經一年了。不同於最開始的demo、本地工具等,這一年裡,都是用Node.js寫的線上業務。從一開始的Node.js同構直出,到最近的Node接入層,也算是對Node開發入門了吧。目前,我一個人維護了大部分組內流傳下來的Node服務,包括內部系統和線上服務。新增的後臺服務,也是儘可能地使用Node進行開發。本文是一下自己最近的一些小小的總結和思考。

本文不會深入講解Node.js本身的特性,架構等等。我也沒有寫過Node擴充套件或者庫什麼的,對Node.js的瞭解也並不夠深入。

為何用Node

對於我來說,對於團隊來說,適用Node的原因其實很簡單:開發起來快。熟悉JS的前端同學可以很快上手,節省成本。選一個http server庫起一個server,選擇合適的中介軟體,匹配好請求路由,看情況合理使用ORM庫連結資料庫、增刪改查即可。

Node的適用場景

Node.js 使用了一個事件驅動、非阻塞式 I/O 的模型,使其輕量又高效。這種模型使得Node.js 可以避免了由於需要等待輸入或者輸出(資料庫、檔案系統、Web伺服器...)響應而造成的 CPU 時間損失。所以,Node.js適合運用在高併發、I/O密集、少量業務邏輯的場景。

對應到平時具體的業務上,如果是內部的系統,大部分僅僅就是需要對某個資料庫進行增刪改查,那麼Server端直接就是Node.js一把梭。

對於線上業務,如果流量不大,並且業務邏輯簡單的情況下,Server端也可以完全使用Node.js。對於流量巨大,複雜度高的專案,一般用Node.js作為接入層,後臺同學負責實現服務。如下圖:

同樣是寫JS,Node.js開發和頁面開發有什麼區別

在瀏覽器端開發頁面,是和使用者打交道、重互動,瀏覽器還提供了各種Web Api供我們使用。Node.js主要面向資料,收到請求後,返回具體的資料。這是兩者在業務路徑上的區別。而真正的區別其實是在於業務模型上(業務模型,這是我自己瞎想的一個詞)。直接用圖表示吧。

開發頁面時,每一個使用者的瀏覽器上都有一份JS程式碼。如果程式碼在某種情況下崩了,只會對當前使用者產生影響,並不會影響其他使用者,使用者重新整理一下即可恢復。而在Node.js中,在不開啟多程式的情況下,所有使用者的請求,都會走進同一份JS程式碼,並且只有一個執行緒在執行這份JS程式碼。如果某個使用者的請求,導致發生錯誤,Node.js程式掛掉,server端直接就掛了。儘管可能有程式守護,掛掉的程式會被重啟,但是在使用者請求量大的情況下,錯誤會被頻繁觸發,可能就會出現server端不停掛掉,不停重啟的情況,對使用者體驗造成影響。

以上,可能是Node.js開發和前端JS開發最大的區別。

Node.js開發時的注意事項

使用者在訪問Node.js服務時,如果某一個請求卡住了,服務遲遲不能返回結果,或者說邏輯出錯,導致服務掛掉,都會帶來大規模的體驗問題。server端的目標,就是要 快速、可靠 地返回資料。

快取

由於Node.js不擅長處理複雜邏輯(JavaScript本身執行效率較低),如果要用Node.js做接入層,應該避免複雜的邏輯。想要快速處理資料並返回,一個至關重要的點:使用快取。

例如,使用Node做React同構直出,renderToString這個Api,可以說是比較重的邏輯了。如果頁面的複雜度高,每次請求都完整執行renderToString,會長時間佔用執行緒來執行程式碼,增加響應時間,降低服務的吞吐量。這個時候,快取就十分重要了。

實現快取的主要方式:記憶體快取。可以使用Map,WeakMap,WeakRef等實現。參考以下簡單的示例程式碼:

const cache = new Map();

router.get('/getContent', async (req, res) => {
  const id = req.query.id;
  
  // 命中快取
  if(cache.get(id)) {
    return res.send(cache.get(id));
  }
  
  // 請求資料
  const rsp = await rpc.get(id);
     // 經過一頓複雜的操作,處理資料
  const content = process(rsp);
  // 設定快取
  cache.set(id, content);
  
  return res.send(content);
});

使用快取時,有一個很重要的問題是:記憶體快取如何更新。一種最簡單的方法,開一個定時器,定期刪除快取,下一次請求到來時,重新設定快取即可。在上述程式碼中,增加如下程式碼:

setTimeout(function() {
  cache.clear();
}, 1000 * 60); // 1分鐘刪除一次快取

如果server端完全使用Node實現,需要用Node端直接連線資料庫,在資料時效性要求不太高、且流量不太大的情況下,就可以使用上述類似的模型,如下圖。這樣可以降低資料庫的壓力且加快Node的響應速度。

另外,還需要注意記憶體快取的大小。如果一直往快取裡寫入新資料,那麼記憶體會越來越大,最終爆掉。可以考慮使用LRU(Least Recently Used)演算法來做快取。開闢一塊記憶體專門作為快取區域。當快取大小達到上限時,淘汰最久未使用的快取。

記憶體快取會隨著程式的重啟而全部失效。

當後臺業務比較複雜,接入層流量,資料量較大時,可以使用如下的架構,使用獨立的記憶體快取服務。Node接入層直接從快取服務取資料,後臺服務直接更新快取服務。

當然,上圖中的架構是最簡單的情形,現實中還需要考慮分散式快取、快取一致性的問題。這又是另外一個話題了。

錯誤處理

由於Node.js語言的特性,Node服務是比較容易出錯的。而一旦出錯,造成的影響就是服務不可用。因此,對於錯誤的處理十分的重要。

處理錯誤,最常用的就是try catch 了。可是 try catch無法捕獲非同步錯誤。Node.js中,非同步操作是十分常見的,非同步操作主要是在回撥函式中暴露錯誤。看一個例子:

const readFile = function(path) {
    return new Promise((resolve,reject) => {
        fs.readFile(path, (err, data) => {
            if(err) { 
                throw err; // catch無法捕獲錯誤,這和Node的eventloop有關。
        // reject(err); // catch可以捕獲
      }
      resolve(data);
        });
    });
}

router.get('/xxx', async function(req, res) {
  try {
    const res = await readFile('xxx');
    ...
  } catch (e){
    // 捕獲錯誤處理
    ...
    res.send(500);
  }
});

上面的程式碼中,readFile 中 throw 出來的錯誤,是無法被catch捕獲的。如果我們把 throw err 換成 Promise.reject(err),catch中是可以捕獲到錯誤的。

我們可以把非同步操作都Promise化,然後統一使用 async 、try、catch 來處理錯誤

但是,總會有地方會被遺漏。這個時候,可以使用process來捕獲全域性錯誤,防止程式直接退出,導致後面的請求掛掉。示例程式碼:

process.on('uncaughtException', (err) => {
  console.error(`${err.message}\n${err.stack}`);
});

process.on('unhandledRejection', (reason, p) => {
  console.error(`Unhandled Rejection at: Promise ${p} reason: `, reason);
});

關於Node.js中錯誤的捕獲,還可以使用domain模組。現在這個模組已經不推薦使用了,我也沒有在專案中實踐過,這裡就不展開了。Node.js 近幾年推出的 async_hooks 模組,也還處於實驗階段,不太建議線上環境直接使用。做好程式守護,開啟多程式,錯誤告警及時修復,養成良好的編碼規範,使用合適的框架,才能提高Node服務的效率及穩定性。

寫在後面

本文總結了Node.js開發一年多以來的實踐總結等。Node.js的開發與前端網頁的開發思路不同,著重點不一樣。我正式開發Node.js的時間也不算太長,一些點並沒有深入的理解,本文僅僅是一些經驗之談。歡迎交流。

相關文章