Tổ chức dự án Flask với Blueprint và application factory

Flask không áp đặt cấu trúc, nên khi ứng dụng lớn dần bạn phải tự tổ chức. Blueprint và mẫu application factory là hai công cụ giúp dự án Flask không biến thành một file khổng lồ.

Vấn đề của cách viết đơn giản

app = Flask(__name__)

@app.route("/")
def trang_chu(): ...

@app.route("/bai-viet")
def danh_sach(): ...
# ... 50 route nữa trong cùng một file

Cách này còn gây khó khi viết kiểm thử: đối tượng app được tạo ngay lúc import, nên không cấu hình khác đi cho môi trường test được.

Blueprint: chia theo chức năng

# ung_dung/blog/routes.py
from flask import Blueprint, render_template, request

bp = Blueprint("blog", __name__, url_prefix="/blog")

@bp.route("/")
def danh_sach():
    trang = request.args.get("trang", 1, type=int)
    bai_viet = BaiViet.query.paginate(page=trang, per_page=12)
    return render_template("blog/danh_sach.html", bai_viet=bai_viet)

@bp.route("/<slug>")
def chi_tiet(slug):
    bai = BaiViet.query.filter_by(slug=slug).first_or_404()
    return render_template("blog/chi_tiet.html", bai=bai)

Sinh URL bằng tên blueprint:

url_for("blog.chi_tiet", slug="bai-dau-tien")
<a href="{{ url_for('blog.chi_tiet', slug=bai.slug) }}">{{ bai.tieu_de }}</a>

Application factory

# ung_dung/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate

db = SQLAlchemy()
migrate = Migrate()

def tao_ung_dung(cau_hinh="ung_dung.cau_hinh.SanXuat"):
    app = Flask(__name__)
    app.config.from_object(cau_hinh)

    db.init_app(app)
    migrate.init_app(app, db)

    from ung_dung.blog.routes import bp as blog_bp
    from ung_dung.tai_khoan.routes import bp as tk_bp
    app.register_blueprint(blog_bp)
    app.register_blueprint(tk_bp)

    from ung_dung.loi import dang_ky_xu_ly_loi
    dang_ky_xu_ly_loi(app)

    return app

Giờ bạn tạo được nhiều phiên bản ứng dụng với cấu hình khác nhau — điều này làm việc viết kiểm thử trở nên đơn giản:

import pytest
from ung_dung import tao_ung_dung, db

@pytest.fixture
def app():
    app = tao_ung_dung("ung_dung.cau_hinh.KiemThu")
    with app.app_context():
        db.create_all()
        yield app
        db.drop_all()

@pytest.fixture
def client(app):
    return app.test_client()

def test_trang_chu(client):
    r = client.get("/")
    assert r.status_code == 200

Cấu hình theo môi trường

# ung_dung/cau_hinh.py
import os

class Chung:
    SECRET_KEY = os.environ["SECRET_KEY"]
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True, "pool_recycle": 3600}

class PhatTrien(Chung):
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = "sqlite:///phat_trien.db"

class KiemThu(Chung):
    TESTING = True
    SECRET_KEY = "khoa-kiem-thu"
    SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"

class SanXuat(Chung):
    DEBUG = False
    SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"]
    SESSION_COOKIE_SECURE = True
    SESSION_COOKIE_HTTPONLY = True
    SESSION_COOKIE_SAMESITE = "Lax"

Dùng os.environ["SECRET_KEY"] chứ đừng dùng .get() kèm giá trị mặc định. Nếu thiếu biến môi trường, ứng dụng phải dừng ngay chứ không được âm thầm chạy với khóa mặc định — đó là lỗ hổng nghiêm trọng.

Cấu trúc thư mục

du_an/
├── ung_dung/
│   ├── __init__.py          # factory
│   ├── cau_hinh.py
│   ├── mo_hinh.py
│   ├── loi.py
│   ├── blog/
│   │   ├── __init__.py
│   │   ├── routes.py
│   │   └── form.py
│   ├── tai_khoan/
│   ├── templates/
│   └── static/
├── tests/
├── migrations/
└── wsgi.py
# wsgi.py
from ung_dung import tao_ung_dung
app = tao_ung_dung()
gunicorn wsgi:app --workers 4

Xử lý lỗi tập trung

# ung_dung/loi.py
from flask import render_template, jsonify, request

def dang_ky_xu_ly_loi(app):
    @app.errorhandler(404)
    def khong_tim_thay(e):
        if request.path.startswith("/api/"):
            return jsonify(loi="Không tìm thấy"), 404
        return render_template("loi/404.html"), 404

    @app.errorhandler(500)
    def loi_may_chu(e):
        app.logger.exception("Lỗi máy chủ")
        if request.path.startswith("/api/"):
            return jsonify(loi="Lỗi máy chủ"), 500
        return render_template("loi/500.html"), 500

Lệnh dòng lệnh riêng

import click

@app.cli.command("tao-admin")
@click.argument("email")
def tao_admin(email):
    """Tạo tài khoản quản trị."""
    nd = NguoiDung(email=email, la_admin=True)
    db.session.add(nd)
    db.session.commit()
    click.echo(f"Đã tạo {email}")
flask tao-admin admin@example.com

Trước và sau mỗi request

@app.before_request
def truoc_moi_request():
    g.thoi_diem = time.perf_counter()

@app.after_request
def sau_moi_request(response):
    thoi_gian = time.perf_counter() - g.thoi_diem
    response.headers["X-Thoi-Gian"] = f"{thoi_gian:.3f}"
    if thoi_gian > 1.0:
        app.logger.warning("Request chậm: %s mất %.2fs", request.path, thoi_gian)
    return response

Ghi log các request chậm là cách rẻ tiền để phát hiện vấn đề hiệu năng trước khi người dùng phàn nàn.

Vài điểm bảo mật

  • Đừng bao giờ để DEBUG = True trên môi trường thật — trang lỗi của Flask cho phép chạy code Python tùy ý
  • Jinja2 tự escape biến trong template .html, nhưng bộ lọc |safe tắt bảo vệ đó
  • Dùng Flask-WTF để có bảo vệ CSRF cho biểu mẫu
  • Đặt SESSION_COOKIE_SECUREHTTPONLY khi chạy qua HTTPS