📖

Node.js ハンズオン 1 〜 3章まとめ

2024/09/23に公開

随分前に読んだ本ですが、再入門しようと思って読み直しています。
その内容を投稿しようと思っています。
自分の理解が浅いところだけまとめていますので、網羅的ではないですが備忘録として記事にしました!

第1章 イントロダクション

クラス継承と prototype について

JavaScriptには、クラス継承を実現する仕組みとして prototype があります。各クラスには class.prototype.method という形でメソッドを追加することができ、オブジェクトがそのクラスのインスタンスかどうかを instanceof 演算子で検証できます。

また、prototype を使用することで、既存のクラスにメソッドやプロパティを動的に追加することが可能です。これは、後から機能を拡張したり、柔軟にクラスの振る舞いを変えたい場合に非常に有用です。

  • prototype チェーン
    prototype チェーンを利用すると、あるオブジェクトが継承している元のクラスの prototype に遡ることができます。例えば、以下のように prototype チェーンを辿ることが可能です。
Foo.__proto__.__proto__.__proto__ === Bar.prototype;

このように、JavaScriptではオブジェクトのプロトタイプを遡ることで、継承関係にあるメソッドやプロパティにアクセスできます。

JavaScriptとTypeScriptのクラス構文

JavaScriptやTypeScriptは、プロトタイプベースの言語ですが、class 構文を導入することで、開発者はクラスベースのプログラミングに近い感覚でコーディングできます。以下は、クラス構文がなかった場合の継承を prototype を使ってどのように実現するかの例です。

  • コンストラクタ関数と prototype の例
// コンストラクタ関数
function Person(name, age) {
  this.name = name;
  this.age = age;
}

// prototypeにメソッドを追加
Person.prototype.greet = function() {
  console.log("Hello, my name is " + this.name);
};

// インスタンスを作成
const person1 = new Person("Alice", 25);
person1.greet(); // "Hello, my name is Alice"

このコードでは、Person というコンストラクタ関数を作成し、prototype を使って greet メソッドを追加しています。この方法により、クラス構文がなかった時代でも、JavaScriptでオブジェクトの継承や再利用が可能でした。

クラス構文の利便性

もし、クラス構文が存在しなかった場合、すべてのオブジェクト継承やメソッドの追加を prototype オブジェクトを介して行う必要がありました。現代のJavaScriptでは、クラス構文を使うことで、より直感的で読みやすいコードを書くことができるようになっています。

第2章 イベントループと非同期処理の基礎

イベントループ

マルチスレッドとC10K問題

  • マルチスレッド: 通常のプログラムでは、複数のスレッドが並行して処理を行います。例えば、複数の作業台で作業をするようなイメージです。処理がブロックされている(待ち時間が発生している)場合は、別の作業台で別の処理を行うことで効率を高めます。
    作業員が各作業台で同時に複数の製品を作成し、製造量が増えれば作業台と作業員も増加するイメージです。同時に多くの処理が可能となります。
  • C10K問題: サーバーが同時に1万以上の接続を処理する際に、各接続に対してスレッドを作成しているとメモリ消費が膨大になります。この問題が「C10K問題」と呼ばれ、特にコンテキストスイッチが発生すると、パフォーマンスが大幅に低下します。
  • コンテキストスイッチ: メモリの上限を超えた場合、OSが現在のスレッドを一旦停止して別のスレッドに切り替えます。この操作にはオーバーヘッドがあり、効率が低下します。

イベントループとシングルスレッド

Node.jsはシングルスレッドですが、イベントループを利用することで非同期処理を実現します。

  • 非同期IO: 非同期処理が発生すると、その処理が完了した後に実行されるコールバックがキューに追加されます。イベントループはこのキューに入ったタスクを順次処理します。
    処理が完了するまで待つのではなく、別のタスクを並行して処理します。非同期IOが完了すると、キューにあるコールバックが優先的に実行されます。
    • 例: コンビニでお弁当を温めている間に、他のお客さんの会計を済ませるような動きです。
  • コールバック: 非同期処理の中に同期処理を混ぜると、意図しない動作を引き起こす可能性があります。例えば、最初の処理は非同期で実行され、2回目はキャッシュされた結果を同期的に処理するケースでは、どちらも非同期で処理することが推奨されます。
  • コールバック地獄: コールバック関数の中でさらにコールバックを呼び出すと、コードがネストされて読みにくくなります。これを避けるために、関数を分割し、1つずつ実行されるように設計しましょう。

Promise

Promise は非同期処理の状態と結果を表すオブジェクトです。Promiseには以下の状態があります。

  1. pending: 処理がまだ確定していない状態
  2. fulfilled: 処理が成功した状態
  3. rejected: 処理が失敗した状態
  4. settled: 処理が確定し、それ以上変更されない状態

よく使われるPromiseのメソッド

  • Promise.all: 引数に渡されたすべてのPromiseが解決されると、結果を返します。1つでも拒否されると、その時点で処理が拒否されます。
  • Promise.race: 渡されたPromiseのうち、1つでも解決されるとその結果を返します。
  • Promise.allSettled: 引数に渡されたPromiseが全て解決または拒否された時点で、その結果を返します。

ジェネレータ

async/await が普及する前、非同期処理を扱うためにジェネレータが使われていました。ジェネレータは途中で処理を一時停止し、結果が返ってくるまで待つことができる仕組みです。現在は async/await によって代替されています。

async/await

async/await は、非同期処理を同期的なスタイルで記述するための構文です。
async キーワードをつけた関数は必ず Promise を返します。
await はその Promise が解決されるまで待機します。
関数外で await を使用することはできませんが、関数内で同期的な記述が可能になります。

  • 実装例
// async関数の定義
async function fetchData() {
  // Promiseを返す非同期処理をawaitで待つ
  const response = await new Promise((resolve) => {
    setTimeout(() => resolve("データ取得完了"), 1000);
  });
  return response;
}

// async関数の呼び出し
fetchData().then((result) => {
  console.log(result); // "データ取得完了"(1秒後に表示)
});

console.log("hoge");
  • 実行結果
hoge
データ取得完了

このように、イベントループと非同期処理の理解はNode.jsを効果的に使いこなすために重要です。Promise や async/await を活用することで、より読みやすくメンテナンスしやすい非同期処理が可能になります。

第3章 EventEmitter と ストリーム

Node.jsのEventEmitterとストリームの基礎

  • EventEmitter
    Node.jsのEventEmitterは、JavaScriptのブラウザ環境で利用されるEventやEvent Listenerと同じような仕組みです。
    特定のイベントを登録・削除・発火させ、イベントが発火された際にコールバック関数を実行できます。
    EventEmitterを利用することで、非同期処理におけるイベントベースのプログラミングが可能になります。
  • ストリーム
    Node.jsのストリームは、大量のデータを効率的に扱うための仕組みです。すべてのデータを一度に処理せず、チャンクと呼ばれる小さなデータ単位で逐次処理を行います。例えば、2GB以上のファイルを一度にメモリに読み込むことができない場合でも、ストリームを使えば小さなチャンク単位で読み込み、メモリを節約しながら処理を進めることが可能です。

ストリームの基本概念

  • チャンク処理: データをチャンク単位で読み込み、処理を進めていきます。
  • メモリ効率の向上: 大規模なデータを扱う際に、一度に全てのデータを読み込むのではなく、少しずつ処理するためメモリ消費を抑えます。
  • pipeメソッド: ストリーム同士をつなぐメソッドです。読み取りストリームから書き込みストリームにデータを流す際に利用されます。
    エラーハンドリング: pipeメソッドでは、複数のストリーム間のエラーは個別に処理する必要がありますが、pipelineメソッドを使うと複数ストリームのエラーハンドリングを一括で行うことができます。
    ストリームの使用例
    以下は、pipelineを使用して、ファイルの内容を大文字に変換して別のファイルに書き込む例です。
const fs = require('fs');
const { pipeline, Transform } = require('stream');

// 変換ストリームを作成
const transformStream = new Transform({
  transform(chunk, encoding, callback) {
    // データを大文字に変換
    const transformedChunk = chunk.toString().toUpperCase();
    callback(null, transformedChunk);
  }
});

// pipelineでストリームを連結
pipeline(
  fs.createReadStream('input.txt'),  // 読み取りストリーム
  transformStream,                   // 変換ストリーム
  fs.createWriteStream('output.txt'), // 書き込みストリーム
  (err) => {
    if (err) {
      console.error('Pipeline failed:', err);
    } else {
      console.log('Pipeline succeeded.');
    }
  }
);
  • finishedメソッド
    ストリームが正常に完了したか、エラーが発生して停止したかを確認する際に、finishedメソッドを使用します。以下の例では、finishedを使ってストリームが完了したかどうかを検知し、エラーハンドリングを行っています。
const { finished, Readable } = require('stream');

const readable = Readable.from(['Hello', 'World']);

finished(readable, (err) => {
  if (err) {
    console.error('Stream finished with error:', err);
  } else {
    console.log('Stream finished successfully');
  }
});

ストリームとフィルタリング

ストリームを使ってデータをフィルタリングするのは、場合によっては複雑になることがあります。特に独自の変換ストリームを作成してフィルタリングを行うことも可能ですが、要件によってはストリームに入る前にフィルタリングした方が効率的な場合もあります。

  • pipeメソッドの注意点
    pipeメソッドは、基本的に直後のエラーしかキャッチしません。複数のストリームがpipeでつながっている場合、各ストリームで個別にエラーハンドリングを実装しないとエラーをキャッチできません。そのため、pipelineメソッドを使って一括でエラーハンドリングを行うことが推奨されます。

Node.jsのストリームは、効率的に大規模データを処理するために不可欠な仕組みです。特にファイルの読み書きやネットワーク通信など、大量のデータを扱うシナリオで威力を発揮します。pipelineやfinishedといった便利なメソッドを使うことで、エラーハンドリングや処理の完了を確実に行うことができます。

今後

1 〜 3章の内容をまとめました。今後は

  • 4 〜 6章まとめ
  • 10章 10.2 npm
  • 11章
    上記の部分だけまた要約して投稿しようと思っています!

Discussion