🐳

DevContainerとHonoとPrismaでバックエンドの環境を構築【忘備録】

2025/01/14に公開

はじめに

開発環境の構築は常に課題となりやすく、特にチームでの開発において「自分の環境では動くのに...」という状況は頻繁に発生します。この記事では、DevContainer を使用して、誰でも同じ開発環境を簡単に構築できる方法と、OpenAPI 定義から型を自動生成する仕組みを紹介します。

DevContainer とは

DevContainer は、VS Code と Docker を組み合わせた開発環境のソリューションです。開発環境全体をコンテナ化し、チーム全体で完全に同じ環境を共有することができます。

主なメリット:

  • チーム全体で完全に同じ開発環境を共有
  • 新メンバーの環境構築が容易
  • ホストマシンを汚さない
  • 開発環境の再現性が高い

環境構築手順

1. 必要なツールのインストール

まず、以下のツールをインストールします:

  • Visual Studio Code
  • Docker Desktop
  • VS Code Remote Development 拡張機能

2. DevContainer 設定ファイルの作成

2.1 基本ディレクトリ構造の作成

mkdir .devcontainer

2.2 devcontainer.json の作成

.devcontainer/devcontainer.json:

{
  "name": "プロジェクト名",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspace",
  "customizations": {
    "vscode": {
      "extensions": [
        "dbaeumer.vscode-eslint",
        "esbenp.prettier-vscode",
        "prisma.prisma",
        "streetsidesoftware.code-spell-checker",
        "christian-kohler.path-intellisense",
        "eamodio.gitlens",
        "github.copilot",
        "github.copilot-chat",
        "gruntfuggly.todo-tree",
        "yoavbls.pretty-ts-errors",
        "aaron-bond.better-comments",
        "mechatroner.rainbow-csv",
        "oderwat.indent-rainbow"
      ]
    }
  },
  "remoteUser": "node"
}

2.3 Dockerfile の作成

.devcontainer/Dockerfile:

ARG NODE_VERSION=20.11.0
FROM node:${NODE_VERSION}

RUN apt-get update && \
    apt-get install -y ca-certificates gnupg && \
    apt-get update && \
    apt-get install -y git python3 && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /workspace

ENV NPM_CONFIG_PREFIX=/home/node/.npm-global
ENV PATH=$PATH:/home/node/.npm-global/bin

USER node

RUN mkdir -p /home/node/.npm-global

2.4 docker-compose.yml の作成

.devcontainer/docker-compose.yml:

version: "3.8"

services:
  app:
    build:
      context: ..
      dockerfile: .devcontainer/Dockerfile
    volumes:
      - ..:/workspace:cached
    command: sleep infinity
    env_file: ../.env
    ports:
      - "3000:3000"
      - "9229:9229"

  db:
    image: postgres:15
    restart: unless-stopped
    tmpfs:
      - /var/lib/postgresql/data
    env_file: ../.env
    ports:
      - "5432:5432"

  studio:
    build:
      context: ..
      dockerfile: .devcontainer/Dockerfile
    volumes:
      - ..:/workspace:cached
    command: npx prisma studio
    env_file: ../.env
    ports:
      - "5555:5555"

3. 環境変数の設定

.env.example:

# Database
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_DB=db_name

# Application
DATABASE_URL=postgresql://user:password@db:5432/db_name
NODE_ENV=development

4. スクリプトの整理

package.json:

{
  "scripts": {
    "dev": "npm run generate-api && npm run generate-aliases && npx prisma generate && npx prisma migrate deploy && npx prisma db seed && tsx watch --inspect=0.0.0.0:9229 src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "prisma:generate": "prisma generate",
    "db:migrate": "prisma migrate dev",
    "db:deploy": "prisma migrate deploy",
    "db:seed": "tsx prisma/seed.ts",
    "db:reset": "prisma migrate reset --force",
    "watch": "NODE_NO_WARNINGS=1 node --loader ts-node/esm scripts/watch-openapi.ts",
    "generate-api": "openapi-typescript openapi/openapi.yaml --output src/generated/api.ts",
    "generate-aliases": "tsx scripts/generate-aliases.ts",
    "test": "jest"
  }
}

OpenAPI 型定義の自動生成と監視

1. ファイル構造

project/
├── openapi/
│   ├── openapi.yaml
│   ├── schemas/
│   ├── paths/
│   └── responses/
├── scripts/
│   ├── watch-openapi.ts
│   └── generate-aliases.ts
└── src/
    ├── generated/
    │   └── api.ts
    └── types/
        └── model.ts

2. 監視スクリプトの実装

scripts/watch-openapi.ts:

import * as chokidar from "chokidar";
import { exec } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import lodash from "lodash";
const { debounce } = lodash;

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// 設定
const CONFIG = {
  baseDir: "../openapi",
  watchDirs: ["", "schemas", "paths", "responses"],
  watchExtensions: [".yaml", ".yml", ".json"],
  debounceMs: 1000,
  commands: [
    {
      name: "generate-api",
      command: "npm run generate-api",
      outputFile: "src/generated/api.ts",
    },
    {
      name: "generate-aliases",
      command: "npm run generate-aliases",
      outputFile: "types/generated-aliases.ts",
    },
  ],
};

class OpenApiWatcher {
  private isProcessing: boolean = false;
  private readonly baseDir: string;
  private readonly watchPaths: string[];
  private watcher: chokidar.FSWatcher | null = null;

  constructor() {
    this.baseDir = path.resolve(__dirname, CONFIG.baseDir);
    this.watchPaths = CONFIG.watchDirs.map((dir) =>
      path.join(this.baseDir, dir)
    );
    this.setupWatcher();
  }

  private setupWatcher(): void {
    console.log("👀 Starting OpenAPI directory watcher...\n");

    // 監視対象のディレクトリを表示
    console.log("📂 Watching directories:");
    this.watchPaths.forEach((watchPath) => {
      const relativePath = path.relative(process.cwd(), watchPath);
      console.log(`   ${relativePath}/`);
    });

    console.log("\n📄 File patterns:");
    CONFIG.watchExtensions.forEach((ext) => {
      console.log(`   *${ext}`);
    });

    console.log("\n⚡ Commands to run on changes:");
    CONFIG.commands.forEach((cmd) => {
      console.log(`   ${cmd.name}: ${cmd.command}`);
    });
    console.log("");

    // chokidarの設定
    const watchPatterns = this.watchPaths
      .map((watchPath) =>
        CONFIG.watchExtensions.map((ext) => path.join(watchPath, `*${ext}`))
      )
      .flat();

    this.watcher = chokidar.watch(watchPatterns, {
      persistent: true,
      ignoreInitial: true,
      awaitWriteFinish: {
        stabilityThreshold: 300,
        pollInterval: 100,
      },
    });

    // ファイル変更イベントのハンドリング
    const handleChange = debounce((filePath: string, eventType: string) => {
      const relativePath = path.relative(process.cwd(), filePath);
      this.handleFileChange(relativePath, eventType);
    }, CONFIG.debounceMs);

    this.watcher
      .on("add", (path) => handleChange(path, "add"))
      .on("change", (path) => handleChange(path, "change"))
      .on("unlink", (path) => handleChange(path, "unlink"))
      .on("error", (error) => console.error("Watcher error:", error));
  }

  private async handleFileChange(
    changedFile: string,
    eventType: string
  ): Promise<void> {
    if (this.isProcessing) {
      console.log("⏳ Generation already in progress, skipping...");
      return;
    }

    try {
      this.isProcessing = true;
      const timestamp = new Date().toLocaleTimeString();
      console.log(`\n[${timestamp}] 🔄 File change detected:`);
      console.log(`   File: ${changedFile}`);
      console.log(`   Event: ${eventType}`);

      for (const cmd of CONFIG.commands) {
        await this.executeCommand(cmd);
      }

      console.log("\n✨ All generations completed successfully!");
    } catch (error) {
      console.error("\n❌ Error during generation:", error);
    } finally {
      this.isProcessing = false;
    }
  }

  private executeCommand(cmd: (typeof CONFIG.commands)[0]): Promise<void> {
    return new Promise((resolve, reject) => {
      console.log(`\n⚡ Running ${cmd.name}...`);

      exec(cmd.command, { cwd: process.cwd() }, (error, stdout, stderr) => {
        if (error) {
          console.error(`❌ Error in ${cmd.name}:`, error);
          reject(error);
          return;
        }

        console.log(`${cmd.name} completed`);
        if (stdout.trim()) {
          console.log("   Output:");
          stdout.split("\n").forEach((line) => {
            if (line.trim()) console.log(`   ${line}`);
          });
        }
        if (stderr.trim()) {
          console.log("   Warnings:");
          stderr.split("\n").forEach((line) => {
            if (line.trim()) console.log(`   ${line}`);
          });
        }

        resolve();
      });
    });
  }

  public close(): void {
    if (this.watcher) {
      this.watcher.close();
    }
  }
}

// エラーハンドリング
process.on("uncaughtException", (error) => {
  console.error("❌ Uncaught exception:", error);
  process.exit(1);
});

// ウォッチャーのインスタンスを作成
const watcher = new OpenApiWatcher();

// プロセス終了時のクリーンアップ
process.on("SIGINT", () => {
  console.log("\n👋 Stopping watcher...");
  watcher.close();
  process.exit(0);
});

3. 型エイリアス生成スクリプト

scripts/generate-aliases.ts:

// scripts/generate-aliases.ts
import * as fs from "fs";
import * as path from "path";
import { fileURLToPath } from "url";

// ESM 環境で __dirname を再現
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// 自動生成された API 型定義のパス
const inputFilePath = path.resolve(__dirname, "../src/generated/api.ts");
// 出力ファイルのパス
const outputFilePath = path.resolve(__dirname, "../src/types/model.ts");

// 入力ファイルを読み込み
const apiFileContent = fs.readFileSync(inputFilePath, "utf-8");

// components["schemas"] を抽出するための正規表現
const schemasRegex = /components\["schemas"\]\["([a-zA-Z0-9_]+)"\]/g;

// 重複を防ぐためにSetを使用
const aliasSet = new Set<string>();

// マッチするすべてのスキーマを取得
let match;
while ((match = schemasRegex.exec(apiFileContent)) !== null) {
  const schemaName = match[1];
  aliasSet.add(
    `export type ${schemaName} = components["schemas"]["${schemaName}"];`
  );
}

// ヘッダーコメントを追加
const fileContent = `/**
 * このファイルは自動生成されています。
 * 直接編集せずに、generate-aliases スクリプトを使用してください。
 */

import type { components } from "../generated/api.js";

${Array.from(aliasSet).sort().join("\n\n")}
`;

// 型エイリアスを出力ファイルに書き込む
fs.writeFileSync(outputFilePath, fileContent, "utf-8");

console.log(`Type aliases generated in: ${outputFilePath}`);

開発環境の使用方法

開発環境の起動

  1. VSCode でプロジェクトを開く
  2. コマンドパレット(Cmd+Shift+P)を開く
  3. "Dev Containers: Reopen in Container" を選択
  4. コンテナのビルドと起動が完了するまで待機
  5. 開発サーバーを起動:npm run dev

OpenAPI 型の自動生成

  1. 開発時の監視開始:
npm run watch
  1. 手動での型生成:
npm run generate-api    # OpenAPI定義から型を生成
npm run generate-aliases # 型エイリアスを生成

アクセス可能な URL

トラブルシューティング

コンテナの再構築が必要な場合

  1. VSCode を通常モードで開く
  2. コマンドパレットから "Dev Containers: Rebuild Container" を選択

データベースのリセットが必要な場合

npm run db:reset

まとめ

DevContainer と OpenAPI 型自動生成を組み合わせることで:

  1. 開発環境の統一化

    • チーム全体で同じ環境を使用可能
    • 環境構築の手間を削減
  2. 型安全性の向上

    • OpenAPI 定義から自動的に型が生成
    • API の型定義が常に最新状態を維持
  3. 開発効率の向上

    • 環境構築や API 型定義の手間を削減
    • より本質的な開発作業に集中可能

この方法を採用することで、より効率的で品質の高い開発が可能になります。

GitHubで編集を提案
株式会社ドットログ

Discussion