Chuyển tới nội dung chính

1 bài viết được gắn thẻ "Flask"

Xem tất cả thẻ

Lộ trình học Python Flask Backend

· 20 phút để đọc

Flask là framework web Python nhỏ nhất trong ba framework lớn (Flask, Django, FastAPI). Chính vì nhỏ mà nó là lựa chọn tốt nhất để hiểu web hoạt động ra sao — bạn tự tay lắp từng bộ phận, không có "phép thuật" che đi bản chất.

Nhược điểm của điều đó: bạn phải tự chọn ORM, tự chọn thư viện auth, tự tổ chức project. Đây là lộ trình giúp bạn làm đúng ngay từ đầu, tránh những sai lầm khiến codebase Flask trở thành mớ hỗn độn sau 6 tháng.


🎯 Flask phù hợp với ai?​

Bạn nên học Flask nếuBạn nên chọn framework khác nếu
Muốn hiểu bản chất web trước khi dùng framework lớnCần làm sản phẩm lớn gấp (→ Django)
Làm API nhỏ, microserviceCần hiệu năng cực cao, async native (→ FastAPI)
Cần kiểm soát tuyệt đối từng thành phầnCần admin panel sẵn (→ Django)
Làm prototype nhanhLàm hệ thống phục vụ model ML (→ FastAPI)
Duy trì hệ thống Flask đang có ở công ty—

💡 Lời khuyên thực tế: học Flask trước, rồi học FastAPI hoặc Django sau. Người học Flask trước hiểu web sâu hơn rõ rệt so với người nhảy thẳng vào Django.


🗓 Lộ trình 16 tuần​

Tuần 1–3    Python nền tảng đủ để làm web
Tuần 4–5 Flask cơ bản: route, template, form
Tuần 6–7 Cấu trúc project chuyên nghiệp (Application Factory + Blueprint)
Tuần 8–9 Database: SQLAlchemy + Alembic migration
Tuần 10–11 REST API + validation + error handling
Tuần 12 Xác thực: session và JWT
Tuần 13–14 Testing với pytest
Tuần 15 Docker + deploy production
Tuần 16 Dự án tổng hợp + phỏng vấn

🐍 Tuần 1–3: Python nền tảng cho web​

Bạn không cần biết mọi thứ về Python, nhưng phải chắc những phần sau vì chúng xuất hiện mỗi ngày trong code Flask:

1. Dict — cấu trúc dữ liệu trung tâm của web:

# JSON request/response về bản chất là dict
du_lieu = {"ten": "Minh", "tuoi": 25, "so_thich": ["đọc sách", "chạy bộ"]}

# Các thao tác phải thành thạo
du_lieu.get("email", "chưa có") # lấy có giá trị mặc định
du_lieu.setdefault("diem", 0) # gán nếu chưa tồn tại
{**du_lieu, "tuoi": 26} # tạo dict mới, ghi đè
{k: v for k, v in du_lieu.items() if k != "so_thich"} # dict comprehension

2. Hàm, decorator — Flask dùng decorator ở khắp nơi:

from functools import wraps
import time
import logging

logger = logging.getLogger(__name__)


def do_thoi_gian(f):
"""Decorator đo thời gian chạy — bạn sẽ tự viết nhiều cái như thế này."""
@wraps(f)
def wrapper(*args, **kwargs):
bat_dau = time.perf_counter()
try:
return f(*args, **kwargs)
finally:
thoi_gian = time.perf_counter() - bat_dau
logger.info("%s chạy mất %.4fs", f.__name__, thoi_gian)
return wrapper


@do_thoi_gian
def xu_ly_du_lieu(ban_ghi):
return [r for r in ban_ghi if r["active"]]

Hiểu @wraps và *args/**kwargs là điều kiện để đọc hiểu @app.route, @login_required, @cache.

3. Xử lý ngoại lệ và làm việc với file:

from pathlib import Path
import json

CAU_HINH = Path("config.json")


def doc_cau_hinh() -> dict:
if not CAU_HINH.exists():
return {}
try:
return json.loads(CAU_HINH.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise ValueError(f"config.json không hợp lệ: {e}") from e

Tiêu chí hết tuần 3: viết được script 200 dòng chia thành nhiều hàm, dùng dict/list thành thạo, hiểu decorator đơn giản.


🌱 Tuần 4–5: Flask cơ bản​

python -m venv .venv
.venv\Scripts\activate
pip install flask python-dotenv
# app.py
from flask import Flask, render_template, request, redirect, url_for, flash, session

app = Flask(__name__)
app.secret_key = "đọc-từ-env-trong-thực-tế"

GHI_CHU = []


@app.route("/")
def trang_chu():
return render_template("index.html", ghi_chu=GHI_CHU)


@app.route("/them", methods=["GET", "POST"])
def them_ghi_chu():
if request.method == "POST":
noi_dung = request.form.get("noi_dung", "").strip()

if not noi_dung:
flash("Bạn chưa nhập nội dung", "loi")
elif len(noi_dung) > 500:
flash("Nội dung quá dài (tối đa 500 ký tự)", "loi")
else:
GHI_CHU.append({"id": len(GHI_CHU) + 1, "noi_dung": noi_dung})
flash("Đã thêm ghi chú", "thanh_cong")
return redirect(url_for("trang_chu"))

return render_template("them.html")

Ba cơ chế Flask cần nắm chắc:

Cơ chếVai tròGhi nhớ
url_for()Sinh URL từ tên hàmLuôn dùng thay vì viết URL cứng
render_template()Render Jinja2Template phải trong templates/
redirect() + flash()Chuyển trang + thông báoPattern POST-redirect-GET

Jinja2 — bảng cú pháp đầy đủ:

{% extends "base.html" %}          {# kế thừa layout #}
{% block noi_dung %}{% endblock %} {# định nghĩa vùng ghi đè #}

{{ bien }} {# in giá trị #}
{{ bien|default("N/A") }} {# filter với mặc định #}
{{ gia|round(2) }} {# filter #}

{% if dieu_kien %}...{% elif %}...{% else %}...{% endif %}
{% for item in ds %}...{% endfor %}
{% for item in ds %}{{ loop.index }} — {{ item }}{% endfor %}

{% include "partial.html" %} {# nhúng file #}
{% macro the_hien(x) %}...{% endmacro %} {# macro #}

⚠️ Khác biệt với Django: Jinja2 (Flask) dùng {{ ham() }} được, Django template thì không. Jinja2 cũng không tự escape biến trong một số ngữ cảnh — luôn cẩn thận với |safe.

Tiêu chí hết tuần 5: làm được app CRUD 1 bảng dùng template, có flash message và validate form.


🏗️ Tuần 6–7: Cấu trúc project chuyên nghiệp​

Đây là bước ngoặt phân biệt người viết Flask "đồ chơi" và người viết Flask đi làm.

Sai lầm của 90% người mới​

❌ app.py (2000 dòng, mọi thứ trong đây)

Vấn đề: không test được, không chia việc được, import vòng, không có config môi trường.

Cấu trúc đúng — Application Factory + Blueprint​

project/
├── app/
│ ├── __init__.py ← application factory
│ ├── config.py ← cấu hình theo môi trường
│ ├── extensions.py ← khởi tạo db, migrate, login...
│ ├── models/
│ │ ├── __init__.py
│ │ └── ghi_chu.py
│ ├── blueprints/
│ │ ├── __init__.py
│ │ ├── main.py
│ │ └── api.py
│ ├── templates/
│ │ ├── base.html
│ │ └── main/index.html
│ └── static/
├── migrations/ ← do Alembic sinh tự động
├── tests/
│ ├── conftest.py
│ └── test_ghi_chu.py
├── .env
├── requirements.txt
├── Dockerfile
└── wsgi.py

app/config.py:

import os
from pathlib import Path

GOC = Path(__file__).resolve().parent.parent


class Config:
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-key-doi-trong-production")
SQLALCHEMY_TRACK_MODIFICATIONS = False
JSON_SORT_KEYS = False


class DevConfig(Config):
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///dev.db")


class TestConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"


class ProdConfig(Config):
DEBUG = False
SQLALCHEMY_DATABASE_URI = os.environ["DATABASE_URL"] # bắt buộc có
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"


CAU_HINH = {"dev": DevConfig, "test": TestConfig, "prod": ProdConfig}

app/extensions.py:

from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_cors import CORS

db = SQLAlchemy()
migrate = Migrate()
cors = CORS()

app/__init__.py:

import os
import logging
from flask import Flask

from .config import CAU_HINH
from .extensions import db, migrate, cors


def create_app(moi_truong: str | None = None) -> Flask:
"""Application factory — luôn dùng pattern này."""
app = Flask(__name__)
moi_truong = moi_truong or os.environ.get("FLASK_ENV", "dev")
app.config.from_object(CAU_HINH[moi_truong])

db.init_app(app)
migrate.init_app(app, db)
cors.init_app(app, resources={r"/api/*": {"origins": "*"}})

from .blueprints.main import bp as main_bp
from .blueprints.api import bp as api_bp
app.register_blueprint(main_bp)
app.register_blueprint(api_bp, url_prefix="/api")

cau_hinh_logging(app)
dang_ky_error_handler(app)

return app


def cau_hinh_logging(app: Flask) -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
)


def dang_ky_error_handler(app: Flask) -> None:
@app.errorhandler(404)
def khong_tim_thay(e):
return {"loi": "Không tìm thấy tài nguyên"}, 404

@app.errorhandler(500)
def loi_server(e):
app.logger.exception("Lỗi 500")
return {"loi": "Lỗi hệ thống"}, 500

app/blueprints/api.py:

from flask import Blueprint, jsonify

bp = Blueprint("api", __name__)


@bp.get("/suc-khoe")
def suc_khoe():
return jsonify({"trang_thai": "ok"})


@bp.get("/ghi-chu")
def danh_sach():
return jsonify({"ghi_chu": []})

Vì sao Application Factory quan trọng:

  1. Test được — mỗi test tạo app riêng với config riêng.
  2. Nhiều môi trường — dev/test/prod dùng cùng code, khác config.
  3. Tránh import vòng — extension khởi tạo riêng, gắn sau.
  4. Chạy nhiều instance — cần cho testing song song.

Tiêu chí hết tuần 7: tạo mới project Flask theo cấu trúc trên trong 15 phút, không cần xem mẫu.


🗄️ Tuần 8–9: Database với SQLAlchemy và Alembic​

pip install flask-sqlalchemy flask-migrate
flask --app wsgi db init # chỉ chạy lần đầu
flask --app wsgi db migrate -m "tạo bảng ghi_chu"
flask --app wsgi db upgrade

app/models/ghi_chu.py:

from datetime import datetime, timezone
from sqlalchemy import String, Text, Boolean, DateTime, func
from sqlalchemy.orm import Mapped, mapped_column

from ..extensions import db


class GhiChu(db.Model):
__tablename__ = "ghi_chu"

id: Mapped[int] = mapped_column(primary_key=True)
tieu_de: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
noi_dung: Mapped[str] = mapped_column(Text, default="")
da_xong: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
ngay_tao: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
ngay_cap_nhat: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), onupdate=lambda: datetime.now(timezone.utc)
)

def to_dict(self) -> dict:
return {
"id": self.id,
"tieu_de": self.tieu_de,
"noi_dung": self.noi_dung,
"da_xong": self.da_xong,
"ngay_tao": self.ngay_tao.isoformat() if self.ngay_tao else None,
}

def __repr__(self) -> str:
return f"<GhiChu {self.id} {self.tieu_de!r}>"

Ba quy tắc vàng về migration:

  1. Không bao giờ sửa file trong migrations/ bằng tay.
  2. Luôn backup trước khi db upgrade trên production.
  3. Mỗi migration = một thay đổi logic. Đừng gộp 5 thay đổi vào 1 migration.

Truy vấn — luôn dùng cách hiện đại (SQLAlchemy 2.0 style):

from sqlalchemy import select
from .extensions import db
from .models.ghi_chu import GhiChu


def lay_tat_ca(chi_chua_xong: bool = False) -> list[GhiChu]:
stmt = select(GhiChu).order_by(GhiChu.ngay_tao.desc())
if chi_chua_xong:
stmt = stmt.where(GhiChu.da_xong.is_(False))
return list(db.session.scalars(stmt))


def tim_theo_tu_khoa(tu_khoa: str) -> list[GhiChu]:
stmt = select(GhiChu).where(GhiChu.tieu_de.ilike(f"%{tu_khoa}%"))
return list(db.session.scalars(stmt))

⚠️ Dùng ilike với tham số — SQLAlchemy tự tham số hoá, an toàn khỏi SQL injection. Nhưng nếu bạn dùng db.session.execute(f"SELECT ... WHERE x = '{value}'") thì đó là lỗ hổng.

Tiêu chí hết tuần 9: tạo model, migrate, CRUD được từ Flask shell, biết dùng ilike, order_by, limit.


🔌 Tuần 10–11: REST API hoàn chỉnh​

# app/blueprints/api.py
from flask import Blueprint, jsonify, request
from marshmallow import Schema, fields, validate, ValidationError

from ..extensions import db
from ..models.ghi_chu import GhiChu

bp = Blueprint("api", __name__)


class GhiChuSchema(Schema):
tieu_de = fields.Str(required=True, validate=validate.Length(min=1, max=200))
noi_dung = fields.Str(load_default="", validate=validate.Length(max=5000))
da_xong = fields.Bool(load_default=False)


schema = GhiChuSchema()
schema_nhieu = GhiChuSchema(many=True)


@bp.get("/ghi-chu")
def danh_sach():
chi_chua_xong = request.args.get("chua_xong", "").lower() in ("1", "true", "yes")
try:
gioi_han = min(int(request.args.get("gioi_han", 50)), 200)
except ValueError:
return jsonify({"loi": "gioi_han phải là số nguyên"}), 400

ds = lay_tat_ca(chi_chua_xong)[:gioi_han]
return jsonify({"du_lieu": schema_nhieu.dump(ds), "so_luong": len(ds)})


@bp.get("/ghi-chu/<int:ghi_chu_id>")
def chi_tiet(ghi_chu_id: int):
gc = db.session.get(GhiChu, ghi_chu_id)
if gc is None:
return jsonify({"loi": f"Không tìm thấy ghi chú id={ghi_chu_id}"}), 404
return jsonify(schema.dump(gc))


@bp.post("/ghi-chu")
def tao_moi():
try:
du_lieu = schema.load(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"loi": "Dữ liệu không hợp lệ", "chi_tiet": e.messages}), 422

gc = GhiChu(**du_lieu)
db.session.add(gc)
db.session.commit()
return jsonify(schema.dump(gc)), 201


@bp.put("/ghi-chu/<int:ghi_chu_id>")
def cap_nhat(ghi_chu_id: int):
gc = db.session.get(GhiChu, ghi_chu_id)
if gc is None:
return jsonify({"loi": "Không tìm thấy"}), 404

try:
du_lieu = schema.load(request.get_json(silent=True) or {}, partial=True)
except ValidationError as e:
return jsonify({"loi": "Dữ liệu không hợp lệ", "chi_tiet": e.messages}), 422

for khoa, gia_tri in du_lieu.items():
setattr(gc, khoa, gia_tri)
db.session.commit()
return jsonify(schema.dump(gc))


@bp.delete("/ghi-chu/<int:ghi_chu_id>")
def xoa(ghi_chu_id: int):
gc = db.session.get(GhiChu, ghi_chu_id)
if gc is None:
return jsonify({"loi": "Không tìm thấy"}), 404
db.session.delete(gc)
db.session.commit()
return "", 204

Điểm mấu chốt cần nhớ:

ViệcCách làm đúngCách làm sai
ValidateMarshmallow/Pydantic → 422Tự viết if rải rác
Trả lỗiJSON có key loi + mã trạng thái đúngTrả 200 kèm success: false
Commit DBCommit một lần, có rollbackCommit nhiều lần trong 1 request
Phân tranggioi_han + offset, có trần tối đaTrả hết 1 triệu bản ghi

🔐 Tuần 12: Xác thực và phân quyền​

pip install flask-login werkzeug pyjwt
from flask_login import UserMixin, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash


class NguoiDung(db.Model, UserMixin):
__tablename__ = "nguoi_dung"

id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(120), unique=True, index=True)
mat_khau_hash: Mapped[str] = mapped_column(String(255))
vai_tro: Mapped[str] = mapped_column(String(20), default="user")

def dat_mat_khau(self, mat_khau: str) -> None:
self.mat_khau_hash = generate_password_hash(mat_khau, method="scrypt")

def kiem_tra_mat_khau(self, mat_khau: str) -> bool:
return check_password_hash(self.mat_khau_hash, mat_khau)

@property
def la_admin(self) -> bool:
return self.vai_tro == "admin"

Ba lỗi bảo mật nghiêm trọng nhất khi làm auth:

  1. Lưu mật khẩu dạng thô hoặc dùng MD5/SHA1. Phải dùng werkzeug.security (scrypt) hoặc bcrypt/argon2.
  2. Không giới hạn số lần đăng nhập sai. Thêm rate limit → chống brute force.
  3. Tin tưởng vai_tro từ client gửi lên. Vai trò phải lấy từ server, không bao giờ từ request body.
# ⚠️ LỖI KINH ĐIỂN — không bao giờ làm thế này
@app.post("/api/xoa")
def xoa():
du_lieu = request.get_json()
if du_lieu.get("vai_tro") == "admin": # ❌ client tự khai mình là admin!
...

🧪 Tuần 13–14: Testing với pytest​

pip install pytest pytest-cov pytest-flask

tests/conftest.py:

import pytest
from app import create_app
from app.extensions import db as _db


@pytest.fixture(scope="session")
def app():
app = create_app("test")
with app.app_context():
_db.create_all()
yield app
_db.drop_all()


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


@pytest.fixture(autouse=True)
def sach_database(app):
"""Mỗi test chạy trên database sạch."""
yield
with app.app_context():
_db.session.rollback()
_db.drop_all()
_db.create_all()

tests/test_ghi_chu.py:

def test_danh_sach_rong(client):
r = client.get("/api/ghi-chu")
assert r.status_code == 200
assert r.get_json()["so_luong"] == 0


def test_tao_ghi_chu(client):
r = client.post("/api/ghi-chu", json={"tieu_de": "Việc cần làm"})
assert r.status_code == 201
assert r.get_json()["tieu_de"] == "Việc cần làm"


def test_tao_thieu_tieu_de(client):
r = client.post("/api/ghi-chu", json={"noi_dung": "abc"})
assert r.status_code == 422
assert "tieu_de" in r.get_json()["chi_tiet"]


def test_lay_khong_ton_tai(client):
r = client.get("/api/ghi-chu/99999")
assert r.status_code == 404


def test_xoa_thanh_cong(client):
tao = client.post("/api/ghi-chu", json={"tieu_de": "X"}).get_json()
r = client.delete(f"/api/ghi-chu/{tao['id']}")
assert r.status_code == 204
assert client.get(f"/api/ghi-chu/{tao['id']}").status_code == 404
pytest -v --cov=app --cov-report=term-missing

Mục tiêu: coverage ≥ 70% cho tầng API. Đừng chạy theo 100% — hãy tập trung test các trường hợp biên: dữ liệu rỗng, giá trị âm, trùng lặp, không có quyền.


🐳 Tuần 15: Docker và deploy​

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn

COPY . .

RUN useradd --create-home appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "--access-logfile", "-", "wsgi:app"]

wsgi.py:

from app import create_app

app = create_app("prod")

if __name__ == "__main__":
app.run()
docker build -t ghi-chu-api .
docker run -p 8000:8000 --env-file .env ghi-chu-api

Gunicorn: bao nhiêu worker?

Số worker = (2 × số CPU core) + 1

Ví dụ server 2 core → 5 worker. Với app nặng I/O (nhiều truy vấn DB), có thể dùng thêm --threads 2.

Checklist trước khi deploy:

  • SECRET_KEY sinh ngẫu nhiên, đọc từ env
  • DEBUG = False
  • Database migration chạy tự động trong pipeline
  • nginx làm reverse proxy + HTTPS
  • Log ra file hoặc dịch vụ log
  • Endpoint /suc-khoe cho load balancer
  • CORS chỉ mở cho domain thật

🎯 Tuần 16: Dự án tổng hợp​

Xây API quản lý ghi chú có người dùng — nhỏ nhưng đầy đủ mọi thứ:

✅ Application factory + Blueprint
✅ PostgreSQL + SQLAlchemy + Alembic
✅ Đăng ký/đăng nhập (JWT hoặc session)
✅ Mỗi user chỉ thấy ghi chú của mình
✅ Tìm kiếm, lọc, phân trang
✅ Test coverage > 70%
✅ Docker + deploy có link thật
✅ README có ảnh chụp + hướng dẫn chạy 3 lệnh

🔍 Debug và quan sát ứng dụng Flask​

Logging đúng cách​

import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path


def cau_hinh_logging(app):
"""Cấu hình logging: vừa ra console vừa ghi file xoay vòng."""
dinh_dang = logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s"
)

console = logging.StreamHandler()
console.setFormatter(dinh_dang)

Path("logs").mkdir(exist_ok=True)
file_handler = RotatingFileHandler(
"logs/app.log", maxBytes=5_000_000, backupCount=5, encoding="utf-8"
)
file_handler.setFormatter(dinh_dang)

app.logger.handlers.clear()
app.logger.addHandler(console)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.DEBUG if app.config["DEBUG"] else logging.INFO)

# Giảm ồn từ thư viện bên thứ ba
logging.getLogger("werkzeug").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)

RotatingFileHandler tự xoay file khi vượt 5 MB và giữ 5 bản cũ — bạn không phải lo log làm đầy đĩa.

Đo thời gian xử lý mỗi request​

import time
import uuid
from flask import g, request


@app.before_request
def bat_dau_do():
g.ma_request = uuid.uuid4().hex[:8]
g.thoi_diem_bat_dau = time.perf_counter()


@app.after_request
def ket_thuc_do(response):
thoi_gian = (time.perf_counter() - g.thoi_diem_bat_dau) * 1000
response.headers["X-Request-ID"] = g.ma_request
app.logger.info(
"[%s] %s %s -> %s (%.1f ms)",
g.ma_request, request.method, request.path, response.status_code, thoi_gian,
)
if thoi_gian > 500:
app.logger.warning("[%s] Request chậm: %s", g.ma_request, request.path)
return response

Header X-Request-ID cho phép bạn lần theo một request cụ thể qua toàn bộ log — vô giá khi debug trên production.

Bảng lỗi Flask hay gặp​

LỗiNguyên nhânCách sửa
TemplateNotFoundSai thư mụcTemplate phải trong templates/ của app
BuildError: Could not build url for endpointSai tên hàm trong url_forKhớp đúng tên hàm Python
Working outside of application contextTruy vấn DB ngoài contextDùng with app.app_context():
Working outside of request contextDùng request ngoài routeTruyền dữ liệu qua tham số
RuntimeError: The session is unavailableThiếu SECRET_KEYĐặt app.secret_key
Circular importImport app trong module khácDùng application factory + blueprint
OperationalError: database is lockedSQLite ghi đồng thờiDùng PostgreSQL cho production
413 Request Entity Too LargeFile upload quá lớnĐặt MAX_CONTENT_LENGTH
# Giới hạn kích thước upload — bảo vệ server khỏi bị làm nghẽn
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024 # 16 MB

🧱 Service layer — tách logic nghiệp vụ khỏi route​

Đây là bước nâng cấp quan trọng nhất để codebase Flask sống được lâu dài.

Vấn đề khi để logic trong route​

# ❌ Route làm quá nhiều việc
@bp.post("/don-hang")
def tao_don_hang():
du_lieu = request.get_json()

# validate
if not du_lieu.get("khach_hang_id"):
return jsonify({"loi": "Thiếu khách hàng"}), 400

# kiểm tra tồn kho
for item in du_lieu["items"]:
sp = db.session.get(SanPham, item["san_pham_id"])
if sp is None or sp.ton_kho < item["so_luong"]:
return jsonify({"loi": f"Hết hàng: {item['san_pham_id']}"}), 409

# tính tiền
tong = sum(item["so_luong"] * item["don_gia"] for item in du_lieu["items"])
giam_gia = tinh_giam_gia(tong, du_lieu.get("ma_giam_gia"))

# tạo bản ghi
dh = DonHang(tong_tien=tong - giam_gia, khach_hang_id=du_lieu["khach_hang_id"])
db.session.add(dh)
db.session.flush()

# trừ tồn kho
for item in du_lieu["items"]:
sp = db.session.get(SanPham, item["san_pham_id"])
sp.ton_kho -= item["so_luong"]

db.session.commit()

# gửi email
gui_email_xac_nhan(dh)

return jsonify(dh.to_dict()), 201

Route này không thể test riêng, không thể tái sử dụng (ví dụ khi nhập đơn từ file Excel), và không thể đọc hiểu khi nghiệp vụ phức tạp thêm.

Cách tách đúng​

services/don_hang.py:

from dataclasses import dataclass
from decimal import Decimal
from sqlalchemy import select, update
from sqlalchemy.orm import Session

from ..extensions import db
from ..models.don_hang import DonHang, ChiTietDonHang
from ..models.san_pham import SanPham
from .exceptions import HetHangError, KhachHangKhongTonTai


@dataclass
class MucDonHang:
san_pham_id: int
so_luong: int
don_gia: Decimal


class DichVuDonHang:
"""Toàn bộ nghiệp vụ đơn hàng nằm ở đây — không phụ thuộc Flask."""

def __init__(self, session: Session):
self.session = session

def tao_don_hang(self, khach_hang_id: int, items: list[MucDonHang],
ma_giam_gia: str | None = None) -> DonHang:
if not items:
raise ValueError("Đơn hàng phải có ít nhất một sản phẩm")

with self.session.begin_nested(): # savepoint — rollback riêng nếu lỗi
self._kiem_tra_ton_kho(items)

tong = sum(m.so_luong * m.don_gia for m in items)
giam_gia = self._tinh_giam_gia(tong, ma_giam_gia)

dh = DonHang(khach_hang_id=khach_hang_id, tong_tien=tong - giam_gia)
self.session.add(dh)
self.session.flush()

for muc in items:
self.session.add(ChiTietDonHang(
don_hang_id=dh.id, san_pham_id=muc.san_pham_id,
so_luong=muc.so_luong, don_gia=muc.don_gia,
))
self.session.execute(
update(SanPham)
.where(SanPham.id == muc.san_pham_id)
.values(ton_kho=SanPham.ton_kho - muc.so_luong)
)

return dh

def _kiem_tra_ton_kho(self, items: list[MucDonHang]) -> None:
for muc in items:
sp = self.session.get(SanPham, muc.san_pham_id)
if sp is None or sp.ton_kho < muc.so_luong:
raise HetHangError(muc.san_pham_id, muc.so_luong)

def _tinh_giam_gia(self, tong: Decimal, ma: str | None) -> Decimal:
if not ma:
return Decimal(0)
# gọi repository mã giảm giá...
return Decimal(0)

Route giờ chỉ còn làm nhiệm vụ HTTP:

@bp.post("/don-hang")
@yeu_cau_dang_nhap
def tao_don_hang():
"""HTTP: parse → gọi service → format response. Không chứa nghiệp vụ."""
try:
du_lieu = schema.load(request.get_json(silent=True) or {})
except ValidationError as e:
return jsonify({"loi": "Dữ liệu không hợp lệ", "chi_tiet": e.messages}), 422

dich_vu = DichVuDonHang(db.session)
try:
dh = dich_vu.tao_don_hang(
khach_hang_id=g.nguoi_dung.id,
items=[MucDonHang(**m) for m in du_lieu["items"]],
ma_giam_gia=du_lieu.get("ma_giam_gia"),
)
db.session.commit()
except HetHangError as e:
db.session.rollback()
return jsonify({"loi": str(e)}), 409

gui_email_xac_nhan(dh) # hoặc đẩy vào Celery
return jsonify(dh.to_dict()), 201

Ba lợi ích cụ thể:

  1. Test được — DichVuDonHang không cần HTTP client, không cần Flask app. Test thuần Python, chạy trong mili-giây.
  2. Tái sử dụng được — cùng service dùng cho API, cho trang admin, cho script nhập đơn từ Excel.
  3. Đọc hiểu được — người mới vào dự án đọc DichVuDonHang.tao_don_hang là hiểu toàn bộ nghiệp vụ, không cần lần theo route.
# Test thuần Python — không cần Flask, không cần HTTP
def test_tao_don_hang_het_hang(session):
sp = SanPham(ten="Bàn phím", gia=Decimal("250000"), ton_kho=1)
session.add(sp)
session.flush()

dich_vu = DichVuDonHang(session)
with pytest.raises(HetHangError):
dich_vu.tao_don_hang(1, [MucDonHang(sp.id, 5, Decimal("250000"))])

⚠️ 7 sai lầm khiến code Flask thành mớ hỗn độn​

  1. Mọi thứ trong một file app.py. Sau 1000 dòng là không cứu được nữa.
  2. Không dùng application factory. Không test được, không có config theo môi trường.
  3. Truy vấn DB trong route. Tách ra service/repository.
  4. Không dùng migration. db.create_all() ở production là thảm hoạ.
  5. Bắt Exception chung chung. Che giấu bug thật.
  6. Hard-code secret. Một lần push lên GitHub là phải đổi hết.
  7. Không viết test. Bạn sẽ sợ sửa code, và sợ sửa code nghĩa là dự án chết.

❓ Câu hỏi thường gặp​

Flask có đủ dùng cho dự án lớn không? Có. Pinterest dùng Flask ở quy mô rất lớn. Nhưng với team đông, bạn cần kỷ luật về cấu trúc (application factory, blueprint, service layer) — đó chính là lý do nhiều team chọn Django: nó ép bạn theo cấu trúc đúng.

Flask có hỗ trợ async không? Có (từ 2.0), nhưng không phải điểm mạnh. Nếu bạn cần async native, FastAPI phù hợp hơn. Flask dùng asyncio cần cẩn thận vì vẫn chạy trên WSGI.

Nên dùng SQLAlchemy hay viết SQL thuần? Với dự án thật: SQLAlchemy. Nhưng phải học SQL trước, vì bạn sẽ cần debug query sinh ra và viết query phức tạp.

Flask-Login hay JWT? Session (Flask-Login) cho web app có giao diện. JWT cho API phục vụ mobile/SPA. Không có cái nào "tốt hơn" — phụ thuộc ngữ cảnh.

Bao lâu để đi làm được với Flask? 4–6 tháng học nghiêm túc (2 giờ/ngày) nếu bạn đã có nền Python. Nếu học từ đầu: 8–10 tháng.


📚 Bài viết liên quan​