Flask実践入門 - 基本的なアプリ構成を問い合わせフォームをつくりながら学ぶ

Python製のマイクロWebフレームワークであるFlaskは最小限の機能のみを提供するシンプルさが特徴です。一方で、シンプルゆえ、アプリを構築する際、どのような構成にするか迷ってしまう局面もあります。本記事はFlaskを使い、実際にアプリをつくりながら基本的な構成の考え方をハンズオンで学びます。解説はテックタッチ株式会社の佐藤昌基さんです。

Flask実践入門 - 基本的なアプリ構成を問い合わせフォームをつくりながら学ぶ

FlaskはPython製のマイクロWebフレームワークですが、いわゆるフルスタックのそれとは違い、最小限の機能のみを提供するフレームワークで、最低限の規約こそあれど、アプリの構成を自由に決められます。

また、シンプルではあるもののデータベース機能をはじめ、多くの拡張機能をサポートしており、これらの拡張機能はFlask自体に実装されているかのように簡単に利用できます。必要に応じてさまざまな拡張機能を追加することで、小規模から大規模まで多様なケースで利用できるように設計されています。

一方で、自由であるがゆえに実践的なアプリ構成に迷うこともあります。本稿では、「ユーザーからの問い合わせアプリ」の実装をモチーフに、Flaskを使った実践的なアプリケーション構成の一例を紹介します。

実践的なアプリケーション全体像

本稿では、実践的なアプリケーション構成の説明を行うため、アプリ自体の作り込みはせず組み立て方を中心に解説します。アプリ自体の作り込みに関することは、筆者が執筆に参加した「Python FlaskによるWebアプリ開発入門」に詳しく書いていますので参考にしてみてください。

本稿で作成するアプリケーション全体構成

.
├── apps
│   ├── app.py
│   ├── auth
│   │   ├── __init__.py
│   │   ├── forms.py
│   │   ├── models.py
│   │   ├── templates
│   │   │   └── auth
│   │   │       ├── base.html
│   │   │       ├── login.html
│   │   │       └── signup.html
│   │   └── views.py
│   ├── config.py
│   └── contact
│       ├── __init__.py
│       ├── templates
│       │   └── contact
│   │   │       ├── base.html
│       │       ├── complete.html
│       │       └── index.html
│       └── views.py
├── local.sqlite
├── .env
├── .env.local
├── .env.testing
└── migrations

プロジェクトセットアップ

本稿ではflaskappというプロジェクト名で仮想環境を有効にしてアプリケーションを作成します。また、Flask拡張やライブラリは以下を利用するのであらかじめインストールしておきましょう

(venv) $ pip install python-dotenv email-validator flask flask-sqlalchemy flask-migrate flask-login flask-wtf
ライブラリ 説明
python-dotenv .envファイルの値を環境変数として利用する
email-validator メールアドレス構文バリデータ
flask Flask本体
flask-sqlalchemy SQLAlchemyのFlask拡張
flask-migrate SQLAlchemyデータベースのMigrationを処理するFlask拡張
flask-login ユーザーセッション管理するFlask拡張
flask-wtf フォーム検証機能のFlask拡張

コンフィグ設定

まずはapps/config.pyを作成し以下のコンフィグを追加しましょう。実践的なアプリでは開発環境の他にstaging環境、本番環境、テスト環境などが存在するのでそれぞれ専用のコンフィグ設定を行います。

from pathlib import Path

basedir = Path(__file__).parent.parent


class BaseConfig:
    """
    BaseConfigクラス
    """

    SECRET_KEY = os.environ["SECRET_KEY"]
    WTF_CSRF_SECRET_KEY = os.environ["WTF_CSRF_SECRET_KEY"]


class LocalConfig(BaseConfig):
    """
    BaseConfigクラスを継承してLocalConfigクラスを作成する
    """

    SQLALCHEMY_DATABASE_URI = f"sqlite:///{basedir / 'local.sqlite'}"
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    SQLALCHEMY_ECHO = True


class StagingConfig(BaseConfig):
    """
    BaseConfigクラスを継承してStagingConfigクラスを作成する
    """

    SQLALCHEMY_DATABASE_URI = f"sqlite:///{basedir / 'stg.sqlite'}"
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    SQLALCHEMY_ECHO = False


class ProductionConfig(BaseConfig):
    """
    BaseConfigクラスを継承してProductionConfigクラスを作成する
    """

    SQLALCHEMY_DATABASE_URI = f"sqlite:///{basedir / 'prd.sqlite'}"
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    SQLALCHEMY_ECHO = False


class TestingConfig(BaseConfig):
    """
    BaseConfigクラスを継承してTestingConfigクラスを作成する
    """

    SQLALCHEMY_DATABASE_URI = f"sqlite:///{basedir / 'testing.sqlite'}"
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    # CSRFトークンチェックを無効にする
    WTF_CSRF_ENABLED = False


# config辞書にマッピングする
config = {
    "testing": TestingConfig,
    "local": LocalConfig,
    "stg": StagingConfig,
    "prd": ProductionConfig,
}

環境変数設定

本稿では各環境ごとに.envファイルを作成し環境毎に環境変数を切り替えられるようにします。また、デプロイする環境にもよりますが、staging環境や本番環境についてはデプロイ先サーバーの環境変数を利用する想定で本プロジェクトへは追加しません。

開発環境用

FLASK_APP=apps.app:create_app
FLASK_ENV=development
FLASK_DEBUG=1
SECRET_KEY="2AZSMss3p5QPbcY2hBsJ"
WTF_CSRF_SECRET_KEY="2AZSMss3p5QPbcY2hBsJ"

テスト用

FLASK_APP=apps.app:create_app('testing')
FLASK_ENV=development
SECRET_KEY="2AZSMss3p5QPbcY2hBsJ"
WTF_CSRF_SECRET_KEY="2AZSMss3p5QPbcY2hBsJ"

create_app 関数を利用し Flask アプリを生成する

続いてapps/app.pyに以下のコードを追加します。create_app関数はFlaskアプリを生成する関数で、これを使うことで、簡単に開発環境やステージング環境、本番環境、テスト環境を切り替えることができます。コンフィグは先程設定したconfigを読み込みます。

from flask import Flask
from apps.config import config


def create_app(config_key="local"):
    app = Flask(__name__)
    app.config.from_object(config[config_key])
    return app

これでプロジェクトのセットアップができました。

実践 1: 問い合わせアプリをつくる

ここから実際にアプリの構成に入っていきます。まずはBlueprintを利用してアプリを追加していきます。Blueprintとは、アプリケーションを分割するためのFlaskの機能です。分割することでアプリケーションが大規模になっても簡潔な状態を保つことができ、保守性が向上します。本稿で作成するアプリは構成の説明を中心とするため、問い合わせアプリと簡易的なものとしています。

Blueprintはアプリを画面のパーツ毎に分けることも可能ですが、その他、サブドメインごとに分割できたり、APIであれば境界づけられたコンテキスト単位で分割できたりと、さまざまな単位で分割可能です。適切なドメイン単位で分割することで、管理しやすい構成になるでしょう。まとめるとBlueprintは以下のような特徴を持っています。

  • アプリケーションが分割できる
  • URLプレフィックスやサブドメインを指定してほかのアプリケーションルートと区別ができる
  • Blueprint単位でテンプレートが分けられる
  • Blueprint単位で静的ファイルが分けられる

さっそく、apps/app.pyregister_blueprintを使って問い合わせアプリを追加してみましょう。

from flask import Flask

from apps.config import config


def create_app(config_key="local"):
    app = Flask(__name__)
    app.config.from_object(config[config_key])

    # 問い合わせアプリを追加する
    from apps.contact import views as contact_views

    app.register_blueprint(contact_views.contact, url_prefix="/contact")

    return app

続いてapps/contact/views.pyを作成し問い合わせ用のエンドポイントを追加します。エンドポイントは、問い合わせフォーム画面と完了画面を返すGETと問い合わせフォームを処理するPOSTを作成します。

from email_validator import EmailNotValidError, validate_email
from flask import Blueprint, flash, redirect, render_template, request, url_for

contact = Blueprint(
    "contact", __name__, template_folder="templates", static_folder="static"
)


@contact.route("/", methods=["GET", "POST"])
def index():
    if request.method == "POST":
        # フォームの値を取得してバリデーションを行う
        user_name = request.form["user_name"]
        email = request.form["email"]
        message = request.form["message"]

        is_valid = True
        if not user_name:
            flash("ユーザー名を入力してください。")
            is_valid = False

        if not email:
            flash("メールアドレスを入力してください。")
            is_valid = False

        try:
            validate_email(email)
        except EmailNotValidError:
            flash("メールアドレスが不正です。")
            is_valid = False

        if not message:
            flash("メッセージを入力してください。")
            is_valid = False

        if not is_valid:
            return redirect(url_for("contact.index"))

        # DBに保存したり、メールを送信したりする処理をする
        # --ここでは省略する
        print(user_name, email, message)

        return redirect(url_for("contact.complete"))
    return render_template("contact/index.html")


@contact.route("/complete")
def complete():
    return render_template("contact/complete.html")

エンドポイント追加後flask routesコマンドを実行するとエンドポイントの情報が確認できます。

$ flask routes

Endpoint          Methods    Rule
----------------  ---------  -------------------------------
contact.complete  GET        /contact/complete
contact.index     GET, POST  /contact/
contact.static    GET        /contact/static/<path:filename>
static            GET        /static/<path:filename>

apps/contact/templates/contact/base.htmlを作成し問い合わせアプリ用の共通テンプレートを作成します。テンプレートエンジンであるjinja2を使うと共通テンプレートが利用でき、必要なテンプレートだけ書くことが可能です。ただしBlueprintを使っている場合、templates直下にアプリ用のディレクトリを挟まないと他アプリとtemplatesのパスが被ってしまい、意図した HTML が表示されなくなるので注意してください。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>{% block title %}{% endblock %}</title>
  </head>

  <body>
    {% block content %}{% endblock %}
  </body>
</html>

contact/base.htmlを継承しapps/contact/templates/contact/index.htmlを追加し問い合わせフォーム画面を作成します。

{% extends "contact/base.html" %}

{% block title %}問い合わせフォーム{% endblock %}

{% block content %}
<div>
  <h1>問い合わせフォーム</h1>

  {% with messages = get_flashed_messages() %}
    {% if messages %}
    <ul>
      {% for message in messages %}
      <li>{{ message }}</li>
      {% endfor %}
    </ul>
    {% endif %}
  {% endwith %}

  <form
    action="{{url_for('contact.index')}}"
    method="POST"
    novalidate="novalidate"
  >
    <p>
      <label for="user_name">名前:</label><input type="text" name="user_name" />
    </p>
    <p>
      <label for="email">メールアドレス:</label><input type="text" name="email" />
    </p>
    <p>
      <label for="message">内容:</label><textarea name="message" cols="40" rows="10"></textarea>
    </p>
    <p><button type="submit">送る</button></p>
  </form>
</div>
{% endblock %}

同様にcontact/base.htmlを継承しapps/contact/templates/contact/complete.htmlを追加し問い合わせ完了画面を作成します。

{% extends "contact/base.html" %}

{% block title %}問い合わせフォーム{% endblock %}

{% block content %}
<p>お問い合わせありがとうございました。</p>
{% endblock %}

動作確認

以上で問い合わせフォームが形になりました。flask runコマンドを実行してhttp://127.0.0.1:5000/contact/にアクセスして動作確認をしてみましょう。

(venv) $ flask run

問い合わせフォームが表示され、何も入力せずに「送る」ボタンをクリックするとバリデーションが効いていることが確認できます。また、値を入力すると正常にサブミットされることが確認できます。

flask1
flask2
flask3
flask4

実践 3: 認証機能

ここからはもう少し機能を追加してみます。各アプリが利用する共通認証機能を作成し、先程作成した問い合わせアプリをログイン済みユーザーしか利用できないように変更します。

データーベースと連携

認証機能を利用するには、まずはユーザー情報が必要です。ユーザー情報をDBに保存できるよう、FlaskアプリをDBと連携します。

SQLAlchemy(ORM)・DBマイグレーション・認証機能が利用できるようapps/app.pyにFlask拡張機能であるflask_sqlalchemyflask_migrateflask_loginを読み込み、以下のように記述します。

from flask import Flask
from flask_login import LoginManager
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy

from apps.config import config

db = SQLAlchemy()
login_manager = LoginManager()
login_manager.login_view = "auth.login"
login_manager.login_message = ""


def create_app(config_key="local"):
    app = Flask(__name__)
    app.config.from_object(config[config_key])

    db.init_app(app)
    Migrate(app, db)
    login_manager.init_app(app)

    from apps.contact import views as contact_views

    app.register_blueprint(contact_views.contact, url_prefix="/contact")

    # 認証用アプリ追加
    from apps.auth import views as auth_views

    app.register_blueprint(auth_views.auth, url_prefix="/auth")

    return app

問い合わせアプリとは違い、フォームをより宣言的に実装できるよう、今回はflask-wtf拡張機能を利用します。apps/auth/form.pyを作成しましょう。

from flask_wtf import FlaskForm
from wtforms import PasswordField, StringField, SubmitField
from wtforms.validators import DataRequired, Email, Length


class SignUpForm(FlaskForm):
    username = StringField(
        "ユーザー名",
        validators=[
            DataRequired("ユーザ名は必須です。"),
            Length(1, 30, "30文字以内で入力してください。"),
        ],
    )
    email = StringField(
        "メールアドレス",
        validators=[
            DataRequired("メールアドレスは必須です。"),
            Email("メールアドレスの形式で入力してください。"),
        ],
    )
    password = PasswordField("パスワード", validators=[DataRequired("パスワードは必須です。")])
    submit = SubmitField("新規登録")


class LoginForm(FlaskForm):
    email = StringField(
        "メールアドレス",
        validators=[
            DataRequired("メールアドレスは必須です。"),
            Email("メールアドレスの形式で入力してください。"),
        ],
    )
    password = PasswordField("パスワード", validators=[DataRequired("パスワードは必須です。")])
    submit = SubmitField("ログイン")

続いてapps/auth/templates/auth/base.htmlを作成し認証機能用の共通テンプレートを作成します。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>{% block title %}{% endblock %}</title>
  </head>

  <body>
    {% block content %}{% endblock %}
  </body>
</html>

さらに、auth/base.htmlを継承しapps/auth/templates/auth/login.htmlを作成しログイン画面用のテンプレートを作成します。

{% extends "auth/base.html" %}

{% block title %}ログイン{% endblock %}

{% blockcontent %}
<div>
  <header>ログイン</header>
  <section>
    <form method="post" action="{{ url_for('auth.login') }}">
      {% for message in get_flashed_messages() %}
      <span>{{ message }}</span>
      {% endfor %}
      {{ form.csrf_token }}
      <p>{{ form.email(placeholder="メールアドレス") }}</p>
      <p>{{ form.password(placeholder="パスワード") }}</p>
      <p>{{ form.submit() }}</p>
      <p></p>
    </form>
  </section>
</div>
{% endblock %}

同様にauth/base.htmlを継承しapps/auth/templates/auth/signup.htmlを作成しサインアップ画面用のテンプレートを作成します。

{% extends "auth/base.html" %}

{% block title %}ユーザー新規登録{% endblock %}

{% block content %}
<div>
  <header>ユーザー新規登録</header>
  <section>
    <form
      method="post"
      action="{{ url_for('auth.signup', next=request.args.get('next')) }}"
    >
      {{ form.csrf_token }}
      {% for message in get_flashed_messages() %}
      <div>{{ message }}</div>
      {% endfor %}

      <p>{{ form.username(size=30, placeholder="ユーザー名") }}</p>
      <p>{{ form.email(placeholder="メールアドレス") }}</p>
      <p>{{ form.password(placeholder="パスワード") }}</p>
      <p>{{ form.submit() }}</p>
    </form>
  </section>
</div>
{% endblock %}

apps/auth/models.pyを作成しユーザーモデルを作成します。このモデルで定義した情報がDBのテーブルとして出力されます。

from datetime import datetime

from apps.app import db, login_manager
from flask_login import UserMixin
from werkzeug.security import check_password_hash, generate_password_hash


class User(db.Model, UserMixin):
    __tablename__ = "users"
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String, index=True)
    email = db.Column(db.String, unique=True, index=True)
    password_hash = db.Column(db.String)
    created_at = db.Column(db.DateTime, default=datetime.now)
    updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)

    @property
    def password(self):
        raise AttributeError("読み取り不可")

    @password.setter
    def password(self, password):
        self.password_hash = generate_password_hash(password)

    def verify_password(self, password):
        return check_password_hash(self.password_hash, password)

    def is_duplicate_email(self):
        return User.query.filter_by(email=self.email).first() is not None


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

apps/auth/views.pyを追加し認証用のエンドポイントを追加します。エンドポイントは、ログイン画面と新規登録画面を返すGETと、ログイン / 新規作成処理 / ログアウト処理をするPOSTを作成します。

from flask import (
    Blueprint,
    render_template,
    redirect,
    url_for,
    flash,
    request,
)
from apps.auth.forms import SignUpForm, LoginForm
from apps.auth.models import User
from apps.app import db
from flask_login import login_user, logout_user

auth = Blueprint("auth", __name__, template_folder="templates", static_folder="static")


@auth.route("/signup", methods=["GET", "POST"])
def signup():
    form = SignUpForm()

    if form.validate_on_submit():
        user = User(
            username=form.username.data,
            email=form.email.data,
            password=form.password.data,
        )

        if user.is_duplicate_email():
            flash("指定のメールアドレスは登録済みです")
            return redirect(url_for("auth.signup"))

        db.session.add(user)
        db.session.commit()

        login_user(user)

        _next = request.args.get("next")
        if _next is None or not _next.startswith("/"):
            _next = url_for("contact.index")
        return redirect(_next)

    return render_template("auth/signup.html", form=form)


@auth.route("/login", methods=["GET", "POST"])
def login():
    form = LoginForm()

    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()

        if user is not None and user.verify_password(form.password.data):
            login_user(user)
            return redirect(url_for("contact.index"))

        flash("メールアドレスかパスワードか不正です")
    return render_template("auth/login.html", form=form)


@auth.route("/logout", methods=["POST"])
def logout():
    logout_user()
    return redirect(url_for("auth.login"))

flask_sqlalchemyflask_migrateを連携するとflask dbコマンドが利用可能になります。下記コマンドを実行してテーブルを生成します。

// 初期化
(venv) $ flask db init
// テーブル生成するためのコードを生成する
(venv) $ flask db migrate
// テーブル生成する
(venv) $ flask db upgrade

実行するとusersテーブルができていることが確認できます。なお、本稿ではSQLiteを利用していますが、MySQLなど他のDBを利用することも可能です。画像はVSCodeで確認したものです。

flask5

問い合わせアプリをログイン必須にする

仕上げに、問い合わせアプリをログイン済みユーザーのみ利用可能にしてみます。apps/contact/views.pyの各エンドポイントに@login_requiredデコレータを追加します。

# 省略
from flask_login import login_required

# 省略
@contact.route("/", methods=["GET", "POST"])
@login_required
def index():
    # 省略

@contact.route("/complete")
@login_required
def complete():
    # 省略

ログイン状態がわかるように問い合わせアプリのapps/contact/templatesにログイン状態を表示します。

<!-- 省略 -->
<body>
  <div>
    {% if current_user.is_authenticated %}
    <p>
      <span>{{ current_user.username }}</span> -
      <span><a href="{{ url_for('auth.logout') }}">ログアウト</a></span>
    </p>
    {% endif %}
  </div>
  <!-- 省略 -->
</body>

動作確認

ログイン必須化は以上の通りです。最後に動作を確認してみましょう。http://127.0.0.1:5000/contactにアクセスするとログイン画面にリダイレクトされるようになりました。

flask6

http://127.0.0.1:5000/auth/signupにアクセスするとユーザー新規登録画面が表示され、ユーザー情報を入力すると問い合わせ画面が表示されるように変わりました。

flask7
flask8

まとめ

以上の通り、問い合わせアプリ作成、そして認証機能を追加しました。ここまでご覧いただけた方は、適切なドメインでアプリを追加することでスケールしやすいプロジェクト構成をFlaskで構築できることが感じていただけたのではないでしょうか。

今回紹介したアプリ以外にも、ちょっとしたアイデアをアプリとして形にする際、DBや認証機能、Formなどの基本を把握していれば簡単に応用できると思います。ぜひ、みなさんのイメージするアプリを手元でつくってみてください。

佐藤昌基(MASAKI Satou)Twitter: @taisa831

flask9
テックタッチ株式会社所属。SIerを経てアライドアーキテクツにてWeb広告・SNSマーケティング関連のWebサービス開発を経験し、テックリードとして複数のWebサービス立ち上げに従事。不動産テック企業にてCTOを経た後、現職。近著(共著)に『Python FlaskによるWebアプリ開発入門 物体検知アプリ&機械学習APIの作り方』がある。

編集:はてな編集部

若手ハイキャリアのスカウト転職