🐳
DevContainerとHonoとPrismaでバックエンドの環境を構築【忘備録】
はじめに
開発環境の構築は常に課題となりやすく、特にチームでの開発において「自分の環境では動くのに...」という状況は頻繁に発生します。この記事では、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}`);
開発環境の使用方法
開発環境の起動
- VSCode でプロジェクトを開く
- コマンドパレット(Cmd+Shift+P)を開く
- "Dev Containers: Reopen in Container" を選択
- コンテナのビルドと起動が完了するまで待機
- 開発サーバーを起動:
npm run dev
OpenAPI 型の自動生成
- 開発時の監視開始:
npm run watch
- 手動での型生成:
npm run generate-api # OpenAPI定義から型を生成
npm run generate-aliases # 型エイリアスを生成
アクセス可能な URL
- アプリケーション: http://localhost:3000
- Prisma Studio: http://localhost:5555
- デバッグポート: 9229
トラブルシューティング
コンテナの再構築が必要な場合
- VSCode を通常モードで開く
- コマンドパレットから "Dev Containers: Rebuild Container" を選択
データベースのリセットが必要な場合
npm run db:reset
まとめ
DevContainer と OpenAPI 型自動生成を組み合わせることで:
-
開発環境の統一化
- チーム全体で同じ環境を使用可能
- 環境構築の手間を削減
-
型安全性の向上
- OpenAPI 定義から自動的に型が生成
- API の型定義が常に最新状態を維持
-
開発効率の向上
- 環境構築や API 型定義の手間を削減
- より本質的な開発作業に集中可能
この方法を採用することで、より効率的で品質の高い開発が可能になります。
Discussion