Michi's Tech Blog

一人前のWebエンジニアを目指して

Flaskを無理やりMVCっぽく運用してみた

こんにちは!
スマレジ テックファームのMichiです!

みなさんはFlaskというフレームワークをご存知でしょうか?
FlaskはPython製のマイクロWebフレームワークで、MVTモデル(Model, View, Template)というアーキテクチャを採用しています。

ですが、「やっぱり使い慣れたMVCの枠組みで実装したい!」ということで、今回はFlaskを無理やりMVCっぽく運用する方法を解説します。

解説

アプリのサンプルはFlask公式ドキュメントのチュートリアルより、記事用に一部改変したものを使います。

ディレクトリ構成

.
├── app
│   ├── controllers
│   └── models
│   └── static
│   └── templates
│   └── __init__.py
│   └── auth.py
│   └── config.py
│   └── database.py
│   └── route.py
├── migrations
├── .env
├── local.sqlite
├── requirements.txt

基本設定

公式ドキュメントではすべての設定を__init__.pyファイルに書きますが、設定ごとに分割したいのでファイルを分けます。

認証系

auth.py

from flask import Flask
from flask_login import LoginManager


login_manager = LoginManager()

def init_auth(app: Flask):
    login_manager.login_view = 'login' 
    login_manager.login_message = ''
    login_manager.init_app(app)

コンフィグ

config.py

from flask import Flask
from pathlib import Path


def set_config(app: Flask):
    app.config.update(
        SECRET_KEY='dev',
        WTF_CSRF_SECRET_KEY='csrf',
        SQLALCHEMY_DATABASE_URI=f'sqlite:///{Path(__file__).parent.parent / "local.sqlite"}',
        SQLALCHEMY_TRACK_MODIFICATIONS=False,
    )

データベース

database.py

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


db = SQLAlchemy()

def init_db(app: Flask):
    db.init_app(app)
    Migrate(app, db)

Flaskインスタンスの作成

__init__.py

from flask import Flask
from flask_wtf.csrf import CSRFProtect

from app.auth import init_auth
from app.database import init_db
from app.config import set_config
from app.route import create_route


def create_app(testing: bool=False):
    app = Flask(__name__)

    csrf = CSRFProtect()
    csrf.init_app(app)

    set_config(app)
    init_db(app)
    init_auth(app)
    create_route(app)

    if testing:
        app.config.update(TESTING=True)

    return app

ここまでで作成した設定ファイルの関数を__init__.py側で呼び出し、引数としてFlaskインスタンスを渡します。 これでアプリの初期設定が完了しました。
尚、create_route(app)については解説がまだですが、後ほど解説します。

モデル

モデルも公式ドキュメントでは一つのファイルにすべてのモデルを定義していますが、今回はテーブルごとに分割します。

├── models
│   └── __init__.py
│   └── article.py
│   └── user.py

modelsディレクトリ直下にそれぞれのモデルと__init__.pyファイルを用意します。

article.py

from app.database import db

from datetime import datetime


class Article(db.Model):
    __tablename__ = 'articles'

    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    title = db.Column(db.String, nullable=True)
    body = db.Column(db.String, nullable=True)
    created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)

    user = db.relationship('User', backref='articles')

user.py

from app.auth import login_manager
from app.database import db

from flask_login import UserMixin


class User(db.Model, UserMixin):
    __tablename__ = 'users'

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String, unique=True, nullable=True)
    password = db.Column(db.String, nullable=True)


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

__init__.py

from .article import Article
from .user import User


__all__ = [Article, User]

__init__.pyに定義したモデルを記述するのを忘れないように。
ここに記述しておかないと、Flaskが各モデルを認識してくれません。

ルーティング

公式ドキュメントではルーティングとビジネスロジックをすべて同じファイル内に記述していますが、責務分離を明確にしたいので、ルーティングとコントローラにそれぞれ分割します。
まずはルーティングから。appディレクトリ直下にroute.pyファイルを置きます。

route.py

from flask import Flask
from flask_login import login_required

from app.controllers import article_controller, auth_controller, user_controller


def create_route(app: Flask):
    @app.route('/signup', methods=['GET', 'POST'])
    def signup():
        return user_controller.signup()

    @app.route('/login', methods=['GET', 'POST'])
    def login():
        return auth_controller.login()

    @app.route('/logout', methods=['GET'])
    def logout():
        return auth_controller.logout()

    @app.route('/', methods=['GET'])
    @login_required
    def index():
        return article_controller.read()

    @app.route('/create', methods=['GET', 'POST'])
    @login_required
    def create():
        return article_controller.create()

    @app.route('/<int:id>/update', methods=['GET', 'POST'])
    @login_required
    def update(id):
        return article_controller.update(id)

    @app.route('/<int:id>/delete', methods=['POST'])
    @login_required
    def delete(id):
        return article_controller.delete(id)

全体を括っているcreate_route関数は、最初に設定した__init__.pyファイルからFlaskインスタンスを渡して呼び出します。

コントローラ

├── controllers
│   └── article_controller.py
│   └── auth_controller.py
│   └── user_controller.py

controllersディレクトリの中身はこんな感じ。
記事のCRUD(article)、認証系(auth)、ユーザー登録(user)でファイルを分割してみました。

article_controller.py

from flask import (
    abort,
    flash,
    redirect,
    render_template,
    request,
    url_for
)
from flask_login import current_user

from app.database import db
from app.models import Article


def read():
    articles = Article.query.order_by('created_at').all()
    return render_template('index.html', articles=articles)


def create():
    if request.method == 'POST':
        title = request.form['title']
        body = request.form['body']
        error = None

        if not title:
            error = 'Title is required.'

        if error is not None:
            flash(error)
        else:
            article = Article(title=title, body=body, user_id=current_user.id)
            db.session.add(article)
            db.session.commit()
            return redirect(url_for('index'))

    return render_template('create.html')


def update(id):
    article = Article.query.filter(Article.id == id).first()

    if article is None:
        abort(404, f"Post id {id} doesn't exist.")
    elif article.user_id != current_user.id:
        abort(403)

    if request.method == 'POST':
        title = request.form['title']
        body = request.form['body']
        error = None

        if not title:
            error = 'Title is required.'

        if error is not None:
            flash(error)
        else:
            article.title = title
            article.body = body
            db.session.commit()
            return redirect(url_for('index'))

    return render_template('update.html', article=article)


def delete(id):
    query = Article.query.filter(Article.id == id)
    article = query.first()

    if article is None:
        abort(404, f"Article id {id} doesn't exist.")
    elif article.user_id != current_user.id:
        abort(403)

    query.delete()
    db.session.commit()
    return redirect(url_for('index'))

auth_controller.py

from flask import (
    flash,
    redirect,
    render_template,
    request,
    url_for,
)
from flask_login import login_user, logout_user
from werkzeug.security import check_password_hash

from app.models import User


def login():
    if request.method == 'POST':
        name = request.form['name']
        password = request.form['password']
        error = None
        user = User.query.filter(User.name == name).first()

        if user is None:
            error = 'Incorrect name.'
        elif not check_password_hash(user.password, password):
            error = 'Incorrect password.'

        if error is None:
            login_user(user)
            return redirect(url_for('index'))

        flash(error)

    return render_template('login.html')


def logout():
    logout_user()
    return redirect(url_for('index'))

user_controller.py

from flask import (
    flash,
    redirect,
    render_template,
    request,
    url_for,
)
from flask_login import login_user
from werkzeug.security import generate_password_hash

from app.database import db
from app.models import User


def signup():
    if request.method == 'POST':
        name = request.form['name']
        password = request.form['password']
        error = None

        if not name:
            error = 'name is required.'
        elif not password:
            error = 'Password is required.'
        elif User.query.filter(User.name == name).first() is not None:
            error = f"User {name} is already registered."

        if error is None:
            user = User(
                name=name,
                password=generate_password_hash(password)
            )
            db.session.add(user)
            db.session.commit()
            login_user(user)
            return redirect(url_for('index'))

        flash(error)

    return render_template('signup.html')

あとは、templatesstaticにHTMLとCSSを書いて、マイグレーションを実行すればアプリ完成です!

まとめ

FlaskをMVCっぽく使う方法について解説しました。

今回はコントローラにDBとのやり取りを記述しましたが、これをサービス層に切り出すとより明確な責務分離が行えます。
(私も実務ではservicesディレクトリを切って運用を行っています)

ここまでやるならDjango使えばええやん