はじめに
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つあります。
- CommonJS:
require()/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ログに寄せると後で集計や検索がしやすい
metaにrequestIdなどを入れると追跡が楽になる
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などへ進んでも、
「なぜこのミドルウェアが必要なのか」「何を隠蔽してくれているのか」
が理解でき、学習が“暗記”から“納得”に変わります。
コメントを残す