Skip to content

Latest commit

 

History

History
2140 lines (1761 loc) · 78.7 KB

2018-07-28:NodeJS 最佳實踐筆記.md

File metadata and controls

2140 lines (1761 loc) · 78.7 KB

NodeJS 最佳實踐 的筆記

文章來源:https://github.com/i0natan/nodebestpractices


(Update: 這篇筆記是 2018 年紀錄的,已經過去蠻久了。有些主題,在實作方面,我建議可以參考 fastify 怎麼做,Express 已經太老舊了)


1.1 採用 components 的架構

用 feature 來區分 folder

範例參考為:像這樣用 feature 來分類

  • components
    • orders
    • otherFeature
    • products
    • users
      • index.js
      • user.js
      • usersAPI.js
      • usersController.js
      • usersDAL.js
      • usersError.js
      • usersService.js
      • userTesting.js
    • workpace
  • libraries

1.2 component 分層,保持 Express 單純(下面有更多解釋)

  • 分層: 處理網路、邏輯、data 處理階層
    • 這樣分層幫助邏輯分離、有助專注這些功能的邏輯
    • 分層後,test 單純很多

bad example: 把 express 的 req, res 傳進去給邏輯、data 處理層使用 這樣就等於依賴、綁定 express 了 應該要 create 新的 object 後再傳進去 (下面是張 gif)

  • 這樣 test 會單純很多,不用 mock express req 之類的

1.3 共用的功能封裝成 npm library

共用的 utility library、組件,應該要發佈出來(public, private npm 都可以)

  • 避免未來 code 散布在不同專案、不同機器上面,難以維護、升級等等,用 npm 一切搞定

1.4 拆分 Express 為 'app' and 'server'

  • 避免把所有 Express 都寫在一個檔案中
    • 難維護、難測試
  • 把 Express 的 code 拆成至少兩類
    • API 相關 (app.js)
    • 網路相關 (port, method (get, post, ...))
    • 這樣拆分,就能在不需要 send request 的情況,就能測試 API 邏輯。(測試上很靈活)
    • 分拆,更能專注特定 code、邏輯。

範例:

const app = express();
app.use(bodyParser.json());
app.use("/api/events", events.API);
app.use("/api/forms", forms);
const app = require('../app');
const http = require('http');

//Get port from environment and store in Express.
const port = normalizePort(process.env.PORT || '3000');
app.set('port', port);

//Create HTTP server.
const server = http.createServer(app);
// test
const app = express();

app.get('/user', function(req, res) {
  res.status(200).json({ name: 'tobi' });
});

request(app)  // npm request
  .get('/user')
  .expect('Content-Type', /json/)
  .expect('Content-Length', '15')
  .expect(200)
  .end(function(err, res) {
    if (err) throw err;
  });

1.5 設定不同環境的環境變數

環境變數應該要

  • 可以從文件中讀取,也能從變數中讀取
  • password 不能被 commit、要 ignore
  • 方便 search、設定是分等級的

推薦 library 來處理

{
  // Customer module configs
  "Customer": {
    "dbConfig": {
      "host": "localhost",
      "port": 5984,
      "dbName": "customers"
    },
    "credit": {
      "initialLimit": 100,
      // Set low for development
      "initialDays": 1
    }
  }
}

2 error handle

2.1 使用 Async-Await 和 promises 用來處理 async 的 error handle

直接使用 promise 或 async-await 來處理 error handle,這會讓它像 try-catch 一樣簡潔。 (可直接用成熟的 promise library來玩) 用 callback 來處理 async error handle 會是災難,用 NodeJS 的 callback 設計,最終一定無法維護。

// 使用 promise 來 error handle
doWork()
 .then(doWork)
 .then(doOtherWork)
 .then((result) => doWork)
 .catch((error) => {throw error;})
 .then(verify);


// 使用 callback 來 error handle
getData(someParameter, (err, result) => {
  if(err != null)
    getMoreData(a, function(err, result){
      if(err != null)
        getMoreData(b, function(c){
          getMoreData(d, function(e){
            if(err != null)
              // 這就是 call back 地獄
  });
});

2.2 只使用內建的 Error 對象。 Use only the built-in Error object

很多人 throw error 會使用 string 或 自定義的類型

  • 這會導致 error handle 處理邏輯與 module 間的調用最終變複雜
  • 使用 built-in Error object 會提升設計一制性。

不然呼叫某些 function、module 時

  • 你不確定會有哪種「錯誤類型」回來,更壞的情況是
  • 使用特定類型的 error 描述錯誤,會導致重要的 error info 缺少,例如 stack trace
// 典型的 throw error 無論是 sync or async
if(!productToAdd)
  throw new Error("How can I add new product when no value provided?");

// 從 EventEmitter throw Error
const myEmitter = new MyEmitter();
myEmitter.emit('error', new Error('whoops!'));

// 從 promise
return new promise(function (resolve, reject) {
  Return DAL.getProduct(productToAdd.id)
    .then((existingProduct) => {
      if(existingProduct != null)
        reject(new Error("Why fooling us and trying to add an existing product?"));
    })})

// anti pattern
// throw string 缺少其他任何 stack trace 的訊息。
if(!productToAdd)
  throw ("How can I add new product when no value provided?");

// =======================
// 從 node 的 Error 派生的集中錯誤對象
function appError(name, httpCode, description, isOperational) {
  Error.call(this);
  Error.captureStackTrace(this);
  this.name = name;
  //...在这赋值其它属性
};

appError.prototype.__proto__ = Error.prototype;
module.exports.appError = appError;

// Client 端拋出一個錯誤
if(user == null)
  throw new appError(
    commonErrors.resourceNotFound,
    commonHTTPErrors.notFound,
    "further explanation",
    true
  )

2.3 區分 運行錯誤 和 程式設計錯誤

operational errors (例如 api 收到一個無效的輸入),指的是已知場景下的錯誤

  • 這類 error 的影響已經被理解並能完整的處理掉。
  • 你了解發生什麼問題及知道影響範圍、可預期、也知道該怎麼對應這 error 的類型

programmer errors 指的是未知的 coding 問題,影響的應用

  • 不知道原因甚至不知道問題發生來源。
  • memory leak 之類

區別這兩個,會讓「處理」更有技巧。

  • 操作 error 相對好處理,通常有 error log 就夠了
  • 程式 error 通常難處理,應用程式常處於不ㄧ致的狀態,大概最好的選擇會是「優雅的重新啟動」
// 將 error 標記為可操作的(受信任的) ?這種做法我不懂
const myError = new Error("How can I add new product when no value provided?");
myError.isOperational = true;

// 或者 你使用的是一些集中式 error factory
function appError(commonType, description, isOperational) {
  Error.call(this);
  Error.captureStackTrace(this);
  this.commonType = commonType;
  this.description = description;
  this.isOperational = isOperational;
};

throw new appError(
  errorManagement.commonErrors.InvalidInput,
  "Describe here what happened",
  true
);

程式Error 中恢復的最好辦法是「立刻 crash」

  • 使用 restarter 執行程序,讓 crash 自動重啟 (pm2),「立刻 crash」是最快能恢復服務的方法。

除非你真的知道你在做什麼

  • 否則,你收到一個 uncaughtException error 時,應該對 service restart 一次
  • 不然 Application 的狀態或和第三方libraray 的狀態可能會不一致,存在風險,最終導致各種荒唐錯誤

2.4 集中錯誤處理,不要在 Express middleware 中處理錯誤

比起 send mail alert,log 應該要封裝在一個特定、集中的對象中。當錯誤產生時,所有終端,如(express middleare、cron taks、unit test)都可以呼叫

  • 如果沒有專門的 error handler,那有可能會漏掉 error 的處理
  • error handler 要負責「讓 error 可被看見
    • 例如,用 logger 記錄一個良好格式的 (error) log
    • 透過 email 把 error 事件送給某些 monitor platform 或者系統管理員
  • 常見的 error handle flow 為
    1. 某一個 module throw error
    2. API 的 router catch 到這個 error
    3. 把 error 傳播到 error 的 handler middleware (e.x. express, koa)
    4. centralized error handler 就會被呼叫
    5. middleware 會被告知,這個 error是否為不受信任的 error,如此一來,Ap 能夠 restart gracefully

注意

  • Express 的 middleware 來 handle errors 方式是一種常見,但錯誤方法
  • 這樣無法涵蓋 non-web interfaces throw 出來的 error
// 不在這邊處理 error
DB.addDocument(newCustomer, (error, result) => {
  if (error)
    throw new Error('Great error explanation comes here', other useful parameters)
});

// API router code,同時 catch sync, async 的 error 並轉給其他 middleware
try {
  customerService
    .addNew(req.body)
    .then((result) => { res.status(200).json(result); })
    .catch((error) => {
      next(error)
    });
} catch (error) {
  next(error);
}

// 處理 error 的 middleware,委託集中式錯誤處理程序來做 error handle
app.use(async (err, req, res, next) => {
  // errorHandler 參照下面的 code
  const isOperationalError = await errorHandler.handleError(err);
  if (!isOperationalError) {
    next(err);
  }
});

一個專門的 Error 對象裡面處理錯誤

module.exports.handler = new errorHandler();

function errorHandler() {
  this.handleError = async (err) => {
    await logger.logError(err);
    await sendMailToAdminIfCritical;
    await saveInOpsQueueIfCritical;
    await determineIfOperationalError;
  };
}

單獨在 middleware 處理是 bad example,後續不好維護、也有機會 miss error 的 handle

// bad example, anti pattern 在 middleware 中處理錯。
app.use(function (err, req, res, next) {
  logger.logError(err);
  if(err.severity == errors.high)
      mailer.sendMail(configuration.adminMail, "Critical error occured", err);
  if(!err.isOperational)
      next(err);
});

為了應用程式活動:要記載應用程式活動(例如,追蹤資料流量或 API 呼叫)

  • 不要使用 console.log()
  • 改用 Winston 或 Bunyan 之類的記載程式庫。

Matteo Collina 有一個 talk

  • The Cost of Logging - Matteo Collina, nearForm
  • https://www.youtube.com/watch?v=Dnx2SPdcDSU
  • 說到 log 的時間常常被 develop 忽略,但其實每次執行 log 都是有 cost 的
    • 假如你的 app 有執行 10 次 log、100 位 users,那就是 -> (10 * 100) ms cost

所以選用更快的 log library

為了確保能處理所有的異常狀況,請使用下列技術:

  • 使用 try-catch
  • 使用 promise

expressJS 官網也是這樣說,最好的辦法是 next() 出去 error,透過 middleware 來傳播 err

在分別討論這兩個主題之前,您對 Node/Express 錯誤處理方式應有基本的瞭解:使用「錯誤優先回呼」,並將錯誤傳播至中介軟體。Node 從非同步函數傳回錯誤時,會採用「錯誤優先回呼」慣例,其中,回呼函數的第一個參數是錯誤物件,接著是後續參數中的結果資料。如果要指出無錯誤,會傳遞 null 作為第一個參數。回呼函數必須同樣遵循「錯誤優先回呼」慣例,才能實際處理錯誤。在 Express 中,最佳作法是使用 next() 函數,透過中介軟體鏈來傳播錯誤。

盡量避免

  • 使用 uncaughtException 事件,此事件是在回歸事件迴圈期間不斷引發異常狀況時產生的
  • 儘管發生異常狀況,該程序會繼續執行。阻止應用程式當機,似乎是個好辦法,但是在未捕捉到異常狀況之後,又繼續執行應用程式,卻是危險作法而不建議這麼做
    • 因為程序的狀態會變得不可靠且無法預測。

2.5 用 Swagger or GraphQL 來 Document API 的 errors

讓 API 的使用者 (developer) 知道有「哪些錯誤」可能會 return

  • 這樣他們才能清楚知道要處理哪些 error、避免 crash
  • RESTful API 可以透過 Swagger 來處理
    • REST API 就使用 status code 來 return。這是使用者絕對需要的
    • e.x. API document 可能預先聲明,當客戶名稱已經存在時(假定API註冊了新用戶),將返回 status code 409,以便使用者能針對給定情況相應地呈現最佳 UX。
    • Swagger 提供了一個生態系統的工具,可以比較輕鬆的建立 document
  • GraphQL 就透過 schema 和 comments

GraphQL Error Example

# should fail because id is not valid
{
  film(id: "1ZmlsbXM6MQ==") {
    title
  }
}
{
  "errors": [
    {
      "message": "No entry in local cache for https://swapi.co/api/films/.../",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "film"
      ]
    }
  ],
  "data": {
    "film": null
  }
}

2.6 發生 unknown error 時,restart process gracefully

當 unknown error 發生時,常見的處理法靠 PM2 來 restart

  • 由 error handler 來決定 error 的處理方式
    • 如果該 error 是可信任的(i.e. operational error, see further explanation within best practice #3)
      • 則寫入日誌文件可能就足夠了。
    • 如果 error 是不認得的,事情就會變得很繁瑣
      • 代表著某些組件可能處於故障狀態,並且所有將來的請求都可能失敗
        • 例如,假設有一個單例的有狀態令牌發行者服務引發了一個異常並丟失了其狀態
        • 從現在開始,它可能會表現異常並導致所有請求失敗。
        • 在這種情況下,終止進程並使用“重啟工具”(例如Forever,PM2等)以乾淨狀態重新開始。
// Assuming developers mark known operational errors with error.isOperational=true, read best practice #3
process.on('uncaughtException', (error) => {
  errorManagement.handler.handleError(error);
  if(!errorManagement.handler.isTrustedError(error))
    process.exit(1)
});

// centralized error handler encapsulates error-handling related logic
function errorHandler() {
  this.handleError = (error) => {
    return logger.logError(error)
      .then(sendMailToAdminIfCritical)
      .then(saveInOpsQueueIfCritical)
      .then(determineIfOperationalError);
  }

  this.isTrustedError = (error) => {
    return error.isOperational;
  }
}
// Assuming developers mark known operational errors with error.isOperational=true, read best practice #3
process.on('uncaughtException', (error: Error) => {
  errorManagement.handler.handleError(error);
  if(!errorManagement.handler.isTrustedError(error))
    process.exit(1)
});

// centralized error object that derives from Node’s Error
export class AppError extends Error {
  public readonly isOperational: boolean;

  constructor(description: string, isOperational: boolean) {
    super(description);
    Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain
    this.isOperational = isOperational;
    Error.captureStackTrace(this);
  }
}

// centralized error handler encapsulates error-handling related logic
class ErrorHandler {
  public async handleError(err: Error): Promise<void> {
    await logger.logError(err);
    await sendMailToAdminIfCritical();
    await saveInOpsQueueIfCritical();
    await determineIfOperationalError();
  };

  public isTrustedError(error: Error) {
    if (error instanceof AppError) {
      return error.isOperational;
    }
    return false;
  }
}

export const handler = new ErrorHandler();

2.7 使用成熟的 logger 來提昇 error 的 visibility

  • 像是 Winston, Bunyan, Log4js or Pino
  • So forget about console.log

一些技巧能從 log 中更快找出 error 的原因

  1. log frequently using different levels (debug, info, error),
  2. when logging, provide contextual information as JSON objects, see example below.
  1. Watch and filter logs using a log querying API (built-in in most loggers) or a log viewer software.
  2. Expose and curate log statement for the operation team using operational intelligence tools like Splunk

其他

  • 每一個 log 都要有 Timestamp
    • 這樣才好 self-explanatory、能辨別順序。
  • 允許設定多個 log destination
    • 舉例,一開始寫 trace log 到某一個 log file
    • 接者有 error 發生時
      1. 寫到同一個 log file 中(上面所指的那個「某一個 log file」)
      2. 接著寫到另一個 error log file 中
      3. 接著 email 通知 admin error 發生

(最後這一點跟我自己的觀念有同樣的味道。無論 log 分多少種 level (不同 log file),最終一定要有一個 log file 是有紀錄「所有 level」的 log。這樣才好一次看出完整的 log 脈絡。)

2.8 Test error flows using your favorite test framework

  • 除了 test 一些正常的結果以外,也要 test 返回「正確的 error」
    • 善用 test framework 達到這點
    • 沒有「test error」,就沒法保證能正常的 handle error
// ensuring the right exception is thrown
describe('Facebook chat', () => {
  it('Notifies on new chat message', () => {
    const chatService = new chatService();
    chatService.participants = getDisconnectedParticipants();
    expect(chatService.sendMessage.bind({ message: 'Hi' }))
      .to.throw(ConnectionError);
  });
});
// ensuring API returns the right HTTP error code
it('Creates new Facebook group', () => {
  const invalidGroupInfo = {};
  return httpRequest({
    method: 'POST',
    uri: 'facebook.com/api/groups',
    resolveWithFullResponse: true,
    body: invalidGroupInfo,
    json: true
  }).then((response) => {
    expect.fail('if we were to execute the code in this block, no error was thrown in the operation above')
  }).catch((response) => {
    expect(400).to.equal(response.statusCode);
  });
});

2.9 使用 Monitoring and performance products (a.k.a APM) 來發現 error 跟 downtime

  • 利用 APM 來主動發現問題
  • Exception != Error
    • error handling 是來處理 code 的 exception
    • App 的 Error 可能會導致
      • slow code paths
      • API downtime
      • lack of computational resources
      • 這就是靠 APM 發揮的地方

APM 產品的常見功能包括:

  • 例如 HTTP API 返回錯誤時發出警報
  • 檢測 API response time 何時降至某個閾值以下
  • 監視 server 資源
  • 具有IT指標的運營智能儀表板以及許多其他功能

APM 產品構成 3 個主要部分:

  1. 網站或 API 監視
    • 外部服務通過 HTTP 請求不斷監視正常運行時間和性能
    • 可以在幾分鐘內完成設置
    • 知名的有: Pingdom, Uptime Robot, and New Relic
  2. 代碼檢測
    • 需要將代理程序嵌入 App 中才能使用 slow code detection,異常統計,性能監視等
    • New Relic, App Dynamics
  3. 運營情報儀表板
    • 重點是為操作團隊提供指標和精選內容
    • 以幫助輕鬆地保持應用程序性能的最高水平
    • 通常涉及匯總多個信息源(應用程序日誌,數據庫日誌,服務器日誌等)和前期儀表板設計工作
    • Datadog, Splunk and Zabbix

2.10 Catch unhandled promise rejections

  • nodejs 中,Promise 如果發生了 reject,但沒有 catch (沒有 catch 的 code)
    • 此時會發生 unhandledRejection error,用它來抓出漏掉 catch 的 error

這個 error 不會被 uncaughtException 抓到

  • 因建議用 process.on('unhandledRejection', callBack) 確保所有 promise error 都得到處理。
// these errors will not get caught by any error handler
// (except unhandledRejection)
DAL.getUserById(1).then((johnSnow) => {
  // this error will just vanish
  if(johnSnow.isAlive === false)
      throw new Error('ahhhh');
});
// Catching unresolved and rejected promises
process.on('unhandledRejection', (reason, p) => {
  // I just caught an unhandled promise rejection,
  // since we already have fallback handler for unhandled errors (see below),
  // let throw and let him handle that
  throw reason;
});

process.on('uncaughtException', (error) => {
  // I just received an error that was never handled, time to handle it and then decide whether a restart is needed
  errorManagement.handler.handleError(error);
  if (!errorManagement.handler.isTrustedError(error))
    process.exit(1);
});

2.11 Fail fast,使用專門的 library 驗證 arguments

  • 這應該要是你的 Express 的 best practice 的一部分
    • Assert API 的 input,避免掉麻煩、難追蹤的 bug

validation code 通常是很難處理的

舉例,User 呼叫 API 時,應該要傳 number,但傳了 "Discount"

  • 假如檢查是 "Discount" != 0(允許的折扣金額大於零)
  • 這樣檢查就過關了...

Anti-pattern: no validation yields nasty bugs

// if the discount is positive let's then redirect the user to print his discount coupons
function redirectToPrintDiscount(httpResponse, member, discount) {
  if (discount != 0) {
    httpResponse.redirect(`/discountPrintView/${member.id}`);
  }
}

redirectToPrintDiscount(httpResponse, someMember);
// forgot to pass the parameter discount, why the heck was the user redirected to the discount screen?

validating complex JSON input using ‘Joi’

var memberSchema = Joi.object().keys({
  password: Joi.string().regex(/^[a-zA-Z0-9]{3,30}$/),
  birthyear: Joi.number().integer().min(1900).max(2013),
  email: Joi.string().email()
});

function addNewMember(newMember) {
  // assertions come first
  Joi.assert(newMember, memberSchema); //throws if validation fails
  // other logic here
}

3 都是屬於 Code Style Practices

3.1 Use ESLint

使用 ESLint,並結合 prettier or beautify

  • 幫忙檢查可能的錯誤
  • 調整錯誤的 code style
  • 減少不必要的程式碼
  • developer 不需要花精神去處理 code style 的問題,交給這些工具去 format

3.2 Node.js 特定的 plugins

除了 ESLint 這種 for vanilla JavaScript 之外,另外還有 Node.js 的 plugin 可以用

這些 plugin 可以找出一些可能的安全性問題

3.3 起始大括號維持在同一行

// good
function someFunction() {
  // code block
}

// bad
function someFunction()
{
  // code block
}

3.4 正確的分隔陳述 (Separate your statements properly)

  • 利用 ESLint 檢查,或者 Prettier 來修正換行問題,避免錯誤換行導致程式碼錯誤

3.5 為所有的 functions 命名

  • 包含 closures and callbacks
    • Avoid anonymous functions
  • 當 profiling node app 時,才能夠區別是哪個 function、才能夠檢查 memory snapshot
    • 不然的話,就只會發現 anonymous functions 佔用大量的 memory

3.6 對 variables, constants, functions and classes 使用命名原則

  • lowerCamelCase for constants, variables and functions
  • UpperCamelCase for classes
  • Use descriptive names, but try to keep them short

3.7 優先選 const,接著是 let,不要用 var

  • const > let >>>>> var

3.8 每隻檔案,都優先 Require modules,而不是在其中的 functions 裡面做

  • 在每個文件的開頭 Require modules
    • 這做法可以,快速的在最上方就說明這隻檔案的依賴性,還可以避免一些潛在的問題

Node.js 中

  • Requires 是 synchronous 的執行
  • 如果在 function 中被呼叫,可能會 block other requests
  • 另外,required 失敗時可能會造成 crash,放在最上面能幫忙最快發現問題,而不是等到某 function 執行時才發現問題

3.9 從 folders 來 Require modules,而不是直接指向檔案

  • 開發 module/library 時,放一個 index.js 來 expose 內部的東西
    • 讓所有 user 都透過它來存取
    • 未來改動時,比較不會 breaking change,例如,改變內部檔案結構
// good
module.exports.SMSProvider = require("./SMSProvider");
module.exports.SMSNumberResolver = require("./SMSNumberResolver");

// bad
module.exports.SMSProvider = require("./SMSProvider/SMSProvider.js");
module.exports.SMSNumberResolver = require("./SMSNumberResolver/SMSNumberResolver.js");

3.10 使用 === operator

  • 使用 === 嚴格相等運算符
0 == ""; // true
0 == "0"; // true

false == "false"; // false
false == "0"; // true

false == undefined; // false
false == null; // false
null == undefined; // true

" \t\r\n " == 0; // true

3.11 使用 Async Await,避免 callbacks

  • Node 8 就支援 Async-await
    • 這樣也支援 try-catch
  • 不然的話就要處理 nesting 的 code,nesting 的 error handle 跟 flow 非常難處理

3.12 使用 arrow function expressions (=>)

雖然上面推薦使用 async-await 來避免拿 function 當作參數 (callback) 但處理 older API 時,還是會遇到使用 promise or callback 的時候

  • 能讓程式碼更簡潔
  • 保持 this 一致 (但,如果可以的話,還是避免使用 this 優先)
  1. Testing And Overall Quality Practices

4.1 至少寫 API testing

  • 小項目 or 時間因素,很多 project 是沒有 test 的
  • 優先對 API寫 test,相對單純、也重要
    • 甚至用工具 e.x. postman,不用寫 code 也能測試
  • 有更多資源時,在繼續加入 unit testing, DB testing, performance testing

4.2 每一個 test name 要包含 3 個部分

  1. 正在測試什麼?被測的單元是?例如,ProductsService.addNewProduct 方法
  2. 在什麼情況和情況下?例如,沒有價格傳遞給該方法
  3. 預期結果是什麼?例如,新產品未獲批准

Code example: a test name that incluces 3 parts

//1. unit under test
describe('Products Service', () => {
  describe('Add new product', () => {
    //2. scenario and 3. expectation
    it('When no price is specified, then the product status is pending approval', () => {
      const newProduct = new ProductService().add(...);
      expect(newProduct.status).to.equal('pendingApproval');
    });
  });
});

Code Example Anti Pattern:

  • one must read the entire test code to understand the intent
describe('Products Service', () => {
  describe('Add new product', () => {
    it('Should return the right status', () => {
      //hmm, what is this test checking? what are the scenario and expectation?
      const newProduct = new ProductService().add(...);
      expect(newProduct.status).to.equal('pendingApproval');
    });
  });
});

4.3 AAA pattern 來組織你的 tests

  • Arrange, Act & Assert (AAA),使用三個獨立的部分來組織測試
  1. Arrange: 準備/模擬 test 所需的場景,可能包括
    • 建立要測試的 constructor 的 instance
    • DB 新增 data
    • mocking/stubbing on objects and any other preparation code
  2. 執行: 執行 unit test (通常就是 1 行 code)
  3. Assert: 確認 return 的 value 符合 ecpect (通常就是 1 行 code)

Code example: a test strcutured with the AAA pattern

describe.skip('Customer classifier', () => {
  test('When customer spent more than 500$, should be classified as premium', () => {
    //Arrange
    const customerToClassify = {spent:505, joined: new Date(), id:1}
    const DBStub = sinon
      .stub(dataAccess, 'getCustomer')
      .reply({id:1, classification: 'regular'});

    //Act
    const receivedClassification = customerClassifier
      .classifyCustomer(customerToClassify);

    //Assert
    expect(receivedClassification).toMatch('premium');
  });
});

Code Example Anti Pattern:

  • no separation, one bulk, harder to interpret
test('Should be classified as premium', () => {
  const customerToClassify = {spent:505, joined: new Date(), id:1}
  const DBStub = sinon
    .stub(dataAccess, 'getCustomer')
    .reply({id:1, classification: 'regular'});
  const receivedClassification = customerClassifier.classifyCustomer(customerToClassify);
  expect(receivedClassification).toMatch('premium');
});

4.4 善用 linter 檢查

4.5 避免 global test,每次測試時,新增該 test 的 data

  • 為避免 test 之間的耦合、干擾,每個 test 應該要自己加入自己的 data、對其自己的 db 進行操作
  • 如果有 performance 問題
    • a balanced compromise might come in the form of seeding the only suite of tests that are not mutating data (e.g. queries)
    • (這段話我完全看不懂,只能猜想是,也是事先準備好一些 not mutating data 來針對此有 performance issue 的 test 做處理)

Code example: each test acts on its own set of data

it('When updating site name, get successful confirmation', async () => {
  //test is adding a fresh new records and acting on the records only
  const siteUnderTest = await SiteService.addSite({
    name: 'siteForUpdateTest'
  });
  const updateNameResult = await SiteService
    .changeName(siteUnderTest, 'newName');
  expect(updateNameResult).to.be(true);
});

Code Example Anti Pattern:

  • test 不是獨立的、它們假設會有 data 會預先設定好
before(() => {
  //adding sites and admins data to our DB. Where is the data? outside. At some external json or migration framework
  await DB.AddSeedDataFromJson('seed.json');
});
it('When updating site name, get successful confirmation', async () => {
  //I know that site name 'portal' exists - I saw it in the seed files
  const siteToUpdate = await SiteService.getSiteByName('Portal');
  const updateNameResult = await SiteService
    .changeName(siteToUpdate, 'newName');
  expect(updateNameResult).to.be(true);
});
it('When querying by site name, get the right site', async () => {
  //I know that site name 'portal' exists - I saw it in the seed files
  const siteToCheck = await SiteService.getSiteByName('Portal');
  expect(siteToCheck.name).to.be.equal('Portal'); //Failure! The previous test change the name :[
});

4.6 定期檢查 dependencies 的弱點

  • 定期利用 npm auditnpx snyk test 檢查 dependencies vulnerabilities

4.7 Tag your tests

  • 不同的測試是針對完全不同的情境
    • e.x. quick smoke, IO-less
  • test 應該在 developer save or commit 時執行
  • full end-to-end test 就在新的 pull request or push 時執行

這邊可以透過 tag 來選擇 test 的類型

  • e.x. #cold #api #sanity (這可能還要看 test library 支不支援)
    • mocha --grep 'sanity'
    • jest -t=auth. (--testNamePattern)

否則,每次都執行所有測試,可能

  • 包括執行數十個 db query 的測試
    • 開發人員在每次進行小的更改時都會非常緩慢,並使開發人員無法運行測試

4.8 檢查 test coverage,有助於確定錯誤的測試模式

  • 看 test coverage 能幫忙找出沒 cover 到的地方
    • e.x. catch clauses
  • 設定 threshold,threshold 沒過時讓 CI fail
    • 這樣才有 CI 自動化來提示你的 test coverage

4.9 檢查過時的 npm packages

  • npm outdated 檢查 package 的版本
  • 也透過 npm audit 檢查 package 安全性問題

升級版本通常可以

  • 改善效能、減少 bundle size、提高安全性

4.10 建立像 production-like 的 e2e 測試環境

  • e2e 測試中,盡可能的讓環境像 production 的環境
    • 連同 data 也要,data 是 e2e test 最弱的地方,有 production data,test 更好
  • 利用 docker-compose 建構 e2e test 環境
    • 如果沒有 docker-compose,就要在每個測試環境(含 developer 的電腦)維護一個測試的 database,並且同步 data。以便測試結果不會隨環境的變化而變化。

4.11 使用靜態分析工具定期重構

  • 在 CI 中加入 ESLint improve code quality

這邊還提到兩個工具,但我還真的從來沒聽過有人在談這兩個工具

  • Sonarqube (2,600+ stars) and Code Climate (1,500+ stars)
  • 用來分析程式碼複雜度

後面兩個太少太少見了,未來應該還是會忽略。

  • 基本上就是靠 hooks and CI 在 commit and push 時跑 ESLint, prettier 來改善 code quality
  • 接著靠 code review

4.12 慎選 CI platform (Jenkins vs CircleCI vs Travis vs ...)

  • 選 CI platform 時,可以看看 plugins 的 ecosystem
    • Jenkins 作為很多 project 的首選,也是因為 community 強大
      • 但相對學習曲線較高
    • CircleCI 等,相關設定比較簡單
  • 需要控制最精細的細節,Jenkins 仍然是首選平台
  • 選擇時,最終還是要要衡量自己需求、CI 流程自定義的需求程度
    • 像 code 放在 github, gitlab,直接用它們的 CI,整合起來一定比較方便。

4.13 Test middlewares in isolation

當 middlewares 有跨越許多請求、很多邏輯時

  • 這時後可以 isolate test,不要把 web framework 整個 run 起來
  • 透過 stubbing and spying { req, res, next } objects 能輕鬆 test

Code example: Testing middleware in isolation

//the middleware we want to test
const unitUnderTest = require("./middleware");
const httpMocks = require("node-mocks-http");
//Jest syntax, equivalent to describe() & it() in Mocha
test("A request without authentication header, should return http status 403", () => {
  const request = httpMocks.createRequest({
    method: "GET",
    url: "/user/42",
    headers: {
      authentication: ""
    }
  });
  const response = httpMocks.createResponse();
  unitUnderTest(request, response);
  expect(response.statusCode).toBe(403);
});

5 是關於產品上 production 的 best practice

5.1. 監控

  • 監控,才能讓你輕鬆的知道 production 發生的狀況
    • 透過 email, slack 通知
  • 挑戰是選擇合適你系統的 tool
  • 先從核心的指標開始監控
    • CPU, server RAM, Node process RAM (少於 1.4GB)
    • the number of errors in the last minute
    • restart process 的次數
    • 平均的 response time
  • 後續才考慮 advanced features
    • e.x. DB profiling
    • cross-service measuring (i.e. measure business transaction)

實現 advanced features 就需要花不少時間了,或者要考慮產品

  • such as Datadog, NewRelic and alike

困難的是,單單要監控核心指標就不是容易的事情

  • 有些是 hardware 相關(CPU),其他有的是在 node 的 process 中(internal error)
  • 所需通常需要額外的設定來整合監控功能

例如

  • cloud vendor monitoring solutions
    • (e.g. AWS CloudWatch, Google StackDriver)
    • 會告訴你現在的 hardware 狀況,但沒有任何關於 app insternal 的資訊
  • Log-based 的 solutions,如 ElasticSearch,通常就不會有 hardware 資訊

常見的方法是

  • 把 App log 傳入 Elastic stack
    • Elastic stack: Elasticsearch、Logstash、Kibana 和 Beats
  • 設定而外的代理來分享 (Beat) hardware-related information

Elastic Stack 日誌分析平臺搭建筆記

5.2 寫好的 log 來增加資訊

開發第一天就要規劃 log

  • 如何收集、儲存和分析 log,確保這些能提供有用資訊
    • 例如 error rate, following an entire transaction

無論記錄多少 log,始終還是需要一些 interface 來包裝 production 的 info

  • e.x. trace errors and core metrics
    • how many errors happen every hour
    • which is your slowest API end-point

要把 log 做到位,需要達到三點

  1. 選知名的 logging library (Winston, pino)
    • 在每個 transaction 的 start, end 都記錄 log
    • 用 JSON format 並包含 context (e.x. userID, 操作類型)
    • 每個 log 都要包含 unique transaction ID (參考 5.14)
    • 最後考慮 agent (like Elastic Beat) 來 log system resource (e.x. CPU, memory)
  2. 統一存放 log 的地方
    • 定期把 log push 過去,聚集一起,簡化、可視化這些 log
    • Elastic stack 是現在很流行的選擇
  3. visualization
    • log 聚集後,就能輕鬆 search (能輕鬆 search log 是很重要的)
    • 更進一步是 visualization 一些重要操作指標
      • e.x. 一天的平均 CPU,過去一小時新用戶新增數量 等等,越多這些越能幫助管理

5.3. 盡可能把任務透過 reverse proxy 處理 (e.g. gzip, SSL)

  • Node 在處理 CPU 密集的 task 表現上還是比較差
    • (如 gzip 壓縮,SSL 終止、serving static files、throttling requests 等)
  • 透過真正的 middleware services 來處理這些事情
    • like nginx, HAproxy or cloud vendor services instead

Nginx Config Example – Using nginx to compress server responses

# configure gzip compression
gzip on;
gzip_comp_level 6;
gzip_vary on;

# configure upstream
upstream myApplication {
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    keepalive 64;
}

#defining web server
server {
    # configure server with ssl and error pages
    listen 80;
    listen 443 ssl;
    ssl_certificate /some/location/sillyfacesociety.com.bundle.crt;
    error_page 502 /errors/502.html;

    # handling static content
    location ~ ^/(images/|img/|javascript/|js/|css/|stylesheets/|flash/|media/|static/|robots.txt|humans.txt|favicon.ico) {
    root /usr/local/silly_face_society/node/public;
    access_log off;
    expires max;
}

5.4. Lock dependencies

  • 要把 dependencies 的版本 lock 住
  • npm 5 後就有 package.lock.json 了、yarn 本來就有 yarn.lock

這點基本上不用太多說明什麼了,記得要用 lock file 來 install dependencies

5.5. 用工具來 Guard process

  • 簡單、單純的架構時,基本的工具就靠 pm2 重啟 crash 的 NodeJS
  • docker 的話,靠 container management 自己處理,但 container 裡面可以用 pm2

5.6. 利用所有的 CPU

  • 基本的情況下,Node 只會在一個 CPU core 上執行,其他 CPU 就閒置
    • 中小型的 App 用 Node PM2 來 replicate Node process 利用 CPUs
    • 大型的 App 就要考慮用 Docker cluster (e.g. K8S, ECS)
  • 接著就用 nginx 來 load balance

5.7. 建議一個 maintenance 的 endpoint

  • 開放一組 �system-related information 的 highly secure HTTP API
    • e.x memory usage and REPL
    • 雖然這類型目的比較推薦使用某些成熟的工具,但還是有些資訊與操作比較容易自己寫 code 實現

目的是

  • 讓 Ops/dev team 用來監視與維護的功能
    • e.x. get heap dump (memory snapshot) of the process
    • 甚至可以直接執行 REPL
    • 這有些資訊就無法靠常見的 DevOps 工具來取得
      • 或者這些工具就是要付費、整合才有此功能

重點

  • endpoint 保持 private、限制只有 admins 才有權限存取
  • avoide DDOS attack

Code example:

// generating a heap dump via code
// https://www.npmjs.com/package/heapdump
const heapdump = require('heapdump');

// Check if request is authorized
function isAuthorized(req) {
  // ...
}

router.get('/ops/heapdump', (req, res, next) => {
  if (!isAuthorized(req)) {
    return res.status(403).send('You are not authorized!');
  }

  logger.info('About to generate heapdump');

  heapdump.writeSnapshot((err, filename) => {
    console.log('heapdump file is ready to be sent to the caller', filename);
    fs.readFile(filename, 'utf-8', (err, data) => {
      res.end(data);
    });
  });
});

除了產生 snapshot 之外,還可以利用 chrome devtool 來查看這些 data (有圖)

5.8. 利用 APM (application performance monitoring) 監控 errors 跟 downtime

  • APM 指的是從 User 的角度 (client side) 去監控系統效能、狀況
    • 好的 APM 大多都是商業方案
  • APM 會主動去 measure 全面 user-experience、跨服務、層級
    • e.x. APM 能警示 end-users 執行的 transaction 開始變慢了跟可能的原因

e.x.

5.9. production-ready (Make your code production-ready)

  • 從 day1 開發時,就要規劃 production 的事情

下面的 tips 對 production 的 maintenance and stability 有很大幫助

  • The twelve-factor guide (這些 factor 已經是業界必讀的了)
  • stateless
    • 讓 server 成為 serverless 狀態(這點後面有再解釋)
  • 盡可能利用 cache,但絕不能因為 cache mismatch 而造成 fail
  • 測試 memory: 開發中就要看看 memory 的使用情況、有沒有 leak
  • functions 要命名,盡量減少匿名 function
    • 因為 memory profiler 這類工具會提供每一個 function 使用多少 memory,如果都是匿名 function 的話,就會看到一大堆 anonymous,難以辨別、追蹤。
  • 利用 CI 找出問題,避免把不好的、有問題的 code 推上 production �- (不只是 production,應該要有連 master branch 都不該進去心態)
  • Log 方面
    • 每個 log 要包含 contextual information
    • JSON format,這樣其他 log 整合工具 e.x. Elastic 就能夠 search 上面的 properties
    • 包含 transaction-id 幫助辨識每一個 requset、釐清關係、找相同 transaction 的 log
  • Error management: Error handling 是 Node.js production 的致命弱點
    • 參考上面 2 error handle 部分

5.10. 評估 memory usage

  • 監控 Node 的 process memory 是必要的
    • 小系統,可以直接 shell 指令查 (node-inspector),缺點就是每次都需要人工操作、觀察
    • 中大系統,還是需要靠監控系統
      • (例如 (AWS CloudWatch,DataDog或任何類似的主動系統)在發生 leak 時發出警報
  • v8 engine 對 memory 有 soft limits usage (1.4GB)

有幾個 development guidelines 也能減少 leak

  • avoid storing data on the global level
  • use streams for data with dynamic size
  • limit variables scope using let and const

5.11. 把 frontend 的 assets 移出 Node

  • Node 來處理 static file 對效能影響很大
    • 畢竟是 single-threaded model
  • 透過其他的服務來處理 assets (nginx, S3, CDN)

透過

  • reverse proxy (e.g. nginx, HAProxy)
  • cloud storage or CDN (e.g. AWS S3, Azure Blob Storage, etc) 這些能大大優化

範例: nginx.conf for static file

# configure gzip compression
gzip on;
keepalive 64;

# defining web server
server {
listen 80;
listen 443 ssl;

# handle static content
location ~ ^/(images/|img/|javascript/|js/|css/|stylesheets/|flash/|media/|static/|robots.txt|humans.txt|favicon.ico) {
root /usr/local/silly_face_society/node/public;
access_log off;
expires max;
}

5.12. 讓 service 為 stateless 狀態 (kill your servers almost every day)

  • 把 data 存在 external data stores
    • (e.g. user sessions, cache, uploaded files)
  • 考慮週期性的重啟你的 server or 使用 serverless
  • 否則特定機器 crash 掉就會給維護帶來挑戰,後續架構也難以 scaling。
  • stateless 讓我們不需要評估、也不用維護 server 的狀態
    • 這樣新增 or 移除 service 就不會有副作用,可以放心操作。

當然,這要看看產品規模。自己的 side project,只有一台機器的狀況,不用考慮這些

Code example: anti-patterns

// 錯誤示範1: 把一個 upload 的檔案存在 server 上 (locally)
// express middleware for handling multipart uploads
const multer = require('multer');
const upload = multer({ dest: 'uploads/' });

app.post('/photos/upload', upload.array('photos', 12), (req, res, next) => {});

// 錯誤示範2: authentication sessions (passport) 存在 local file or memory
const FileStore = require('session-file-store')(session);
app.use(session({
  store: new FileStore(options),
  secret: 'keyboard cat'
}));

// 錯誤示範3: storing information on the global object
Global.someCacheLike.result = { somedata };

5.13. 多利用工具幫忙自動檢測弱點

用這些工具幫忙檢查可能的弱點

5.14. 每一個 log 都指定一個 transaction id

  • 在同一個 request 中的每一個 log 都指定一個相同的 identifier, transaction-id: {some value}
    • 查 error log 時,這能幫助推斷狀況、了解前面的發生順序
    • 但在 Nodejs 中,async 這種特性下,這不容易實現

題外話,這點在 microservice 架構上更重要

  • 一個 request 中間可能關聯、傳過好幾個 service
  • 為每一個 request 附上一個 identifier
  • 不然,中間出錯死掉的話,根本無從查起這個 request 是從哪邊開始、中間經過的狀況,最後死在哪邊
    • 呼叫其他 service,就使用 header,如 x-transaction-id 來傳遞 id,保持相同
curl -I https://github.com

可以看到 github 有用 header 帶一個,可能也是相似的目的

  • X-GitHub-Request-Id: E3EC:38D6:2399C:33AA0:5EF9BF02

example: 收到一個 request,set transaction id

// The following example is using the npm library continuation-local-storage to isolate requests

const { createNamespace } = require('continuation-local-storage');
const session = createNamespace('my session');

router.get('/:id', (req, res, next) => {
  session.set('transactionId', 'some unique GUID');
  someService.getById(req.params.id);
  logger.info('Starting now to get something by id');
});

// Now any other service or components can have access to the contextual, per-request, data
class someService {
  getById(id) {
    logger.info('Starting to get something by id');
    // other logic comes here
  }
}

// The logger can now append the transaction id to each entry so that entries from the same request will have the same value
class logger {
  info (message) {
    console.log(`${message} ${session.get('transactionId')}`);
  }
}

5.15. Set NODE_ENV=production

  • 設定環境變數 NODE_ENVproduction
  • 很多的 library, framework
    • 會針對 development 做許多除錯的功能,例如提出建議、警告等等
    • 會針對 production 優化,並且移除不需要的 develop 功能與功能
  • expressjs 光是這樣設定,performance 可能就有 3 倍的差距

更常見的做法是在 package.json 的 scripts 加上

{
  "start:prod": "cross-env NODE_ENV=production NODE_PATH=./server pm2 start --interpreter ./node_modules/.bin/babel-node ./server",
}

針對 client 端的會靠打包工具 webpack 處理

5.16. Design automated, atomic and zero-downtime deployments

  • 透過 docker and CI 來提升部署的自動化與流程
    • 減少手動錯誤
    • 自動化加速部署速度

部署時間長 -> production downtime & human-related error 提高

5.17. 使用 LTS (long term support) 的 Node.js release

  • LTS 專注於穩定性和安全性,因此它們是最適合生產的產品
  • LTS 的版本至少維持 18 個月、以偶數版本號(例如4、6、8)
  • LTS 的變更僅限於 fix bug,包括穩定性,安全性更新,這些都確保不會破壞現有 App
    • Current release line 的 lifespan 更短,更新頻率更高

5.18. 在 App 裡面,不要靠 hard-coded 來做「logs 路由」

當在 container-based/cloud-based 環境時,containers 經常會因為 scaling 而擴展 or shut down

  • 所以要由執行環境 (container) 來決定 log 的 destination
  • App 只負責「什麼需要 log」、「stdout/stderr 輸出
    • 參考下面範例

Code Example – Anti-pattern: Log routing tightly coupled to application

const { createLogger, transports, winston } = require('winston');
/**
*  Requiring `winston-mongodb` will expose
*  `winston.transports.MongoDB`
*/
require('winston-mongodb');

// log to two different files, which the application now must be concerned with
// hard code path
const logger = createLogger({
  transports: [new transports.File({ filename: 'combined.log' })],
  exceptionHandlers: [new transports.File({ filename: 'exceptions.log' })]
});

// log to MongoDB, which the application now must be concerned with
// hard code MongoDB
winston.add(winston.transports.MongoDB, options);

Code Example – Better log handling + Docker example

// In the application:
const logger = new winston.Logger({
  level: 'info',
  transports: [
    new (winston.transports.Console)()
  ]
});

logger.log('info', 'Test Log Message with some parameter %s', 'some parameter', { anything: 'This is metadata' });

Then, in the docker container daemon.json:

{
  "log-driver": "splunk", // just using Splunk as an example, it could be another storage type
  "log-opts": {
    "splunk-token": "",
    "splunk-url": "",
    //...
  }
}

這個範例的 flow 就會是

  • log -> stdout -> Docker container -> Splunk

關於 docker 的 Splunk log, daemon.json

5.19. 上 production 時,使用 npm ci 指令來安裝 packages

  • npm ci 安裝符合 package.json and package-lock.json 版本的 packages
    • 否則可能 QA or 不同的 production 有著不一樣的 packages 版本

6 關於 Security 的 Best Practices

6.1. 使用 security rules 相關的 linter

  • 透過 linter 來幫忙發現 security issue
    • e.x. eslint-plugin-security, tslint-config-security
  • 在使用 pre-git 相關的 hooks,確保在 push remote 前先經過 linter 的檢查

6.2. 利用 middleware 限制 concurrent requests 數量

  • DOS attack 非常普遍了,透過其他服務
    • cloud load balancers, cloud firewalls, nginx
    • 另外在一層 rate-limiter-flexible middleware 來防範
      • 比較小、比較不重要的,可以用 express-rate-limit

nginx 本身就有非常好的 rate limiting 功能, ref:

而 Node.js 的 middleware 就是讓我們有多一個選擇(多一層彈性)

rate-limiter-flexible

const http = require('http');
const redis = require('redis');
const { RateLimiterRedis } = require('rate-limiter-flexible');

const redisClient = redis.createClient({
  enable_offline_queue: false,
});

// Maximum 20 requests per second
const rateLimiter = new RateLimiterRedis({
  storeClient: redisClient,
  points: 20,
  duration: 1,
  blockDuration: 2, // block for 2 seconds if consumed more than 20 points per second
});

http.createServer(async (req, res) => {
  try {
  const rateLimiterRes = await rateLimiter.consume(req.socket.remoteAddress);
  // Some app logic here

  res.writeHead(200);
  res.end();
  } catch {
  res.writeHead(429);
  res.end('Too Many Requests');
  }
})
 .listen(3000);

express-rate-limit

const RateLimit = require('express-rate-limit');
// important if behind a proxy to ensure client IP is passed to req.ip
app.enable('trust proxy');

const apiLimiter = new RateLimit({
  windowMs: 15*60*1000, // 15 minutes
  max: 100,
});

// only apply to requests that begin with /user/
app.use('/user/', apiLimiter);

6.3 從 config files 去讀取 secrets,或者用 packages 去 encrypt 它

  • 永遠不要把 secrets 直接 plain-text 在 configuration or code 裡面
  • 用 secret-management systems�
    • e.x Vault products, Kubernetes/Docker Secrets, or environment variables

最後手段(如果一定要存在 code 裡面的話)

  • 要對儲在 source control 的 secrets 進行加密和管理
    • (rolling keys, expiring, auditing等)
  • pre-commit/push hooks to prevent committing secrets accidentally �- 不然很容易被 expose

Node.js 最常見處理 keys and secrets 的方法

  • using environment variables (on the system where it is being run)
    • 設定後,就能透過 process.env 存取
  • 對於必須要存在 code 裡面的 secrets,透過 cryptr 加密

善用工具來避免 secrets 被 public 出去

  • e.x. git-secrets
// Accessing an API key stored in an environment variable
const azure = require('azure');

const apiKey = process.env.AZURE_STORAGE_KEY;
const blobService = azure.createBlobService(apiKey);
const Cryptr = require('cryptr');
const cryptr = new Cryptr(process.env.SECRET);

let accessToken = cryptr.decrypt('e74d7c0de21e72aaffc8f2eef2bdb7c1');

// outputs decrypted string which was not stored in source control
console.log(accessToken);

6.4. 用 ORM/ODM 來避免 query injection

  • 一定要使用 ORM/ODM 或者 database libraryescapes data 或者支援 named or indexed parameterized queries
  • 驗證 user 輸入的 data type
  • 永遠不要單單使用 template strings or string concatenation 輸入 queries
  • 知名的 libraries 都有內建保護,來避免 injection 了
    • (e.g. Sequelize, Knex, mongoose)

只要透過

透過這兩個來保護系統,基本上可以確保系統安全無虞

6.5. Collection of generic security best practices

很多重要安全議題跟 Node.js 沒有直接的相關,但也值得提出來講 參閱這邊整理、跟 OWASP 提出的建議

6.6. 設定 HTTP response headers 強化 security

  • 設定 headers 來避免像是 cross-site scripting (XSS), clickjacking 攻擊
    • 透過 helmet 的 library 輕鬆設定
  • 可以找一些類似 https://securityheaders.com/ 的網站掃描

安全相關的 headers 有這些

  • HTTP Strict Transport Security (HSTS)
  • Public Key Pinning for HTTP (HPKP)
  • X-Frame-Options
  • X-XSS-Protection
  • X-Content-Type-Options
  • Referrer-Policy
  • Expect-CT
  • Content-Security-Policy
  • Additional Resource

基本上可以透過 express or koa 相關的 Helmet library 處理。 下面針對這些 header 做說明

HTTP Strict Transport Security (HSTS)

  • 告訴 browser 應強制使用 HTTPS 取代 HTTP,減少連線劫持風險。
    • 一定要使用 HTTPS,並且當網站憑證出問題時,使用者不可以忽略警告而繼續訪問網站
    • max-age: 在未來多久時間內瀏覽器必須遵守,單位是秒
    • includeSubDomains: 是否要將子域名也加入此機制中,是可選 directive
    • preload 目的是讓使用者即使在第一次訪問網站時,也可以強制使用者走 HTTPS 協定
      • (要再去 google 一些關於「第一次走 http」時的處理方法)
    • protocol downgrade attacks
    • cookie hijacking

great ref

舉例: 啟用 HSTS

Strict-Transport-Security: max-age=2592000; includeSubDomains

上面表示

  • 在接下來的 86400 秒內(一周),
  • 訪問 example.com 以及其子域名(如:b.example.com、a.b.example.com)時,都必須走 HTTPS 協定
    • 且該網站憑證必須是有效的
  • 而如果沒有 includeSubDomains,則就只限定 example.com 此域名

Public Key Pinning for HTTP (HPKP) (這 header 感覺不需要處理)

  • 是要防止攻擊者利用數位憑證認證機構(CA)錯誤簽發的憑證進行中間人攻擊的一種安全機制
  • 採用公鑰固定時,網站會提供已授權公鑰的雜湊列表,指示 client 端在後續通訊中只接受列表上的公鑰

wiki 資訊

  • 2016年,Netcraft 在有關 SSL 的調研中稱,只有 0.09% 的憑證在使用 HTTP 公鑰固定
    • 實際有效的HTTP公鑰固定憑證數量低於3000
    • 該技術尚處於萌芽期,網站技術人員對其缺乏重視和理解
  • 更重要的是,錯誤的部署可能帶來網站方面無法接受的嚴重後果
    • User 在相當長一段時間內(取決於max-age的組態)因新憑證公鑰與舊HPKP策略不符,對網站的合法訪問都將遭拒
  • 因為網站部署率過低,Chrome 67 中終止了對 HPKP 的支援
  • https://zh.wikipedia.org/zh-tw/HTTP%E5%85%AC%E9%92%A5%E5%9B%BA%E5%AE%9A

X-Frame-Options

  • 用來防止 clickjacking, iFrame 攻擊
  • DENY: 無論如何都不能被嵌入到 frame 中,即使是自家網站也不行。
  • SAMEORIGIN: 當符合同源政策下,才能被嵌入到 frame 中。
  • ALLOW-FROM uri: 唯有列表許可的 URI 才能嵌入到 frame 中。

Header Example - Deny embedding of your application

X-Frame-Options: deny

X-XSS-Protection

  • 是舊有的屬性,基本上可以被 Content-Security-Policy 取代
  • 但是還是可以為那些沒有支援 Content-Security-Policy 的瀏覽器提供一層保護
    • 0: 關閉 XSS 過濾功能
    • 1: 開啟 XSS 過濾功能,如果偵測到 XSS 攻擊的話,瀏覽器會刪除不安全的部分
    • 1; mode=block: 開啟 XSS 過濾功能,如果偵測到 XSS 攻擊的話,瀏覽器不會把網頁給渲染出來
    • 1;report= (Chromium only) 開啟 XSS 過濾功能,如果偵測到 XSS 攻擊的話,瀏覽器會回報到指定的 URI

X-Content-Type-Options

  • 避免瀏覽器執行不符 Content-type 的操作

這個範例

X-Content-Type-Options: nosniff

下面兩種情況的請求將被阻止:

  • 請求類型是 "style" 但是 MIME 類型不是 "text/css",
  • 請求類型是 "script" 但是 MIME 類型不是 JavaScript MIME 類型。

Referrer-Policy

  • 是對 Referrer策略

Referrer 包含了

  • 當前請求頁面的來源頁面的地址
  • 服務端一般使用 Referer header 識別訪問來源,可能會以此進行統計分析、log 記錄以及緩存優化等

幾個 value 設定

  • no-referrer: 整個 Referer header 會被移除。訪問來源信息不隨著請求一起發送。
  • no-referrer-when-downgrade(默認值):
    • 在沒有指定任何策略的情況下用戶代理的默認行為
    • 在同等安全級別的情況下,引用頁面的地址會被發送(HTTPS -> HTTPS)
    • 但是在降級的情況下不會被發送 (HTTPS -> HTTP)。
  • origin: 在任何情況下,僅發送文件的源作為引用地址。例如
  • origin-when-cross-origin
    • 對於同源的請求,會發送完整的 URL 作為引用地址
    • 對於非同源請求僅發送文件的源
  • strict-origin-when-cross-origin:
    • 對於同源的請求,會發送完整的 URL 作為引用地址
    • 在同等安全級別的情況下,發送文件的源作為引用地址(HTTPS -> HTTPS)
    • 在降級的情況下不發送此 header (HTTPS->HTTP)。
  • "https://github.com/" 就有設 "origin-when-cross-origin, strict-origin-when-cross-origin"

Expect-CT

  • 取代 HPKP 的 header

如何實現 Expect-CT header?

  • 首先,確保當前的證書支持 CT,可以通過生成 SSL 實驗室報告來實現。
  • 接下來,使用 header
  • Expect-CT: enforce,max-age=30,report-uri="https://ABSOLUTE_REPORT_URL"
    • max-age:data 存儲在 browser 緩存中的持續時間(以秒為單位)
    • report-uri:違規報告將送到的 URL,必須是絕對URL(例如https://example.com/report)
    • enforce:如果存在不符合 CT 的證書,則指示是否應該建立連接

(這個 header 還是不太懂實際上要怎麼活用、github 有使用) curl -I https://github.com

expect-ct: max-age=2592000, report-uri="https://api.github.com/_private/browser/errors"

Content-Security-Policy

其中一個最重要的 header,有很多完整的文件了

6.7. 持續並且自動化監控 dependencies 的 vulnerable

多利用 npm audit

6.8. 避免使用 Node.js 的 crypto library 處理 passwords,應該要用 Bcrypt

  • Passwords or secrets (API keys) 的儲存應該要被 secure hash + salt 才對
    • 利用 bcrypt
  • 不然容易被暴力 or 字典攻擊

建議使用

  • bcrypt: https://www.npmjs.com/package/bcrypt,而不是 native crypto
    • 可以指定要多少回合。越多 hash rounds ==> 越安全、但也越慢
  • 因為 Node.js 的 crypto 所使用的 Math.random() 是可預測的
    • 不應該拿來來產生密碼 or token
try {
// asynchronously generate a secure password using 10 hashing rounds
  const hash = await bcrypt.hash('myPassword', 10);
  // Store secure hash in user record

  // compare a provided password input with saved hash
  const match = await bcrypt.compare('somePassword', hash);
  if (match) {
   // Passwords match
  } else {
   // Passwords don't match
  }
} catch {
  logger.error('could not hash password.')
}

6.9. Escape HTML, JS and CSS 的輸出

這些都是 XSS 的方法,攻擊者可能把惡意的 JS 存到你的 DB 中,接著傳送給其他 User 造成攻擊

  • 利用 library 減少這些情況
  • 對這些 data 做 encoding or escaping

這種情況,許多 library 和 HTML 模板引擎都提供轉義功能

  • 例如:escape-html,node-esap
  • 不僅應轉義 HTML 內容,還應轉義 CSS 和 JavaScript

6.10. 驗證傳入的 JSON 的 schemas

  • 驗證傳入的 requests' body payload,確保它符合格式
  • 避免每一個 route 專門去寫 validation coding,善用 lightweight JSON-based validation
    • e.x. jsonschema or joi
      • 也可能需要 Validator.js 之類一起支持驗證

確保儘早驗證

  • 例如,Express middleware 將 request 傳到 route 之前驗證 request body

6.11. 支持 blacklisting JWTs 功能

JWT,(假如是 Passport.js),默認情況下,沒有機制可以撤消對已發布 token 的訪問 一旦發現某些惡意用戶活動,就無法阻止他們訪問系統

  • 所以要透過 blacklist 減輕情況
    • Expired, or misplaced tokens could be used maliciously by a third party to access an application (甚至冒充原持有人)

JWT 是 stateless 的

  • 一旦發行,該 token 就可以通過 App 驗證。

導致的問題是安全問題

  • 因為只要提供的簽名與 App 期望的簽名匹配,簽名仍然有效,因此洩漏的 token 仍可以使用並且無法撤銷

因此,在使用 JWT 身份驗證時

  • App 應管理已過期或已吊銷 token 的黑名單,保持用戶的安全。

express-jwt-blacklist 範例 (看起來用的人實在很少阿 @@|||)

const jwt = require('express-jwt');
const blacklist = require('express-jwt-blacklist');

blacklist.configure({
  tokenId: 'jti',
  strict: true,
  store: {
    type: 'memcached',
    host: '127.0.0.1',
    port: 11211,
    keyPrefix: 'mywebapp:',
    options: {
      timeout: 1000
    }
  }
});

app.use(jwt({
  secret: 'my-secret',
  isRevoked: blacklist.isRevoked
}));

app.get('/logout', (req, res) => {
  blacklist.revoke(req.user)
  res.sendStatus(200);
});

6.12. 暴力攻擊

用兩個指標來限制 authorization

  1. 相同「user unique ID/name」和「IP地址」的「連續失敗嘗試次數」
  2. 是一段時間內來自「IP 地址」的「失敗嘗試次數」
    • 例如,如果 IP 地址在一天之內進行 100 次失敗嘗試,則阻止

否則:攻擊者可以發出無限制的自動密碼嘗試,來獲取 App 上的訪問權限 如果讓 /login/admin 較高權限的路由沒有速率限制

6.13. 不要用 root 權限執行 Node.js

  • 否則攻擊者有機會能不受限制地控製本地計算機
    • (例如,更改 iptable 並將流量重新路由到他的服務器)

大多 Node.js App 不需要 root 權限,但有兩種常見的情況可能需要 root:

  1. 特權 port(例如 80 port),Node.js 必須以 root 運行
    • 建議使用非特權 port,然後用 nginx 反向代理回 80
  2. Docker containers 運行預設就是 root

example: Building a Docker image as non-root

FROM node:latest
COPY package.json .
RUN npm install
COPY . .
EXPOSE 3000
USER node
CMD ["node", "server.js"]

6.14. 用 reverse-proxy 或 middleware 限制 payload size

  • body payload 越大,single thread 處理負擔越大
  • 攻擊者不需要大量 requests 就能達到攻擊的目的
  • 在邊緣就設下限制,能有效避免此問題(如: firewall, ELB

express 的 body-parser 預設 100kb

nginx 範例

http {
    ...
    # Limit the body size for ALL incoming requests to 1 MB
    client_max_body_size 1m;
}

server {
    ...
    # Limit the body size for incoming requests to this specific server block to 1 MB
    client_max_body_size 1m;
}

location /upload {
    ...
    # Limit the body size for incoming requests to this route to 1 MB
    client_max_body_size 1m;
}

6.15. 避免 JS 的 eval

  • evil 容易被 XSS
  • new Function() 也應該要禁止 (這點不是很懂怎麼實際被攻擊)
  • setTimeoutsetInterval 的參數也不該用動態傳數 JS code 的方式

這些接受 string parameter representing a JavaScript expression, statement, or sequence of statements

  • 不受信任的 User input 可能會 XSS
  • 建議重構代碼,使其不依賴於這些功能

Code example

// example of malicious code which an attacker was able to input
const userInput = "require('child_process').spawn('rm', ['-rf', '/'])";

// malicious code executed
eval(userInput);

6.16. 避免惡意的 RegEx 導致 single thread 過載

Some OWASP examples for vulnerable RegEx patterns:

// Code Example – Enabling SSL/TLS using the Express framework
const saferegex = require('safe-regex');
const emailRegex = /^([a-zA-Z0-9])(([\-.]|[_]+)?([a-zA-Z0-9]+))*(@){1}[a-z0-9]+[.]{1}(([a-z]{2,3})|([a-z]{2,3}[.]{1}[a-z]{2,3}))$/;

// should output false
// because the emailRegex 就是一個潛在的安全問題,執行起來會是指數時間
console.log(saferegex(emailRegex));

// instead of the regex pattern, 改用 validator.js:
const validator = require('validator');
console.log(validator.isEmail('liran.tal@gmail.com'));

6.17. 避免用 variable 的方式 load module

  • 因為有可能是 User 自己輸入的路徑,所以要避免 requiring/importing 作為參數
  • fs.readFile 也是一樣
  • Eslint-plugin-security 可以幫忙檢查

攻擊者可能

  • 透過這樣的方式,存取到不該被 public 的資料

Code example

// insecure, as helperPath variable may have been modified by user input
const badWayToRequireUploadHelpers = require(helperPath);

// secure
const uploadHelpers = require('./helpers/upload');

6.18. 執行 unsafe code 時,在 sandbox 裡執行

  • 在 run-time 時,如果要執行 external code (e.g. plugin),在 sandbox 環境下執行
    • isolates and guards the main code
  • 可以透過
  • 不然可能會有這些問題
    • 存取敏感的環境變數
    • 執行 infinite loops
    • memory overloading

如果希望最大程度地減少損害,甚至可能使流程成功終止

  • 要在 sandbox 來完全隔離 resources, crashes
  • 另外我們要能跟 sandbox share information

6.19. 使用 child processes 時要特別注意

  • 盡量避免使用 child processes
  • 使用的時候,要對 input 做驗證、檢查,避免 shell injection attacks
    • 可以進一步使用 user/group identities run your process (uid/gid 參數)
  • 最好使用 child_process.execFile 只執行帶有一組屬性的單個命令,不允許擴展 shell 參數
    • 類似 child_process.exec,但是預設為直接衍生命令而不先建立 shell。
      • exec 會建立一個 shell 並在該 shell 中執行命令,完成時回傳於 callback
  • 攻擊者有機會輸入系統命令,child processes 可能會導致遠程命令執行或 Shell 注入攻擊
// Dangers of unsanitized child process executions
const { exec } = require('child_process');

// as an example, take a script that takes two arguments, one of them is unsanitized user input
exec('"/path/to/test file/someScript.sh" --someOption ' + input);

// -> imagine what could happen if the user simply enters something like '&& rm -rf --no-preserve-root /'
// you'd be in for an unwanted surprise

From the Node.js child process documentation:

Never pass unsanitized user input to this function. Any input containing shell metacharacters may be used to trigger arbitrary command execution.

6.20. 把 clients 端的 error details 隱藏掉

很多人會寫自己的 error handling,並使用 Error objects (也被視為 best practice)

  • 當這樣做時,確保不要 return 整個 Error object 到 client 去
  • 不然,可能會從 stack trace 洩漏敏感資訊,如
    • server file paths
    • 使用哪些 third party modules
    • any internal workflows
    • 洩漏這些資訊,就可能讓 attacker 挖掘可能的攻擊機會

Code example: Express error handler

// error handler
// 這樣才不會讓 client 端、User 看到 stacktraces
app.use((err, req, res, next) => {
  res.status(err.status || 500);
  res.render('error', {
    message: err.message,
    error: {}
  });
});

6.21. npm or Yarn 要設定 2FA

在 development chain 的任何一步驟,都應該使用 MFA (multi-factor authentication) 保護

  • npm/Yarn 就是容易被攻擊的地方
    • 攻擊者,一旦取得權限,就能發布帶有惡意的 library 出去給你的使用者
  • 使用 2FA 保護

事件

6.22. 調整你的 session middleware 設定

每個 web framework 和技術都有「已知的弱點」,這些讓攻擊者知道要怎麼下手,嘗試攻擊

  • 所以,使用 default 的設定,就有可能被 "framework-specific hijacking attacks" 攻擊
    • X-Powered-By header
  • 隱藏你的 tech stack 有機會減少被攻擊的可能

每個 Web framework 和技術都有其已知的弱點

  • 告訴攻擊者我們使用的 framework 對他們有很大的幫助
  • session middlewares 的默認置定可以使 app 以類似於 X-Powered-By header 的方式暴露於特定於 module 和 framework 的劫持攻擊
  • 嘗試隱藏可識別並揭示你的 tech stack 的任何內容(如 Node.js, Express)

大多知名的 session middlewares 通常都不會設定 best practice/secure cookie settings

  • default 不設定這些,保持最乾淨、簡單的狀況給 User
  • 所以要調整這些 default,減少風險

最常見的 default 是 session name

同樣 express-session

  • cookie.secure 設置為 false為 default
  • true 會將 cookie的 傳輸限制為 https,這可以防止中間人攻擊

expressjs 官方文件

Code example: Setting secure cookie settings

// using the express session middleware
app.use(session({
 secret: 'youruniquesecret', // secret string used in the signing of the session ID that is stored in the cookie
 name: 'youruniquename', // set a unique name to remove the default connect.sid
 cookie: {
   httpOnly: true, // minimize risk of XSS attacks by restricting the client from reading the cookie
   secure: true, // only send cookie over https
   maxAge: 60000*60*24 // set cookie expiry length in ms
 }
}));

6.23. 當 process 要 crash 時,透過設定來避免 DOS attacks

(Avoid DOS attacks by explicitly setting when a process should crash)

當沒有 handle error 時,Nodejs 的 procress 就會 crash 很多 best practices 都會推薦這時候,就讓 procress exit 然後 restart,當作 error 的 handle 方式

  • 但這種方式,有時候給 attacker 一個大門
  • 一旦被 attacker 發現某個 api、router 他能讓你產生 unhandle 的 error
    • 它只要一直送這個 request,就讓 App 不停的 crash and exit 了

這一點並沒有 code example。光文字說明,我也沒有很懂要怎麼處理。 看來只能看情況而定了

  • 如果本來就有期望某些 error,你會期望 App exit and restart
    • 這時候可以再考慮看看,這會不會被 attacker 拿來攻擊

6.24. 避免 unsafe 的 redirects

  • 不驗證 User 輸入的 redirects,就可能讓 attackers 網絡釣魚詐騙
    • 竊取 User data, credentials or other malicious actions

Example: Unsafe express redirect using user input

// Unsafe expressjs redirect using user input
const express = require('express');
const app = express();

app.get('/login', (req, res, next) => {
  if (req.session.isAuthenticated()) {
    res.redirect(req.query.url);
  }
});

避免 unsafe redirects 解決方法

  • 避免依賴 User 的輸入
  • 如果必須使用 User 的輸入,則使用白名單的方法,確保安全 safe redirects

(這個觀念跟設計 restful api 一樣,如果有開放的 api 是要依據 user input 來 redirect,也應該要有白名單機制,這樣才能控制)

Example: Safe redirect whitelist

const whitelist = {
  'https://google.com': 1
};

function getValidRedirect(url) {
    // check if the url starts with a single slash
  if (url.match(/^\/(?!\/)/)) {
    // Prepend our domain to make sure
    return 'https://example.com' + url;
  }
    // Otherwise check against a whitelist
  return whitelist[url] ? url : '/';
}

app.get('/login', (req, res, next) => {
  if (req.session.isAuthenticated()) {
    res.redirect(getValidRedirect(req.query.url));
  }
});

6.25. Avoid publishing secrets to the npm registry

要避免把 secret 發佈到 npm registry

  • .npmignore (黑名單機制)
  • package.jsonfiles 欄位 (白名單機制)

另外可以先使用 --dry-run flag,來試跑看看,確認發佈的檔案有哪些

另外要特別注意!!!

  • 同時有 .npmignore.gitignore
    • npm 只會 follw .npmignore
    • npm 會 無視 .gitignore
  • 只有 .gitignore 時,npm 會 follw .gitignore
  • 所以有時候,有些開發人員更新了 .gitignore,卻忘了更新 .npmignore 這也會個問題

Example .npmignore file

#tests
test
coverage

#build tools
.travis.yml
.jenkins.yml

#environment
.env
.config

Example use of files array in package.json

{
  "files" : [
    "dist/moment.js",
    "dist/moment.min.js"
  ]
}

7 關於 Performance Best Practices (2020.05.26 第 7 部分還沒完成)

7.1. 避免 block event loop

  • 要避免 CPU intensive 的 tasks,這會 block Event Loop

Node.js 處理 Event Loop 主要在 single thread 上循環多個 queues

  • breaking long tasks into small steps then using the Worker Pool are some examples of how to avoid blocking the Event Loop.

主要參考

// Example: blocking the event loop
function sleep (ms) {
  const future = Date.now() + ms
  while (Date.now() < future);
}

server.get('/', (req, res, next) => {
  sleep(30)
  res.send({})
  next()
})

7.2. 優先用 native JS methods,而不是 user-land utils like Lodash

  • 隨著 V8,和 ES 新的標準,native JS 的效能已經改善很多
    • 使用 lodash, underscore 反而是不必要的相依性,有些還比較慢