T-CREATOR

Node.js × Fastify で爆速 REST API:スキーマ駆動とプラグイン設計を学ぶ

Node.js × Fastify で爆速 REST API:スキーマ駆動とプラグイン設計を学ぶ

Node.js でバックエンド API を作りたいけれど、Express では物足りないと感じたことはありませんか?Fastify は高速性とモダンな設計思想で注目を集めている Web フレームワークです。本記事では、Fastify の最大の特徴である「スキーマ駆動開発」と「プラグインアーキテクチャ」を中心に、実践的な REST API の構築方法をご紹介します。初めて Fastify に触れる方でも理解できるよう、段階的に解説していきますね。

背景

Fastify とは

Fastify は Node.js 向けの高速な Web フレームワークで、2016 年に誕生しました。Express の後継として設計され、パフォーマンス、開発者体験、そして型安全性を重視しています。

以下の図は、Fastify の基本アーキテクチャを示しています。

mermaidflowchart TB
  request["HTTPリクエスト"] --> router["ルーター"]
  router --> validation["スキーマ検証"]
  validation --> handler["ハンドラー"]
  handler --> serialization["レスポンス<br/>シリアライズ"]
  serialization --> response["HTTPレスポンス"]

  schema["JSONスキーマ"] -.->|定義| validation
  schema -.->|定義| serialization

  plugins["プラグイン"] -.->|拡張| router
  plugins -.->|拡張| handler

図の要点として、リクエストはスキーマ検証を経てハンドラーで処理され、レスポンスもスキーマに基づいてシリアライズされます。プラグインがすべての処理を拡張可能にしているのが特徴です。

Fastify が選ばれる理由

Fastify が多くの開発者に支持される理由を表にまとめました。

#特徴説明
1高速性Express の約 2 倍のスループットを実現
2スキーマ駆動JSON Schema による型安全なバリデーション
3プラグイン設計カプセル化されたモジュール構造
4TypeScript サポート型定義が完備され開発効率が向上
5非同期優先async/await をネイティブサポート

これらの特徴により、大規模なプロジェクトでも保守性を保ちながら高速な API を構築できます。

スキーマ駆動開発のメリット

スキーマ駆動開発とは、API の入出力を JSON Schema で定義し、それに基づいて検証とドキュメント生成を自動化する手法です。

mermaidflowchart LR
  define["スキーマ定義"] --> validate["自動バリデーション"]
  define --> serialize["高速シリアライズ"]
  define --> docs["API<br/>ドキュメント生成"]
  define --> types["TypeScript<br/>型生成"]

  validate --> safety["型安全性"]
  serialize --> performance["パフォーマンス"]
  docs --> dx["開発者体験"]
  types --> dx

スキーマを一度定義すれば、バリデーション、シリアライゼーション、ドキュメント生成が自動化され、開発効率が劇的に向上するのです。

課題

Express の限界

従来の Express では、以下のような課題が顕在化していました。

#課題内容
1パフォーマンスシングルスレッドでの処理限界
2バリデーション手動実装が必要で型安全性が低い
3レスポンス最適化JSON シリアライズが非効率
4プラグイン管理グローバルスコープによる依存関係の複雑化
5TypeScript 対応型定義の不完全性

特に、リクエストのバリデーションを手動で行うと、コードが冗長になり保守性が低下します。また、エラーハンドリングも統一しにくいという問題がありました。

型安全性の欠如

Express では、リクエストパラメータやボディの型が実行時まで保証されません。

typescript// Express での典型的な問題
app.post('/users', (req, res) => {
  // req.body.name が存在するか?string か?
  // 実行時までわからない
  const name = req.body.name;
  // ...
});

このコードでは、req.body.name の型が不明で、実行時エラーのリスクが高まります。開発時にバグを検出できないのは大きな課題ですね。

パフォーマンスボトルネック

Express は以下の点でパフォーマンス上の制約があります。

  • JSON のシリアライズが遅い
  • ルーティングのマッチングが非効率
  • ミドルウェアのオーバーヘッド

大量のリクエストを処理する際、これらがボトルネックとなり、サーバーリソースを圧迫してしまうのです。

解決策

Fastify によるスキーマ駆動開発

Fastify は JSON Schema を活用し、型安全で高速な API を実現します。

mermaidflowchart TB
  schema_def["JSON Schema<br/>定義"] --> compile["スキーマ<br/>コンパイル"]
  compile --> ajv["AJV<br/>バリデーター"]
  compile --> fast_json["fast-json-stringify<br/>シリアライザー"]

  request["リクエスト"] --> ajv
  ajv -->|OK| handler["ハンドラー"]
  ajv -->|NG| error["400 Error"]

  handler --> result["結果"]
  result --> fast_json
  fast_json --> response["レスポンス"]

スキーマはコンパイルされて高速なバリデーターとシリアライザーに変換されます。この仕組みにより、実行時のパフォーマンスが大幅に向上するのです。

プラグインによるモジュール化

Fastify のプラグインシステムは、依存関係を明確にし、カプセル化を実現します。

mermaidflowchart TB
  root["ルートインスタンス"]
  root --> plugin_a["プラグイン A"]
  root --> plugin_b["プラグイン B"]

  plugin_a --> child_a1["子プラグイン A1"]
  plugin_a --> child_a2["子プラグイン A2"]

  plugin_b --> child_b1["子プラグイン B1"]

  subgraph scope_a["スコープ A"]
    plugin_a
    child_a1
    child_a2
  end

  subgraph scope_b["スコープ B"]
    plugin_b
    child_b1
  end

各プラグインは独自のスコープを持ち、他のプラグインに影響を与えません。これにより、大規模なアプリケーションでも依存関係を管理しやすくなります。

具体例

プロジェクトのセットアップ

まず、Fastify プロジェクトを作成しましょう。

bashmkdir fastify-rest-api
cd fastify-rest-api
yarn init -y

必要なパッケージをインストールします。

bashyarn add fastify
yarn add -D typescript @types/node
yarn add -D ts-node nodemon

TypeScript の設定ファイルを作成します。

json{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

この設定で、TypeScript の厳格な型チェックを有効にし、安全なコードを書けるようにしています。

基本的な Fastify サーバー

シンプルな Fastify サーバーを作成してみましょう。

typescript// src/server.ts
import Fastify from 'fastify';

// Fastify インスタンスを作成
const fastify = Fastify({
  logger: true, // ロガーを有効化
});

このコードで Fastify のインスタンスを作成しています。logger: true により、リクエストやエラーのログが自動的に出力されます。

基本的なルートを追加します。

typescript// ヘルスチェックエンドポイント
fastify.get('/health', async (request, reply) => {
  return {
    status: 'ok',
    timestamp: new Date().toISOString(),
  };
});

サーバーを起動する処理を追加しましょう。

typescript// サーバー起動
const start = async () => {
  try {
    await fastify.listen({ port: 3000, host: '0.0.0.0' });
    console.log(
      'Server is running on http://localhost:3000'
    );
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

この構造により、エラーが発生した場合も適切にログ出力され、プロセスが終了します。

スキーマ駆動の REST API

ユーザー管理 API をスキーマ駆動で実装してみます。

スキーマ定義

まず、ユーザーのスキーマを定義します。

typescript// src/schemas/user.schema.ts

// ユーザーオブジェクトのスキーマ
export const userSchema = {
  type: 'object',
  properties: {
    id: { type: 'string', format: 'uuid' },
    name: { type: 'string', minLength: 1, maxLength: 100 },
    email: { type: 'string', format: 'email' },
    age: { type: 'integer', minimum: 0, maximum: 150 },
  },
  required: ['id', 'name', 'email'],
} as const;

このスキーマでは、各フィールドの型と制約を明確に定義しています。as const により TypeScript の型推論が強化されます。

POST リクエスト用のスキーマを定義します。

typescript// ユーザー作成リクエストのスキーマ
export const createUserSchema = {
  body: {
    type: 'object',
    properties: {
      name: {
        type: 'string',
        minLength: 1,
        maxLength: 100,
      },
      email: { type: 'string', format: 'email' },
      age: { type: 'integer', minimum: 0, maximum: 150 },
    },
    required: ['name', 'email'],
  },
} as const;

レスポンスのスキーマも定義しておきます。

typescript// レスポンススキーマ
export const createUserResponseSchema = {
  response: {
    201: {
      type: 'object',
      properties: {
        success: { type: 'boolean' },
        data: userSchema,
      },
    },
  },
} as const;

このように、リクエストとレスポンスの両方をスキーマで定義することで、API の契約が明確になりますね。

TypeScript 型定義

スキーマから TypeScript の型を生成します。

typescript// src/types/user.types.ts
import { FromSchema } from 'json-schema-to-ts';
import {
  userSchema,
  createUserSchema,
} from '../schemas/user.schema';

// スキーマから型を生成
export type User = FromSchema<typeof userSchema>;
export type CreateUserBody = FromSchema<
  typeof createUserSchema.body
>;

json-schema-to-ts パッケージを使うことで、スキーマと型が完全に同期します。

bashyarn add json-schema-to-ts

ルートハンドラーの実装

ユーザー作成エンドポイントを実装しましょう。

typescript// src/routes/users.routes.ts
import {
  FastifyInstance,
  FastifyRequest,
  FastifyReply,
} from 'fastify';
import { randomUUID } from 'crypto';
import { CreateUserBody, User } from '../types/user.types';
import {
  createUserSchema,
  createUserResponseSchema,
} from '../schemas/user.schema';

インメモリでユーザーを保存するストレージを用意します。

typescript// 簡易的なデータストア(本番環境ではDBを使用)
const users: User[] = [];

ユーザー作成ハンドラーを実装します。

typescript// ユーザー作成ハンドラー
async function createUserHandler(
  request: FastifyRequest<{ Body: CreateUserBody }>,
  reply: FastifyReply
) {
  const { name, email, age } = request.body;

  // 新規ユーザーを作成
  const newUser: User = {
    id: randomUUID(),
    name,
    email,
    ...(age !== undefined && { age }),
  };

  users.push(newUser);

  // 201 Created を返す
  reply.code(201).send({
    success: true,
    data: newUser,
  });
}

このハンドラーでは、リクエストボディがスキーマで検証済みなので、安全にデータにアクセスできます。

ルートを登録する関数を作成します。

typescript// ルート登録
export async function userRoutes(fastify: FastifyInstance) {
  // POST /users - ユーザー作成
  fastify.post('/users', {
    schema: {
      ...createUserSchema,
      ...createUserResponseSchema,
      tags: ['users'],
      summary: 'ユーザーを作成',
      description: '新しいユーザーを作成します',
    },
    handler: createUserHandler,
  });
}

スキーマをルート定義に含めることで、自動的にバリデーションとドキュメント生成が行われます。

ユーザー取得エンドポイント

ユーザー一覧取得のスキーマを定義します。

typescript// src/schemas/user.schema.ts に追加

export const getUsersResponseSchema = {
  response: {
    200: {
      type: 'object',
      properties: {
        success: { type: 'boolean' },
        data: {
          type: 'array',
          items: userSchema,
        },
        total: { type: 'integer' },
      },
    },
  },
} as const;

ユーザー取得ハンドラーを実装します。

typescript// src/routes/users.routes.ts に追加

async function getUsersHandler(
  request: FastifyRequest,
  reply: FastifyReply
) {
  reply.send({
    success: true,
    data: users,
    total: users.length,
  });
}

ルートに追加します。

typescript// userRoutes 関数内に追加

// GET /users - ユーザー一覧取得
fastify.get('/users', {
  schema: {
    ...getUsersResponseSchema,
    tags: ['users'],
    summary: 'ユーザー一覧を取得',
  },
  handler: getUsersHandler,
});

これで、ユーザーの作成と取得ができるようになりました。

プラグイン設計

Fastify の強力なプラグインシステムを活用してみましょう。

データベースプラグイン

データベース接続をプラグイン化します。

typescript// src/plugins/database.plugin.ts
import {
  FastifyInstance,
  FastifyPluginAsync,
} from 'fastify';
import fp from 'fastify-plugin';

// データベース接続の型定義
export interface DatabaseConnection {
  query: (sql: string, params?: any[]) => Promise<any>;
  close: () => Promise<void>;
}

fastify-plugin を使うことで、プラグインのスコープを制御できます。

bashyarn add fastify-plugin

プラグインを実装します。

typescript// データベースプラグイン
const databasePlugin: FastifyPluginAsync = async (
  fastify: FastifyInstance
) => {
  // データベース接続を初期化(ここでは簡易的にモック)
  const db: DatabaseConnection = {
    query: async (sql: string, params?: any[]) => {
      fastify.log.info({ sql, params }, 'Executing query');
      return [];
    },
    close: async () => {
      fastify.log.info('Closing database connection');
    },
  };

  // Fastify インスタンスに db を追加
  fastify.decorate('db', db);

  // アプリ終了時にクリーンアップ
  fastify.addHook('onClose', async () => {
    await db.close();
  });
};

プラグインをエクスポートします。

typescript// 型定義を拡張
declare module 'fastify' {
  interface FastifyInstance {
    db: DatabaseConnection;
  }
}

export default fp(databasePlugin, {
  name: 'database',
});

この実装により、すべてのルートで fastify.db を通じてデータベースにアクセスできるようになります。

認証プラグイン

JWT 認証をプラグイン化してみます。

bashyarn add @fastify/jwt

認証プラグインを作成します。

typescript// src/plugins/auth.plugin.ts
import {
  FastifyInstance,
  FastifyPluginAsync,
} from 'fastify';
import fp from 'fastify-plugin';
import jwt from '@fastify/jwt';

const authPlugin: FastifyPluginAsync = async (
  fastify: FastifyInstance
) => {
  // JWT プラグインを登録
  await fastify.register(jwt, {
    secret: process.env.JWT_SECRET || 'supersecret',
  });

  // 認証デコレーターを追加
  fastify.decorate(
    'authenticate',
    async function (request, reply) {
      try {
        await request.jwtVerify();
      } catch (err) {
        reply.code(401).send({ error: 'Unauthorized' });
      }
    }
  );
};

型定義を拡張します。

typescriptdeclare module 'fastify' {
  interface FastifyInstance {
    authenticate: (
      request: any,
      reply: any
    ) => Promise<void>;
  }
}

export default fp(authPlugin, {
  name: 'auth',
  dependencies: ['jwt'],
});

dependencies により、JWT プラグインが先に読み込まれることが保証されます。

プラグインの登録

メインサーバーファイルでプラグインを登録しましょう。

typescript// src/server.ts を更新
import Fastify from 'fastify';
import databasePlugin from './plugins/database.plugin';
import authPlugin from './plugins/auth.plugin';
import { userRoutes } from './routes/users.routes';

const fastify = Fastify({
  logger: true,
});

プラグインを登録します。

typescript// プラグインを登録
async function registerPlugins() {
  await fastify.register(databasePlugin);
  await fastify.register(authPlugin);
}

ルートを登録します。

typescript// ルートを登録
async function registerRoutes() {
  await fastify.register(userRoutes, { prefix: '/api/v1' });
}

起動処理を更新します。

typescriptconst start = async () => {
  try {
    await registerPlugins();
    await registerRoutes();

    await fastify.listen({ port: 3000, host: '0.0.0.0' });
    console.log(
      'Server is running on http://localhost:3000'
    );
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();

これで、プラグインとルートが適切な順序で登録され、モジュール化された API が完成しました。

保護されたルートの実装

認証が必要なエンドポイントを作成してみます。

typescript// src/routes/users.routes.ts に追加

// ユーザー削除ハンドラー(認証必須)
async function deleteUserHandler(
  request: FastifyRequest<{ Params: { id: string } }>,
  reply: FastifyReply
) {
  const { id } = request.params;
  const index = users.findIndex((u) => u.id === id);

  if (index === -1) {
    return reply.code(404).send({
      success: false,
      error: 'User not found',
    });
  }

  users.splice(index, 1);

  reply.send({
    success: true,
    message: 'User deleted successfully',
  });
}

認証プリフックを使ってルートを保護します。

typescript// userRoutes 関数内に追加

// DELETE /users/:id - ユーザー削除(認証必須)
fastify.delete('/users/:id', {
  preHandler: [fastify.authenticate], // 認証ミドルウェア
  schema: {
    params: {
      type: 'object',
      properties: {
        id: { type: 'string', format: 'uuid' },
      },
      required: ['id'],
    },
    tags: ['users'],
    summary: 'ユーザーを削除',
    security: [{ bearerAuth: [] }],
  },
  handler: deleteUserHandler,
});

preHandler で認証チェックを行い、認証済みユーザーのみがアクセスできるようになります。

エラーハンドリング

統一的なエラーハンドリングを実装しましょう。

typescript// src/plugins/error-handler.plugin.ts
import {
  FastifyInstance,
  FastifyPluginAsync,
  FastifyError,
} from 'fastify';
import fp from 'fastify-plugin';

const errorHandlerPlugin: FastifyPluginAsync = async (
  fastify: FastifyInstance
) => {
  // カスタムエラーハンドラーを設定
  fastify.setErrorHandler(
    (error: FastifyError, request, reply) => {
      const statusCode = error.statusCode || 500;

      fastify.log.error({
        error: error.message,
        stack: error.stack,
        url: request.url,
        method: request.method,
      });

      // 開発環境ではスタックトレースを含める
      const response = {
        success: false,
        error: error.message,
        code: error.code,
        ...(process.env.NODE_ENV === 'development' && {
          stack: error.stack,
        }),
      };

      reply.code(statusCode).send(response);
    }
  );
};

バリデーションエラーも適切に処理します。

typescript  // バリデーションエラーのカスタマイズ
  fastify.setValidatorCompiler(({ schema }) => {
    return (data) => {
      // AJV による検証
      // エラー時は詳細なメッセージを返す
      return { value: data };
    };
  });
};

export default fp(errorHandlerPlugin, {
  name: 'error-handler',
});

このプラグインにより、すべてのエラーが統一的に処理され、適切なログとレスポンスが生成されます。

API ドキュメント生成

Swagger UI でドキュメントを自動生成しましょう。

bashyarn add @fastify/swagger @fastify/swagger-ui

Swagger プラグインを設定します。

typescript// src/plugins/swagger.plugin.ts
import { FastifyInstance, FastifyPluginAsync } from 'fastify';
import fp from 'fastify-plugin';
import swagger from '@fastify/swagger';
import swaggerUi from '@fastify/swagger-ui';

const swaggerPlugin: FastifyPluginAsync = async (fastify: FastifyInstance) => {
  // Swagger を登録
  await fastify.register(swagger, {
    openapi: {
      info: {
        title: 'Fastify REST API',
        description: 'スキーマ駆動の REST API サンプル',
        version: '1.0.0',
      },
      servers: [
        {
          url: 'http://localhost:3000',
          description: 'Development server',
        },
      ],
      tags: [
        { name: 'users', description: 'ユーザー管理エンドポイント' },
      ],
      components: {
        securitySchemes: {
          bearerAuth: {
            type: 'http',
            scheme: 'bearer',
            bearerFormat: 'JWT',
          },
        },
      },
    },
  });

Swagger UI を設定します。

typescript  // Swagger UI を登録
  await fastify.register(swaggerUi, {
    routePrefix: '/docs',
    uiConfig: {
      docExpansion: 'list',
      deepLinking: false,
    },
    staticCSP: true,
  });
};

export default fp(swaggerPlugin, {
  name: 'swagger',
});

サーバーに登録します。

typescript// src/server.ts の registerPlugins に追加
import swaggerPlugin from './plugins/swagger.plugin';

async function registerPlugins() {
  await fastify.register(swaggerPlugin); // 追加
  await fastify.register(databasePlugin);
  await fastify.register(authPlugin);
}

これで、http:​/​​/​localhost:3000​/​docs にアクセスすると、自動生成された API ドキュメントが表示されます。スキーマから生成されるため、常に最新の状態が保たれますね。

テストの実装

Fastify アプリケーションのテストを書いてみましょう。

bashyarn add -D jest @types/jest ts-jest
yarn add -D @faker-js/faker

Jest の設定ファイルを作成します。

json{
  "preset": "ts-jest",
  "testEnvironment": "node",
  "roots": ["<rootDir>/src"],
  "testMatch": ["**/__tests__/**/*.test.ts"],
  "collectCoverageFrom": ["src/**/*.ts", "!src/**/*.d.ts"]
}

ユーザー API のテストを実装します。

typescript// src/__tests__/users.test.ts
import Fastify, { FastifyInstance } from 'fastify';
import { userRoutes } from '../routes/users.routes';

describe('User API', () => {
  let fastify: FastifyInstance;

  // テスト前にサーバーを起動
  beforeAll(async () => {
    fastify = Fastify();
    await fastify.register(userRoutes, { prefix: '/api/v1' });
    await fastify.ready();
  });

  // テスト後にサーバーを終了
  afterAll(async () => {
    await fastify.close();
  });

ユーザー作成のテストを記述します。

typescripttest('POST /api/v1/users - ユーザーを作成できる', async () => {
  const response = await fastify.inject({
    method: 'POST',
    url: '/api/v1/users',
    payload: {
      name: 'Test User',
      email: 'test@example.com',
      age: 25,
    },
  });

  expect(response.statusCode).toBe(201);
  const body = JSON.parse(response.body);
  expect(body.success).toBe(true);
  expect(body.data).toHaveProperty('id');
  expect(body.data.name).toBe('Test User');
});

バリデーションエラーのテストも追加します。

typescript  test('POST /api/v1/users - 不正なデータでエラーになる', async () => {
    const response = await fastify.inject({
      method: 'POST',
      url: '/api/v1/users',
      payload: {
        name: '', // 空文字はNG
        email: 'invalid-email', // 不正なメール形式
      },
    });

    expect(response.statusCode).toBe(400);
  });
});

fastify.inject() を使うことで、実際の HTTP リクエストをシミュレートできます。テストが高速で、ポート待ち受けも不要なので効率的です。

パフォーマンス測定

Fastify のパフォーマンスを測定してみましょう。

typescript// src/routes/benchmark.routes.ts
import { FastifyInstance } from 'fastify';

export async function benchmarkRoutes(
  fastify: FastifyInstance
) {
  // シンプルな JSON レスポンス
  fastify.get('/benchmark/simple', async () => {
    return { message: 'Hello, World!' };
  });

  // スキーマ付き JSON レスポンス
  fastify.get(
    '/benchmark/schema',
    {
      schema: {
        response: {
          200: {
            type: 'object',
            properties: {
              message: { type: 'string' },
              timestamp: { type: 'string' },
              count: { type: 'integer' },
            },
          },
        },
      },
    },
    async () => {
      return {
        message: 'Hello, World!',
        timestamp: new Date().toISOString(),
        count: 1000,
      };
    }
  );
}

スキーマを使ったレスポンスは、fast-json-stringify により約 2〜3 倍高速化されます。

負荷テストツールで測定してみましょう。

bash# autocannon をインストール
yarn global add autocannon

# ベンチマーク実行
autocannon -c 100 -d 10 http://localhost:3000/benchmark/schema

このコマンドで、100 並列接続、10 秒間の負荷テストを実行できます。結果として、Fastify は高いスループットを示すはずです。

まとめ

本記事では、Fastify を使った REST API 開発について、スキーマ駆動とプラグイン設計を中心に解説しました。

スキーマ駆動開発のポイント

  • JSON Schema でリクエスト・レスポンスを定義
  • 自動バリデーションとシリアライゼーション
  • TypeScript 型との完全な同期
  • API ドキュメントの自動生成

プラグインアーキテクチャの利点

  • カプセル化された依存関係
  • 再利用可能なモジュール
  • 明確なライフサイクル管理
  • テストしやすい構造

Fastify は、Express の使いやすさを保ちながら、モダンな機能と高速性を提供しています。特に大規模プロジェクトや、パフォーマンスが重要なアプリケーションで真価を発揮するでしょう。

スキーマ駆動開発により、型安全性と開発効率が向上し、プラグインシステムにより保守性の高いコードを書けます。ぜひ、次のプロジェクトで Fastify を試してみてください。

関連リンク