EY-Office ブログ

Pythonの軽量WebフレームワークFlaskを使ってみたが、おもいのほか苦労した

以前のブログでDjangoを学びましたが、初心者へのプログラミング教育で使うには少し大げさかなと思いました。 そこで、軽量なWebフレームワークFlaskを使ってみることにしました。
Ruby言語でいうとDjango ≒ Ruby on Rails でFlask ≒ Sinatra でしょうか。

Flask Bing Image Creatorが生成した画像を使っています

Flaskの簡単な解説

Flaskを使った最低限のWebアプリのコードは以下のようになります。

インストール
$ mkdir flask
$ cd flask
$ pip install Flask
$ touch server.py
$ mkdir templates
$ touch templates/index.html
コード server.py

/にアクセスがあると、index関数が動き、render_template APIでindex.htmlテンプレートでHTMLが生成されます。 またテンプレートには、キーname、値"Flask"が渡されます。

from flask import Flask, render_template

app = Flask(__name__)

@app.route('/')
def index():
  return render_template('index.html', name="Flask")

if __name__ == '__main__':
  app.run(debug=True, port=3000)
コード templates/index.html

{{...}}は括弧内の値をHTMLの中に埋め込みます。また{% if ... %} ・・・ {% endif %}{% for ... %} ・・・ {% endfor %} などの制御構造が書けます。 → テンプレートの詳細

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello</title>
  </head>
  <body>
    <h1>Hello {{ name }}!</h1>
  </body>
</html>
起動
$ python server.py

サンプルアプリケーション

今回のサンプルアプリケーションはジャンケンではなく、小遣い帳です。

  • アクセスすると小遣い帳が表示されます
  • 小遣い帳は、日付順で表示されます
  • 小遣い帳には、残高が自動的に計算され表示されます
  • 右端の「削除」ボタンを押すと小遣い帳の行が削除できます
  • フォームで日付、内容、入金/出金を入力して「追加」ボタンを押すと小遣い帳に追加されます
  • 小遣い帳のデータはデータベースsqlite3に格納されています
  • スタイリングにはTailwind CSSを使っています

初期版

コード server.py
  • ① sqlite3への接続、別スレットでの接続を許しています。詳細は後で説明します
  • ② SELECT文の戻りはデフォルトではタプル(Tuple)ですが、この設定でRowオブジェクトになりカラム名でアクセス出来るようになり、コードが読みやすくなります
  • ③ connはsqlite3への接続オブジェクト、cursorはデータベース操作等のオブジェクトです
  • ④ フォームから送られて来た数値(金額)の処理関数
    • 空文字列ならNoneが戻り、数字文字列ならIntegerに変換した値が戻ります
  • @app.routeはURLアクセスと関数を対応付けるデコレータです、ここでは/へGETまたはPOSTアクセスがあった場合にindex関数が起動されます
  • ⑥ POSTアクセスがあった場合(フォームの「追加」ボタンを押した時)の処理です
    • 日付や内容は、受け取ったパラメーター文字列の前後のスペースを削除します
    • 入金・出金はnumber_value関数で数値、またはNoneに変換します
  • ⑦ INSERT文でフォームに入力された値をallowance_bookテーブルに書き込みます
  • ⑧ その後、index関数に対応するURL / にリダイレクトします
  • ⑨ GETアクセスの場合はSELECT文でallowance_bookテーブルの全データを取得します
    • COALESCE(money_in, 0)はmoney_inカラムの値がnullなら0、nullでなければその値が戻ります
    • SUM(COALESCE(money_in, 0) - COALESCE(money_out, 0)) で入金から出金を引いた値の合計が計算されます
    • OVER (ORDER BY date, id)があるのでdate,idの小さい順でソートした並びで、取得行までの合計(=残高)が計算されます
  • fetchall()で全取得データの配列が戻ります
  • render_template関数でindex.htmlが表示されます、その際に使われるallowance_bookテーブルの値allowancesを渡しています
from flask import Flask, render_template, request, redirect, url_for
import sqlite3

app = Flask(__name__)
conn = sqlite3.connect('db/allowance_book.db', check_same_thread=False) # ← ①
conn.row_factory = sqlite3.Row                                          # ← ②
cursor = conn.cursor()                                                  # ← ③

def number_value(value):                                                # ← ④
  s = value.strip()
  if s == '':
    return None
  else:
    return int(s)

@app.route('/', methods=['GET', 'POST'])                        # ← ⑤
def index():
  if request.method == 'POST':                                  # ← ⑥
    date = request.form['date'].strip()
    description = request.form['description'].strip()
    money_in = number_value(request.form['money_in'])
    money_out = number_value(request.form['money_out'])         # ↓ ⑦
    cursor.execute(''''
      INSERT INTO allowance_book (date, description, money_in, money_out)
      VALUES (?, ?, ?, ?)
    ''',
      (date, description, money_in, money_out))
    conn.commit()
    return redirect(url_for('index'))                           # ← ⑧
                                                                # ↓ ⑨
  cursor.execute('''
    SELECT id, date, description, money_in, money_out,
    SUM(COALESCE(money_in, 0) - COALESCE(money_out, 0)) OVER (ORDER BY date, id)
      AS balance
    FROM allowance_book ORDER BY date, id
  ''')
  allowances = cursor.fetchall()                                # ← ⑩
  return render_template('index.html', allowances=allowances)   # ← ⑪

@app.route('/delete', methods=['POST'])                         # ← ⑫
def delete():
  id = request.form['id']
  cursor.execute('DELETE FROM allowance_book WHERE id = ?', (id,))   # ← ⑬
  conn.commit()
  return redirect(url_for('index'))                             # ← ⑭

if __name__ == '__main__':                                      # ← ⑮
                                # ↓ ⑯
  cursor.execute('''
  CREATE TABLE IF NOT EXISTS allowance_book (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    date DATE,
    description VARCHAR(255),
    money_in INTEGER,
    money_out INTEGER
  )
  ''')

  app.run(debug=True, port=3000)                               # ← ⑰
  • ⑫ 削除処理、/delete URLへのPOSTアクセスがあった場合delete関数が実行されます
  • ⑬ パラメーターidの値を使い、削除を実行するDELETE文を実行します
  • ⑭ その後、index関数に対応するURL / にリダイレクトします
  • python server.pyで実行された際に行う処理をこのif文内に書きます
  • ⑯ もしallowance_bookテーブルが無ければCREATE TABLEでallowance_bookテーブルを作成します
  • ⑰ flaskの開発用サーバーを起動します、デバッグ情報の表示、ポート番号を3000に設定しています
コード templates/index.html
  • ① TailwindCSSのCSSファイルを読み込んでいます。これでは全てのCSSがダウンロードされるので本番環境ではお薦めできません
  • ② このforで、allowances配列の内容を取り出しながら繰り返し、全小遣い帳データを表示します
  • {{...}}は括弧内の変数やオブジェクトの値をHTMLに埋め込みます
    • allowance.dateと書くとallowance['date']と同等に参照できます
  • ④ 削除ボタンのフォーム
  • ⑤ 小遣い帳の追加フォーム
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>小遣い帳</title>                            {# ↓ ① #}
    <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
  </head>
  <body>
    <div class="m-6 w-1/2">
      <h1 class="text-3xl font-bold">小遣い帳</h1>
      <table class="mt-4 min-w-full divide-y divide-gray-200 shadow-sm rounded-lg overflow-hidden">
        <thead class="bg-gray-50">
          <tr>
            <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日付</th>
            <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">内容</th>
            <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">入金</th>
            <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">出金</th>
            <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">残高</th>
            <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
          </tr>
        </thead>
        <tbody class="bg-white divide-y divide-gray-200">
          {% for allowance in allowances %}                    {# ← ② #}
            <tr>                                               {# ↓ ③ #}
              <td class="px-6 py-4 whitespace-nowrap">{{ allowance.date }}</td>
              <td class="px-6 py-4 whitespace-nowrap">{{ allowance.description }}</td>
              <td class="px-6 py-4 whitespace-nowrap text-right">{{ allowance.money_in or '' }}</td>
              <td class="px-6 py-4 whitespace-nowrap text-right">{{ allowance.money_out or '' }}</td>
              <td class="px-6 py-4 whitespace-nowrap text-right">{{ allowance.balance }}</td>
              <td>
                <form action="/delete" method="POST">          {# ← ④ #}
                  <input type="hidden" name="id" value="{{ allowance.id }}">
                  <button type="submit" class="px-2 py-1 text-xs font-medium text-center text-white bg-red-700 rounded hover:bg-red-800">削除</button>
                </form>
              </td>
            </tr>
          {% endfor %}
        </tbody>
      </table>

      {# ↓ ⑤ #}
      <form action="/" method="POST" class="my-6 p-6 bg-white shadow-md rounded-lg">
        <h2 class="text-2xl font-bold mb-4">新しい項目を追加</h2>
        <div class="space-y-4">
          <div>
            <label for="date" class="block text-sm font-medium text-gray-700">日付</label>
            <input type="date" name="date" id="date" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500" required>
          </div>
          <div>
            <label for="description" class="block text-sm font-medium text-gray-700">内容</label>
            <input type="text" name="description" id="description" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500" placeholder="例: おこづかい" required>
          </div>
          <div>
            <label for="money_in" class="block text-sm font-medium text-gray-700">入金</label>
            <input type="number" name="money_in" id="money_in" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500" placeholder="0">
          </div>
          <div>
            <label for="money_out" class="block text-sm font-medium text-gray-700">出金</label>
            <input type="number" name="money_out" id="money_out" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500" placeholder="0">
          </div>
        </div>
        <div class="mt-6 text-right">
          <button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
            追加
          </button>
        </div>
      </form>
    </div>
  </body>
</html>

バリデーションの追加

上のコードは入力値のチェックを行っていません。入金と出金を入れなくても、両方入れてもエラーになりません。そこで入力値のバリデーションを追加してみます。

コード server.py
  • ① フォームから送られて来た数値(金額)の処理関数にエラーチェックを追加
    • 2番目の戻り値がTrueならエラーです
    • 数値文字列かのチェックは正規表現(re)を使っています
  • ② errors配列にエラーメッセージが入ります
  • ③ 各入力のエラーチェック
    • 日付は正規表現でチェック
    • 内容入力があるかチェック
    • 入金・出金はparse_number関数でチェック
    • 入金・出金が両方とも無い場合、両方がある場合はエラーにしています
  • ④ データのINSERTはエラーがない場合のみ行い、index関数に対応するURL / にリダイレクトします
  • ⑤ 小遣い帳データの表示は、入力でエラーが在った場合も表示します
  • ⑥ 表示テンプレートには、小遣い帳データの他にフォームに入力された値の辞書(request.form.to_dict())、エラーメッセージが渡されます
rom flask import Flask, render_template, request, redirect, url_for
import sqlite3
import re

# Flaskアプリケーションの初期化
app = Flask(__name__)

# SQLiteデータベースの接続
# Flaskはマルチスレッドで動かないので、スレッド間でのデータベース接続を許可する
conn = sqlite3.connect('db/allowance_book.db', check_same_thread=False)
conn.row_factory = sqlite3.Row   # 行を辞書形式で取得
cursor = conn.cursor()

# 数字を解析するヘルパー関数
def parse_number(value):                                             # ← ①
  if value == '':
    return None, False
  elif re.match(r'\d+', value):
    return int(value), False
  else:
    return 0, True

# メインページのルーティング
@app.route('/', methods=['GET', 'POST'])
def index():
  errors = []                                                       # ← ②

  if request.method == 'POST':
    # フォームからのデータを取得
    date = request.form['date'].strip()
    description = request.form['description'].strip()
    money_in_s = request.form['money_in'].strip()
    money_out_s = request.form['money_out'].strip()

    # 入力値の検証
    if not re.match(r'\d{4}-\d{2}-\d{2}', date):                   # ← ③
      errors.append('正しい日付を入力してください')
    if description == '':
      errors.append('内容を入力してください')
    money_in, error = parse_number(money_in_s)
    if error:
      errors.append('入金が数字ではありません')
    money_out, error = parse_number(money_out_s)
    if error:
      errors.append('出金が数字ではありません')
    if money_in == None and money_out == None:
      errors.append('入金または出金を入力してください')
    if money_in != None and money_out != None:
      errors.append('入金と出金の両方を入力しないでください')

    # エラーがなければデータベースに保存
    if len(errors) == 0:                                           # ← ④
      cursor.execute('INSERT INTO allowance_book (date, description, money_in, money_out) VALUES (?, ?, ?, ?)',
        (date, description, money_in, money_out))
      conn.commit()
      return redirect(url_for('index'))

  # GETリクエストまたはエラーがある場合、データを取得して表示               # ↓ ⑤
  cursor.execute('''
    SELECT id, date, description, money_in, money_out,
    SUM(COALESCE(money_in, 0) - COALESCE(money_out, 0)) OVER (ORDER BY date, id) AS balance
    FROM allowance_book
  ''')
  allowances = cursor.fetchall()
  return render_template('index.html',                            # ↓ ⑥
    allowances=allowances, form_data=request.form.to_dict(), errors=errors)

  ・・・ 以下省略・・・
コード templates/index.html
  • ① エラーメッセージの表示を追加
  • ② 入力ミスが起きたときの入力値を表示するためのコードを追加
    • form_dataに人間が入力した値が入っています
<!DOCTYPE html>
<html>

・・・ 省略・・・

      <form action="/" method="POST" class="my-6 p-6 bg-white shadow-md rounded-lg">
        <h2 class="text-2xl font-bold mb-4">新しい項目を追加</h2>

        {% if errors|length > 0 %}                      {# ← ① #}
          <div <div class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50 " role="alert">
            <ul>
              {% for error in errors %}
                <li>{{ error }}</li>
              {% endfor %}
            </ul>
          </div>
        {% endif %}

        <div class="space-y-4">
          <div>
            <label for="date" class="block text-sm font-medium text-gray-700">日付</label>
                                            {# ↓ ② #}
            <input type="date" value="{{ form_data.date if form_data else '' }}"
              name="date" id="date" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500">
          </div>
          <div>
            <label for="description" class="block text-sm font-medium text-gray-700">内容</label>
            <input type="text" value="{{ form_data.description if form_data else '' }}"
              name="description" id="description" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500" placeholder="例: おこづかい">
          </div>
          <div>
            <label for="money_in" class="block text-sm font-medium text-gray-700">入金</label>
            <input type="text" value="{{ form_data.money_in if form_data else '' }}"
              name="money_in" id="money_in" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500" placeholder="0">
          </div>
          <div>
            <label for="money_out" class="block text-sm font-medium text-gray-700">出金</label>
            <input type="text" value="{{ form_data.money_out if form_data else '' }}"
              name="money_out" id="money_out" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500" placeholder="0">
          </div>
        </div>
        <div class="mt-6 text-right">
          <button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
            追加
          </button>
        </div>
      </form>
    </div>
  </body>
</html>

苦労したこと

Flask(開発サーバー)ではHTTPリクエストの対応にはスレッドを作り実行します。sqlite3ライブラリーはデフォルトでは異なるスレッドからの(同時)アクセスを許さないようになっています。
しかし、Flask(開発サーバー)はデフォルトでは複数の同時リクエストを平行処理しないので、sqlite3への異なるスレッドからのアクセスを許しても問題は起きません。そこで heck_same_thread=False を指定しています。
これを指定しないと実行時に sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same thread. エラーが発生します。

また、Flaskにはバリデーション用のWTFormsライブラリーがあります。これはバリデーションとFormを組み合わせたライブラリーで少ないコードでバリデーションが行えますが初心者には判りにくいかな?と思い止めて、ベタなコードを書きました。

スレッドの補足:

  • app.run()にthreaded=Trueオプションを指定するとリクエストを平行処理できます
  • 本コードでも起動時のCREATE TABLE実行中に、/にアクセスがありindex関数が動作するとsqlite3が正しくない動作をする可能性があります

まとめ

私は今までにRubyやJavaScript等で多数のWebアプリを作って来ました。しかし、異なる言語には異なる考え・文化があり、最初は戸惑いますね。😅

- about -

EY-Office代表取締役
・プログラマー
吉田裕美の
開発者向けブログ