ExpressとMySQLで構築するAPI開発
通信トラブルの切り分けと解決法まとめ
はじめに
Node.js (Express) と MySQL を用いた認証機能の実装において、APIテストツール(Echo API)からのリクエスト時に発生した
- 「フリーズ(タイムアウト)」
- 「404 Not Found」
- 「500 Internal Server Error」
といった通信エラーの切り分け手法と、
開発時の課題点についてのナレッジをまとめます。
本開発は仮想の書籍管理システムの開発目録です
目次
開発環境・構成
- バックエンド: Node.js / TypeScript / Express
-
データベース: MySQL (
mysql2/promise) -
パスワード暗号化:
bcrypt - 検証ツール: Echo API
本トラブルから得た3つの大きな学び
1. 通信疎通の有無は「最上流のミドルウェア」で切り分ける
コードが正しいにもかかわらず404エラー(Not Found)が返る場合、原因がクライアント側(URLの誤りなど)にあるのか、サーバー側(ルーティングのミス)にあるのかを初期段階で切り分ける必要があります。
Expressのミドルウェアの最上流(express.json()のすぐ下など)に、以下の簡易的なログ処理を一時的に挿入する手法が有効です。
app.use((req, res, next) => {
console.log(`[通信感知] メソッド: ${req.method} | URL: ${req.url}`);
next();
});
これを仕込んだ状態でリクエストを送信し、サーバーのターミナルにログが一切出力されない場合は、「通信が現在のExpressサーバープロセスに到達していない」ことが確定します。
2. ポートの占拠(ゾンビプロセス)への警戒
【概要】
エディタの再起動やターミナルの強制終了時に、
古いサーバープロセス(Node.jsなど)がポート(3000番など)を
保持したままバックグラウンドで生き残ることがあります。
この状態になると、新しく npm run dev を立ち上げても、
APIクライアントからの通信は裏で生き残っている
「古い(新しいルーティングが適用されていない)Express」に到達するため、
404エラーを返し続ける原因になります。
【対策:動作が不可解な場合】
エディタ(VS Codeなど)とPCを再起動し、
バックグラウンドプロセスを完全にリセットする。
ターミナルで lsof -i :3000 を実行し、
ポートを占拠しているプロセス(PID)を特定・強制終了する。
3. HTTPステータスコードによる「エラーの現在地」の把握
ステータスコードの持つ意味を正しく解釈することで、デバッグの検索範囲を大幅に絞り込むことができます。
| ステータスコード | 今回のケースにおける要因 |
|---|---|
| 403 Forbidden | 通信は届いているが、CSRF対策等のセキュリティミドルウェアによりアクセスが拒否されている。 |
| 404 Not Found | 親ルートと子ルートのパス定義の重複。または、別プロセスのサーバーに通信が届いている。 |
| 500 Internal Error | サーバーに到達し、ルーティングも通過したが、データベース処理等の内部ロジックでクラッシュしている。 |
今後に活かすべき課題点
1. catch ブロックでのエラーログ出力を徹底する
例外処理の catch (error) 内でエラー内容を出力(console.error)していないと、エラーの具体的な原因が隠蔽され、一律で500エラーのJSONが返るだけの状態になります。
// 避けるべき例(原因の特定が困難になる)
} catch (error) {
return res.status(500).json({ error: 'サーバー内部でエラーが発生しました' });
}
// 推奨される例(ターミナルで即座に原因を特定可能)
} catch (error) {
console.error('MySQLのINSERTでエラーが発生しました:', error);
return res.status(500).json({ error: 'サーバー内部でエラーが発生しました' });
}
今回はエラーログを明示的に出力するように変更したことで、「MySQLのテーブル定義(カラム名)と、プログラム側のSQL文に相違がある」という根本原因を特定できました。
2. ルーティングにおけるパスの重複に留意する
Expressのルーティングでは、親(index.ts)で指定したパスは子(router)側に自動的に引き継がれます。
// index.ts (親)
app.use('/api/auth', authRouter);
// routes/auth.ts (子)
// 誤り:router.post('/api/auth/register', ...) -> 重複によりパスがズレて404になる
// 正解:親の分を差し引いたシンプルなエンドポイントを指定する
router.post('/register', async (req, res) => { ... });
最終確定したソースコード
1. データベース接続設定 (src/db.ts)
import mysql from 'mysql2/promise';
const pool = mysql.createPool({
host: 'localhost',
user: 'root',
password: 'YOUR_PASSWORD',
database: 'mobile_library',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
export default pool;
2. パスワードハッシュ化関数 (src/utils/auth.ts)
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 10;
export async function hashPassword(password: string): Promise<string> {
return await bcrypt.hash(password, SALT_ROUNDS);
}
3. ルーティング定義 (src/routes/auth.ts)
import express from 'express';
import { hashPassword } from '../utils/auth';
import pool from '../db';
const router = express.Router();
router.post('/register', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'ユーザー名とパスワードは必須です' });
}
const hashedPassword = await hashPassword(password);
const sql = 'INSERT INTO users (username, password_hash) VALUES (?, ?)';
await pool.execute(sql, [username, hashedPassword]);
return res.status(201).json({
message: 'ユーザー登録が完了しました!',
user: { username }
});
} catch (error: any) {
console.error('MySQLのINSERTでエラーが発生しました:', error);
if (error.code === 'ER_DUP_ENTRY') {
return res.status(400).json({ error: 'このユーザー名は既に存在します' });
}
return res.status(500).json({ error: 'サーバー内部でエラーが発生しました' });
}
});
export default router;
4. メインサーバーファイル (src/index.ts)
import express from 'express';
import cors from 'cors';
import bookRoutes from './routes/bookRoutes';
import authRouter from './routes/auth';
const app = express();
const PORT = 3000;
app.use(cors());
app.use(express.json());
// 通信切り分け用の検証ミドルウェア
app.use((req, res, next) => {
console.log(`[通信感知] メソッド: ${req.method} | URL: ${req.url}`);
next();
});
// APIルートの適用
app.use('/api/auth', authRouter);
app.use('/api', bookRoutes);
app.listen(PORT, () => {
console.log(`🚀 バックエンドサーバーが http://localhost:${PORT} で起動しました`);
});
付録時系列エラー発生と対処の記録
1. プール接続のタイムアウト設定による型エラー
| 項目 | 内容 |
|---|---|
| 事象: | db.ts へタイムアウト設定を追加した際、TypeScriptで型エラーが発生。 |
| 原因: | mysql2 の型定義に存在しないプロパティ名を指定していたため。 |
| 対処: | 複雑な設定を削除し、公式ドキュメントに準拠したシンプルな接続プールに差し戻した。 |
2. nodemon の正常終了(clean exit)によるサーバー停止
| 項目 | 内容 |
|---|---|
| 事象: | サーバーが起動ログを表示した直後に終了し、通信を待機しない状態になった。 |
| 原因: | メインファイル(index.ts)側で新設ルーターのインポートや適用(app.use)を行っていなかったため。 |
| 対処: | index.ts に authRouter を正しく適用し、常時ポートを監視する状態へ修正した。 |
3. 外部ライブラリのインポートエラー(MODULE_NOT_FOUND)
| 項目 | 内容 |
|---|---|
| 事象: | サーバー起動時に Error: Cannot find module 'bcrypt' が発生しクラッシュ。 |
| 原因: | コード上でインポートしているが、ローカル環境にパッケージが未インストールだったため。 |
| 対処: | ターミナルより npm install bcrypt を実行して解決。 |
4. 403 Forbidden(アクセス拒否)の発生
| 項目 | 内容 |
|---|---|
| 事象: | Echo APIから通信を送信した際、403エラーが返却された。 |
| 原因: | 既存のセキュリティチェック(CSRF対策等)にリクエストが引っかかったため。 |
| 対処: | index.ts 内での適用順序を見直し、セキュリティミドルウェアの影響を受けない最上流の位置へ配置を移動した。 |
5. 404 Not Found とターミナルの無反応
| 項目 | 内容 |
|---|---|
| 事象: | エラーが返るが、稼働中のターミナルログ(最上流の監視ログ)が動かない。 |
| 原因: | バックグラウンドで古いExpressプロセス(ゾンビプロセス)がポート3000番を占拠し続けていたため。 |
| 対処: | VS Codeを完全に再起動することで古いプロセスを強制終了させ、最新のサーバープロセスへ正常に疎通させた。 |
6. 500 Internal Server Error(データベース挿入失敗)
| 項目 | 内容 |
|---|---|
| 事象: | 通信は疎通したものの、500番エラーとなりユーザー登録に失敗。 |
| 原因: | MySQLの実際のカラム名と、auth.ts 内の INSERT 文に記述したカラム名に乖離があったため。 |
| 対処: | catch ブロックに console.error(error) を追加してエラーを露呈させ、カラム名の相違を修正してAPIを開通させた。 |