Flask + SQLAlchemyで作る「キャリア・クエスト」- 仮想キャリア体験プラットフォームの裏側（Part 2）

## はじめに

この記事は「キャリア・クエスト」の技術解説の続きです。Part 1では、プロジェクトの概要、技術選択の理由、アーキテクチャ設計、そしてデータベース設計について説明しました。Part 2では、以下の内容を詳しく解説します：

- ユーザー認証システムの構築
- キャリアシミュレーションロジックの実装
- フロントエンドの実装と工夫点
- 開発で直面した課題と解決策

## 目次

1. [ユーザー認証システムの構築](#1-ユーザー認証システムの構築)
2. [キャリアシミュレーションロジックの実装](#2-キャリアシミュレーションロジックの実装)
3. [フロントエンドの実装と工夫点](#3-フロントエンドの実装と工夫点)
4. [開発で直面した課題と解決策](#4-開発で直面した課題と解決策)

## 1. ユーザー認証システムの構築

ユーザー認証システムは、アプリケーションのセキュリティにとって非常に重要です。Flask-Loginを使用して、安全で使いやすい認証システムを実装しました。

### ユーザー登録の実装

```python:auth.py
from flask import Flask, request, render_template, redirect, url_for, flash
from flask_login import LoginManager, login_user, login_required, logout_user, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from models import User, db

app = Flask(__name__)
login_manager = LoginManager(app)
login_manager.login_view = 'login'

@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        
        user = User.query.filter_by(username=username).first()
        if user:
            flash('このユーザー名は既に使用されています。', 'error')
            return redirect(url_for('register'))
        
        new_user = User(username=username, password=generate_password_hash(password, method='sha256'))
        db.session.add(new_user)
        db.session.commit()
        
        flash('登録が完了しました。ログインしてください。', 'success')
        return redirect(url_for('login'))
    
    return render_template('register.html')
```

このコードの主なポイントは以下の通りです：

1. `Flask-Login`を使用して、ログイン状態の管理を簡素化しています。
2. パスワードは`generate_password_hash`関数を使用してハッシュ化しています。これにより、平文でのパスワード保存を避け、セキュリティを向上させています。
3. ユーザー名の重複チェックを行い、既存のユーザー名での登録を防いでいます。
4. フラッシュメッセージを使用して、ユーザーに適切なフィードバックを提供しています。

### ログイン機能の実装

```python:auth.py
@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        user = User.query.filter_by(username=username).first()
        
        if user and check_password_hash(user.password, password):
            login_user(user)
            flash('ログインに成功しました。', 'success')
            return redirect(url_for('dashboard'))
        else:
            flash('ログインに失敗しました。ユーザー名またはパスワードを確認してください。', 'error')
    
    return render_template('login.html')

@app.route('/logout')
@login_required
def logout():
    logout_user()
    flash('ログアウトしました。', 'info')
    return redirect(url_for('index'))
```

ログイン機能の実装ポイント：

1. `check_password_hash`関数を使用して、入力されたパスワードとハッシュ化されたパスワードを安全に比較しています。
2. ログイン成功時は`login_user`関数を使用してユーザーをログイン状態にします。
3. `@login_required`デコレータを使用して、ログインが必要なルートを保護しています。
4. ログアウト機能も実装し、`logout_user`関数でセッションを終了させています。

## 2. キャリアシミュレーションロジックの実装

キャリアシミュレーションは本アプリケーションの核となる機能です。ユーザーの選択に基づいて、キャリアパスをシミュレートします。

```python:simulation.py
import json
from models import User, TimelineEvent, CareerChoice, db

def simulate_career_choice(user_id, choice_id):
    user = User.query.get(user_id)
    choice = CareerChoice.query.get(choice_id)
    
    if not user or not choice:
        return False, "Invalid user or choice"
    
    # キャリアパスの更新
    career_path = json.loads(user.career_path)
    career_path.append(choice_id)
    user.career_path = json.dumps(career_path)
    
    # タイムラインイベントの作成
    event = TimelineEvent(
        user_id=user.id,
        event_type='career_choice',
        description=f'選択: {choice.name}'
    )
    db.session.add(event)
    
    # 次の選択肢を決定
    next_choices = json.loads(choice.next_choices)
    
    # ユーザーの状態更新（例：タイムクリスタルの増減）
    user.time_crystals += 1  # 選択ごとにタイムクリスタルを1つ獲得
    
    db.session.commit()
    
    return True, next_choices

@app.route('/make_choice', methods=['POST'])
@login_required
def make_choice():
    choice_id = request.form.get('choice_id')
    success, result = simulate_career_choice(current_user.id, choice_id)
    
    if success:
        flash('キャリア選択が反映されました。', 'success')
        return redirect(url_for('game', next_choices=result))
    else:
        flash('選択の処理中にエラーが発生しました。', 'error')
        return redirect(url_for('game'))
```

シミュレーションロジックの主なポイント：

1. ユーザーのキャリアパスを`career_path`フィールドに JSON 形式で保存しています。これにより、複雑なパスの履歴を柔軟に管理できます。
2. 各選択をタイムラインイベントとして記録し、ユーザーの行動履歴を追跡しています。
3. 次の選択肢は`CareerChoice`モデルの`next_choices`フィールドから動的に取得します。これにより、柔軟なシナリオ設計が可能になります。
4. ゲーム内通貨（タイムクリスタル）の管理も行っており、ユーザーのアクションに応じて増減させています。

## 3. フロントエンドの実装と工夫点

フロントエンドは主に HTML、CSS、JavaScript を使用して実装しました。特に、ユーザーエクスペリエンスを向上させるために以下の点に注力しました。

### インタラクティブな選択肢の表示

```javascript:game.js
document.addEventListener('DOMContentLoaded', function() {
    const choiceButtons = document.querySelectorAll('.choice-button');
    
    choiceButtons.forEach(button => {
        button.addEventListener('click', function() {
            const choiceId = this.dataset.choiceId;
            document.getElementById('choice_id').value = choiceId;
            document.getElementById('choice-form').submit();
        });
    });
});
```

このJavaScriptコードは、選択肢ボタンをクリックした際の動作を定義しています。ユーザーの選択をスムーズに処理し、サーバーにデータを送信します。

### タイムラインの動的更新

```javascript:timeline.js
function updateTimeline() {
    fetch('/api/timeline')
        .then(response => response.json())
        .then(data => {
            const timelineContainer = document.getElementById('timeline-container');
            timelineContainer.innerHTML = '';
            data.forEach(event => {
                const eventElement = document.createElement('div');
                eventElement.className = 'timeline-event';
                eventElement.innerHTML = `
                    <h3>${event.event_type}</h3>
                    <p>${event.description}</p>
                    <small>${new Date(event.timestamp).toLocaleString()}</small>
                `;
                timelineContainer.appendChild(eventElement);
            });
        });
}

// 5秒ごとにタイムラインを更新
setInterval(updateTimeline, 5000);
```

このコードは、ユーザーのタイムラインを定期的に更新します。Ajax を使用してサーバーから最新のイベントデータを取得し、DOM を動的に更新しています。

## 4. 開発で直面した課題と解決策

### 課題1: 複雑なキャリアパスの管理

**問題**: ユーザーの選択肢が増えるにつれて、可能なキャリアパスの組み合わせが指数関数的に増加し、管理が困難になりました。

**解決策**: 
1. キャリアパスをグラフ構造として設計し、各ノードを`CareerChoice`モデルとして表現しました。
2. 次の選択肢を動的に生成するアルゴリズムを実装し、ハードコードされたパスに依存しないようにしました。
3. A/Bテストを導入し、新しいキャリアパスの有効性を検証できるようにしました。

### 課題2: パフォーマンスの最適化

**問題**: ユーザー数の増加に伴い、特にタイムライン機能でパフォーマンスの低下が見られました。

**解決策**:
1. データベースにインデックスを追加し、クエリの速度を改善しました。
2. Redisを導入し、頻繁にアクセスされるデータをキャッシュしました。
3. ページネーションを実装し、一度に表示するデータ量を制限しました。

```python:optimization.py
from flask_caching import Cache

cache = Cache(app, config={'CACHE_TYPE': 'redis'})

@app.route('/timeline')
@cache.cached(timeout=60)  # 1分間キャッシュ
def get_timeline():
    page = request.args.get('page', 1, type=int)
    events = TimelineEvent.query.filter_by(user_id=current_user.id)\
        .order_by(TimelineEvent.timestamp.desc())\
        .paginate(page=page, per_page=20)
    return render_template('timeline.html', events=events)
```

このコードでは、Redisを使用したキャッシュとページネーションを組み合わせて、タイムライン表示のパフォーマンスを大幅に改善しています。

## まとめ

「キャリア・クエスト」の開発を通じて、複雑なビジネスロジックを持つWebアプリケーションの設計と実装について多くの学びがありました。特に、ユーザーの行動に基づいて動的にコンテンツを生成し、それを効率的に管理・表示する方法は、他の多くのプロジェクトにも応用できる貴重な経験となりました。

今後は、機械学習を活用してよりパーソナライズされたキャリア提案を行うなど、さらなる機能の拡張を計画しています。

この記事が「キャリア・クエスト」の技術的な側面を理解する助けになれば幸いです。質問やフィードバックがあれば、ぜひコメントでお知らせください。

## 参考リンク

- [Flask公式ドキュメント](https://flask.palletsprojects.com/)
- [SQLAlchemy公式ドキュメント](https://docs.sqlalchemy.org/)
- [Flask-Login ドキュメント](https://flask-login.readthedocs.io/)
- [Redis公式サイト](https://redis.io/)