[Node.js] expressで作るHTTPサーバ - 入門編 その3「非同期処理とファイル」

※この記事は専門学校の講義用に作成されたものです

Node.js+expressでHTTPサーバを作成する第3段。今回は最終的にファイル処理について取り上げるのですが、その前に知っておかないとグローバル変数以上のハマりどころである「非同期処理」についても説明します。Node.jsはApacheなどのWebサーバと比べると仕組みが大きく異ります。この違いをまずは抑えましょう。

これまでのお話

環境構築や基本的な開発の流れは第1回を参照ください。 blog.katsubemakito.net

前回(第2回)の記事は以下です。 blog.katsubemakito.net

前提のお話

Node.jsはシングルスレッド

Apacheには2つの動作モードが用意されていますが、いずれも同時に複数のリクエストをさばける仕組みになっています。以下はpreforkで動かした場合の図ですが、親プロセスによって複数の子プロセスが生成され、この子プロセスがリクエストを並行してさばきます。もし子プロセスがに何らかの異常が発生して死んでしまった場合でも親プロセスが新しく子プロセスを生成します。その間のリクエストは他の子プロセスが受け持ちます。

ところがNode.jsでは同時に1つしか相手にしない「シングルスレッド」方式を戦略的に採用しています。

一見効率が悪いように思えますがこれは「C10k問題」への対応です。詳細は割愛しますがC10k問題とはサーバに接続するクライアント数が膨大になるとそれに比例しスレッドが増加、最終的にCPUやメモリに余裕があるのに処理が行えずサーバがハングアップしてしまう問題のことです。Node.jsではスレッドが増えるのが問題であるなら1つのスレッドですべて処理すれば良いのでは?というシンプルな発想で迎え撃ったというわけです。 ※なおCはClient、10kはそのままで10000を指します。クライアントが10000個接続されると死ぬという意味になりますが、10000はあくまで目安で「非常に多くのクライアント」というのが本来の意味のようです。

Node.jsでSocket.ioなどを利用しリアルタイム通信が話題になったのは、このC10k問題が解消され多数のクライアントが同時に接続可能になったという背景がここから想像できますね。

非同期処理で高速化

もちろん1つのスレッドで順番に処理するだけでは効率が悪いことは明白です。そこでNode.jsでは「非同期処理」という考え方を採用しています。難しそうに聞こえますが、要は「待ち時間に別の処理を行う」ということです。

例えばカレーを作る際には「ご飯」とその上にかける「ルー」を用意するわけですが、以下のようにご飯が炊きあがるのを待ってからルーの用意をするのは非常に効率が悪いですよね。

通常はご飯を炊飯器にセットしてボタンを押したら、炊きあがるまでの待ち時間でルーの準備を行います(もちろんご飯とルーの順番は逆でも良いですw)

Node.jsは例えばファイルに大量のデータを書き込んでいるスキマ時間で、実は裏側で別の計算を行っていたりします。この非同期処理の実装によって高速な処理を実現しています。逆説的に言えば非同期の反対である同期処理を行うとNode.jsはとたんにパフォーマンスが落ちてしまいます。バッチ処理コマンドラインなど1度に1人しか動かさないようなケースではかまいませんが、サーバのように不特定多数の人が同時に利用するようなシーンでの同期処理は出来るだけ避けるのが定石です。

ファイルに保存する

まずはNode.jsの標準的な機能でファイルに保存する方法を試します。

同期版

こちらが同期処理版。同じくfsモジュールを利用しますが、最後にSyncが付いているメソッドは大抵の場合は同期処理を行う物になります。ここでも最後のconsole.log()の実行タイミングに注目してください。

const fs = require("fs");
const data = "Hello Node";  // 書き込むデータ準備

try{
  fs.writeFileSync("file1.txt", data);
  console.log("正常に書き込みが完了しました");
}
catch(e){
  console.log(e.message);
}

console.log("最後まで実行しました");

非同期版

まずは非同期処理です。標準モジュールのfsを利用します。最後のconsole.log()が実行されるタイミングにも注目してください。

const fs = require("fs");
const data = "Hello Node";  // 書き込むデータ準備

// 書き込み
fs.writeFile("file1.txt", data, (err) => {
  if (err){
    console.log(`[error] ${err}`);
  }

  console.log("正常に書き込みが完了しました");
});

console.log("最後まで実行しました");

より詳細な情報が必要な場合は以下の記事を参照してください。 blog.katsubemakito.net

ファイルから読み込む

読み込み方も書き込みと似たような利用方法になります。

同期

const fs = require("fs");

try{
  const data = fs.readFileSync("file1.txt");
  console.log(data.toString());
}
catch(e){
  console.log(e.message);
}

console.log("最後まで実行しました");

非同期

const fs = require("fs");

fs.readFile("file1.txt", (err, data) => {
  if (err){
    console.log(`[error] ${err}`);
  }

  console.log(data.toString());
});

console.log("最後まで実行しました");

より詳細な情報が必要な場合は以下の記事を参照してください。 blog.katsubemakito.net

アクセスカウンター(同期/ファイル版)

まずは非同期ではなく、同期版を試してみます。

準備

サーバを実行するフォルダ内にdata.txtという名前のファイルを用意し、適当な数字を記入しておきます。

$ cat data.txt
11125

ソースコード

以下のファイルをserve2.jsとして保存します(ファイル名は何でもかまいません)。

const fs = require("fs");
const express = require("express");
const app  = express();
const port = 3000;
const file = "data.txt";

// ルーティング
app.get("/", (req, res)=>{
  try{
    let count = fs.readFileSync(file);          // 読み込み
    fs.writeFileSync(file, (Number(count)+1));  // 書き込み
    res.send(`あなたは${count}人目のお客様です`);
  }
  catch(e){
    res.send(`エラーが発生しました ${e.message}`);
  }
});

// サーバを起動
app.listen(port, ()=>{
  console.log(`Running at http://localhost:${port}/`);
});

実行する

いつものように実行したいファイル名をnodeに渡します。

$ node serve2.js
Running at http://localhost:3000/

ブラウザからアクセスしてみます。

データファイルの内容が再読み込みする度に増えていれば成功です。

$ cat data.txt
11126

アクセスカウンター(非同期/ファイル版)

非同期処理の場合も書いてみましょう。

ソースコード

実行結果は同じになるので割愛しますが、めちゃめちゃまどろっこしいですねw

const fs = require("fs");
const express = require("express");
const app  = express();
const port = 3000;
const file = "data.txt";

//------------------------
// ルーティング
//------------------------
app.get("/", (req, res)=>{
  //------------------------
  // 読み込み
  //------------------------
  fs.readFile(file, (err, data)=>{
    const count = Number(data);

    if( err ){
      res.send(`エラーが発生しました ${err}`);
      return(false);
    }

    //------------------------
    // 書き込み
    //------------------------
    fs.writeFile(file, count+1, (err)=>{
      if( err ){
        res.send(`エラーが発生しました ${err}`);
        return(false);
      }

      res.send(`あなたは${count}人目のお客様です`);
    })

  });
});

// サーバを起動
app.listen(port, ()=>{
  console.log(`Running at http://localhost:${port}/`);
});

良いとこ取りをしたい

同期版はすっきり書けるけどパフォーマンス的に劣り、非同期版はパフォーマンスが出るけど書き方がまどろっこしいという現実がわかりました。困りましたねw

そこで登場したのがPromiseやawait/asyncという仕組みです (次回に続く)

続き

※鋭意執筆中(来週公開予定)