Node.js 開発における Koa2・データベース・WebSocket・テスト自動化の実践ガイド

Koa2 フレームワークのアーキテクチャと実装パターン

Koa2 は、Node.js 環境で軽量かつ表現力豊かな Web アプリケーションを構築するための次世代フレームワークです。Express の開発チームによって設計されており、カーネル部分にミドルウェアをバンドルせず、開発者が柔軟に機能を組み合わせられる設計思想を採用しています。Promise と async/await をネイティブサポートすることで、コールバック地獄を解消し、エラーハンドリングの精度を向上させています。

コンテキストオブジェクトと基本的なセットアップ

Koa2 の核となるのは、リクエストとレスポンスを包み込む Context オブジェクトです。従来の Node.js 組み込みオブジェクトを直接操作する代わりに、Koa が提供するアクセス修飾子を通じてデータやり取りを行います。

const Koa = require('koa');
const application = new Koa();

application.use(async (ctx) => {
  ctx.response.body = { status: 'active', message: 'サービス起動完了' };
  ctx.response.type = 'application/json';
});

const PORT = 3000;
application.listen(PORT, () => {
  console.log(`サーバーをポート ${PORT} で待機中`);
});

コンテキストには、ctx.request(クエリ文字列、ヘッダー、メソッド)と ctx.response(ステータスコード、ボディ、コンテンツタイプ)が統合されています。直接 reqres を操作する代わりに、これらのエイリアスを使うことで、ミドルウェア間のデータ受け渡しが安全かつ簡潔になります。

Express との設計思想の違い

Koa2 と Express の最大の差異は、ミドルウェアの実行モデルにあります。Express は線形モデルを採用しており、ミドルウェアが順次実行され、一度レスポンスが送信されるとフローが終了します。対照的に Koa2 は「洋葱モデル(Onion Model)」を採用しており、await next() を境界として、内側のミドルウェア処理が完了するまで外側のコードが一時停止します。これにより、レスポンス送信前のログ出力や、グローバルなエラーキャッチ、実行時間の計測が直感的に実装できます。

ルーティング・静的ファイル配信・テンプレート処理

Koa2 にはデフォルトのルーティング機能が含まれていないため、koa-router を導入して経路を定義します。また、静的ファイルの配信や EJS テンプレートのレンダリングも、それぞれ専用ミドルウェアで補完します。

const Router = require('koa-router');
const serveStatic = require('koa-static');
const renderEngine = require('koa-views');
const path = require('path');

const apiRoutes = new Router();

apiRoutes.prefix('/v1/data');

apiRoutes.get('/products', (ctx) => {
  ctx.body = [{ id: 101, name: 'サーバーOS' }, { id: 102, name: 'ロードバランサー' }];
});

apiRoutes.post('/products/:sku', async (ctx) => {
  const newRecord = ctx.request.body;
  ctx.status = 201;
  ctx.body = { success: true, createdId: newRecord.sku };
});

// ルーター適用
application.use(apiRoutes.routes()).use(apiRoutes.allowedMethods());

// 静的ファイル配信
application.use(serveStatic(path.resolve(__dirname, 'assets')));

// テンプレートエンジン設定
application.use(renderEngine(path.resolve(__dirname, 'templates'), {
  extension: 'ejs'
}));

// レンダリング例
application.use(async (ctx) => {
  await ctx.render('dashboard', { pageTitle: '管理画面', userRole: 'admin' });
});

認証・ファイルアップロード・非同期データベース連携

ユーザーセッションの管理にはクッキーと JWT を併用します。クッキーの読み書きは ctx.cookies で直接的に行い、JWT の検証は認可ミドルウェア内でヘッダーからトークンを抽出して実施します。

const jwt = require('jsonwebtoken');
const SECRET_KEY = process.env.AUTH_SECRET || 'default_signing_key';

// 認可チェックミドルウェア
application.use(async (ctx, next) => {
  const targetPath = ctx.request.path;
  if (targetPath.startsWith('/auth/')) return next();

  const authHeader = ctx.request.header.authorization;
  if (!authHeader) {
    ctx.throw(401, '認証情報が不足しています');
  }

  try {
    const tokenPayload = jwt.verify(authHeader.split(' ')[1], SECRET_KEY);
    ctx.state.currentUser = tokenPayload;
    await next();
  } catch (err) {
    ctx.throw(403, 'トークンの有効期限が切れています');
  }
});

ファイルアップロードには @koa/multer を使用し、ディスクストレージに一時保存します。MongoDB 連携には mongoose を採用し、スキーマ定義とモデル生成をモジュール化します。

const multer = require('@koa/multer');
const uploadMiddleware = multer({ dest: 'storage/uploads/' });

router.post('/upload', uploadMiddleware.single('document'), (ctx) => {
  ctx.body = { 
    originalName: ctx.file.originalname, 
    savedPath: ctx.file.path 
  };
});

// Mongoose スキーマ定義
const mongoose = require('mongoose');
const { Schema } = mongoose;

const accountSchema = new Schema({
  displayName: { type: String, required: true },
  accessLevel: { type: Number, default: 1 },
  profileUrl: String
}, { timestamps: true });

module.exports = mongoose.model('Account', accountSchema);

RDBMS 連携と SQL クエリの最適化

リレーショナルデータベースは、構造化されたデータと整合性を重視するシステムに適しています。行と列で構成されるテーブル形式は、複雑な結合クエリやトランザクション処理に強みを発揮します。一方、非リレーショナルデータベースはスキーマレスな設計が特徴で、大量の非構造化データや高頻度な書き込み処理に優れています。

SQL クエリの基礎と結合操作

CRUD 操作は以下の構文に従います。

  • 登録: INSERT INTO departments (name, budget) VALUES ('R&D', 500000);
  • 更新: UPDATE departments SET budget = 520000 WHERE name = 'R&D';
  • 削除: DELETE FROM departments WHERE id = 5;
  • 検索: SELECT name, budget FROM departments WHERE budget > 400000 ORDER BY budget DESC LIMIT 20;

複数のテーブルを参照する場合、INNER JOIN は両方のテーブルに一致するレコードのみを抽出します。外部結合(LEFT/RIGHT OUTER JOIN)を使用すると、片方のテーブルに存在しないレコードも NULL で補完して取得できます。結合操作ではテーブルに別名を付与し、カラム名の衝突を回避することが推奨されます。

Node.js における MySQL クライアントの実装

mysql2 パッケージの Promise 対応プール機能を活用し、非同期クエリを安全に処理します。

const mysql = require('mysql2/promise');

const dbConfig = {
  host: '127.0.0.1',
  user: 'db_admin',
  password: 'secure_pass',
  database: 'enterprise_db',
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
};

const dbPool = mysql.createPool(dbConfig);

async function fetchActiveStaff() {
  try {
    const [rows] = await dbPool.execute(
      'SELECT emp_id, full_name, dept_code FROM staff WHERE is_active = ?', 
      [1]
    );
    return rows;
  } catch (error) {
    console.error('DB 取得エラー:', error.message);
    throw error;
  }
}

async function insertNewProject(projectData) {
  const query = `INSERT INTO projects (title, deadline, manager_id) VALUES (?, ?, ?)`;
  const [result] = await dbPool.execute(query, [
    projectData.title,
    projectData.deadline,
    projectData.managerId
  ]);
  return result.insertId;
}

プレースホルダ(?)を使用することで SQL インジェクション対策が講じられ、プーリング接続により高負荷時の接続オーバーヘッドが抑制されます。

WebSocket プロトコルとリアルタイム通信基盤

HTTP はリクエスト-レスポンス型の単一方向プロトコルであり、クライアントが要求するまでサーバーからデータを送信できません。WebSocket は、既存の HTTP ハンドシェイクを経由して接続を確立した後、TCP 層上で双方向通信を継続するプロトコルです。これにより、ポーリングやロングポーリングに比べて遅延が極めて小さく、帯域幅の消費も抑えられます。

低レベル実装と高機能ライブラリの比較

ws モジュールはプロトコルに忠実な低レベル実装を提供します。一方、socket.io は自動再接続、名前空間、ルーム管理、バイナリデータ処理、フォールバック(Ajax ポーリング)などの機能を提供し、開発効率を大幅に向上させます。

// ws モジュールを用いたサーバー実装
const WebSocket = require('ws');
const socketServer = new WebSocket.Server({ port: 9090 });

socketServer.on('connection', (client, req) => {
  const urlParams = new URLSearchParams(req.url.split('?')[1]);
  const authKey = urlParams.get('apiKey');
  
  if (authKey !== 'VALID_KEY') {
    client.close(1008, '認証失敗');
    return;
  }

  client.isAlive = true;
  client.on('message', (rawData) => {
    const payload = JSON.parse(rawData);
    if (payload.action === 'broadcast') {
      socketServer.clients.forEach((peer) => {
        if (peer.readyState === WebSocket.OPEN && peer !== client) {
          peer.send(JSON.stringify({ 
            source: 'system', 
            content: payload.content 
          }));
        }
      });
    }
  });

  client.on('close', () => {
    console.log('クライアント切断');
  });
});
// socket.io を用いた実装
const { Server } = require('socket.io');
const io = new Server(serverInstance, { cors: { origin: '*' } });

io.on('connection', (socket) => {
  const token = socket.handshake.auth.token;
  if (!verifyToken(token)) {
    socket.disconnect();
    return;
  }

  socket.join('global_channel');

  socket.on('chat_message', (payload) => {
    io.to('global_channel').emit('new_message', {
      senderId: socket.id,
      text: payload.text,
      timestamp: Date.now()
    });
  });

  socket.on('disconnect', () => {
    console.log(`セッション終了: ${socket.id}`);
  });
});

テスト駆動開発と Mocha 活用手順

ユニットテストは、個々の関数やモジュールが期待通りに動作することを保証する自動化プロセスです。Mocha は JavaScript 環境で広く採用されているテストランナーであり、ブラウザと Node.js の両方で動作します。テストケースの定義、非同期処理の対応、事前/事後処理のフック機能を備えています。

アサーションライブラリと非同期/HTTP テスト

Mocha 自体はアサーション機能を提供しないため、Chai などのサードパーティライブラリと組み合わせて使用します。Chai は expectshouldassert の 3 つのスタイルをサポートしています。

const chai = require('chai');
const expect = chai.expect;
const calculateVat = require('../utils/taxCalculator');

describe('税金計算モジュールの検証', () => {
  it('標準税率を正しく適用すべき', () => {
    expect(calculateVat(1000, 0.1)).to.equal(1100);
  });

  it('ゼロ値に対してエラーをスローすべき', () => {
    expect(() => calculateVat(-500, 0.1)).to.throw('Invalid amount');
  });

  it('非同期データ取得を待機すべき', async () => {
    const config = await import('../config/db.json');
    expect(config.connection.host).to.be.a('string');
  });
});

HTTP エンドポイントのテストには supertest を使用します。実際のサーバーを起動せずとも、モジュール単位でリクエストとレスポンスを検証できます。

const request = require('supertest');
const apiApp = require('../app');

describe('API 基盤の整合性テスト', () => {
  it('GET /health が 200 と JSON を返す', async () => {
    const response = await request(apiApp)
      .get('/health')
      .set('Accept', 'application/json');
    
    expect(response.status).to.equal(200);
    expect(response.body).to.have.property('uptime');
  });
});

テストライフサイクルの制御

Mocha はテストスイートの実行前後にフック関数を設定できます。データベースの初期化、モックサーバーの起動、リソースの解放などの処理を一元管理します。

const { describe, it, before, after, beforeEach, afterEach } = require('mocha');
const { expect } = require('chai');
const storageAdapter = require('../lib/storage');

describe('ストレージアダプタの動作確認', () => {
  let dbClient;

  before(async () => {
    dbClient = await storageAdapter.connect('mongodb://test-env/local');
  });

  beforeEach(async () => {
    await dbClient.collection('cache').deleteMany({});
  });

  it('新規レコードの保存と取得', async () => {
    const docId = await dbClient.collection('cache').insertOne({ key: 'temp' });
    expect(docId.insertedId).to.exist;
  });

  afterEach(async () => {
    // テスト後のクリーンアップ処理
  });

  after(async () => {
    await dbClient.close();
  });
});

これらのフックを適切に利用することで、テスト間のデータ汚染を防ぎ、再現性の高い検証環境を構築できます。

タグ: koa2 express Node.js MySQL websocket

5月13日 02:44 投稿