一篇文章構建你的 NodeJS 知識體系

RingChenng發表於2019-01-26

最近讀《重學前端》,開篇就是讓你擁有自己的知識體系圖譜,後續學的東西補充到相應的模組,既可以加深對原有知識的理解,又可以強化記憶,很不錯的學習方案。

這篇文章主要知識點來自:

說明

比較好的 markdown 的檢視方式是直接用 VSCode 開啟大綱,這樣整個脈絡一目瞭然,後續補充知識點也很快定位到相應的位置:

01.png

這個 markdown 檔案已經丟到 Github,有更新會直接推這裡:

github.com/ringcrl/nod…

部落格上也會記錄一些好玩的東西:

www.chenng.cn/archives/

安裝

# 使用 nvm 安裝
https://github.com/creationix/nvm#install-script # Git install

nvm install
nvm alias default

# 解除安裝 pkg 安裝版
sudo rm -rf /usr/local/{bin/{node,npm},lib/node_modules/npm,lib/node,share/man/*/node.*}
複製程式碼

全域性變數

require(id)

  • 內建模組直接從記憶體載入
  • 檔案模組通過檔案查詢定位到檔案
  • 包通過 package.json 裡面的 main 欄位查詢入口檔案

module.exports

// 通過如下模組包裝得到
(funciton (exports, require, module, __filename, __dirname) { // 包裝頭

}); // 包裝尾
複製程式碼

JSON 檔案

  • 通過 fs.readFileSync() 載入
  • 通過 JSON.parse() 解析

載入大檔案

  • require 成功後會快取檔案
  • 大量使用會導致大量資料駐留在記憶體中,導致 GC 頻分和記憶體洩露

module.exports 和 exports

執行時

(funciton(exports, require, module, __filename, __dirname) { // 包裝頭
  console.log('hello world!') // 原始檔案
}); // 包裝尾
複製程式碼

exports

  • exports 是 module 的屬性,預設情況是空物件
  • require 一個模組實際得到的是該模組的 exports 屬性
  • exports.xxx 匯出具有多個屬性的物件
  • module.exports = xxx 匯出一個物件

使用

// module-2.js
exports.method = function() {
  return 'Hello';
};

exports.method2 = function() {
  return 'Hello again';
};

// module-1.js
const module2 = require('./module-2');
console.log(module2.method()); // Hello
console.log(module2.method2()); // Hello again
複製程式碼

路徑變數

console.log('__dirname:', __dirname); // 資料夾
console.log('__filename:', __filename); // 檔案

path.join(__dirname, 'views', 'view.html'); // 如果不希望自己手動處理 / 的問題,使用 path.join
複製程式碼

console

佔位符 型別 例子
%s String console.log('%s', 'value')
%d Number console.log('%d', 3.14)
%j JSON console.log('%j', {name: 'Chenng'})

process

檢視 PATH

node

console.log(process.env.PATH.split(':').join('\n'));
複製程式碼

設定 PATH

process.env.PATH += ':/a_new_path_to_executables';
複製程式碼

獲取資訊

// 獲取平臺資訊
process.arch // x64
process.platform // darwin

// 獲取記憶體使用情況
process.memoryUsage();

// 獲取命令列引數
process.argv
複製程式碼

nextTick

process.nextTick 方法允許你把一個回撥放在下一次時間輪詢佇列的頭上,這意味著可以用來延遲執行,結果是比 setTimeout 更有效率。

const EventEmitter = require('events').EventEmitter;

function complexOperations() {
  const events = new EventEmitter();

  process.nextTick(function () {
    events.emit('success');
  });

  return events;
}

complexOperations().on('success', function () {
  console.log('success!');
});
複製程式碼

Buffer

如果沒有提供編碼格式,檔案操作以及很多網路操作就會將資料作為 Buffer 型別返回。

toString

預設轉為 UTF-8 格式,還支援 asciibase64 等。

data URI

// 生成 data URI
const fs = require('fs');
const mime = 'image/png';
const encoding = 'base64';
const base64Data = fs.readFileSync(`${__dirname}/monkey.png`).toString(encoding);
const uri = `data:${mime};${encoding},${base64Data}`;
console.log(uri);

// data URI 轉檔案
const fs = require('fs');
const uri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgA...';
const base64Data = uri.split(',')[1];
const buf = Buffer(base64Data, 'base64');
fs.writeFileSync(`${__dirname}/secondmonkey.png`, buf);
複製程式碼

events

const EventEmitter = require('events').EventEmitter;

const AudioDevice = {
  play: function (track) {
    console.log('play', track);
  },
  stop: function () {
    console.log('stop');
  },
};

class MusicPlayer extends EventEmitter {
  constructor() {
    super();
    this.playing = false; 
  }
}

const musicPlayer = new MusicPlayer();
musicPlayer.on('play', function (track) {
  this.playing = true;
  AudioDevice.play(track);
});
musicPlayer.on('stop', function () {
  this.playing = false;
  AudioDevice.stop();
});

musicPlayer.emit('play', 'The Roots - The Fire');
setTimeout(function () {
  musicPlayer.emit('stop');
}, 1000);

// 處理異常
// EventEmitter 例項發生錯誤會發出一個 error 事件
// 如果沒有監聽器,預設動作是列印一個堆疊並退出程式
musicPlayer.on('error', function (err) {
  console.err('Error:', err);
});
複製程式碼

util

promisify

const util = require('util');
const fs = require('fs');
const readAsync = util.promisify(fs.readFile);

async function init() {
  try {
    let data = await readAsync('./package.json');

    data  =JSON.parse(data);

    console.log(data.name);
  } catch (err) {
    console.log(err);
  }
}
複製程式碼

理解流

流是基於事件的 API,用於管理和處理資料。

  • 流是能夠讀寫的
  • 是基於事件實現的一個例項

理解流的最好方式就是想象一下沒有流的時候怎麼處理資料:

  • fs.readFileSync 同步讀取檔案,程式會阻塞,所有資料被讀到記憶體
  • fs.readFile 阻止程式阻塞,但仍會將檔案所有資料讀取到記憶體中
  • 希望少記憶體讀取大檔案,讀取一個資料塊到記憶體處理完再去索取更多的資料

流的型別

  • 內建:許多核心模組都實現了流介面,如 fs.createReadStream
  • HTTP:處理網路技術的流
  • 直譯器:第三方模組 XML、JSON 直譯器
  • 瀏覽器:Node 流可以被擴充使用在瀏覽器
  • Audio:流介面的聲音模組
  • RPC(遠端呼叫):通過網路傳送流是程式間通訊的有效方式
  • 測試:使用流的測試庫

使用內建流 API

靜態 web 伺服器

想要通過網路高效且支援大檔案的傳送一個檔案到一個客戶端。

不使用流

const http = require('http');
const fs = require('fs');

http.createServer((req, res) => {
  fs.readFile(`${__dirname}/index.html`, (err, data) => {
    if (err) {
      res.statusCode = 500;
      res.end(String(err));
      return;
    }

    res.end(data);
  });
}).listen(8000);
複製程式碼

使用流

const http = require('http');
const fs = require('fs');

http.createServer((req, res) => {
  fs.createReadStream(`${__dirname}/index.html`).pipe(res);
}).listen(8000);
複製程式碼
  • 更少程式碼,更加高效
  • 提供一個緩衝區傳送到客戶端

使用流 + gzip

const http = require('http');
const fs = require('fs');
const zlib = require('zlib');

http.createServer((req, res) => {
  res.writeHead(200, {
    'content-encoding': 'gzip',
  });
  fs.createReadStream(`${__dirname}/index.html`)
    .pipe(zlib.createGzip())
    .pipe(res);
}).listen(8000);
複製程式碼

流的錯誤處理

const fs = require('fs');
const stream = fs.createReadStream('not-found');

stream.on('error', (err) => {
  console.trace();
  console.error('Stack:', err.stack);
  console.error('The error raised was:', err);
});
複製程式碼

使用流基類

可讀流 - JSON 行解析器

可讀流被用來為 I/O 源提供靈活的 API,也可以被用作解析器:

  • 繼承自 steam.Readable 類
  • 並實現一個 _read(size) 方法

json-lines.txt

{ "position": 0, "letter": "a" }
{ "position": 1, "letter": "b" }
{ "position": 2, "letter": "c" }
{ "position": 3, "letter": "d" }
{ "position": 4, "letter": "e" }
{ "position": 5, "letter": "f" }
{ "position": 6, "letter": "g" }
{ "position": 7, "letter": "h" }
{ "position": 8, "letter": "i" }
{ "position": 9, "letter": "j" }
複製程式碼

JSONLineReader.js

const stream = require('stream');
const fs = require('fs');
const util = require('util');

class JSONLineReader extends stream.Readable {
  constructor(source) {
    super();
    this._source = source;
    this._foundLineEnd = false;
    this._buffer = '';

    source.on('readable', () => {
      this.read();
    });
  }

  // 所有定製 stream.Readable 類都需要實現 _read 方法
  _read(size) {
    let chunk;
    let line;
    let result;

    if (this._buffer.length === 0) {
      chunk = this._source.read();
      this._buffer += chunk;
    }

    const lineIndex = this._buffer.indexOf('\n');

    if (lineIndex !== -1) {
      line = this._buffer.slice(0, lineIndex); // 從 buffer 的開始擷取第一行來獲取一些文字進行解析
      if (line) {
        result = JSON.parse(line);
        this._buffer = this._buffer.slice(lineIndex + 1);
        this.emit('object', result); // 當一個 JSON 記錄解析出來的時候,觸發一個 object 事件
        this.push(util.inspect(result)); // 將解析好的 SJON 發回內部佇列
      } else {
        this._buffer = this._buffer.slice(1);
      }
    }
  }
}

const input = fs.createReadStream(`${__dirname}/json-lines.txt`, {
  encoding: 'utf8',
});
const jsonLineReader = new JSONLineReader(input); // 建立一個 JSONLineReader 例項,傳遞一個檔案流給它處理

jsonLineReader.on('object', (obj) => {
  console.log('pos:', obj.position, '- letter:', obj.letter);
});
複製程式碼

可寫流 - 文字變色

可寫的流可用於輸出資料到底層 I/O:

  • 繼承自 stream.Writable
  • 實現一個 _write 方法向底層源資料傳送資料
cat json-lines.txt | node stram_writable.js
複製程式碼

stram_writable.js

const stream = require('stream');

class GreenStream extends stream.Writable {
  constructor(options) {
    super(options);
  }

  _write(chunk, encoding, cb) {
    process.stdout.write(`\u001b[32m${chunk}\u001b[39m`);
    cb();
  }
}

process.stdin.pipe(new GreenStream());
複製程式碼

雙工流 - 接受和轉換資料

雙工流允許傳送和接受資料:

  • 繼承自 stream.Duplex
  • 實現 _read_write 方法

轉換流 - 解析資料

使用流改變資料為另一種格式,並且高效地管理記憶體:

  • 繼承自 stream.Transform
  • 實現 _transform 方法

測試流

使用 Node 內建的斷言模組測試

const assert = require('assert');
const fs = require('fs');
const CSVParser = require('./csvparser');

const parser = new CSVParser();
const actual = [];

fs.createReadStream(`${__dirname}/sample.csv`)
  .pipe(parser);

process.on('exit', function () {
  actual.push(parser.read());
  actual.push(parser.read());
  actual.push(parser.read());

  const expected = [
    { name: 'Alex', location: 'UK', role: 'admin' },
    { name: 'Sam', location: 'France', role: 'user' },
    { name: 'John', location: 'Canada', role: 'user' },
  ];

  assert.deepEqual(expected, actual);
});
複製程式碼

檔案系統

fs 模組互動

  • POSIX 檔案 I/O
  • 檔案流
  • 批量檔案 I/O
  • 檔案監控

POSIX 檔案系統

fs 方法 描述
fs.truncate 截斷或者擴充檔案到制定的長度
fs.ftruncate 和 truncate 一樣,但將檔案描述符作為引數
fs.chown 改變檔案的所有者以及組
fs.fchown 和 chown 一樣,但將檔案描述符作為引數
fs.lchown 和 chown 一樣,但不解析符號連結
fs.stat 獲取檔案狀態
fs.lstat 和 stat 一樣,但是返回資訊是關於符號連結而不是它指向的內容
fs.fstat 和 stat 一樣,但將檔案描述符作為引數
fs.link 建立一個硬連結
fs.symlink 建立一個軟連線
fs.readlink 讀取一個軟連線的值
fs.realpath 返回規範的絕對路徑名
fs.unlink 刪除檔案
fs.rmdir 刪除檔案目錄
fs.mkdir 建立檔案目錄
fs.readdir 讀取一個檔案目錄的內容
fs.close 關閉一個檔案描述符
fs.open 開啟或者建立一個檔案用來讀取或者寫入
fs.utimes 設定檔案的讀取和修改時間
fs.futimes 和 utimes 一樣,但將檔案描述符作為引數
fs.fsync 同步磁碟中的檔案資料
fs.write 寫入資料到一個檔案
fs.read 讀取一個檔案的資料
const fs = require('fs');
const assert = require('assert');

const fd = fs.openSync('./file.txt', 'w+');
const writeBuf = new Buffer('some data to write');
fs.writeSync(fd, writeBuf, 0, writeBuf.length, 0);

const readBuf = new Buffer(writeBuf.length);
fs.readSync(fd, readBuf, 0, writeBuf.length, 0);
assert.equal(writeBuf.toString(), readBuf.toString());

fs.closeSync(fd);
複製程式碼

讀寫流

const fs = require('fs');
const readable = fs.createReadStream('./original.txt');
const writeable = fs.createWriteStream('./copy.txt');
readable.pipe(writeable);
複製程式碼

檔案監控

fs.watchFilefs.watch 低效,但更好用。

同步讀取與 require

同步 fs 的方法應該在第一次初始化應用的時候使用。

const fs = require('fs');
const config = JSON.parse(fs.readFileSync('./config.json').toString());
init(config);
複製程式碼

require:

const config = require('./config.json);
init(config);
複製程式碼
  • 模組會被全域性緩衝,其他檔案也載入並修改,會影響到整個系統載入了此檔案的模組
  • 可以通過 Object.freeze 來凍結一個物件

檔案描述

檔案描述是在作業系統中管理的在程式中開啟檔案所關聯的一些數字或者索引。作業系統通過指派一個唯一的整數給每個開啟的檔案用來檢視關於這個檔案

Stream 檔案描述 描述
stdin 0 標準輸入
stdout 1 標準輸出
stderr 2 標準錯誤

console.log('Log')process.stdout.write('log') 的語法糖。

一個檔案描述是 open 以及 openSync 方法呼叫返回的一個數字

const fd = fs.openSync('myfile', 'a');
console.log(typeof fd === 'number'); // true
複製程式碼

檔案鎖

協同多個程式同時訪問一個檔案,保證檔案的完整性以及資料不能丟失:

  • 強制鎖(在核心級別執行)
  • 諮詢鎖(非強制,只在涉及到程式訂閱了相同的鎖機制)
    • node-fs-ext 通過 flock 鎖住一個檔案
  • 使用鎖檔案
    • 程式 A 嘗試建立一個鎖檔案,並且成功了
    • 程式 A 已經獲得了這個鎖,可以修改共享的資源
    • 程式 B 嘗試建立一個鎖檔案,但失敗了,無法修改共享的資源

Node 實現鎖檔案

  • 使用獨佔標記建立鎖檔案
  • 使用 mkdir 建立鎖檔案

獨佔標記

// 所有需要開啟檔案的方法,fs.writeFile、fs.createWriteStream、fs.open 都有一個 x 標記
// 這個檔案應該已獨佔開啟,若這個檔案存在,檔案不能被開啟
fs.open('config.lock', 'wx', (err) => {
  if (err) { return console.err(err); }
});

// 最好將當前程式號寫進檔案鎖中
// 當有異常的時候就知道最後這個鎖的程式
fs.writeFile(
  'config.lock',
  process.pid,
  { flogs: 'wx' },
  (err) => {
    if (err) { return console.error(err) };
  },
);
複製程式碼

mkdir 檔案鎖

獨佔標記有個問題,可能有些系統不能識別 0_EXCL 標記。另一個方案是把鎖檔案換成一個目錄,PID 可以寫入目錄中的一個檔案。

fs.mkidr('config.lock', (err) => {
  if (err) { return console.error(err); }
  fs.writeFile(`/config.lock/${process.pid}`, (err) => {
    if (err) { return console.error(err); }
  });
});
複製程式碼

lock 模組實現

github.com/npm/lockfil…

const fs = require('fs');
const lockDir = 'config.lock';
let hasLock = false;

exports.lock = function (cb) { // 獲取鎖
  if (hasLock) { return cb(); } // 已經獲取了一個鎖
  fs.mkdir(lockDir, function (err) {
    if (err) { return cb(err); } // 無法建立鎖

    fs.writeFile(lockDir + '/' + process.pid, function (err) { // 把 PID寫入到目錄中以便除錯
      if (err) { console.error(err); } // 無法寫入 PID,繼續執行
      hasLock = true; // 鎖建立了
      return cb();
    });
  });
};

exports.unlock = function (cb) { // 解鎖方法
  if (!hasLock) { return cb(); } // 如果沒有需要解開的鎖
  fs.unlink(lockDir + '/' + process.pid, function (err) {
    if (err) { return cb(err); }

    fs.rmdir(lockDir, function (err) {
      if (err) return cb(err);
      hasLock = false;
      cb();
    });
  });
};

process.on('exit', function () {
  if (hasLock) {
    fs.unlinkSync(lockDir + '/' + process.pid); // 如果還有鎖,在退出之前同步刪除掉
    fs.rmdirSync(lockDir);
    console.log('removed lock');
  }
});
複製程式碼

遞迴檔案操作

一個線上庫:mkdirp

遞迴:要解決我們的問題就要先解決更小的相同的問題。

dir-a
├── dir-b
│   ├── dir-c
│   │   ├── dir-d
│   │   │   └── file-e.png
│   │   └── file-e.png
│   ├── file-c.js
│   └── file-d.txt
├── file-a.js
└── file-b.txt
複製程式碼

查詢模組:find /asset/dir-a -name="file.*"

[
  'dir-a/dir-b/dir-c/dir-d/file-e.png',
  'dir-a/dir-b/dir-c/file-e.png',
  'dir-a/dir-b/file-c.js',
  'dir-a/dir-b/file-d.txt',
  'dir-a/file-a.js',
  'dir-a/file-b.txt',
]
複製程式碼
const fs = require('fs');
const join = require('path').join;

// 同步查詢
exports.findSync = function (nameRe, startPath) {
  const results = [];

  function finder(path) {
    const files = fs.readdirSync(path);

    for (let i = 0; i < files.length; i++) {
      const fpath = join(path, files[i]);
      const stats = fs.statSync(fpath);

      if (stats.isDirectory()) { finder(fpath); }

      if (stats.isFile() && nameRe.test(files[i])) {
        results.push(fpath);
      }
    }
  }

  finder(startPath);
  return results;
};

// 非同步查詢
exports.find = function (nameRe, startPath, cb) { // cb 可以傳入 console.log,靈活
  const results = [];
  let asyncOps = 0; // 2

  function finder(path) {
    asyncOps++;
    fs.readdir(path, function (er, files) {
      if (er) { return cb(er); }

      files.forEach(function (file) {
        const fpath = join(path, file);

        asyncOps++;
        fs.stat(fpath, function (er, stats) {
          if (er) { return cb(er); }

          if (stats.isDirectory()) finder(fpath);

          if (stats.isFile() && nameRe.test(file)) {
            results.push(fpath);
          }

          asyncOps--;
          if (asyncOps == 0) {
            cb(null, results);
          }
        });
      });

      asyncOps--;
      if (asyncOps == 0) {
        cb(null, results);
      }
    });
  }

  finder(startPath);
};

console.log(exports.findSync(/file.*/, `${__dirname}/dir-a`));
console.log(exports.find(/file.*/, `${__dirname}/dir-a`, console.log));
複製程式碼

監視檔案和資料夾

想要監聽一個檔案或者目錄,並在檔案更改後執行一個動作。

const fs = require('fs');
fs.watch('./watchdir', console.log); // 穩定且快
fs.watchFile('./watchdir', console.log); // 跨平臺
複製程式碼

逐行地讀取檔案流

const fs = require('fs');
const readline = require('readline');

const rl = readline.createInterface({
  input: fs.createReadStream('/etc/hosts'),
  crlfDelay: Infinity
});

rl.on('line', (line) => {
  console.log(`cc ${line}`);
  const extract = line.match(/(\d+\.\d+\.\d+\.\d+) (.*)/);
});
複製程式碼

網路

獲取本地 IP

function get_local_ip() {
  const interfaces = require('os').networkInterfaces();
  let IPAdress = '';
  for (const devName in interfaces) {
    const iface = interfaces[devName];
    for (let i = 0; i < iface.length; i++) {
      const alias = iface[i];
      if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) {
        IPAdress = alias.address;
      }
    }
  }
  return IPAdress;
}
複製程式碼

TCP 客戶端

NodeJS 使用 net 模組建立 TCP 連線和服務。

啟動與測試 TCP

const assert = require('assert');
const net = require('net');
let clients = 0;
let expectedAssertions = 2;

const server = net.createServer(function (client) {
  clients++;
  const clientId = clients;
  console.log('Client connected:', clientId);

  client.on('end', function () {
    console.log('Client disconnected:', clientId);
  });

  client.write('Welcome client: ' + clientId);
  client.pipe(client);
});

server.listen(8000, function () {
  console.log('Server started on port 8000');

  runTest(1, function () {
    runTest(2, function () {
      console.log('Tests finished');
      assert.equal(0, expectedAssertions);
      server.close();
    });
  });
});

function runTest(expectedId, done) {
  const client = net.connect(8000);

  client.on('data', function (data) {
    const expected = 'Welcome client: ' + expectedId;
    assert.equal(data.toString(), expected);
    expectedAssertions--;
    client.end();
  });

  client.on('end', done);
}
複製程式碼

UDP 客戶端

利用 dgram 模組建立資料包 socket,然後利用 socket.send 傳送資料。

檔案傳送服務

const dgram = require('dgram');
const fs = require('fs');
const port = 41230;
const defaultSize = 16;

function Client(remoteIP) {
  const inStream = fs.createReadStream(__filename); // 從當前檔案建立可讀流
  const socket = dgram.createSocket('udp4'); // 建立新的資料流 socket 作為客戶端

  inStream.on('readable', function () {
    sendData(); // 當可讀流準備好,開始傳送資料到伺服器
  });

  function sendData() {
    const message = inStream.read(defaultSize); // 讀取資料塊

    if (!message) {
      return socket.unref(); // 客戶端完成任務後,使用 unref 安全關閉它
    }

    // 傳送資料到伺服器
    socket.send(message, 0, message.length, port, remoteIP, function () {
        sendData();
      }
    );
  }
}

function Server() {
  const socket = dgram.createSocket('udp4'); // 建立一個 socket 提供服務

  socket.on('message', function (msg) {
    process.stdout.write(msg.toString());
  });

  socket.on('listening', function () {
    console.log('Server ready:', socket.address());
  });

  socket.bind(port);
}

if (process.argv[2] === 'client') { // 根據命令列選項確定執行客戶端還是服務端
  new Client(process.argv[3]);
} else {
  new Server();
}
複製程式碼

HTTP 客戶端

使用 http.createServerhttp.createClient 執行 HTTP 服務。

啟動與測試 HTTP

const assert = require('assert');
const http = require('http');

const server = http.createServer(function(req, res) {
  res.writeHead(200, { 'Content-Type': 'text/plain' }); // 寫入基於文字的響應頭
  res.write('Hello, world.'); // 傳送訊息回客戶端
  res.end();
});

server.listen(8000, function() {
  console.log('Listening on port 8000');
});

const req = http.request({ port: 8000}, function(res) { // 建立請求
  console.log('HTTP headers:', res.headers);
  res.on('data', function(data) { // 給 data 事件建立監聽,確保和期望值一致
    console.log('Body:', data.toString());
    assert.equal('Hello, world.', data.toString());
    assert.equal(200, res.statusCode);
    server.unref();
    console.log('測試完成');
  });
});

req.end();
複製程式碼

重定向

HTTP 標準定義了標識重定向發生時的狀態碼,它也指出了客戶端應該檢查無限迴圈。

  • 300:多重選擇
  • 301:永久移動到新位置
  • 302:找到重定向跳轉
  • 303:參見其他資訊
  • 304:沒有改動
  • 305:使用代理
  • 307:臨時重定向
const http = require('http');
const https = require('https');
const url = require('url'); // 有很多接續 URLs 的方法

// 建構函式被用來建立一個物件來構成請求物件的宣告週期
function Request() {
  this.maxRedirects = 10;
  this.redirects = 0;
}

Request.prototype.get = function(href, callback) {
  const uri = url.parse(href); // 解析 URLs 成為 Node http 模組使用的格式,確定是否使用 HTTPS
  const options = { host: uri.host, path: uri.path };
  const httpGet = uri.protocol === 'http:' ? http.get : https.get;

  console.log('GET:', href);

  function processResponse(response) {
    if (response.statusCode >= 300 && response.statusCode < 400) { // 檢查狀態碼是否在 HTTP 重定向範圍
      if (this.redirects >= this.maxRedirects) {
        this.error = new Error('Too many redirects for: ' + href);
      } else {
        this.redirects++; // 重定向計數自增
        href = url.resolve(options.host, response.headers.location); // 使用 url.resolve 確保相對路徑的 URLs 轉換為絕對路徑 URLs
        return this.get(href, callback);
      }
    }

    response.url = href;
    response.redirects = this.redirects;

    console.log('Redirected:', href);

    function end() {
      console.log('Connection ended');
      callback(this.error, response);
    }

    response.on('data', function(data) {
      console.log('Got data, length:', data.length);
    });

    response.on('end', end.bind(this)); // 繫結回撥到 Request 例項,確保能拿到例項屬性
  }

  httpGet(options, processResponse.bind(this))
    .on('error', function(err) {
      callback(err);
    });
};

const request = new Request();
request.get('http://google.com/', function(err, res) {
  if (err) {
    console.error(err);
  } else {
    console.log(`
      Fetched URL: ${res.url} with ${res.redirects} redirects
    `);
    process.exit();
  }
});
複製程式碼

HTTP 代理

  • ISP 使用透明代理使網路更加高效
  • 使用快取代理伺服器減少寬頻
  • Web 應用程式的 DevOps 利用他們提升應用程式效能
const http = require('http');
const url = require('url');

http.createServer(function(req, res) {
  console.log('start request:', req.url);
  const options = url.parse(req.url);
  console.log(options);
  options.headers = req.headers;
  const proxyRequest = http.request(options, function(proxyResponse) { // 建立請求來複制原始的請求
    proxyResponse.on('data', function(chunk) { // 監聽資料,返回給瀏覽器
      console.log('proxyResponse length:', chunk.length);
      res.write(chunk, 'binary');
    });

    proxyResponse.on('end', function() { // 追蹤代理請求完成
      console.log('proxied request ended');
      res.end();
    });

    res.writeHead(proxyResponse.statusCode, proxyResponse.headers); // 傳送頭部資訊給伺服器
  });

  req.on('data', function(chunk) { // 捕獲從瀏覽器傳送到伺服器的資料
    console.log('in request length:', chunk.length);
    proxyRequest.write(chunk, 'binary');
  });

  req.on('end', function() { // 追蹤原始的請求什麼時候結束
    console.log('original request ended');
    proxyRequest.end();
  });
}).listen(8888); // 監聽來自本地瀏覽器的連線
複製程式碼

封裝 request-promise

const https = require('https');
const promisify = require('util').promisify;

https.get[promisify.custom] = function getAsync(options) {
  return new Promise((resolve, reject) => {
    https.get(options, (response) => {
      response.end = new Promise((resolve) => response.on('end', resolve));
      resolve(response);
    }).on('error', reject);
  });
};
const rp = promisify(https.get);

(async () => {
  const res = await rp('https://jsonmock.hackerrank.com/api/movies/search/?Title=Spiderman&page=1');
  let body = '';
  res.on('data', (chunk) => body += chunk);
  await res.end;

  console.log(body);
})();
複製程式碼

DNS 請求

使用 dns 模組建立 DNS 請求。

  • A:dns.resolve,A 記錄儲存 IP 地址
  • TXT:dns.resulveTxt,文字值可以用於在 DNS 上構建其他服務
  • SRV:dns.resolveSrv,服務記錄定義服務的定位資料,通常包含主機名和埠號
  • NS:dns.resolveNs,指定域名伺服器
  • CNAME:dns.resolveCname,相關的域名記錄,設定為域名而不是 IP 地址
const dns = require('dns');

dns.resolve('www.chenng.cn', function (err, addresses) {
  if (err) {
    console.error(err);
  }

  console.log('Addresses:', addresses);
});
複製程式碼

crypto 庫加密解密

const crypto = require('crypto')

function aesEncrypt(data, key = 'key') {
  const cipher = crypto.createCipher('aes192', key)
  let crypted = cipher.update(data, 'utf8', 'hex')
  crypted += cipher.final('hex')
  return crypted
}

function aesDecrypt(encrypted, key = 'key') {
  const decipher = crypto.createDecipher('aes192', key)
  let decrypted = decipher.update(encrypted, 'hex', 'utf8')
  decrypted += decipher.final('utf8')
  return decrypted
}
複製程式碼

發起 HTTP 請求的方法

  • HTTP 標準庫
    • 無需安裝外部依賴
    • 需要以塊為單位接受資料,自己監聽 end 事件
    • HTTP 和 HTTPS 是兩個模組,需要區分使用
  • Request 庫
    • 使用方便
    • 有 promise 版本 request-promise
  • Axios
    • 既可以用在瀏覽器又可以用在 NodeJS
    • 可以使用 axios.all 併發多個請求
  • SuperAgent
    • 可以鏈式使用
  • node-fetch
    • 瀏覽器的 fetch 移植過來的

子程式

執行外部應用

基本概念

  • 4個非同步方法:exec、execFile、fork、spawn
    • Node
      • fork:想將一個 Node 程式作為一個獨立的程式來執行的時候使用,是的計算處理和檔案描述器脫離 Node 主程式
    • 非 Node
      • spawn:處理一些會有很多子程式 I/O 時、程式會有大量輸出時使用
      • execFile:只需執行一個外部程式的時候使用,執行速度快,處理使用者輸入相對安全
      • exec:想直接訪問執行緒的 shell 命令時使用,一定要注意使用者輸入
  • 3個同步方法:execSync、execFileSync、spawnSync
  • 通過 API 建立出來的子程式和父程式沒有任何必然聯絡

execFile

  • 會把輸出結果快取好,通過回撥返回最後結果或者異常資訊
const cp = require('child_process');

cp.execFile('echo', ['hello', 'world'], (err, stdout, stderr) => {
  if (err) { console.error(err); }
  console.log('stdout: ', stdout);
  console.log('stderr: ', stderr);
});
複製程式碼

spawn

  • 通過流可以使用有大量資料輸出的外部應用,節約記憶體
  • 使用流提高資料響應效率
  • spawn 方法返回一個 I/O 的流介面

單一任務

const cp = require('child_process');

const child = cp.spawn('echo', ['hello', 'world']);
child.on('error', console.error);
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
複製程式碼

多工串聯

const cp = require('child_process');
const path = require('path');

const cat = cp.spawn('cat', [path.resolve(__dirname, 'messy.txt')]);
const sort = cp.spawn('sort');
const uniq = cp.spawn('uniq');

cat.stdout.pipe(sort.stdin);
sort.stdout.pipe(uniq.stdin);
uniq.stdout.pipe(process.stdout);
複製程式碼

exec

  • 只有一個字串命令
  • 和 shell 一模一樣
const cp = require('child_process');

cp.exec(`cat ${__dirname}/messy.txt | sort | uniq`, (err, stdout, stderr) => {
  console.log(stdout);
});
複製程式碼

fork

  • fork 方法會開發一個 IPC 通道,不同的 Node 程式進行訊息傳送
  • 一個子程式消耗 30ms 啟動時間和 10MB 記憶體
  • 子程式:process.on('message')process.send()
  • 父程式:child.on('message')child.send()

父子通訊

// parent.js
const cp = require('child_process');

const child = cp.fork('./child', { silent: true });
child.send('monkeys');
child.on('message', function (message) {
  console.log('got message from child', message, typeof message);
})
child.stdout.pipe(process.stdout);

setTimeout(function () {
  child.disconnect();
}, 3000);
複製程式碼
// child.js
process.on('message', function (message) {
  console.log('got one', message);
  process.send('no pizza');
  process.send(1);
  process.send({ my: 'object' });
  process.send(false);
  process.send(null);
});

console.log(process);
複製程式碼

常用技巧

退出時殺死所有子程式

  • 保留對由 spawn 返回的 ChildProcess 物件的引用,並在退出主程式時將其殺死
const spawn = require('child_process').spawn;
const children = [];

process.on('exit', function () {
  console.log('killing', children.length, 'child processes');
  children.forEach(function (child) {
    child.kill();
  });
});

children.push(spawn('/bin/sleep', ['10']));
children.push(spawn('/bin/sleep', ['10']));
children.push(spawn('/bin/sleep', ['10']));

setTimeout(function () { process.exit(0); }, 3000);
複製程式碼

Cluster 的理解

  • 解決 NodeJS 單程式無法充分利用多核 CPU 問題
  • 通過 master-cluster 模式可以使得應用更加健壯
  • Cluster 底層是 child_process 模組,除了可以傳送普通訊息,還可以傳送底層物件 TCPUDP
  • TCP 主程式傳送到子程式,子程式能根據訊息重建出 TCP 連線,Cluster 可以決定 fork 出合適的硬體資源的子程式數

Node 多執行緒

單執行緒問題

  • 對 cpu 利用不足
  • 某個未捕獲的異常可能會導致整個程式的退出

Node 執行緒

  • Node 程式佔用了 7 個執行緒
  • Node 中最核心的是 v8 引擎,在 Node 啟動後,會建立 v8 的例項,這個例項是多執行緒的
    • 主執行緒:編譯、執行程式碼
    • 編譯/優化執行緒:在主執行緒執行的時候,可以優化程式碼
    • 分析器執行緒:記錄分析程式碼執行時間,為 Crankshaft 優化程式碼執行提供依據
    • 垃圾回收的幾個執行緒
  • JavaScript 的執行是單執行緒的,但 Javascript 的宿主環境,無論是 Node 還是瀏覽器都是多執行緒的

非同步 IO

  • Node 中有一些 IO 操作(DNS,FS)和一些 CPU 密集計算(Zlib,Crypto)會啟用 Node 的執行緒池
  • 執行緒池預設大小為 4,可以手動更改執行緒池預設大小
process.env.UV_THREADPOOL_SIZE = 64
複製程式碼

cluster 多程式

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  console.log(`主程式 ${process.pid} 正在執行`);
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`工作程式 ${worker.process.pid} 已退出`);
  });
} else {
  // 工作程式可以共享任何 TCP 連線。
  // 在本例子中,共享的是 HTTP 伺服器。
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('Hello World');
  }).listen(8000);
  console.log(`工作程式 ${process.pid} 已啟動`);
}
複製程式碼
  • 一共有 9 個程式,其中一個主程式,cpu 個數 x cpu 核數 = 2 x 4 = 8 個 子程式
  • 無論 child_process 還是 cluster,都不是多執行緒模型,而是多程式模型
  • 應對單執行緒問題,通常使用多程式的方式來模擬多執行緒

真 Node 多執行緒

  • Node 10.5.0 的釋出,給出了一個實驗性質的模組 worker_threads 給 Node 提供真正的多執行緒能力
  • worker_thread 模組中有 4 個物件和 2 個類
    • isMainThread: 是否是主執行緒,原始碼中是通過 threadId === 0 進行判斷的。
    • MessagePort: 用於執行緒之間的通訊,繼承自 EventEmitter。
    • MessageChannel: 用於建立非同步、雙向通訊的通道例項。
    • threadId: 執行緒 ID。
    • Worker: 用於在主執行緒中建立子執行緒。第一個引數為 filename,表示子執行緒執行的入口。
    • parentPort: 在 worker 執行緒裡是表示父程式的 MessagePort 型別的物件,在主執行緒裡為 null
    • workerData: 用於在主程式中向子程式傳遞資料(data 副本)
const {
  isMainThread,
  parentPort,
  workerData,
  threadId,
  MessageChannel,
  MessagePort,
  Worker
} = require('worker_threads');

function mainThread() {
  for (let i = 0; i < 5; i++) {
    const worker = new Worker(__filename, { workerData: i });
    worker.on('exit', code => { console.log(`main: worker stopped with exit code ${code}`); });
    worker.on('message', msg => {
      console.log(`main: receive ${msg}`);
      worker.postMessage(msg + 1);
    });
  }
}

function workerThread() {
  console.log(`worker: workerDate ${workerData}`);
  parentPort.on('message', msg => {
    console.log(`worker: receive ${msg}`);
  }),
  parentPort.postMessage(workerData);
}

if (isMainThread) {
  mainThread();
} else {
  workerThread();
}
複製程式碼

執行緒通訊

const assert = require('assert');
const {
  Worker,
  MessageChannel,
  MessagePort,
  isMainThread,
  parentPort
} = require('worker_threads');
if (isMainThread) {
  const worker = new Worker(__filename);
  const subChannel = new MessageChannel();
  worker.postMessage({ hereIsYourPort: subChannel.port1 }, [subChannel.port1]);
  subChannel.port2.on('message', (value) => {
    console.log('received:', value);
  });
} else {
  parentPort.once('message', (value) => {
    assert(value.hereIsYourPort instanceof MessagePort);
    value.hereIsYourPort.postMessage('the worker is sending this');
    value.hereIsYourPort.close();
  });
}
複製程式碼

多程式 vs 多執行緒

程式是資源分配的最小單位,執行緒是CPU排程的最小單位

專案管理

元件式構建

github.com/i0natan/nod…

多環境配置

  • JSON 配置檔案
  • 環境變數 使用第三方模組管理(nconf)

依賴管理

  • dependencies:模組正常執行需要的依賴
  • devDependencies:開發時候需要的依賴
  • optionalDependencies:非必要依賴,某種程度上增強
  • peerDependencies:執行時依賴,限定版本

異常處理

處理未捕獲的異常

  • 除非開發者記得新增.catch語句,在這些地方丟擲的錯誤都不會被 uncaughtException 事件處理程式來處理,然後消失掉。
  • Node 應用不會奔潰,但可能導致記憶體洩露
process.on('uncaughtException', (error) => {
  // 我剛收到一個從未被處理的錯誤
  // 現在處理它,並決定是否需要重啟應用
  errorManagement.handler.handleError(error);
  if (!errorManagement.handler.isTrustedError(error)) {
    process.exit(1);
  }
});

process.on('unhandledRejection', (reason, p) => {
  // 我剛剛捕獲了一個未處理的promise rejection,
  // 因為我們已經有了對於未處理錯誤的後備的處理機制(見下面)
  // 直接丟擲,讓它來處理
  throw reason;
});
複製程式碼

通過 domain 管理異常

  • 通過 domain 模組的 create 方法建立例項
  • 某個錯誤已經任何其他錯誤都會被同一個 error 處理方法處理
  • 任何在這個回撥中導致錯誤的程式碼都會被 domain 覆蓋到
  • 允許我們程式碼在一個沙盒執行,並且可以使用 res 物件給使用者反饋
const domain = require('domain');
const audioDomain = domain.create();

audioDomain.on('error', function(err) {
  console.log('audioDomain error:', err);
});

audioDomain.run(function() {
  const musicPlayer = new MusicPlayer();
  musicPlayer.play();
});
複製程式碼

Joi 驗證引數

const 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
}
複製程式碼

Kibana 系統監控

github.com/i0natan/nod…

上線實踐

使用 winston 記錄日記

var winston = require('winston');
var moment = require('moment');

const logger = new (winston.Logger)({
  transports: [
    new (winston.transports.Console)({
      timestamp: function() {
        return moment().format('YYYY-MM-DD HH:mm:ss')
      },
      formatter: function(params) {
        let time = params.timestamp() // 時間
        let message = params.message // 手動資訊
        let meta = params.meta && Object.keys(params.meta).length ? '\n\t'+ JSON.stringify(params.meta) : ''
        return `${time} ${message}`
      },
    }),
    new (winston.transports.File)({
      filename: `${__dirname}/../winston/winston.log`,
      json: false,
      timestamp: function() {
        return moment().format('YYYY-MM-DD HH:mm:ss')
      },
      formatter: function(params) {
        let time = params.timestamp() // 時間
        let message = params.message // 手動資訊
        let meta = params.meta && Object.keys(params.meta).length ? '\n\t'+ JSON.stringify(params.meta) : ''
        return `${time} ${message}`
      }
    })
  ]
})

module.exports = logger

// logger.error('error')
// logger.warm('warm')
// logger.info('info')
複製程式碼

委託反向代理

Node 處理 CPU 密集型任務,如 gzipping,SSL termination 等,表現糟糕。相反,使用一個真正的中介軟體服務像 Nginx 更好。否則可憐的單執行緒 Node 將不幸地忙於處理網路任務,而不是處理應用程式核心,效能會相應降低。

雖然 express.js 通過一些 connect 中介軟體處理靜態檔案,但你不應該使用它。Nginx 可以更好地處理靜態檔案,並可以防止請求動態內容堵塞我們的 node 程式。

# 配置 gzip 壓縮
gzip on;
gzip_comp_level 6;
gzip_vary on;

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

#定義 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;
}
複製程式碼

檢測有漏洞的依賴項

docs.npmjs.com/cli/audit

PM2 HTTP 叢集配置

工作執行緒配置

  • pm2 start app.js -i 4-i 4 是以 cluster_mode 形式執行 app,有 4 個工作執行緒,如果配置 0,PM2 會根據 CPU 核心數來生成對應的工作執行緒
  • 工作執行緒掛了 PM2 會立即將其重啟
  • pm2 scale <app name> <n> 對叢集進行擴充套件

PM2 自動啟動

  • pm2 save 儲存當前執行的應用
  • pm2 startup 啟動

效能實踐

避免使用 Lodash

  • 使用像 lodash 這樣的方法庫這會導致不必要的依賴和較慢的效能
  • 隨著新的 V8 引擎和新的 ES 標準的引入,原生方法得到了改進,現在效能比方法庫提高了 50%

使用 ESLint 外掛檢測:

{
  "extends": [
    "plugin:you-dont-need-lodash-underscore/compatible"
  ]
}
複製程式碼

benchmark

const _ = require('lodash'),
  __ = require('underscore'),
  Suite = require('benchmark').Suite,
  opts = require('./utils');
  //cf. https://github.com/Berkmann18/NativeVsUtils/blob/master/utils.js

const concatSuite = new Suite('concat', opts);
const array = [0, 1, 2];

concatSuite.add('lodash', () => _.concat(array, 3, 4, 5))
  .add('underscore', () => __.concat(array, 3, 4, 5))
  .add('native', () => array.concat(3, 4, 5))
  .run({ 'async': true });
複製程式碼

使用 prof 進行效能分析

  • 使用 tick-processor 工具處理分析
node --prof profile-test.js
複製程式碼
npm install tick -g

node-tick-processor
複製程式碼

使用 headdump 堆快照

  • 程式碼載入模組進行快照檔案生成
  • Chrome Profiles 載入快照檔案
yarn add heapdump -D
複製程式碼
const heapdump = require('heapdump');
const string = '1 string to rule them all';

const leakyArr = [];
let count = 2;
setInterval(function () {
  leakyArr.push(string.replace(/1/g, count++));
}, 0);

setInterval(function () {
  if (heapdump.writeSnapshot()) console.log('wrote snapshot');
}, 20000);
複製程式碼

應用安全清單

helmet 設定安全響應頭

檢測頭部配置:Security Headers

應用程式應該使用安全的 header 來防止攻擊者使用常見的攻擊方式,諸如跨站點指令碼攻擊(XSS)、跨站請求偽造(CSRF)。可以使用模組 helmet 輕鬆進行配置。

  • 構造
    • X-Frame-Options:sameorigin。提供點選劫持保護,iframe 只能同源。
  • 傳輸
    • Strict-Transport-Security:max-age=31536000; includeSubDomains。強制 HTTPS,這減少了web 應用程式中錯誤通過 cookies 和外部連結,洩露會話資料,並防止中間人攻擊
  • 內容
    • X-Content-Type-Options:nosniff。阻止從宣告的內容型別中嗅探響應,減少了使用者上傳惡意內容造成的風險
    • Content-Type:text/html;charset=utf-8。指示瀏覽器將頁面解釋為特定的內容型別,而不是依賴瀏覽器進行假設
  • XSS
    • X-XSS-Protection:1; mode=block。啟用了內建於最新 web 瀏覽器中的跨站點指令碼(XSS)過濾器
  • 下載
    • X-Download-Options:noopen。
  • 快取
    • Cache-Control:no-cache。web 應中返回的資料可以由使用者瀏覽器以及中間代理快取。該指令指示他們不要保留頁面內容,以免其他人從這些快取中訪問敏感內容
    • Pragma:no-cache。同上
    • Expires:-1。web 響應中返回的資料可以由使用者瀏覽器以及中間代理快取。該指令通過將到期時間設定為一個值來防止這種情況。
  • 訪問控制
    • Access-Control-Allow-Origin:not *。'Access-Control-Allow-Origin: *' 預設在現代瀏覽器中禁用
    • X-Permitted-Cross-Domain-Policies:master-only。指示只有指定的檔案在此域中才被視為有效
  • 內容安全策略
    • Content-Security-Policy:內容安全策略需要仔細調整並精確定義策略
  • 伺服器資訊
    • Server:不顯示。

使用 security-linter 外掛

使用安全檢驗外掛 eslint-plugin-security 或者 tslint-config-security

koa-ratelimit 限制併發請求

DOS 攻擊非常流行而且相對容易處理。使用外部服務,比如 cloud 負載均衡, cloud 防火牆, nginx, 或者(對於小的,不是那麼重要的app)一個速率限制中介軟體(比如 koa-ratelimit),來實現速率限制。

純文字機密資訊放置

儲存在原始碼管理中的機密資訊必須進行加密和管理 (滾動金鑰(rolling keys)、過期時間、稽核等)。使用 pre-commit/push 鉤子防止意外提交機密資訊。

ORM/ODM 庫防止查詢注入漏洞

要防止 SQL/NoSQL 注入和其他惡意攻擊, 請始終使用 ORM/ODM 或 database 庫來轉義資料或支援命名的或索引的引數化查詢, 並注意驗證使用者輸入的預期型別。不要只使用 JavaScript 模板字串或字串串聯將值插入到查詢語句中, 因為這會將應用程式置於廣泛的漏洞中。

庫:

  • TypeORM
  • sequelize
  • mongoose
  • Knex
  • Objection.js
  • waterline

使用 Bcrypt 代替 Crypto

密碼或機密資訊(API 金鑰)應該使用安全的 hash + salt 函式(bcrypt)來儲存, 因為效能和安全原因, 這應該是其 JavaScript 實現的首選。

// 使用10個雜湊回合非同步生成安全密碼
bcrypt.hash('myPassword', 10, function(err, hash) {
  // 在使用者記錄中儲存安全雜湊
});

// 將提供的密碼輸入與已儲存的雜湊進行比較
bcrypt.compare('somePassword', hash, function(err, match) {
  if(match) {
   // 密碼匹配
  } else {
   // 密碼不匹配
  } 
});
複製程式碼

轉義 HTML、JS 和 CSS 輸出

傳送給瀏覽器的不受信任資料可能會被執行, 而不是顯示, 這通常被稱為跨站點指令碼(XSS)攻擊。使用專用庫將資料顯式標記為不應執行的純文字內容(例如:編碼、轉義),可以減輕這種問題。

驗證傳入的 JSON schemas

驗證傳入請求的 body payload,並確保其符合預期要求, 如果沒有, 則快速報錯。為了避免每個路由中繁瑣的驗證編碼, 您可以使用基於 JSON 的輕量級驗證架構,比如 jsonschemajoi

支援黑名單的 JWT

當使用 JSON Web Tokens(例如, 通過 Passport.js), 預設情況下, 沒有任何機制可以從發出的令牌中撤消訪問許可權。一旦發現了一些惡意使用者活動, 只要它們持有有效的標記, 就無法阻止他們訪問系統。通過實現一個不受信任令牌的黑名單,並在每個請求上驗證,來減輕此問題。

const jwt = require('express-jwt');
const blacklist = require('express-jwt-blacklist');
 
app.use(jwt({
  secret: 'my-secret',
  isRevoked: blacklist.isRevoked
}));
 
app.get('/logout', function (req, res) {
  blacklist.revoke(req.user)
  res.sendStatus(200);
});
複製程式碼

限制每個使用者允許的登入請求

一類保護暴力破解的中介軟體,比如 express-brute,應該被用在 express 的應用中,來防止暴力/字典攻擊;這類攻擊主要應用於一些敏感路由,比如 /admin 或者 /login,基於某些請求屬性, 如使用者名稱, 或其他識別符號, 如正文引數等。否則攻擊者可以發出無限制的密碼匹配嘗試, 以獲取對應用程式中特權帳戶的訪問許可權。

const ExpressBrute = require('express-brute');
const RedisStore = require('express-brute-redis');

const redisStore = new RedisStore({
  host: '127.0.0.1',
  port: 6379
});

// Start slowing requests after 5 failed 
// attempts to login for the same user
const loginBruteforce = new ExpressBrute(redisStore, {
  freeRetries: 5,
  minWait: 5 * 60 * 1000, // 5 minutes
  maxWait: 60 * 60 * 1000, // 1 hour
  failCallback: failCallback,
  handleStoreError: handleStoreErrorCallback
});

app.post('/login',
  loginBruteforce.getMiddleware({
    key: function (req, res, next) {
      // prevent too many attempts for the same username
      next(req.body.username);
    }
  }), // error 403 if we hit this route too often
  function (req, res, next) {
    if (User.isValidLogin(req.body.username, req.body.password)) {
      // reset the failure counter for valid login
      req.brute.reset(function () {
        res.redirect('/'); // logged in
      });
    } else {
      // handle invalid user
    }
  }
);
複製程式碼

使用非 root 使用者執行 Node.js

Node.js 作為一個具有無限許可權的 root 使用者執行,這是一種普遍的情景。例如,在 Docker 容器中,這是預設行為。建議建立一個非 root 使用者,並儲存到 Docker 映象中(下面給出了示例),或者通過呼叫帶有"-u username" 的容器來代表此使用者執行該程式。否則在伺服器上執行指令碼的攻擊者在本地計算機上獲得無限制的權利 (例如,改變 iptable,引流到他的伺服器上)

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

使用反向代理或中介軟體限制負載大小

請求 body 有效載荷越大, Node.js 的單執行緒就越難處理它。這是攻擊者在沒有大量請求(DOS/DDOS 攻擊)的情況下,就可以讓伺服器跪下的機會。在邊緣上(例如,防火牆,ELB)限制傳入請求的 body 大小,或者通過配置 express body parser 僅接收小的載荷,可以減輕這種問題。否則您的應用程式將不得不處理大的請求, 無法處理它必須完成的其他重要工作, 從而導致對 DOS 攻擊的效能影響和脆弱性。

express:

const express = require('express');

const app = express();

// body-parser defaults to a body size limit of 300kb
app.use(express.json({ limit: '300kb' })); 

// Request with json body
app.post('/json', (req, res) => {

    // Check if request payload content-type matches json
    // because body-parser does not check for content types
    if (!req.is('json')) {
        return res.sendStatus(415); // Unsupported media type if request doesn't have JSON body
    }

    res.send('Hooray, it worked!');
});

app.listen(3000, () => console.log('Example app listening on port 3000!'));
複製程式碼

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;
}
複製程式碼

防止 RegEx 讓 NodeJS 過載

匹配文字的使用者輸入需要大量的 CPU 週期來處理。在某種程度上,正則處理是效率低下的,比如驗證 10 個單詞的單個請求可能阻止整個 event loop 長達6秒。由於這個原因,偏向第三方的驗證包,比如validator.js,而不是採用正則,或者使用 safe-regex 來檢測有問題的正規表示式。

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 is vulnerable to redos attacks
console.log(saferegex(emailRegex));

// instead of the regex pattern, use validator:
const validator = require('validator');
console.log(validator.isEmail('liran.tal@gmail.com'));
複製程式碼

在沙箱中執行不安全程式碼

當任務執行在執行時給出的外部程式碼時(例如, 外掛), 使用任何型別的沙盒執行環境保護主程式碼,並隔離開主程式碼和外掛。這可以通過一個專用的過程來實現 (例如:cluster.fork()), 無伺服器環境或充當沙盒的專用 npm 包。

  • 一個專門的子程式 - 這提供了一個快速的資訊隔離, 但要求制約子程式, 限制其執行時間, 並從錯誤中恢復
  • 一個基於雲的無服務框架滿足所有沙盒要求,但動態部署和呼叫Faas方法不是本部分的內容
  • 一些 npm 庫,比如 sandboxvm2 允許通過一行程式碼執行隔離程式碼。儘管後一種選擇在簡單中獲勝, 但它提供了有限的保護。
const Sandbox = require("sandbox");
const s = new Sandbox();

s.run( "lol)hai", function( output ) {
  console.log(output);
  //output='Synatx error'
});

// Example 4 - Restricted code
s.run( "process.platform", function( output ) {
  console.log(output);
  //output=Null
})

// Example 5 - Infinite loop
s.run( "while (true) {}", function( output ) {
  console.log(output);
  //output='Timeout'
})
複製程式碼

隱藏客戶端的錯誤詳細資訊

預設情況下, 整合的 express 錯誤處理程式隱藏錯誤詳細資訊。但是, 極有可能, 您實現自己的錯誤處理邏輯與自定義錯誤物件(被許多人認為是最佳做法)。如果這樣做, 請確保不將整個 Error 物件返回到客戶端, 這可能包含一些敏感的應用程式詳細資訊。否則敏感應用程式詳細資訊(如伺服器檔案路徑、使用中的第三方模組和可能被攻擊者利用的應用程式的其他內部工作流)可能會從 stack trace 發現的資訊中洩露。

// production error handler
// no stacktraces leaked to user
app.use(function(err, req, res, next) {
    res.status(err.status || 500);
    res.render('error', {
        message: err.message,
        error: {}
    });
});
複製程式碼

對 npm 或 Yarn,配置 2FA

開發鏈中的任何步驟都應使用 MFA(多重身份驗證)進行保護, npm/Yarn 對於那些能夠掌握某些開發人員密碼的攻擊者來說是一個很好的機會。使用開發人員憑據, 攻擊者可以向跨專案和服務廣泛安裝的庫中注入惡意程式碼。甚至可能在網路上公開發布。在 npm 中啟用兩層身份驗證(2-factor-authentication), 攻擊者幾乎沒有機會改變您的軟體包程式碼。

itnext.io/eslint-back…

session 中介軟體設定

每個 web 框架和技術都有其已知的弱點,告訴攻擊者我們使用的 web 框架對他們來說是很大的幫助。使用 session 中介軟體的預設設定, 可以以類似於 X-Powered-Byheader 的方式向模組和框架特定的劫持攻擊公開您的應用。嘗試隱藏識別和揭露技術棧的任何內容(例如:Nonde.js, express)。否則可以通過不安全的連線傳送cookie, 攻擊者可能會使用會話標識來標識web應用程式的基礎框架以及特定於模組的漏洞。

// 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
 }
}));
複製程式碼

csurf 防止 CSRF

路由層:

var cookieParser = require('cookie-parser');  
var csrf = require('csurf');  
var bodyParser = require('body-parser');  
var express = require('express');

// 設定路由中介軟體
var csrfProtection = csrf({ cookie: true });  
var parseForm = bodyParser.urlencoded({ extended: false });

var app = express();

// 我們需要這個,因為在 csrfProtection 中 “cookie” 是正確的
app.use(cookieParser());

app.get('/form', csrfProtection, function(req, res) {  
  // 將 CSRFToken 傳遞給檢視
  res.render('send', { csrfToken: req.csrfToken() });
});

app.post('/process', parseForm, csrfProtection, function(req, res) {  
  res.send('data is being processed');
});
複製程式碼

展示層:

<form action="/process" method="POST">  
  <input type="hidden" name="_csrf" value="{{csrfToken}}">

  Favorite color: <input type="text" name="favoriteColor">
  <button type="submit">Submit</button>
</form>  
複製程式碼

綜合應用

watch 服務

const fs = require('fs');
const exec = require('child_process').exec;

function watch() {
  const child = exec('node server.js');
  const watcher = fs.watch(__dirname + '/server.js', function () {
    console.log('File changed, reloading.');
    child.kill();
    watcher.close();
    watch();
  });
}

watch();
複製程式碼

RESTful web 應用

  • REST 意思是表徵性狀態傳輸

  • 使用正確的 HTTP 方法、URLs 和頭部資訊來建立語義化 RESTful API

  • GET /gages:獲取

  • POST /pages:建立

  • GET /pages/10:獲取 pages10

  • PATCH /pages/10:更新 pages10

  • PUT /pages/10:替換 pages10

  • DELETE /pages/10:刪除 pages10

let app;
const express = require('express');
const routes = require('./routes');

module.exports = app = express();

app.use(express.json()); // 使用 JSON body 解析
app.use(express.methodOverride()); // 允許一個查詢引數來制定額外的 HTTP 方法

// 資源使用的路由
app.get('/pages', routes.pages.index);
app.get('/pages/:id', routes.pages.show);
app.post('/pages', routes.pages.create);
app.patch('/pages/:id', routes.pages.patch);
app.put('/pages/:id', routes.pages.update);
app.del('/pages/:id', routes.pages.remove);
複製程式碼

中介軟體應用

const express = require('express');
const app = express();
const Schema = require('validate');
const xml2json = require('xml2json');
const util = require('util');
const Page = new Schema();

Page.path('title').type('string').required(); // 資料校驗確保頁面有標題

function ValidatorError(errors) { // 從錯誤物件繼承,校驗出現的錯誤在錯誤中介軟體處理
  this.statusCode = 400;
  this.message = errors.join(', ');
}
util.inherits(ValidatorError, Error);

function xmlMiddleware(req, res, next) { // 處理 xml 的中介軟體
  if (!req.is('xml')) return next();

  let body = '';
  req.on('data', function (str) { // 從客戶端讀到資料時觸發
    body += str;
  });

  req.on('end', function () {
    req.body = xml2json.toJson(body.toString(), {
      object: true,
      sanitize: false,
    });
    next();
  });
}

function checkValidXml(req, res, next) { // 資料校驗中介軟體
  const page = Page.validate(req.body.page);
  if (page.errors.length) {
    next(new ValidatorError(page.errors)); // 傳遞錯誤給 next 阻止路由繼續執行
  } else {
    next();
  }
}

function errorHandler(err, req, res, next) { // 錯誤處理中介軟體
  console.error('errorHandler', err);
  res.send(err.statusCode || 500, err.message);
}

app.use(xmlMiddleware); // 應用 XML 中介軟體到所有的請求中

app.post('/pages', checkValidXml, function (req, res) { // 特定的請求校驗 xml
  console.log('Valid page:', req.body.page);
  res.send(req.body);
});

app.use(errorHandler); // 新增錯誤處理中介軟體

app.listen(3000);
複製程式碼

通過事件組織應用

// 監聽使用者註冊成功訊息,繫結郵件程式
const express = require('express');
const app = express();
const emails = require('./emails');
const routes = require('./routes');

app.use(express.json());

app.post('/users', routes.users.create); // 設定路由建立使用者

app.on('user:created', emails.welcome); // 監聽建立成功事件,繫結 email 程式碼

module.exports = app;
複製程式碼
// 使用者註冊成功發起事件
const User = require('./../models/user');

module.exports.create = function (req, res, next) {
  const user = new User(req.body);
  user.save(function (err) {
    if (err) return next(err);
    res.app.emit('user:created', user); // 當使用者成功註冊時觸發建立使用者事件
    res.send('User created');
  });
};
複製程式碼

WebSocket 與 session

const express = require('express');
const WebSocketServer = require('ws').Server;
const parseCookie = express.cookieParser('some secret'); // 載入解析 cookie 中介軟體,設定密碼
const MemoryStore = express.session.MemoryStore; // 載入要使用的會話儲存
const store = new MemoryStore();

const app = express();
const server = app.listen(process.env.PORT || 3000);

app.use(parseCookie);
app.use(express.session({ store: store, secret: 'some secret' })); // 告知 Express 使用會話儲存和設定密碼(使用 session 中介軟體)
app.use(express.static(__dirname + '/public'));

app.get('/random', function (req, res) { // 測試測試用的會話值
  req.session.random = Math.random().toString();
  res.send(200);
});

// 設定 WebSocket 伺服器,將其傳遞給 Express 伺服器
// 需要傳遞已有的 Express 服務(listen 的返回物件)
const webSocketServer = new WebSocketServer({ server: server });

// 在連線事件給客戶端建立 WebSocket
webSocketServer.on('connection', function (ws) {
  let session;

  ws.on('message', function (data, flags) {
    const message = JSON.parse(data);

    // 客戶端傳送的 JSON,需要一些程式碼來解析 JSON 字串確定是否可用
    if (message.type === 'getSession') {
      parseCookie(ws.upgradeReq, null, function (err) {
        // 從 HTTP 的更新請求中獲取 WebSocket 的會話 ID
        // 一旦 WebSockets 伺服器有一個連線,session ID 可以用=從初始化請求中的 cookies 中獲取
        const sid = ws.upgradeReq.signedCookies['connect.sid'];

        // 從儲存中獲取使用者的會話資訊
        // 只需要在初始化的請求中傳遞一個引用給解析 cookie 的中介軟體
        // 然後 session 可以使用 session 儲存的 get 方法載入
        store.get(sid, function (err, loadedSession) {
          if (err) console.error(err);
          session = loadedSession;
          ws.send('session.random: ' + session.random, {
            mask: false,
          }); // session 載入後會把一個包含了 session 值的訊息發回給客戶端
        });
      });
    } else {
      ws.send('Unknown command');
    }
  });
});
複製程式碼
<!DOCTYPE html>
<html>

<head>
  <script>
    const host = window.document.location.host.replace(/:.*/, '');
    const ws = new WebSocket('ws://' + host + ':3000');

    setInterval(function () {
      ws.send('{ "type": "getSession" }'); // 定期向伺服器傳送訊息
    }, 1000);

    ws.onmessage = function (event) {
      document.getElementById('message').innerHTML = event.data;
    };
  </script>
</head>

<body>
  <h1>WebSocket sessions</h1>
  <div id='message'></div><br>
</body>

</html>
複製程式碼

Express4 中介軟體

package 描述
body-parser 解析 URL 編碼 和 JSON POST 請求的 body 資料
compression 壓縮伺服器響應
connect-timeout 請求允許超時
cookie-parser 從 HTTP 頭部資訊中解析 cookies,結果放在 req.cookies
cookie-session 使用 cookies 來支援簡單會話
csurf 在會話中新增 token,防禦 CSRF 攻擊
errorhandler Connect 中使用的預設錯誤處理
express-session 簡單的會話處理,使用 stores 擴充套件來吧會話資訊寫入到資料庫或檔案中
method-override 對映新的 HTTP 動詞到請求變數中的 _method
morgan 日誌格式化
response-time 跟蹤響應時間
serve-favicon 傳送網站圖示
serve-index 目錄列表
whost 允許路由匹配子域名

JWT

JSON Web Token(縮寫 JWT)是目前最流行的跨域認證解決方案。

跨域認證

一般流程

  • 使用者向伺服器傳送使用者名稱和密碼
  • 伺服器驗證通過後,在當前對話(session)裡面儲存相關資料,比如使用者角色、登入時間等等
  • 伺服器向使用者返回一個 session_id,寫入使用者的 Cookie
  • 使用者隨後的每一次請求,都會通過 Cookie,將 session_id 傳回伺服器
  • 伺服器收到 session_id,找到前期儲存的資料,由此得知使用者的身份

session 共享

在伺服器叢集,要求 session 資料共享,每臺伺服器都能夠讀取 session:

  • 一種解決方案是 session 資料持久化,寫入資料庫或別的持久層。各種服務收到請求後,都向持久層請求資料。這種方案的優點是架構清晰,缺點是工程量比較大。另外,持久層萬一掛了,就會單點失敗。
  • 另一種方案是伺服器索性不儲存 session 資料了,所有資料都儲存在客戶端,每次請求都發回伺服器。JWT 就是這種方案的一個代表。

JWT

原理

  • 伺服器認證以後,生成一個 JSON 物件,發回給使用者
  • 使用者與服務端通訊的時候,都要發回這個 JSON 物件,伺服器完全只靠這個物件認定使用者身份
  • 防止篡改會加上簽名

資料結構

Header(頭部).Payload(負載).Signature(簽名):

  • Header:JSON,使用 Base64 URL 轉成字串
  • Payload:JSON,使用 Base64 URL 轉成字串
  • Signature:對前兩部分的簽名
Header
{
  "alg": "HS256", // 簽名的演算法
  "typ": "JWT" // token 的型別
}
複製程式碼
Payload
{
  // 7 個官方欄位
  "iss": "簽發人",
  "exp": "過期時間",
  "sub": "主題",
  "aud": "受眾",
  "nbf": "生效時間",
  "iat": "簽發時間",
  "jti": "編號",
  // 定義私有欄位
  "name": "Chenng" 
}
複製程式碼
Signature
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret) # secret 祕鑰只有伺服器知道
複製程式碼

使用方式

  • 客戶端收到伺服器返回的 JWT,可以儲存在 Cookie 裡面,也可以儲存在 localStorage
  • 放在 Cookie 裡面自動傳送,但是這樣不能跨域,所以更好的做法是放在 HTTP 請求的頭資訊 Authorization 欄位裡面

特點

  • JWT 不僅可以用於認證,也可以用於交換資訊。有效使用 JWT,可以降低伺服器查詢資料庫的次數
  • JWT 的最大缺點是,由於伺服器不儲存 session 狀態,因此無法在使用過程中廢止某個 token,或者更改 token 的許可權。也就是說,一旦 JWT 簽發了,在到期之前就會始終有效,除非伺服器部署額外的邏輯
  • JWT 本身包含了認證資訊,一旦洩露,任何人都可以獲得該令牌的所有許可權。為了減少盜用,JWT 的有效期應該設定得比較短。對於一些比較重要的許可權,使用時應該再次對使用者進行認證

koa

核心物件

  • HTTP 接收 解析 響應
  • 中介軟體 執行上下文
  • Koa 中一切的流程都是中介軟體

原始碼組成

  • application
  • context
  • request
  • response

中介軟體的使用

const Koa = require('koa');

const app = new Koa();

const mid1 = async (ctx, next) => {
  ctx.body = 'Hi';
  await next(); // next 執行下一個中介軟體
  ctx.body += ' there';
};
const mid2 = async (ctx, next) => {
  ctx.type = 'text/html; chartset=utf-8';
  await next();
};
const mid3 = async (ctx, next) => {
  ctx.body += ' chenng';
  await next();
};

app.use(mid1);
app.use(mid2);
app.use(mid3);

app.listen(2333);
// Hi chenng there
複製程式碼

返回媒體資源

router
  .get('/api/dynamic_image/codewars', async (ctx, next) => {
    const res = await axios.get('https://www.codewars.com/users/ringcrl');
    const [, kyu, score] = res.data
      .match(/<div class="stat"><b>Rank:<\/b>(.+?)<\/div><div class="stat"><b>Honor:<\/b>(.+?)<\/div>/);
    const svg = `
      <svg xmlns="http://www.w3.org/2000/svg" width="80" height="20">
        <rect x="0" y="0" width="80" height="20" fill="#fff" stroke-width="2" stroke="#cccccc"></rect>
        <rect x="0" y="0" width="50" height="20" fill="#5b5b5b"></rect>
        <text x="5" y="15" class="small" fill="#fff" style="font-size: 14px;">${kyu}</text>
        <rect x="50" y="0" width="30" height="20" fill="#3275b0"></rect>
        <text x="53" y="15" class="small" fill="#fff" style="font-size: 14px">${score}</text>
      </svg>
    `;
    ctx.set('Content-Type', 'image/svg+xml');
    ctx.body = Buffer.from(svg);
    await next();
  });
複製程式碼

Web API 設計

需求

  • 易於使用
  • 便於修改
  • 健壯性好
  • 不怕公之於眾

重要準則

  • 設計容易記憶、功能一目瞭然
  • 使用合適的 HTTP 方法
  • 選擇合適的英語單詞,注意單詞的單複數形式
  • 使用 OAuth 2.0 進行認證

API 通用資源網站 ProgrammableWeb(www.programmableweb.com)中有各種已經公開的 Web API 文件,多觀察一下

公鑰加密私鑰解密

生成公鑰私鑰

利用 openssl 生成公鑰私鑰 
生成公鑰:openssl genrsa -out rsa_private_key.pem 1024 
生成私鑰:openssl rsa -in rsa_private_key.pem -pubout -out rsa_public_key.pem
複製程式碼

crypto 使用

const crypto = require('crypto');
const fs = require('fs');

const publicKey = fs.readFileSync(`${__dirname}/rsa_public_key.pem`).toString('ascii');
const privateKey = fs.readFileSync(`${__dirname}/rsa_private_key.pem`).toString('ascii');
console.log(publicKey);
console.log(privateKey);
const data = 'Chenng';
console.log('content: ', data);

//公鑰加密
const encodeData = crypto.publicEncrypt(
  publicKey,
  Buffer.from(data),
).toString('base64');
console.log('encode: ', encodeData);

//私鑰解密
const decodeData = crypto.privateDecrypt(
  privateKey,
  Buffer.from(encodeData, 'base64'),
);
console.log('decode: ', decodeData.toString());
複製程式碼

redis 快取介面

  • 部分不用實時更新的資料使用 redis 進行快取
  • 使用 node-schedule 在每晚定時呼叫介面

redis 使用

const redis = require('redis');
const redisClient = redis.createClient();
const getAsync = promisify(redisClient.get).bind(redisClient);

let codewarsRes = JSON.parse(await getAsync('codewarsRes'));
if (!codewarsRes) {
  const res = await axios.get('https://www.codewars.com/users/ringcrl');
  codewarsRes = res.data;
  redisClient.set('codewarsRes', JSON.stringify(codewarsRes), 'EX', 86000);
}
複製程式碼

node-schedule 使用

const schedule = require('node-schedule');
const axios = require('axios');

schedule.scheduleJob('* 23 59 * *', function () {
  axios.get('https://static.chenng.cn/api/dynamic_image/leetcode_problems');
  axios.get('https://static.chenng.cn/api/dynamic_image/leetcode');
  axios.get('https://static.chenng.cn/api/dynamic_image/codewars');
});
複製程式碼

參考地址

相關文章