Flask + Python-socketioでシンプルなチャットアプリ「EasyChat」を作ってみた

## はじめに

必要最小限の機能に絞った直感的に使えるチャットアプリ「EasyChat」を開発しました。本記事では、Flaskとpython-socketioを使った開発過程と実装の詳細をご紹介します。

## アプリケーション概要

**EasyChat**は以下の特徴を持ったシンプルなチャットアプリケーションです：

- 必要最小限の機能による直感的な操作性
- 登録からチャット開始までの手順が簡単（メールアドレス不要）
- 軽量で高速な動作
- シンプルで見やすいUI/UX
- レスポンシブデザインによるモバイル対応
- ダークモード・ライトモード切替機能

## 技術スタック

### バックエンド
- **Flask**: 軽量かつ高速な開発が可能なPythonフレームワーク
- **python-socketio**: WebSocketによるリアルタイム通信の実装
- **SQLAlchemy**: ORMによるデータベース操作とマイグレーション管理

Flaskを選んだ理由は、最小限の機能から始めて必要に応じて拡張できる柔軟性と、開発速度の速さです。特にプロトタイピングから本格的な実装までのスピードを重視しました。

### フロントエンド
- **HTML/CSS/JavaScript**
- **Jinja2テンプレートエンジン**

フロントエンドは特別なフレームワークを使わず、基本技術で実装しました。これにより、余計な依存関係を減らし、軽量なアプリケーションを実現しています。

### データベース
- **開発環境**: MySQL 8.0
- **本番環境**: PostgreSQL (Render)

SQLAlchemyを用いることで、異なる環境間でのデータベース移行をスムーズに行えるようにしました。

### 開発ツール
- **Cursor**: AIアシスト機能付きコードエディタ
- **MySQL Sequel Ace**: データベース管理
- **Git/GitHub**: バージョン管理

特に、Cursorを活用したことで開発効率が大幅に向上しました。コード生成やデバッグのサポートにより、繰り返しのタスクを効率化できました。

## 主な機能

### 1. ユーザー認証機能
[![Image from Gyazo](https://i.gyazo.com/fe100ecab4d44cb879cad9c0a6eb1c5a.png)](https://gyazo.com/fe100ecab4d44cb879cad9c0a6eb1c5a)
[![Image from Gyazo](https://i.gyazo.com/5d9db1ac678dccbf109ae819759547c6.png)](https://gyazo.com/5d9db1ac678dccbf109ae819759547c6)

- シンプルなログインフォーム
- ユーザー名とパスワードのみで登録（メールアドレス不要）
- 登録後は自動的にログイン状態に
- セッション維持によるログイン状態の記憶
- 明確なエラーメッセージと具体的なガイダンス

### 2. チャンネル管理機能
[![Image from Gyazo](https://i.gyazo.com/69587cfceafc7ac16c354f2e5a1b7a5b.png)](https://gyazo.com/69587cfceafc7ac16c354f2e5a1b7a5b)

- チャンネルの作成・編集・削除
- モーダルウィンドウでの操作
- 作成者のみが編集・削除可能な権限管理

### 3. メッセージング機能
[![Image from Gyazo](https://i.gyazo.com/0a09e92c386191cd6eeab687a1631a11.gif)](https://gyazo.com/0a09e92c386191cd6eeab687a1631a11)

- リアルタイムメッセージ送受信
- 絵文字リアクション機能
- メッセージの編集・削除
- メンション機能
  [![Image from Gyazo](https://i.gyazo.com/d6f63188cf7600417fd1ca2fc7fcf2cd.gif)](https://gyazo.com/d6f63188cf7600417fd1ca2fc7fcf2cd)
- リアクション操作
  [![Image from Gyazo](https://i.gyazo.com/7a901b539f8dd8779e687620870f7ccb.gif)](https://gyazo.com/7a901b539f8dd8779e687620870f7ccb)

### 4. レスポンシブデザイン
[![Image from Gyazo](https://i.gyazo.com/e6aa3ca7ba08635e0aa964670759142e.png)](https://gyazo.com/e6aa3ca7ba08635e0aa964670759142e)
[![Image from Gyazo](https://i.gyazo.com/25d529be2510a6798361d9ae34d67466.png)](https://gyazo.com/25d529be2510a6798361d9ae34d67466)

- スマートフォン・タブレット対応
- ハンバーガーメニューとサイドバー
  [![Image from Gyazo](https://i.gyazo.com/4cf9f260b53365b51059bbf287af1813.gif)](https://gyazo.com/4cf9f260b53365b51059bbf287af1813)
- タップしやすいボタンサイズとスワイプ操作

### 5. ダークモード
[![Image from Gyazo](https://i.gyazo.com/cbbfc3592f28f86170a0f4e7f053561e.gif)](https://gyazo.com/cbbfc3592f28f86170a0f4e7f053561e)

- 切り替え可能なテーマ設定
- ブラウザ保存による設定維持
- システム設定（OS）との連動オプション

### 6. その他の機能
- **エラー処理とフィードバック**
  [![Image from Gyazo](https://i.gyazo.com/cfda81309f6f87670425b4962ec5eda9.gif)](https://gyazo.com/cfda81309f6f87670425b4962ec5eda9)
- **画像共有機能**
  [![Image from Gyazo](https://i.gyazo.com/847a81e75c0bc1f12ada1ddc4e3806d9.gif)](https://gyazo.com/847a81e75c0bc1f12ada1ddc4e3806d9)
- **メッセージ検索機能**
  [![Image from Gyazo](https://i.gyazo.com/ee1c59f54c6c6be61c6d1ce6f3b41fc4.gif)](https://gyazo.com/ee1c59f54c6c6be61c6d1ce6f3b41fc4)
- **チャンネル検索機能**
  [![Image from Gyazo](https://i.gyazo.com/8392785069b236135bb720111c98ac36.gif)](https://gyazo.com/8392785069b236135bb720111c98ac36)
- **ユーザープロフィール機能**
  [![Image from Gyazo](https://i.gyazo.com/141a2d33bf9ba2723ef6c8fe7eea2789.gif)](https://gyazo.com/141a2d33bf9ba2723ef6c8fe7eea2789)

## データベース設計

アプリケーションのコアとなる4つのモデルを実装しました

![データベース設計](https://i.gyazo.com/d42f995872bd2db01522efbb6df1fbe9.png)

## 画面遷移図
画面遷移図も書きました

![画面遷移図](https://i.gyazo.com/29d1dcd78b5748b92a40184ab09cf0db.png)
## コード解説

### WebSocket通信の実装

```python
# サーバーサイド
from flask import Flask
from flask_socketio import SocketIO, join_room

app = Flask(__name__)
socketio = SocketIO(app, cors_allowed_origins="*")

# メッセージ送信イベントのハンドラ
@socketio.on('send_message')
def handle_message(data):
    message = Message(
        content=data['content'],
        user_id=data['user_id'],
        channel_id=data['channel_id']
    )
    db.session.add(message)
    db.session.commit()
    
    # チャンネルの全メンバーにブロードキャスト
    socketio.emit('receive_message', {
        'id': message.id,
        'content': message.content,
        'username': message.user.username,
        'channel_id': message.channel_id,
        'created_at': message.created_at.strftime('%Y-%m-%d %H:%M:%S')
    }, room=data['channel_id'])

# チャンネル参加
@socketio.on('join')
def on_join(data):
    room = data['channel_id']
    join_room(room)
```

```javascript
// クライアントサイド
const socket = io();

// メッセージ受信イベント
socket.on('receive_message', function(data) {
    if (data.channel_id === currentChannelId) {
        appendMessage(data);
    }
});

// メッセージ送信関数
function sendMessage() {
    const content = messageInput.value.trim();
    
    if (content !== '') {
        socket.emit('send_message', {
            content: content,
            user_id: currentUserId,
            channel_id: currentChannelId
        });
        messageInput.value = '';
    }
}

// チャンネル参加時
function joinChannel(channelId) {
    socket.emit('join', {channel_id: channelId});
    loadMessages(channelId);
}
```

### リアクション機能の実装

```python
@app.route('/api/reactions', methods=['POST'])
@login_required
def add_reaction():
    data = request.json
    message_id = data.get('message_id')
    emoji = data.get('emoji')
    
    # 既存のリアクションをチェック
    existing_reaction = Reaction.query.filter_by(
        user_id=current_user.id,
        message_id=message_id,
        emoji=emoji
    ).first()
    
    if existing_reaction:
        # 既存のリアクションを削除（トグル機能）
        db.session.delete(existing_reaction)
        db.session.commit()
        socketio.emit('reaction_removed', {
            'message_id': message_id,
            'emoji': emoji,
            'user_id': current_user.id
        })
        return jsonify({'status': 'removed'})
    else:
        # 新しいリアクションを追加
        reaction = Reaction(
            user_id=current_user.id,
            message_id=message_id,
            emoji=emoji
        )
        db.session.add(reaction)
        db.session.commit()
        socketio.emit('reaction_added', {
            'message_id': message_id,
            'emoji': emoji,
            'user_id': current_user.id
        })
        return jsonify({'status': 'added'})
```

### ダークモードの実装

```javascript
// テーマ設定の読み込み
function loadThemePreference() {
    const savedTheme = localStorage.getItem('theme');
    
    if (savedTheme === 'dark') {
        document.documentElement.classList.add('dark-mode');
        document.getElementById('theme-toggle').checked = true;
    } else if (savedTheme === 'light') {
        document.documentElement.classList.remove('dark-mode');
        document.getElementById('theme-toggle').checked = false;
    } else {
        // システム設定に従う
        if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
            document.documentElement.classList.add('dark-mode');
            document.getElementById('theme-toggle').checked = true;
        }
    }
}

// テーマ切り替え
function toggleTheme() {
    const isDarkMode = document.documentElement.classList.toggle('dark-mode');
    localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
}
```

## 開発プロセスと工夫したポイント

### テスト駆動開発

```python
import pytest
from app.models import Message
import uuid

class TestMessageFeatures:
    def test_create_message(self, auth_client, test_channel):
        """メッセージ作成のテスト"""
        client, user_id = auth_client
        
        response = client.post('/chat/send', data={
            'message': 'テストメッセージ',
            'channel_id': test_channel['id']
        })
        
        assert response.status_code == 302
        # DBに保存されていることを確認
        message = Message.query.filter_by(content='テストメッセージ').first()
        assert message is not None
        assert message.user_id == user_id
    
    def test_edit_message(self, auth_client, db):
        """メッセージ編集のテスト"""
        client, user_id = auth_client
        
        # テスト用メッセージを作成
        message = Message(id=str(uuid.uuid4()), content="元の内容", user_id=user_id)
        db.session.add(message)
        db.session.commit()
        
        # 編集リクエスト
        client.post(f'/chat/messages/{message.id}/edit', data={'content': '編集後の内容'})
        
        # 変更が反映されていることを確認
        updated = Message.query.get(message.id)
        assert updated.content == '編集後の内容'
        assert updated.is_edited == True
```

### WebSocket接続の安定化

```javascript
// 接続が切れた時の再接続処理
socket.on('disconnect', function() {
    console.log('接続が切断されました。再接続を試みます...');
    setTimeout(function() {
        socket.connect();
    }, 1000);
});
```

### モバイル環境での最適化

```javascript
// キーボード表示時のレイアウト調整
const messageInput = document.getElementById('message-input');
const chatContainer = document.getElementById('chat-container');

messageInput.addEventListener('focus', function() {
    if (window.innerWidth <= 767) {
        setTimeout(function() {
            chatContainer.scrollTop = chatContainer.scrollHeight;
        }, 300);
    }
});
```

### 効率的なデータベース設計

```python
class Message(db.Model):
    __tablename__ = 'messages'
    id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
    content = db.Column(db.Text, nullable=False)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
    channel_id = db.Column(db.Integer, db.ForeignKey('channels.id'), nullable=False, index=True)
    created_at = db.Column(db.DateTime, default=datetime.utcnow, index=True)
    is_edited = db.Column(db.Boolean, default=False)
    
    # リレーションシップ
    user = db.relationship('User', backref=db.backref('messages', lazy=True))
    channel = db.relationship('Channel', backref=db.backref('messages', lazy=True))
    reactions = db.relationship('Reaction', backref='message', lazy=True, cascade='all, delete-orphan')
```

## AIコーディングアシスタント「Cursor」を活用した開発と学習プロセス

Cursorの活用による主なメリット：

1. **コードの自動生成**：基本的なCRUD操作やルーティングなどの定型コードを短時間で生成
2. **ドキュメント参照の効率化**：Flaskやpython-socketioのドキュメントを参照しながらコーディング
3. **リファクタリング支援**：コードの問題点を指摘し、改善案を提案
4. **デバッグ支援**：エラーメッセージの解析と解決策の提案
5. **学習ツールとしての活用**：FlaskやWebSocketなどの基礎概念をコード生成から学習

開発する中で得た気づき：
- 明確な指示（「WebSocketでリアルタイム更新機能を実装して」など）→ 的確なコード生成
- 曖昧な指示（「チャット機能を作って」など）→ より一般的/保守的なコード提案
- **学習目的の質問**（「WebSocketの基本概念と実装方法を説明して」など）→ 教育的なレスポンスとサンプルコード

### 基礎を学びながらの開発プロセス

本プロジェクトでは、Cursorを単なるコード生成ツールではなく、「対話型学習環境」として活用しました：

1. **概念理解から実装へ**：
   - 最初に「WebSocketとは何か？」「Flask-SocketIOの基本的な使い方」などの基礎知識を質問
   - 得られた説明を基に、チャットアプリケーションでの実装方法を段階的に学習

2. **エラーからの学習**：
   - 発生したエラーについて「なぜこのエラーが起きるのか」を質問
   - 原因と解決策の説明から、背景にある技術的な概念を深く理解

3. **コード解説を通じた学習**：
   - 生成されたコードの各部分について「この部分は何をしているのか」を質問
   - コードの意図と動作原理を理解することで、自分の知識として定着

例えば、WebSocketの実装においては、最初は基本的な概念（コネクション、イベント、ルーム）を学び、次に簡単なエコーサーバーを作成し、最終的にチャットルーム機能を実装するというステップで学習を進めました。

具体的な機能とその実装方法を明確に指示することで、より効率的にAIの支援を受けながら、同時に技術的な基礎知識を身につけることができました。

## 今後の改善予定

1. **リアルタイム通知機能**: 新着メッセージをポップアップで通知
2. **お気に入りチャンネル機能**: よく使うチャンネルをピン留め
3. **簡易ファイル共有機能**: PDF・文書ファイルのアップロード
4. **チャンネル作成・更新のリアルタイム反映**: 現状はページリロードが必要

## 技術的な改善課題

1. **モデル定義の統合**: 時刻処理の一貫化
2. **認証ロジックの集約と簡素化**: 複数箇所に分散した処理を整理
3. **大規模ファイルの分割**: ビュー関数と長大なテンプレートの整理
4. **フロントエンドコードの最適化**: JS/CSSの分離とモジュール化

## まとめ

「EasyChat」は、シンプルなデザインと必要最小限の機能に絞ったUI/UXで、誰でも直感的に使えるチャットアプリケーションを目指して開発しました。SQLAlchemyを使ったデータモデリング、python-socketioによるリアルタイム通信、レスポンシブデザインとダークモードなど、モダンなWeb技術を取り入れつつも、シンプルさを損なわない設計に力を入れました。

## 参考リンク
- [GitHub リポジトリ](https://github.com/Eden0412/easychat)
- [Flask公式ドキュメント](https://flask.palletsprojects.com/)
- [Flask-SocketIO公式ドキュメント](https://flask-socketio.readthedocs.io/)
- [SQLAlchemy公式ドキュメント](https://docs.sqlalchemy.org/)
- [Cursor エディタ](https://cursor.sh/)