Node.js入門|イベントループから環境変数・ログ設計まで「動く」だけで終わらせない最初の一歩

はじめに

Node.jsは「JavaScriptでサーバーサイドが書ける」だけの存在ではありません。ブラウザで動いていたJSが、API・バッチ・CLI・WebSocket・キュー処理など、より“運用に近い場所”へ広がることで、アプリケーションの作り方そのものが変わります。

一方で、入門段階でありがちな落とし穴もあります。

  • とりあえず動いたけど、停止できない(SIGINT対応がない)
  • ログがconsole.logだらけで、後から追えない
  • 環境変数や設定の切り替えが雑で、事故りやすい
  • 非同期の理解があいまいで、なぜか詰まる

このブログでは、Hello Worldより一段だけ実務寄りに寄せて、「HTTPサーバーを立てる」→「ルーティング」→「設定(env)とログ」→「エラーハンドリング」→「Graceful Shutdown」までを、手を動かしながら一気に押さえます。フレームワークに頼る前の“素のNode.js”を理解しておくと、後でExpressやFastifyなどに移ったときに吸収が早くなります。


座学

1) Node.jsは何が得意?

Node.jsの強みは、I/O(ネットワーク・ファイル・DBなど)待ちが多い処理で力を発揮する点です。リクエスト処理の途中で「外部に問い合わせて結果待ち」という時間が発生しても、その間に別のリクエストをさばける設計になっています。

逆に、**CPUを長時間占有する重い計算(画像変換、機械学習推論の一部など)**を1プロセスで延々回すのは不得意になりがちです(ワーカーに逃がす・別サービス化する・Worker Threadsを使う…などが必要になります)。

2) イベントループと非同期I/Oの感覚

Node.jsは基本的に「1つのスレッドでイベントループを回す」モデルです。

  • すぐ終わる処理はその場で実行
  • 時間のかかるI/Oは“依頼だけして”結果が返ったらコールバック(またはPromise)で続きが動く

ここで大事なのは、**“並列”ではなく“並行”**のイメージ。I/O待ちの隙間時間で他の仕事を進めているだけです。
「awaitを書いたら全部止まる?」みたいな不安はこの感覚で解消できます(止まるのは“その関数の続き”で、プロセス全体は次のイベントを処理できます)。

3) CommonJSとES Modules(入門で混乱しがちなやつ)

Node.jsにはモジュール形式が2つあります。

  • CommonJSrequire() / module.exports(昔からのNode文化)
  • ES Modules (ESM)import / export(ブラウザと同じ方向性)

入門では、プロジェクトでどちらに寄せるかを決めるのが重要です。最近はESMが増えていますが、ライブラリの都合で混在することもあります。この記事のハンズオンでは、扱いやすい ESM に寄せます(package.json"type": "module")。

4) “動く”より先に必要な3点セット

実務で最低限欲しいのはこの3つです。

  • 設定(環境変数):開発/本番で値が変わる前提で設計する
  • ログ:障害時に原因へ辿り着ける情報を残す(リクエスト単位の識別など)
  • 終了処理:プロセス終了時にサーバーを閉じ、処理中のリクエストをなるべく落とさない(Graceful Shutdown)

「Express入門」や「API入門」に行く前に、ここを押さえると後で伸びます。


ハンズオン

ここからは、外部フレームワークなしで「小さくても実務っぽいHTTPサーバー」を作ります。目的は“仕組みが見える形で”理解することです。

0) 前提

  • Node.js 18+(できればLTS)
  • ターミナルが使えること

1) プロジェクト作成

mkdir node-intro && cd node-intro
npm init -y
npm pkg set type=module
npm i dotenv

構成はこんな感じにします。

node-intro/
  src/
    server.js
    router.js
    logger.js
    config.js
  .env
  package.json

.envを作成:

PORT=3000
LOG_LEVEL=info

2) 設定読み込み(config.js)

src/config.js

import 'dotenv/config';

const port = Number(process.env.PORT ?? 3000);
if (Number.isNaN(port)) throw new Error('PORT must be a number');

export const config = {
  port,
  logLevel: process.env.LOG_LEVEL ?? 'info',
};

ポイント:

  • PORTが数字でない場合は早めに落とす(設定ミスを起動時に検知)
  • “どこに設定があるか”を1ファイルに集約すると保守が楽

3) ほどよいログ(logger.js)

src/logger.js

import { config } from './config.js';

const levels = ['debug', 'info', 'warn', 'error'];
const current = levels.indexOf(config.logLevel);

function shouldLog(level) {
  return levels.indexOf(level) >= current;
}

export const logger = {
  debug: (msg, meta) => shouldLog('debug') && console.log(JSON.stringify({ level: 'debug', msg, ...meta })),
  info:  (msg, meta) => shouldLog('info')  && console.log(JSON.stringify({ level: 'info',  msg, ...meta })),
  warn:  (msg, meta) => shouldLog('warn')  && console.warn(JSON.stringify({ level: 'warn',  msg, ...meta })),
  error: (msg, meta) => shouldLog('error') && console.error(JSON.stringify({ level: 'error', msg, ...meta })),
};

ポイント:

  • 文字列ログより、JSONログに寄せると後で集計や検索がしやすい
  • metarequestIdなどを入れると追跡が楽になる

4) ルーティング(router.js)

src/router.js

import { logger } from './logger.js';

function json(res, status, body) {
  const data = JSON.stringify(body);
  res.writeHead(status, {
    'content-type': 'application/json; charset=utf-8',
    'content-length': Buffer.byteLength(data),
  });
  res.end(data);
}

export async function handle(req, res, ctx) {
  const url = new URL(req.url, `http://${req.headers.host}`);

  // 簡易ルーティング
  if (req.method === 'GET' && url.pathname === '/health') {
    return json(res, 200, { ok: true, time: new Date().toISOString() });
  }

  if (req.method === 'GET' && url.pathname === '/hello') {
    const name = url.searchParams.get('name') ?? 'world';
    logger.info('hello endpoint called', { requestId: ctx.requestId, name });
    return json(res, 200, { message: `Hello, ${name}!` });
  }

  // 404
  return json(res, 404, { error: 'Not Found' });
}

ポイント:

  • new URL()でパースするとクエリが扱いやすい
  • ctx(コンテキスト)にリクエスト共通情報を入れて、関数間で渡すと拡張が簡単

5) サーバー本体 + エラー処理 + Graceful Shutdown(server.js)

src/server.js

import http from 'node:http';
import crypto from 'node:crypto';
import { config } from './config.js';
import { logger } from './logger.js';
import { handle } from './router.js';

const server = http.createServer(async (req, res) => {
  const requestId = crypto.randomUUID();
  const start = Date.now();

  // 例外で落ちないようにガード
  try {
    await handle(req, res, { requestId });
  } catch (err) {
    logger.error('unhandled error', { requestId, err: String(err) });
    res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
    res.end(JSON.stringify({ error: 'Internal Server Error', requestId }));
  } finally {
    const ms = Date.now() - start;
    logger.info('request finished', {
      requestId,
      method: req.method,
      url: req.url,
      ms,
    });
  }
});

server.listen(config.port, () => {
  logger.info('server started', { port: config.port });
});

// Graceful shutdown
function shutdown(signal) {
  logger.warn('shutdown start', { signal });
  server.close(() => {
    logger.warn('server closed');
    process.exit(0);
  });

  // いつまでも終了しないのを防ぐ
  setTimeout(() => {
    logger.error('force exit');
    process.exit(1);
  }, 10_000).unref();
}

process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));

ポイント:

  • try/catch/finallyでリクエスト単位の障害を握りつぶさず、500を返してログに残す
  • requestIdがあるだけで障害解析の速度が段違い
  • server.close()で新規受付を止め、処理中の接続終了を待つ

6) 起動して動作確認

package.jsonに起動コマンドを追加:

npm pkg set scripts.dev="node src/server.js"

起動:

npm run dev

確認:

curl http://localhost:3000/health
curl "http://localhost:3000/hello?name=saigo"
curl -i http://localhost:3000/unknown

ここまでで、単なるHello Worldではなく、

  • 設定(env)
  • JSONログ
  • ルーティング
  • 例外時500
  • Graceful Shutdown
    が揃った“最小の実務骨格”ができました。

まとめ

Node.js入門で最初に身につけたいのは、フレームワーク固有の書き方よりも「Node.jsという実行環境がどう動くか」です。今回のハンズオンでは、素のhttpモジュールを使って、実務に必要な最低限の設計要素(設定・ログ・エラーハンドリング・安全な終了)を一通り体験しました。

この骨格を持っていると、次にExpress/Fastify/NestJSなどへ進んでも、
「なぜこのミドルウェアが必要なのか」「何を隠蔽してくれているのか」
が理解でき、学習が“暗記”から“納得”に変わります。


投稿日

カテゴリー:

投稿者:

タグ:

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です