PR
PR

【Python_study_Day16】フォームの二重送信を防ぐ「PRGパターン」とは?クリアボタン実装で学ぶWebの基本

記事内に広告が含まれています。

Pythonの学習16日目です。

今回は、検証環境でHTML変換サービスを使っていて改良したい、以下の機能を追加しました。

  • テキストフィールドをクリアする機能とボタンを追加
  • 変換後の内容をクリップボードにコピーする機能とボタンを追加

変換後の内容をクリップボードにコピーする機能については、index.htmlにJavaScriptを追加することで実現出来るのですが、こちらについては、Pythonと関係ないのでGemeni先生に教えてもらったものをそのまま紹介する形となっています…

テキストフィールドをクリアするボタンを追加

これまでは、HTMLを変換するたびに、テキストフィールドの内容を手動で全選択して削除していました。

テストを繰り返すうちにこの手作業がだんだん面倒になってきたため、「クリア」ボタンを一つ押すだけでテキストを空にできる機能を追加することにしました。

このクリア機能を実現する方法をGemini先生に聞いたところ、『PRG(Post-Redirect-Get)パターン』という設計手法を用いるのが一般的だと分かりました。

PRG(Post-Redirect-Get)パターンとは

ひと言で言えば、フォーム送信後、ユーザーがブラウザの更新ボタン(F5キー)を押した際に、データが二重に送信されてしまうのを防ぐための設計手法(デザインパターン)のことです。

PRGパターンが必要な理由

もしPRGパターンを使わないと、次のような流れで意図しない再送信の問題が起こってしまいます。

  1. POSTリクエスト
  2. ユーザーがフォーム(例えば、HTML変換サービスの「変換」ボタン)を送信すると、データがサーバーにPOSTリクエストで送られます。

  3. 処理と応答
  4. サーバーは受け取ったデータ(入力されたHTMLコード)を処理し、結果(変換後のHTML)を含んだページをブラウザに直接返します。

  5. ブラウザの更新
  6. ユーザーが、その結果が表示されたページでブラウザの更新ボタン(F5キー)を押します

  7. データの再送信
  8. ブラウザは「最後に行ったリクエスト(POST)をもう一度実行しますか?」という警告を表示します。

    ここでユーザーが「はい」を押すと、まったく同じデータが再びサーバーに送信されてしまうのです。

幸い、今回のHTML変換サービスでは同じ処理がもう一度行われるだけで済みますが、もしこれがECサイトの注文フォームやブログの投稿フォームだった場合を想像してみてください。

「商品が二重に注文される」「同じブログ記事が2回投稿される」といった、致命的な問題を引き起こしてしまうのです。

PRGパターンによる解決策

このやっかいな問題を鮮やかに解決してくれるのがPRGパターンで、その仕組みは、以下のようになっています。

  1. POSTリクエスト
  2. ユーザーがフォームを押し、データがサーバーにがサーバーに送られます。

  3. サーバーでの処理
  4. サーバーは送信されたデータを処理します。(例:データベースに保存、HTMLを変換するなど)

  5. Redirect (リダイレクト)
  6. ここが最も重要なポイントです。

    サーバーは処理結果のページを直接返すのではなく、「別のページに移動してください」という指示(HTTP 302/303リダイレクト)をブラウザに送り返します。

  7. GETリクエスト
  8. 指示を受け取ったブラウザは、指定された新しいURLに対して、今度はGETリクエストを自動的に送信します。

  9. 最終的な応答
  10. サーバーはGETリクエストに応じて、最終的な結果ページを返します。

このリダイレクトというワンクッションを挟むことで、ユーザーのブラウザが行った最後の操作が「安全なGETリクエスト」に上書きされます。

そのため、ユーザーがページを更新しても、再実行されるのはそのGETリクエストだけとなり、フォームデータが二重に送信される危険がなくなるのです。

PRGパターンのメリットまとめ

PRGパターンを導入すると、主に3つの大きなメリットがあります。

  1. 重複送信の防止 (最大のメリット)
  2. ブラウザを更新してもフォームが再送信されなくなり、意図しない処理の重複を確実に防ぎます。

  3. クリーンなURL
  4. ユーザーが見ている結果ページのURLがGETリクエストのものになるため、そのURLをブックマークしたり、他の人に共有したりできます。(POSTリクエストで表示されたページは、ブックマークしても正しく機能しません)

  5. 直感的なブラウザ操作
  6. ブラウザの「戻る」「進む」ボタンを使っても、煩わしいフォーム再送信の警告が出なくなり、ユーザーはストレスなくサイトを閲覧できます。

PRGパターンを導入するためのコード修正

PRGパターンを導入するために、今回はapp.pyファイルに以下の変更を加えます。

「redirect」と「url_for」をインポート

POSTリクエストの処理後に別のページへリダイレクトするため、Flaskからredirect関数とurl_for関数をインポートします。

from flask import Flask, render_template, request, redirect, url_for

フォームクラスにクリアボタンを追加する

WTFormsで定義しているHtmlEscapeFormクラスに、クリアボタンの定義を追加します。

具体的には、submitの下に「clear = SubmitField('クリア')」の一行を書き加えるだけです。

class HtmlEscapeForm(FlaskForm):
    html_code = TextAreaField(
        '変換したいHTMLコードを入力してください(最大10,000文字):', 
        validators=[
            DataRequired(message="HTMLコードは必須です。"), 
            Length(max=10000, message="入力されたHTMLコードが長すぎます(最大10,000文字)。") 
        ]
    )
    submit = SubmitField('変換')
    # ↓ この行を追加
    clear = SubmitField('クリア')

index関数を修正してPRGパターンを実装する

app.pyのindex関数を修正し、PRGパターンを実装します。

クリアボタンが押された場合にリダイレクト処理が入るのが大きな変更点です。

# '/' (ルートURL) にアクセスがあった場合の処理を定義
@app.route('/', methods=['GET', 'POST'])
@limiter.limit("15 per minute") 
def index():
    form = HtmlEscapeForm()
    escaped_html = None

    # フォームがPOSTメソッドで送信された場合の処理
    if request.method == 'POST':

        # ここからが変更点
        # クリアボタンが押された場合 (POSTリクエスト)
        if form.clear.data:
            # 何も処理せず、同じページ('/')にリダイレクトする
            # これがPRGパターンの「Redirect」と「Get」にあたる
            return redirect(url_for('index'))
        # ここまでが変更点

        
        # 変換ボタンが押され、バリデーションが成功したかを判定
        if form.validate_on_submit():
            original_html_input = form.html_code.data
            escaped_html = html.escape(original_html_input)

    return render_template('index.html', form=form, escaped_html=escaped_html)

変更点の解説:クリアボタンの裏側で何が起きているか

先ほど修正したコードの中でも、特に重要なのがクリアボタンが押された際のredirect処理です。

ここでは、ユーザーが「クリア」ボタンを押してからフォームが空になるまでの流れを、ステップごとに説明していきます。

1. (Post)フォームが送信される if form.clear.data:

ユーザーが「クリア」ボタンを押すと、ブラウザはサーバーに対してPOSTリクエストを送信します。

サーバー側ではif form.clear.data:の条件がTrueとなり、次の処理に進みます。

2. (Redirect) サーバーが「向き先」を指示する return redirect(url_for('index'))

条件を満たしたサーバーは、return redirect(url_for('index'))を実行します。

これはHTMLページを返すのではなく、ブラウザに対して「indexページ(つまりルート/)にもう一度アクセスし直してください」というリダイレクト指示を送り返します。

3. (Get) ブラウザが指示に従う

リダイレクト指示を受け取ったブラウザは、その指示に素直に従い、指定されたURL(/)に対してGETリクエストを自動的に再送信します。

4. 結果:クリーンなページが表示される

サーバーはGETリクエストに応じて、新しいindex.htmlをブラウザに返します。

GETリクエストで表示されたページなので、テキストエリアは空の状態となります。

index.htmlの変更

app.py側の準備が整ったので、次はフロントエンド(ブラウザに表示される画面)の修正です。

クリアボタンの追加と、変換結果をクリップボードにコピーするためのボタンを追加するために、index.htmlの編集を行います。

クリアボタンを追加する

{{ form.clear() }}という記述を追加するだけです。

これは、先ほどapp.pyのHtmlEscapeFormクラスで定義したclear = SubmitField(...)を、実際のHTMLボタンとして表示(レンダリング)するためのJinja2記法です。

form.submit()(変換ボタン)の隣に並べて配置することにします。

         <div class="form-group">
             {{ form.submit() }} {{ form.clear() }}
         </div>

変換結果をコピーするボタンを追加する

次に、変換結果をワンクリックでコピーできる機能を追加します。

このコピー機能は、PythonやFlaskとは直接関係せず、ブラウザ側で動作するJavaScriptの領域になります。

そのため、この記事での詳細な解説は省略し、実装するコードの紹介に留めます。(ちなみに、以下のコードはGemini先生に作成してもらったものです)

HTMLの修正

変換結果を表示する部分に、コピーボタン(<button>)を追加します。

 {% if escaped_html %}
     <div class="result-header">
         <h2>変換結果:</h2>
         <button id="copyButton">クリップボードにコピー</button>
     </div>
     <pre id="resultText">{{ escaped_html }}</pre>
     {% endif %}

JavaScriptの追加

ボタンがクリックされたときに動作するJavaScriptのコードを追加します。

<script>
    const copyButton = document.getElementById('copyButton');

    if (copyButton) {
        copyButton.addEventListener('click', () => {
            // Clipboard APIが利用可能かチェック(HTTP接続などでは利用不可)
            if (!navigator.clipboard) {
                alert('お使いのブラウザまたは接続環境では、クリップボード機能を利用できません。');
                console.error('Clipboard API not available.');
                return; // 処理を中断
            }

            const resultTextElement = document.getElementById('resultText');
            const textToCopy = resultTextElement.innerText;

            navigator.clipboard.writeText(textToCopy).then(() => {
                // コピー成功時の処理
                copyButton.textContent = 'コピーしました!';
                copyButton.disabled = true;
                setTimeout(() => {
                    copyButton.disabled = false;
                    copyButton.textContent = 'クリップボードにコピー';
                }, 2000);
            }).catch(err => {
                // コピー失敗時の処理(ユーザーが権限を拒否した場合など)
                console.error('Failed to copy text: ', err);
                alert('クリップボードへのコピーに失敗しました。権限が許可されていない可能性があります。');
            });
        });
    }

【補足】SECRET_KEYの設定確認

今回、PRGパターンによるクリア機能の追加とは直接関係ありませんが、app.pyのセキュリティ設定に関して、以下の重要な変更も合わせて行っています。

変更の理由

これまで、Flask-WTFのCSRF保護機能で使うSECRET_KEYを、os.environ.get('FLASK_SECRET_KEY') を使って環境変数から取得していました。

この.get()メソッドは、環境変数が存在しない場合にNoneを返しますが、Flask-WTFのCSRF保護機能はSECRET_KEYがNoneの状態では動作しません。

つまり、SECRET_KEYを設定し忘れたままデプロイしてしまうと、CSRF保護が機能しない脆弱な状態でアプリケーションが意図せず起動してしまう危険性がありました。

この問題を解決するため、REDIS_URLのチェックと同様にtry...exceptブロックを使い、SECRET_KEYが設定されていない場合はエラー(RuntimeError)を発生させてアプリケーションの起動自体を停止させるように変更しました。

変更前

.get()を使っていたため、環境変数がなくてもNoneが設定されるだけで起動はしてしまっていました。

app.config['SECRET_KEY'] = os.environ.get('FLASK_SECRET_KEY')

変更後

os.environ[...]という直接参照とtry...exceptを組み合わせることで、キーが存在しない場合(KeyError)にプログラムを確実に停止させます。

try:
    app.config['SECRET_KEY'] = os.environ['FLASK_SECRET_KEY']
except KeyError:
    # Flask-WTFのCSRF保護機能にはSECRET_KEYが必須です
    raise RuntimeError("環境変数 'FLASK_SECRET_KEY' が設定されていません")

最終的な app.py の全コード

これまでの解説で追加・修正した内容をすべて反映した、app.pyの最終的なコードは以下の通りです。

# 必要なモジュールをインポート
import html
import os
from flask import Flask, render_template, request, redirect, url_for
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_wtf import FlaskForm
from wtforms import TextAreaField, SubmitField
from wtforms.validators import DataRequired, Length
from dotenv import load_dotenv

# .envファイルから環境変数をロード
load_dotenv()

# Flaskアプリケーションのインスタンスを作成
app = Flask(__name__)

# 環境変数からSECRET_KEYの値を取得
try:
    app.config['SECRET_KEY'] = os.environ['FLASK_SECRET_KEY']
except KeyError:
    # Flask-WTFのCSRF保護機能にはSECRET_KEYが必須です
    raise RuntimeError("環境変数 'FLASK_SECRET_KEY' が設定されていません")
try:
    redis_storage_uri = os.environ["REDIS_URL"]
except KeyError:
    # エラーメッセージを分かりやすく表示して終了
    raise RuntimeError("環境変数 'REDIS_URL' が設定されていません")

# Limiterを初期化し、アプリに適用
limiter = Limiter(
    get_remote_address,
    app=app,
    default_limits=["200 per day", "60 per hour"],
    storage_uri=redis_storage_uri
)


# HTMLエスケープフォームの定義
class HtmlEscapeForm(FlaskForm):
    html_code = TextAreaField(
        '変換したいHTMLコードを入力してください(最大10,000文字):', 
        validators=[
            DataRequired(message="HTMLコードは必須です。"), 
            Length(max=10000, message="入力されたHTMLコードが長すぎます(最大10,000文字)。") 
        ]
    )
    submit = SubmitField('変換')
    clear = SubmitField('クリア')

# '/' (ルートURL) にアクセスがあった場合の処理を定義
@app.route('/', methods=['GET', 'POST'])
@limiter.limit("15 per minute") 
def index():
    form = HtmlEscapeForm()
    escaped_html = None

    # フォームが送信(POST)された場合の処理
    if request.method == 'POST':
        # クリアボタンが押された場合
        if form.clear.data:
            # 何も処理せず、同じページ('/')にリダイレクト
            # これでブラウザの履歴にはGETリクエストが残り、リロードしてもフォームが再送信されない
            return redirect(url_for('index'))
        
        # 変換ボタンが押され、バリデーションが成功した場合
        if form.validate_on_submit():
            original_html_input = form.html_code.data
            escaped_html = html.escape(original_html_input)

    return render_template('index.html', form=form, escaped_html=escaped_html)

最終的な index.html の全コード

app.pyの変更と合わせ、クリアボタンとコピペ機能を追加したindex.htmlの最終的なコードは以下の通りです。

<!DOCTYPE html>
 <html lang="ja">
 <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>HTMLエスケープツール</title>
     <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
 </head>
 <body>
 <h1>HTMLエスケープツール</h1>
 <form method="POST">
     {{ form.csrf_token }}
         <div class="form-group">
             {{ form.html_code.label }}<br>
             {{ form.html_code(rows=10, cols=80, placeholder="例: <div>Hello!</div>") }}
             {% if form.html_code.errors %}
                 <ul class="errors">
                 {% for error in form.html_code.errors %}
                     <li class="error">{{ error }}</li>
                 {% endfor %}
                 </ul>
             {% endif %}
         </div>
         <div class="form-group">
             {{ form.submit() }} {{ form.clear() }}
         </div>
 </form>

 {% if escaped_html %}
     <div class="result-header">
         <h2>変換結果:</h2>
         <button id="copyButton">クリップボードにコピー</button>
     </div>
     <pre id="resultText">{{ escaped_html }}</pre>
     {% endif %}


<script>
    const copyButton = document.getElementById('copyButton');

    if (copyButton) {
        copyButton.addEventListener('click', () => {
            // Clipboard APIが利用可能かチェック(HTTP接続などでは利用不可)
            if (!navigator.clipboard) {
                alert('お使いのブラウザまたは接続環境では、クリップボード機能を利用できません。');
                console.error('Clipboard API not available.');
                return; // 処理を中断
            }

            const resultTextElement = document.getElementById('resultText');
            const textToCopy = resultTextElement.innerText;

            navigator.clipboard.writeText(textToCopy).then(() => {
                // コピー成功時の処理
                copyButton.textContent = 'コピーしました!';
                copyButton.disabled = true;
                setTimeout(() => {
                    copyButton.disabled = false;
                    copyButton.textContent = 'クリップボードにコピー';
                }, 2000);
            }).catch(err => {
                // コピー失敗時の処理(ユーザーが権限を拒否した場合など)
                console.error('Failed to copy text: ', err);
                alert('クリップボードへのコピーに失敗しました。権限が許可されていない可能性があります。');
            });
        });
    }
</script>
</body>
</html>

まとめ:学習16日目の成果と学び

今回は、Python学習16日目の記録として、自作のHTML変換サービスをより実用的にするための機能改善に取り組みました。

一見地味な機能追加でしたが、その過程でWebアプリケーション開発における非常に重要な概念を学ぶことができました。

単なる機能追加に留まらず、「なぜそうするのか?」というWeb開発の基本原則やセキュリティの重要性を再確認できた、非常に有意義な学習となりました。

特に大きな学びとなったのは、以下の2点です。

PRG (Post-Redirect-Get) パターンの学習と実装

「クリアボタン」の実装をきっかけに、フォームの二重送信を防ぐための重要な設計パターンである「PRGパターン」を学び、なぜリダイレクトが必要なのかを理論から実践まで深く理解できました。

セキュリティと堅牢性の強化

SECRET_KEYが未設定の場合、意図せず脆弱な状態でアプリが起動するのを防ぐため、try...exceptブロックを使って起動時にチェックする処理を追加しました。

コメント

タイトルとURLをコピーしました