Docker + Express + MySQL で作る AI コードフォーマッター

Node.js(Express)とMySQLをDockerで構築する手順を、順を追って解説します。

プロジェクト構成

project/
├── docker-compose.yml
├── Dockerfile
├── package.json
└── src/
    ├── index.js
    ├── db/
    │   ├── connection.js
    │   └── init/
    │       └── 01-schema.sql
    └── public/
        ├── index.html
        ├── style.css
        └── script.js

主要ファイルの実装

docker-compose.yml

version: '3.8'
services:
  app:
    build: .
    ports:
      - "4000:4000"
    volumes:
      - .:/usr/src/app
      - /usr/src/app/node_modules
    environment:
      - PORT=4000
      - DB_HOST=db
      - DB_USER=vbauser
      - DB_PASSWORD=vbapassword
      - DB_NAME=vba_formatter
    depends_on:
      - db

  db:
    image: mysql:8.0
    platform: linux/amd64
    ports:
      - "3307:3306"
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: vba_formatter
      MYSQL_USER: vbauser
      MYSQL_PASSWORD: vbapassword
      TZ: Asia/Tokyo
    volumes:
      - mysql_data:/var/lib/mysql
      - ./src/db/init:/docker-entrypoint-initdb.d

volumes:
  mysql_data:

Dockerfile

FROM node:20-slim

WORKDIR /usr/src/app

RUN apt-get update && apt-get install -y default-mysql-client

COPY package*.json ./
RUN npm install

COPY . .

EXPOSE 4000

CMD ["npm", "run", "dev"]

package.json

{
    "name": "vba-formatter",
    "version": "1.0.0",
    "main": "src/index.js",
    "scripts": {
      "start": "node src/index.js",
      "dev": "nodemon src/index.js"
    },
    "dependencies": {
      "@aws-sdk/client-bedrock-runtime": "^3.0.0",
      "express": "^4.18.2",
      "mysql2": "^3.6.5",
      "nodemon": "^3.0.2",
      "dotenv": "^16.3.1"
    }
  }

Express サーバーの実装

データベース接続設定 (src/db/connection.js)

const express = require('express');
const path = require('path');
const db = require('./db/connection');

const app = express();

app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());

// API エンドポイント
app.get('/api/data', async (req, res) => {
    try {
        const [rows] = await db.query('SELECT * FROM your_table');
        res.json({ success: true, data: rows });
    } catch (error) {
        res.status(500).json({ success: false, error: error.message });
    }
});

const PORT = process.env.PORT || 4000;
app.listen(PORT, '0.0.0.0', () => {
    console.log(`Server running on http://localhost:${PORT}`);
});

メインサーバーファイル (src/index.js)

// サーバーのメインファイル (index.js)
const path = require('path');
const express = require('express');
const { analyzeCode } = require('./bedrock/analyzer');
const db = require('./db/connection');

const app = express();

app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());

// CORS設定
app.use((req, res, next) => {
    res.header('Access-Control-Allow-Origin', '*');
    res.header('Access-Control-Allow-Methods', 'GET, PUT, POST, DELETE');
    res.header('Access-Control-Allow-Headers', 'Content-Type');
    next();
});

// Content-Type設定
app.use((req, res, next) => {
    if (req.path.startsWith('/api') || req.path.startsWith('/prompts') || req.path === '/format' || req.path === '/prompt-list') {
        res.setHeader('Content-Type', 'application/json; charset=utf-8');
    }
    next();
});

// プロンプト一覧取得API
app.get('/api/prompt-info', async (req, res) => {
    try {
        const [rows] = await db.query(`
            SELECT id, name, content, updated_at
            FROM prompts
            ORDER BY id
        `);
        res.json({ success: true, prompts: rows });
    } catch (error) {
        res.status(500).json({
            success: false,
            error: 'プロンプト情報の取得に失敗しました'
        });
    }
});

// フォーマットAPI
app.post('/format', async (req, res) => {
    try {
        const { code, promptId } = req.body;
        
        if (!code) {
            return res.status(400).json({
                success: false,
                error: 'コードが提供されていません'
            });
        }

        const [rows] = await db.query(
            'SELECT content FROM prompts WHERE id = ?',
            [promptId]
        );

        if (rows.length === 0) {
            return res.status(404).json({
                success: false,
                error: 'プロンプトが見つかりません'
            });
        }

        const formattedCode = await analyzeCode(code, rows[0].content);
        res.json({ success: true, formatted: formattedCode });
    } catch (error) {
        res.status(500).json({
            success: false,
            error: error.message
        });
    }
});

// プロンプト更新API
app.put('/prompts/:id', async (req, res) => {
    try {
        const { id } = req.params;
        const { content } = req.body;

        const [result] = await db.query(
            'UPDATE prompts SET content = ? WHERE id = ?',
            [content, id]
        );

        if (result.affectedRows === 0) {
            return res.status(404).json({
                success: false,
                error: 'プロンプトが見つかりません'
            });
        }

        res.json({ success: true, message: '更新しました' });
    } catch (error) {
        res.status(500).json({
            success: false,
            error: error.message
        });
    }
});

const PORT = process.env.PORT || 4000;
app.listen(PORT, '0.0.0.0', () => {
    console.log(`Server running on http://localhost:${PORT}`);
});

src/public/index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>VBA Formatter</title>
    <link rel="stylesheet" href="https://unpkg.com/ress/dist/ress.min.css" />
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link rel="stylesheet" href="style.css">
    <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@100..900&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
</head>
<body>
    <div class="container">
        <h1>VBA Formatter</h1>
        <div class="nav-links">
            <a href="index.html">ホーム</a>
            <a href="about.html">About</a>
        </div>

        <div class="prompt-section">
            <h2>AIへの指示内容</h2>
            <div class="prompt-selector">
                <select id="promptSelect" onchange="changePrompt()">
                    <option value="">読み込み中...</option>
                </select>
                <button id="editButton" onclick="toggleEdit()" class="edit-button">
                    編集
                </button>
            </div>
            <div class="prompt-display-container">
                <div class="user-icon"></div>
                <div class="prompt-display-wrapper">
                    <pre id="promptDisplay" class="prompt-display" contenteditable="false">ユーザーからの指示内容がここに表示されます</pre>
                </div>
            </div>
            <button id="saveButton" onclick="savePrompt()" class="save-button" style="display: none;">
                保存
            </button>
        </div>

        <div class="editors">
            <div class="editor">
                <h2>分析するコード</h2>
                <textarea id="input" placeholder="コードを入力してください"></textarea>
                <input type="file" id="vbaFile" accept=".bas,.txt,.html,.css,.js">
            </div>
            <div class="editor">
                <h2>改善後のコード</h2>
                <textarea id="output" readonly></textarea>
                <button onclick="formatCode()" id="formatButton">Format</button>
                <div id="popup" class="popup">クリップボードにコピーしました</div>
            </div>
        </div>
    </div>
    <script src="./script.js"></script>
</body>
</html>

src/public/style.css

body {
    font-family: "Noto Sans JP", serif;
    background-color: #f1f2f3;
}

.container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 0 2rem 2rem;
}

h1 {
    background-color: #fff;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    text-align: center;
    letter-spacing: 0.1em;
    padding: 1rem 0;
    margin: 0 calc(50% - 50vw);
    width: 100vw;
    margin-bottom: 2rem;
}

h2 {
    margin-bottom: 1rem;
    text-align: center;
    letter-spacing: .1em;
}

.nav-links {
    text-align: center;
    margin-bottom: 2rem;
}

.nav-links a {
    display: inline-block;
    padding: 0.5rem 1rem;
    background-color: #e9be3b;
    color: white;
    text-decoration: none;
    border-radius: 4px;
    margin: 0 0.5rem;
}

.nav-links a:hover {
    background-color: #d3b458;
}

.prompt-section {
    margin-bottom: 2rem;
}

.prompt-selector {
    display: flex;
    justify-content: center;
    gap: 1rem;
    margin-bottom: 1rem;
}

.edit-button {
    background-color: #4a90e2;
    height: 2.5rem;
}

.edit-button:hover {
    background-color: #357abd;
}

.save-button {
    display: none;
    margin: 1rem auto;
    background-color: #4CAF50;
    width: 200px;
}

.save-button:hover {
    background-color: #45a049;
}

.prompt-display[contenteditable="true"] {
    border: 2px solid #4a90e2;
    padding: 1rem;
    outline: none;
}

.prompt-display-container {
    display: flex;
    align-items: end;
    justify-content: center;
    gap: 2rem;
    margin: 0 auto;
}

.user-icon {
    width: 40px;
    height: 40px;
    background-color: #f6ce55;
    border-radius: 50%;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    margin-bottom: 3rem;
    position: relative;
}

.prompt-display-wrapper {
    max-width: 80%;
    display: flex;
    align-items: center;
    justify-content: center;
    background-color: #fff;
    border-radius: 5rem 5rem 5rem 0;
    box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
    padding: 2rem;
}

.prompt-display {
    font-family: monospace;
    font-size: 0.825rem;
    line-height: 1.5;
    white-space: pre-wrap;
    max-height: 400px;
    min-height: 300px;
    min-width: 600px;
    height: auto;
    overflow-y: auto;
    resize: vertical;
    padding: 1rem;
}

.editors {
    display: flex;
    gap: 2rem;
    margin-bottom: 1rem;
    position: relative;
}

.editor {
    flex: 1;
    display: flex;
    flex-direction: column;
}

.editor > * {
    margin-bottom: 1rem;
}

.editor > *:last-child {
    margin-top: auto!important;
}

.editor input {
    height: 3rem;
    background-color: #ccc;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    width: 100%;
    transition: all 0.3s ease;
}

.editor textarea {
    width: 100%;
    height: 500px;
    font-family: monospace;
    padding: 10px;
    font-size: 14px;
    line-height: 1.5;
    border: 2px solid #ccc;
    background-color: #fff;
    border-radius: 4px;
}

button {
    height: 3rem;
    padding: 0.5rem 1rem;
    background-color: #e9be3b;
    border-radius: 4px;
    color: white;
    letter-spacing: 0.1em;
    border: none;
    cursor: pointer;
    position: relative;
}

button:hover {
    background-color: #d3b458;
    transition: all 0.3s ease;
}

.popup {
    visibility: hidden;
    position: absolute;
    bottom: 4.5rem;
    right: 0.5rem;
    background-color: rgba(76, 175, 80, 0.8);
    color: white;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    letter-spacing: 0.1em;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
    z-index: 1000;
    opacity: 0;
    transform: translateY(1rem);
    transition: opacity 0.5s ease, transform 0.5s ease, visibility 0.5s;
}

.popup.show {
    visibility: visible;
    opacity: 1;
    transform: translateY(0);
}

Docker環境の起動

現在の状況

DockerでVBAフォーマッターを作成中で、日本語が文字化けする問題に直面しています。

  1. 関連するファイルを共有:
  • docker-compose.yml
  • connection.js
  • index.js
  • package.json
  • エラーログやスクリーンショット
  1. これまでに試したこと:
  • DBの文字コード設定確認(utf8mb4)
  • Bufferを使用したデコード処理 など

そして、先ほどのnpm installについては、Dockerコンテナ内で実行する場合は:

試したこと:

DBの文字コード設定確認

  • 文字セットが全て utf8mb4 であることを確認
  • collation が utf8mb4_unicode_ci であることを確認

connection.js での試み

  • charset の指定
  • collation, encoding の追加(警告が出たため無効)
  • typeCast オプションの追加

index.js での試み

  • Buffer を使用したデコード処理
  • Content-Type ヘッダーの設定
  • HEX形式でのデータ取得

データ確認

  • DBに直接アクセスして日本語データが正常なことを確認
  • hex形式でのデータ内容確認