Node.js 中的一股清流:理解 Stream(流)的基本概念

KaysonLi發表於2019-12-06

接觸過 Node.js 的開發人員可能知道,流(Stream)這個概念比較難理解,也不太好處理。

這篇文章就來幫你理解流的概念,以及如何使用它。別擔心,一定會搞懂的。

流(Stream)是什麼?

流(Stream)是驅動 Node.js 應用的基礎概念之一。它是資料處理方法,用於按順序將輸入讀寫到輸出中。

流是一種處理讀寫檔案、網路通訊或任何端到端資訊交換的有效方式。

流的獨特之處在於,它不像傳統的程式那樣一次將一個檔案讀入記憶體,而是逐塊讀取資料、處理其內容,而不是將其全部儲存在記憶體中。

這使得流在處理大量資料時非常強大,例如,檔案可能大於你的空閒記憶體,不可能將整個檔案讀入記憶體來處理,這時候流就發揮作用了。

我們以 YouTube 或 Netflix 等流媒體服務為例:這些服務不會讓你立即下載完整的視訊和音訊,而是瀏覽器將視訊作為連續流的資料塊,可以做到使用者立即收看。

然而,流並不僅僅用來處理媒體或大資料,它還賦予了程式碼的“可組合性”。在設計時考慮到可組合性意味著幾個元件可以以某種方式組合以產生相同型別的結果。在 Node.js 中,通過使用流將資料從其他更小的程式碼段中匯入或匯出,可以組成功能強大的程式碼段。

為什麼要用流

與其他資料處理方法相比,流有兩個主要優勢:

  1. 記憶體效率: 不需要載入大量的資料到記憶體就可以處理
  2. 時間效率: 一旦有了資料就開始處理,而不必等待傳輸完所有資料

Node.js 中的 4 種流(Stream)

  1. 可寫流: 可寫入資料的流。例如fs.createWriteStream() 可以使用流將資料寫入檔案。
  2. 可讀流: 可讀取資料的流。例如fs.createReadStream() 可以從檔案讀取內容。
  3. 雙工流: 既可讀又可寫的流。例如 net.Socket
  4. 轉換流: 可以在資料寫入和讀取時修改或轉換資料的流。例如,在檔案壓縮操作中,可以向檔案寫入壓縮資料,並從檔案中讀取解壓資料。

如果你用過 Node.js,可能已經遇到過流了。例如,在基於 Node.js 的 HTTP 伺服器中,request 是可讀流,response 是可寫流。還有fs 模組,能同時處理可讀和可寫檔案流。只要你用 Express,就是在使用流與客戶端進行互動,流也被用於各種資料庫連線驅動程式中,因為 TCP 套接字、TLS 堆疊和其他連線都是基於 Node.js 流的。

如何建立可讀流

引入模組並初始化:

const Stream = require('stream')
const readableStream = new Stream.Readable()

複製程式碼

初始化後就可以給它傳送資料了:

readableStream.push('ping!')
readableStream.push('pong!')

複製程式碼

非同步迭代器(async iterator)

強烈建議在處理流時使用非同步迭代器。非同步迭代是一種非同步檢索資料容器內容的協議,意味著當前的“任務”可能在檢索資料項之前暫停。另外,值得一提的是,流的非同步迭代器的內部實現使用了 readable事件。

當從可讀的流讀取資料時,可以使用 async iterator:

import * as fs from 'fs';

async function logChunks(readable) {
  for await (const chunk of readable) {
    console.log(chunk);
  }
}

const readable = fs.createReadStream(
  'tmp/test.txt', {encoding: 'utf8'});
logChunks(readable);

// Output:
// 'This is a test!\n'

複製程式碼

也可以在字串中收集可讀流的內容:

import { Readable } from 'stream';

async function readableToString2(readable) {
  let result = '';
  for await (const chunk of readable) {
    result += chunk;
  }
  return result;
}

const readable = Readable.from('Good morning!', { encoding: 'utf8' });
assert.equal(await readableToString2(readable), 'Good morning!');

複製程式碼

注意,在本例中,我們必須使用非同步函式,因為我們希望返回一個 Promise。

記得不要將非同步函式與 EventEmitter 搞混了,因為目前無法捕獲從事件處理程式中發出的 rejection,從而導致難以跟蹤 bug 和記憶體洩漏。當前的最佳實踐是始終將非同步函式的內容封裝在 try/catch 塊中並處理錯誤,但這很容易出錯。這個 pull request就是為了解決這個問題,如果能加入到 Node 核心程式碼的話。

Readable.from(): 從 iterables 建立可讀流

stream.Readable.from(iterable, [options]) 是一個實用方法,用於從迭代器建立可讀流,其中的 iterable 包含了資料。iterable 可以是同步迭代的,也可以是非同步迭代的。options 是可選的,可以用於指定文字編碼。

const { Readable } = require('stream');

async function * generate() {
  yield 'hello';
  yield 'streams';
}

const readable = Readable.from(generate());

readable.on('data', (chunk) => {
  console.log(chunk);
});

複製程式碼

兩種讀取模式

根據 Streams API,可讀流有兩種操作模式: flowing 和 paused。 無論流是處於流模式還是暫停模式,可讀流都可以用物件模式或非物件模式。

  • flowing 模式中,資料從底層系統自動讀取,並通過 EventEmitter 介面以儘可能快的速度使用事件提供給應用程式。

  • paused 模式中,必須顯式地呼叫 stream.read() 方法來從流中讀取資料塊。

在 flowing 模式中,要從流中讀取資料,可以監聽 data 事件並繫結回撥。當資料塊可用時,可讀流發出 data 事件並執行回撥。程式碼如下:

var fs = require("fs");
var data = '';

var readerStream = fs.createReadStream('file.txt'); //Create a readable stream

readerStream.setEncoding('UTF8'); // Set the encoding to be utf8\. 

// 處理 stream 事件 --> data, end, 和 error
readerStream.on('data', function(chunk) {
   data += chunk;
});

readerStream.on('end',function() {
   console.log(data);
});

readerStream.on('error', function(err) {
   console.log(err.stack);
});

console.log("Program Ended");

複製程式碼

函式呼叫 fs.createReadStream() 提供了一個可讀流。一開始,流處於靜止狀態。只要監聽 data 事件並繫結回撥,它就開始流動。然後,讀取資料塊並將其傳遞給回撥。流的實現者可以決定 data 事件發出的頻率。例如,HTTP 請求可以在每讀取幾 KB 資料時發出一個 data 事件。當你從檔案中讀取資料時,你可能會採取每讀取一行就發出 data 事件。

當沒有更多的資料要讀取(到達尾部)時,流就會發出 end 事件。在上面的程式碼中,我們監聽了這個事件,以便在結束時得到通知。

另外,如果出現錯誤,流將發出錯誤並通知。

在 paused 模式下,你只需要反覆呼叫流例項上的 read(),直到每一塊資料都被讀取,如下所示:

var fs = require('fs');
var readableStream = fs.createReadStream('file.txt');
var data = '';
var chunk;

readableStream.on('readable', function() {
    while ((chunk=readableStream.read()) != null) {
        data += chunk;
    }
});

readableStream.on('end', function() {
    console.log(data)
});
複製程式碼

read() 函式從內部緩衝區讀取一些資料並返回。當沒有要讀取的內容時,它返回 null。因此,在while迴圈中,我們檢查null並終止迴圈。請注意,readable事件是在可以從流中讀取資料塊時發出的。


所有Readable資料流都以 paused 模式開始,但可以通過以下方式切換到 flowing 模式

  • 新增 data 事件處理器
  • 呼叫 stream.resume() 方法
  • 呼叫 stream.pipe() 方法傳送資料到一個 Writable

Readable可以使用以下幾種方式切換回 paused 模式:

  • 如果沒有管道(pipe)目標,呼叫stream.pause()方法
  • 如果有管道(pipe)目標,刪除所有管道目標。可以通過呼叫 stream.unpipe() 方法來刪除多個管道目標。

要記住的重要概念是,除非提供了一種用於消費或忽略該資料的機制,否則Readable 將不會生成資料。如果消費機制被禁用或取消,Readable嘗試停止生成資料。 新增一個readable 事件處理程式會自動使流停止流動,並通過readable.read()消費資料。如果刪除了readable事件處理程式,那麼如果存在data事件處理程式,則流就會再次開始流動。

如何建立可寫流

要將資料寫入可寫流,你需要在流例項上呼叫write()。 如下所示:

var fs = require('fs');
var readableStream = fs.createReadStream('file1.txt');
var writableStream = fs.createWriteStream('file2.txt');

readableStream.setEncoding('utf8');

readableStream.on('data', function(chunk) {
    writableStream.write(chunk);
});

複製程式碼

上面的程式碼簡單直白。它只是簡單地從輸入流中讀取資料塊,並使用write()寫入目標位置。該函式返回一個布林值,表明操作是否成功。如果為true,則寫入成功,你可以繼續寫入更多資料。 如果返回 false,則表示出了點問題,目前無法寫入任何內容。可寫流將通過發出drain事件來通知你何時可以開始寫入更多資料。

呼叫writable.end()方法表明沒有更多資料將被寫入Writable。 如果提供可選的回撥函式,將作為finish事件的監聽器函式。

// 寫入 'hello, ' 然後以 'world!' 結束
const fs = require('fs');
const file = fs.createWriteStream('example.txt');
file.write('hello, ');
file.end('world!');
// 不允許寫更多內容!

複製程式碼

使用可寫流,你可以從可讀流中讀取資料:

const Stream = require('stream')

const readableStream = new Stream.Readable()
const writableStream = new Stream.Writable()

writableStream._write = (chunk, encoding, next) => {
    console.log(chunk.toString())
    next()
}

readableStream.pipe(writableStream)

readableStream.push('ping!')
readableStream.push('pong!')

writableStream.end()

複製程式碼

你還可以使用非同步迭代器寫入可寫流,這也是建議的做法:

import * as util from 'util';
import * as stream from 'stream';
import * as fs from 'fs';
import {once} from 'events';

const finished = util.promisify(stream.finished); // (A)

async function writeIterableToFile(iterable, filePath) {
  const writable = fs.createWriteStream(filePath, {encoding: 'utf8'});
  for await (const chunk of iterable) {
    if (!writable.write(chunk)) { // (B)
      // 處理反壓
      await once(writable, 'drain');
    }
  }
  writable.end(); // (C)
  // 等待完成,如果有錯誤則丟擲
  await finished(writable);
}

await writeIterableToFile(
  ['One', ' line of text.\n'], 'tmp/log.txt');
assert.equal(
  fs.readFileSync('tmp/log.txt', {encoding: 'utf8'}),
  'One line of text.\n');

複製程式碼

stream.finished()的預設版本是基於回撥的,但是可以通過util.promisify()轉換為基於 Promise 的版本(A行)。

在此示例中,使用了以下兩種模式:

寫入可寫流,同時處理反壓(短時負載高峰導致系統接收資料的速率遠高於它處理資料的速率)(B行):

if (!writable.write(chunk)) {
  await once(writable, 'drain');
}

複製程式碼

關閉可寫流,並等待寫入完成(C行):

writable.end();
await finished(writable);

複製程式碼

pipeline()

管道是一種機制,是將一個流的輸出作為另一流的輸入。它通常用於從一個流中獲取資料並將該流的輸出傳遞到另外的流。管道操作沒有限制,換句話說,管道用於分步驟處理流資料。

Node 10.x 引入了stream.pipeline()。 這是一種模組方法,用於在流之間進行管道傳輸,轉發錯誤資訊和資料清理,並在管道完成後提供回撥。

下面是使用 pipeline 的一個例子:

const { pipeline } = require('stream');
const fs = require('fs');
const zlib = require('zlib');

// 使用 pipeline API 輕鬆管理多個管道流,並且在管道全部完成時得到通知
// 一個用來高效壓縮超大視訊檔案的管道

pipeline(
  fs.createReadStream('The.Matrix.1080p.mkv'),
  zlib.createGzip(),
  fs.createWriteStream('The.Matrix.1080p.mkv.gz'),
  (err) => {
    if (err) {
      console.error('Pipeline failed', err);
    } else {
      console.log('Pipeline succeeded');
    }
  }
);

複製程式碼

應該使用pipeline 而不是 pipe,因為pipe是不安全的。

Stream 模組

Node.js stream 模組 是構建所有流 API 的基礎。

Stream 模組是 Node.js 中預設提供的內建模組。 Stream 是 EventEmitter 類的例項,該類在Node 中用於非同步處理事件。 因此,流本質上是基於事件的。

使用stream模組只需:

const stream = require('stream');

複製程式碼

stream 模組對於建立新型流例項非常有用。通常沒有必要使用stream模組來消費流。

基於流的 Node.js API

由於它們的優點,Node.js 許多核心模組提供了原生流處理功能,最值得注意的是這些:

  • net.Socket 基於流的主要 node api,是以下大部分 API 的基礎
  • process.stdin 返回連線到 stdin 的流
  • process.stdout返回連線到 stdout 的流
  • process.stderr  返回連線到 stderr 的流
  • fs.createReadStream() 建立一個檔案可讀流
  • fs.createWriteStream() 建立一個檔案可寫流
  • net.connect() 初始化一個基於流的連線
  • http.request() 返回 http.ClientRequest類的一個例項,是一個可寫流
  • zlib.createGzip() 用 gzip (一種壓縮演算法)將資料壓縮到流
  • zlib.createGunzip() 解壓 gzip 流
  • zlib.createDeflate() 用 deflate (一種壓縮演算法)將資料壓縮到流
  • zlib.createInflate() 解壓 deflate 流

Streams 備忘單

型別 功能
Readable 資料提供者
Writable 資料接收者
Transform 提供者和接收者
Duplex 提供者和接收者(獨立的)

更多內容請查閱文件: Stream (nodejs.org)

Streams

const Readable = require('stream').Readable
const Writable = require('stream').Writable
const Transform = require('stream').Transform

複製程式碼

管道 Piping

clock()              // 可讀流
  .pipe(xformer())   // 轉換流
  .pipe(renderer())  // 可寫流

複製程式碼

方法

stream.push(/*...*/)         // Emit a chunk
stream.emit('error', error)  // Raise an error
stream.push(null)            // Close a stream

複製程式碼

事件

const st = source() // 假設 source() 是可讀流
st.on('data', (data) => { console.log('<-', data) })
st.on('error', (err) => { console.log('!', err.message) })
st.on('close', () => { console.log('** bye') })
st.on('finish', () => { console.log('** bye') })

複製程式碼

Flowing 模式

// 開啟和關閉 flowing 模式
st.resume()
st.pause()
// 自動開啟 flowing 模式
st.on('data', /*...*/)

複製程式碼

可讀流

function clock () {
  const stream = new Readable({
    objectMode: true,
    read() {} // 自己實現 read() 方法,如果要按需讀取
  })

  setInterval(() => {
    stream.push({ time: new Date() })
  }, 1000)

  return stream
}
複製程式碼

可讀流是資料生成器,用stream.push()寫入資料。

轉換流

function xformer () {
  let count = 0

  return new Transform({
    objectMode: true,
    transform: (data, _, done) => {
      done(null, { ...data, index: count++ })
    }
  })
}

複製程式碼

將轉換後的資料塊傳給 done(null, chunk).

可寫流

function renderer () {
  return new Writable({
    objectMode: true,
    write: (data, _, done) => {
      console.log('<-', data)
      done()
    }
  })
}

複製程式碼

全部串起來

clock()              // 可讀流
  .pipe(xformer())   // 轉換流
  .pipe(renderer())  // 可寫流

複製程式碼

以下是與可寫流相關的一些重要事件:

  • error – 在寫入/管道操作發生了錯誤時傳送
  • pipeline – 當將可讀流傳遞到可寫流中時,可寫流會發出此事件。
  • unpipe – 當你在可讀流上呼叫unpipe並停止將其輸送到目標流中時發出。

總結

這就是所有關於流的基礎知識。 流、管道和鏈式操作是 Node.js 的核心和最強大的功能。流確實可以幫助你編寫簡潔而高效的程式碼來操作 I/O。

此外,還有一個值得期待的Node.js戰略計劃叫做BOB,目標是改善 Node.js 的流資料介面,既可應用於 Node.js 內部核心,將來還有希望用於公開 API。

更多技術乾貨,請關注微信公眾號:1024譯站

微信公眾號:1024譯站

相關文章