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

3 bài viết được gắn thẻ "Web Development"

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​

Lộ trình học Python Django Backend

· 25 phút để đọc

Django là framework "pin kèm mọi thứ" của Python: ORM, admin, xác thực, form, migration, bảo mật — tất cả có sẵn. Triết lý của nó là quyết định thay bạn để bạn không phải chọn lại từ đầu mỗi dự án.

Điều đó có nghĩa Django phù hợp nhất khi bạn cần giao sản phẩm nhanh và làm việc nhóm đông người với quy ước chung.


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

Nên chọn Django khiNên chọn framework khác khi
Làm web app có giao diện + adminCần API siêu nhẹ, ít tính năng (→ Flask)
Cần CRUD nhanh cho nhiều bảngCần async native, WebSocket nhiều (→ FastAPI)
Team đông, cần quy ước chungMuốn toàn quyền kiểm soát từng thành phần (→ Flask)
Cần auth, phân quyền, bảo mật sẵnPrototype vài chục dòng
Làm CMS, ERP, hệ thống nội bộ—

💡 Lợi thế độc nhất: Django Admin. Chỉ với 10 dòng admin.py, bạn có giao diện quản trị có tìm kiếm, lọc, phân quyền, export. Xây thứ tương tự bằng Flask mất vài tuần.


🗓 Lộ trình 16 tuần​

Tuần 1–2    Python + kiến thức web cơ bản (HTTP, SQL)
Tuần 3–4 Django cơ bản: project, app, view, template, URL
Tuần 5–7 Model, migration, Django ORM, admin
Tuần 8–9 Form, class-based view, xác thực
Tuần 10–11 Django REST Framework: serializer, viewset, router
Tuần 12 Testing, tối ưu truy vấn, cache
Tuần 13 Celery, tác vụ nền, signal
Tuần 14–15 Docker, deploy production, bảo mật
Tuần 16 Dự án tổng hợp + phỏng vấn

🐍 Tuần 1–2: Nền tảng bắt buộc​

Trước khi học Django, bạn cần chắc hai thứ mà nhiều người bỏ qua rồi trả giá:

1. SQL — vì ORM không cứu bạn khi truy vấn chậm:

SELECT t.ten, COUNT(s.id) AS so_sach, SUM(s.gia) AS tong
FROM tac_gia t
JOIN sach s ON s.tac_gia_id = t.id
WHERE s.nam_xuat_ban >= 2020
GROUP BY t.id, t.ten
HAVING COUNT(s.id) > 2
ORDER BY tong DESC
LIMIT 10;

Hiểu JOIN, GROUP BY, INDEX, EXPLAIN là điều kiện để debug QuerySet của Django.

2. HTTP — vì Django là framework web:

Thành phầnCần hiểu
MethodGET, POST, PUT, PATCH, DELETE — và tính idempotent của từng cái
Mã trạng thái200, 201, 204, 400, 401, 403, 404, 500
HeaderContent-Type, Authorization, Cookie
Cơ chếRequest/response, session, cookie, CSRF

🌱 Tuần 3–4: Django cơ bản​

python -m venv .venv
.venv\Scripts\activate
pip install django
django-admin startproject config .
python manage.py startapp blog

Vì sao dùng config làm tên project: tránh trùng tên với package bên trong (nhiều người đặt project là mysite rồi gặp lỗi import khó hiểu sau này).

MTV — kiến trúc của Django​

flowchart LR
A[Browser] -->|URL| B[urls.py]
B --> C[views.py]
C --> D[models.py ORM]
D --> E[(Database)]
C --> F[Template]
F --> A
Thành phầnVai tròSo với MVC
ModelĐịnh nghĩa dữ liệuGiống Model
TemplateHiển thịGiống View
ViewXử lý logicGiống Controller

URL và View​

# blog/views.py
from django.http import JsonResponse, Http404
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages

from .models import BaiViet


def danh_sach_bai_viet(request):
ds = BaiViet.objects.filter(da_xuat_ban=True).select_related("tac_gia")
return render(request, "blog/danh_sach.html", {"bai_viet": ds})


def chi_tiet_bai_viet(request, slug: str):
bai_viet = get_object_or_404(BaiViet, slug=slug, da_xuat_ban=True)
return render(request, "blog/chi_tiet.html", {"bai_viet": bai_viet})
# blog/urls.py
from django.urls import path
from . import views

app_name = "blog"

urlpatterns = [
path("", views.danh_sach_bai_viet, name="danh_sach"),
path("<slug:slug>/", views.chi_tiet_bai_viet, name="chi_tiet"),
]
# config/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
path("admin/", admin.site.urls),
path("blog/", include("blog.urls")),
]

Bốn loại path converter:

ConverterKhớp vớiVí dụ
<int:id>Số nguyên/bai-viet/42/
<slug:slug>Chuỗi slug hoá/bai-viet/hoc-python-co-ban/
<str:ten>Chuỗi không có //tac-gia/minh/
<uuid:ma>UUID/don-hang/550e8400-.../

⚠️ Luôn dùng get_object_or_404 thay vì BaiViet.objects.get(...). get() ném DoesNotExist → lỗi 500. get_object_or_404 mới trả 404 đúng.


🗄️ Tuần 5–7: Model, Migration và ORM​

Model đúng chuẩn​

# blog/models.py
from django.db import models
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify


class DanhMuc(models.Model):
ten = models.CharField("Tên danh mục", max_length=100, unique=True)
slug = models.SlugField("Đường dẫn", max_length=120, unique=True)

class Meta:
verbose_name = "Danh mục"
verbose_name_plural = "Danh mục"
ordering = ["ten"]

def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.ten, allow_unicode=True)
super().save(*args, **kwargs)

def __str__(self):
return self.ten


class BaiViet(models.Model):
TRANG_THAI = [
("nhap", "Bản nháp"),
("cho_duyet", "Chờ duyệt"),
("xuat_ban", "Đã xuất bản"),
]

tieu_de = models.CharField("Tiêu đề", max_length=250)
slug = models.SlugField("Đường dẫn", max_length=260, unique=True, db_index=True)
noi_dung = models.TextField("Nội dung")
tom_tat = models.CharField("Tóm tắt", max_length=500, blank=True)

tac_gia = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="bai_viet",
verbose_name="Tác giả",
)
danh_muc = models.ForeignKey(
DanhMuc, on_delete=models.SET_NULL, null=True, blank=True,
related_name="bai_viet", verbose_name="Danh mục",
)
the = models.ManyToManyField("The", blank=True, related_name="bai_viet", verbose_name="Thẻ")

trang_thai = models.CharField(max_length=20, choices=TRANG_THAI, default="nhap", db_index=True)
luot_xem = models.PositiveIntegerField("Lượt xem", default=0)
ngay_tao = models.DateTimeField(auto_now_add=True, db_index=True)
ngay_cap_nhat = models.DateTimeField(auto_now=True)
ngay_xuat_ban = models.DateTimeField(null=True, blank=True)

class Meta:
verbose_name = "Bài viết"
verbose_name_plural = "Bài viết"
ordering = ["-ngay_tao"]
indexes = [
models.Index(fields=["trang_thai", "-ngay_tao"]),
models.Index(fields=["tac_gia", "trang_thai"]),
]
constraints = [
models.CheckConstraint(
check=models.Q(luot_xem__gte=0),
name="luot_xem_khong_am",
),
]

def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.tieu_de, allow_unicode=True)[:260]
if self.trang_thai == "xuat_ban" and self.ngay_xuat_ban is None:
self.ngay_xuat_ban = timezone.now()
super().save(*args, **kwargs)

def get_absolute_url(self):
return reverse("blog:chi_tiet", kwargs={"slug": self.slug})

@property
def da_xuat_ban(self):
return self.trang_thai == "xuat_ban"

def __str__(self):
return self.tieu_de


class The(models.Model):
ten = models.CharField(max_length=60, unique=True)
slug = models.SlugField(max_length=80, unique=True)

def __str__(self):
return self.ten

Năm quyết định thiết kế quan trọng trong đoạn code trên:

Quyết địnhLý do
on_delete=models.CASCADE cho tác giảXoá user → xoá bài của họ
on_delete=models.SET_NULL cho danh mụcXoá danh mục không nên xoá bài
db_index=True cho slug, trang_thai, ngay_taoCác trường hay lọc/sắp xếp
indexes tổ hợpQuery thật luôn lọc theo nhiều cột
CheckConstraintRàng buộc logic ngay ở tầng database

⚠️ Tuyệt đối không dùng FloatField cho tiền. Luôn DecimalField. Sai số dấu phẩy động có thể làm lệch sổ sách vài đồng — và trong tài chính, đó là lỗi không thể chấp nhận.

Migration — quy trình bất di bất dịch​

python manage.py makemigrations blog
python manage.py migrate
python manage.py showmigrations blog
python manage.py sqlmigrate blog 0001 # xem SQL thật sẽ chạy

Ba quy tắc:

  1. Không bao giờ sửa file trong migrations/ bằng tay.
  2. Luôn sqlmigrate để xem SQL trước khi migrate production.
  3. Đổi tên cột cần migration đặc biệt — Django hiểu là xoá + tạo mới → mất dữ liệu.

ORM — bảng tra cứu cần thuộc​

from django.db.models import Q, F, Count, Sum, Avg, Max, Case, When, Value, IntegerField

# --- Lọc ---
BaiViet.objects.filter(trang_thai="xuat_ban")
BaiViet.objects.filter(luot_xem__gte=1000)
BaiViet.objects.filter(tieu_de__icontains="python")
BaiViet.objects.filter(danh_muc__isnull=True)
BaiViet.objects.filter(ngay_tao__year=2026, ngay_tao__month=10)
BaiViet.objects.filter(Q(trang_thai="xuat_ban") | Q(luot_xem__gt=5000))
BaiViet.objects.exclude(trang_thai="nhap")

# --- Sắp xếp & giới hạn ---
BaiViet.objects.order_by("-luot_xem")[:10]
BaiViet.objects.order_by(F("luot_xem").desc(nulls_last=True))

# --- Truy vấn liên bảng ---
BaiViet.objects.filter(the__ten="python").distinct()
BaiViet.objects.filter(danh_muc__slug="huong-dan")

# --- Aggregate ---
BaiViet.objects.aggregate(tong_luot_xem=Sum("luot_xem"), tb=Avg("luot_xem"))

# --- Annotate (tính toán trên từng bản ghi) ---
DanhMuc.objects.annotate(so_bai=Count("bai_viet")).filter(so_bai__gt=0)

# --- Cập nhật bằng F() — tránh race condition ---
BaiViet.objects.filter(pk=1).update(luot_xem=F("luot_xem") + 1)

# --- bulk_create / bulk_update — nhanh hơn nhiều lần ---
BaiViet.objects.bulk_create([BaiViet(tieu_de=f"Bài {i}") for i in range(1000)], batch_size=500)

F() — điều mà người mới luôn làm sai:

# ❌ SAI — đọc rồi ghi, có race condition
bv = BaiViet.objects.get(pk=1)
bv.luot_xem = bv.luot_xem + 1 # hai request đồng thời → mất 1 lượt
bv.save()

# ✅ ĐÚNG — database tự tăng, atomic
BaiViet.objects.filter(pk=1).update(luot_xem=F("luot_xem") + 1)

Django Admin​

# blog/admin.py
from django.contrib import admin
from django.utils.html import format_html
from .models import BaiViet, DanhMuc, The


@admin.register(DanhMuc)
class DanhMucAdmin(admin.ModelAdmin):
list_display = ("ten", "slug", "so_bai_viet")
search_fields = ("ten",)
prepopulated_fields = {"slug": ("ten",)}

@admin.display(description="Số bài viết")
def so_bai_viet(self, obj):
return obj.bai_viet.count()


@admin.register(BaiViet)
class BaiVietAdmin(admin.ModelAdmin):
list_display = ("tieu_de", "tac_gia", "danh_muc", "trang_thai_badge", "luot_xem", "ngay_tao")
list_filter = ("trang_thai", "danh_muc", "ngay_tao")
search_fields = ("tieu_de", "noi_dung", "tac_gia__username")
prepopulated_fields = {"slug": ("tieu_de",)}
date_hierarchy = "ngay_tao"
autocomplete_fields = ("tac_gia", "danh_muc")
filter_horizontal = ("the",)
list_select_related = ("tac_gia", "danh_muc") # ← tránh N+1 trong admin
readonly_fields = ("luot_xem", "ngay_tao", "ngay_cap_nhat")
list_per_page = 25
actions = ["duyet_hang_loat"]

fieldsets = (
("Nội dung", {"fields": ("tieu_de", "slug", "tom_tat", "noi_dung")}),
("Phân loại", {"fields": ("danh_muc", "the")}),
("Xuất bản", {"fields": ("tac_gia", "trang_thai", "ngay_xuat_ban")}),
("Thống kê", {"fields": ("luot_xem", "ngay_tao", "ngay_cap_nhat"), "classes": ("collapse",)}),
)

@admin.display(description="Trạng thái", ordering="trang_thai")
def trang_thai_badge(self, obj):
mau = {"nhap": "#6b7280", "cho_duyet": "#f59e0b", "xuat_ban": "#16a34a"}[obj.trang_thai]
return format_html(
'<span style="background:{};color:#fff;padding:2px 8px;border-radius:10px">{}</span>',
mau, obj.get_trang_thai_display(),
)

@admin.action(description="Duyệt và xuất bản các bài đã chọn")
def duyet_hang_loat(self, request, queryset):
so_luong = queryset.update(trang_thai="xuat_ban")
self.message_user(request, f"Đã xuất bản {so_luong} bài viết.")

Đoạn này cho bạn: badge màu theo trạng thái, action hàng loạt, fieldsets gọn gàng, và list_select_related để admin không chạy N+1 query.


📝 Tuần 8–9: Form, Class-based View, xác thực​

# blog/forms.py
from django import forms
from django.core.exceptions import ValidationError
from .models import BaiViet


class BaiVietForm(forms.ModelForm):
class Meta:
model = BaiViet
fields = ["tieu_de", "tom_tat", "noi_dung", "danh_muc", "the", "trang_thai"]
widgets = {
"tom_tat": forms.Textarea(attrs={"rows": 3, "maxlength": 500}),
"noi_dung": forms.Textarea(attrs={"rows": 20}),
}
help_texts = {"tom_tat": "Tối đa 500 ký tự, hiển thị ở trang danh sách."}

def clean_tieu_de(self):
tieu_de = self.cleaned_data["tieu_de"].strip()
if len(tieu_de) < 10:
raise ValidationError("Tiêu đề phải có ít nhất 10 ký tự.")
return tieu_de

def clean(self):
du_lieu = super().clean()
if du_lieu.get("trang_thai") == "xuat_ban" and not du_lieu.get("tom_tat"):
self.add_error("tom_tat", "Bài xuất bản phải có tóm tắt.")
return du_lieu

Class-based View — dùng khi nào?

Loại viewDùng choVí dụ
TemplateViewTrang tĩnhGiới thiệu, liên hệ
ListViewDanh sách có phân trangDanh sách bài viết
DetailViewChi tiết một bản ghiChi tiết bài viết
CreateViewForm tạo mớiTạo bài viết
UpdateViewForm cập nhậtSửa bài viết
DeleteViewXác nhận xoáXoá bài viết
from django.views.generic import ListView, DetailView, CreateView, UpdateView
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.urls import reverse_lazy


class DanhSachBaiVietView(ListView):
model = BaiViet
template_name = "blog/danh_sach.html"
context_object_name = "bai_viet"
paginate_by = 12

def get_queryset(self):
qs = BaiViet.objects.filter(trang_thai="xuat_ban").select_related("tac_gia", "danh_muc")
if danh_muc := self.request.GET.get("danh_muc"):
qs = qs.filter(danh_muc__slug=danh_muc)
if tu_khoa := self.request.GET.get("q"):
qs = qs.filter(tieu_de__icontains=tu_khoa)
return qs


class TaoBaiVietView(LoginRequiredMixin, CreateView):
model = BaiViet
form_class = BaiVietForm
template_name = "blog/form.html"
success_url = reverse_lazy("blog:danh_sach")

def form_valid(self, form):
form.instance.tac_gia = self.request.user
return super().form_valid(form)

💡 Kinh nghiệm thực tế: dùng ListView/DetailView vì chúng tiết kiệm nhiều code. Nhưng khi logic phình ra, quay về function view — dễ đọc hơn. Đừng cố nhồi mọi thứ vào generic view.


🔌 Tuần 10–11: Django REST Framework​

pip install djangorestframework djangorestframework-simplejwt django-filter
# blog/serializers.py
from rest_framework import serializers
from .models import BaiViet, DanhMuc


class DanhMucSerializer(serializers.ModelSerializer):
so_bai_viet = serializers.IntegerField(read_only=True)

class Meta:
model = DanhMuc
fields = ["id", "ten", "slug", "so_bai_viet"]


class BaiVietListSerializer(serializers.ModelSerializer):
tac_gia_ten = serializers.CharField(source="tac_gia.username", read_only=True)
danh_muc_ten = serializers.CharField(source="danh_muc.ten", read_only=True, default=None)

class Meta:
model = BaiViet
fields = ["id", "tieu_de", "slug", "tom_tat", "tac_gia_ten",
"danh_muc_ten", "luot_xem", "ngay_xuat_ban"]


class BaiVietDetailSerializer(serializers.ModelSerializer):
tac_gia = serializers.StringRelatedField(read_only=True)
danh_muc = DanhMucSerializer(read_only=True)
danh_muc_id = serializers.PrimaryKeyRelatedField(
queryset=DanhMuc.objects.all(), source="danh_muc", write_only=True,
required=False, allow_null=True,
)

class Meta:
model = BaiViet
fields = ["id", "tieu_de", "slug", "tom_tat", "noi_dung",
"tac_gia", "danh_muc", "danh_muc_id", "the",
"trang_thai", "luot_xem", "ngay_tao", "ngay_xuat_ban"]
read_only_fields = ["slug", "luot_xem", "ngay_tao", "ngay_xuat_ban"]

def validate_tieu_de(self, value):
if len(value.strip()) < 10:
raise serializers.ValidationError("Tiêu đề phải có ít nhất 10 ký tự.")
return value.strip()
# blog/api_views.py
from django.db.models import Count, Q
from rest_framework import viewsets, filters, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from django.db.models import F

from .models import BaiViet, DanhMuc
from .serializers import BaiVietListSerializer, BaiVietDetailSerializer, DanhMucSerializer


class BaiVietViewSet(viewsets.ModelViewSet):
queryset = (
BaiViet.objects
.select_related("tac_gia", "danh_muc")
.prefetch_related("the")
)
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ["trang_thai", "danh_muc"]
search_fields = ["tieu_de", "tom_tat", "noi_dung"]
ordering_fields = ["ngay_tao", "luot_xem"]
ordering = ["-ngay_tao"]
lookup_field = "slug"

def get_serializer_class(self):
if self.action == "list":
return BaiVietListSerializer
return BaiVietDetailSerializer

def get_permissions(self):
if self.action in ("list", "retrieve"):
return [permissions.AllowAny()]
return [permissions.IsAuthenticated()]

def perform_create(self, serializer):
serializer.save(tac_gia=self.request.user)

@action(detail=True, methods=["post"], permission_classes=[permissions.AllowAny])
def tang_luot_xem(self, request, slug=None):
BaiViet.objects.filter(slug=slug).update(luot_xem=F("luot_xem") + 1)
bai_viet = self.get_object()
return Response({"luot_xem": bai_viet.luot_xem}, status=status.HTTP_200_OK)


class DanhMucViewSet(viewsets.ReadOnlyModelViewSet):
queryset = DanhMuc.objects.annotate(so_bai_viet=Count("bai_viet", filter=Q(bai_viet__trang_thai="xuat_ban")))
serializer_class = DanhMucSerializer
lookup_field = "slug"
# blog/api_urls.py
from rest_framework.routers import DefaultRouter
from .api_views import BaiVietViewSet, DanhMucViewSet

router = DefaultRouter()
router.register("bai-viet", BaiVietViewSet, basename="bai-viet")
router.register("danh-muc", DanhMucViewSet, basename="danh-muc")

urlpatterns = router.urls

Những gì bạn vừa có mà không phải viết:

  • CRUD đầy đủ cho /api/bai-viet/ và /api/danh-muc/.
  • Tìm kiếm ?search=python, lọc ?trang_thai=xuat_ban, sắp xếp ?ordering=-luot_xem.
  • Phân trang tự động.
  • Endpoint tuỳ chỉnh POST /api/bai-viet/{slug}/tang_luot_xem/.
  • Giao diện thử API tại /api/ trong browser.

Cấu hình DRF trong settings.py:

REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {"anon": "60/min", "user": "2000/hour"},
"DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
}

⚡ Tuần 12: Testing và tối ưu truy vấn​

Testing​

# blog/tests/test_api.py
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth.models import User
from rest_framework.test import APIClient

from blog.models import BaiViet


class BaiVietAPITest(TestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user("minh", password="matkhau123")
self.bai_viet = BaiViet.objects.create(
tieu_de="Hướng dẫn Django cơ bản",
noi_dung="Nội dung...",
tac_gia=self.user,
trang_thai="xuat_ban",
)

def test_danh_sach_khong_can_dang_nhap(self):
r = self.client.get(reverse("bai-viet-list"))
self.assertEqual(r.status_code, 200)
self.assertEqual(r.data["count"], 1)

def test_tao_bai_viet_can_dang_nhap(self):
r = self.client.post(reverse("bai-viet-list"), {"tieu_de": "Bài mới"})
self.assertEqual(r.status_code, 401)

def test_tao_bai_viet_thanh_cong(self):
self.client.force_authenticate(self.user)
r = self.client.post(reverse("bai-viet-list"), {
"tieu_de": "Bài viết hoàn toàn mới",
"noi_dung": "Nội dung",
"trang_thai": "nhap",
})
self.assertEqual(r.status_code, 201)
self.assertEqual(r.data["tac_gia"]["id"], self.user.id)

def test_tieu_de_qua_ngan(self):
self.client.force_authenticate(self.user)
r = self.client.post(reverse("bai-viet-list"), {"tieu_de": "Ngắn"})
self.assertEqual(r.status_code, 400)
self.assertIn("tieu_de", r.data)

def test_tang_luot_xem(self):
url = reverse("bai-viet-tang-luot-xem", kwargs={"slug": self.bai_viet.slug})
self.client.post(url)
self.client.post(url)
self.bai_viet.refresh_from_db()
self.assertEqual(self.bai_viet.luot_xem, 2)
python manage.py test --parallel
coverage run --source='.' manage.py test && coverage report

Tối ưu N+1 — vấn đề hiệu năng số 1 của Django​

# ❌ N+1: 1 query lấy 100 bài + 100 query lấy tác giả = 101 query
for bv in BaiViet.objects.all():
print(bv.tac_gia.username) # mỗi lần là 1 query!

# ✅ 1 query duy nhất với JOIN
for bv in BaiViet.objects.select_related("tac_gia", "danh_muc"):
print(bv.tac_gia.username)

# ❌ N+1 với ManyToMany: 1 + 100 query
for bv in BaiViet.objects.all():
print([t.ten for t in bv.the.all()])

# ✅ prefetch_related: 2 query tổng cộng
for bv in BaiViet.objects.prefetch_related("the"):
print([t.ten for t in bv.the.all()])
HàmDùng choSố query
select_relatedForeignKey, OneToOne1 (JOIN)
prefetch_relatedManyToMany, quan hệ ngược2
Prefetchprefetch có lọc2

Công cụ phát hiện N+1:

# Cài django-debug-toolbar hoặc dùng assertNumQueries trong test
from django.test.utils import CaptureQueriesContext
from django.db import connection

with CaptureQueriesContext(connection) as ctx:
list(BaiViet.objects.select_related("tac_gia").all()[:20])
print(f"Số query: {len(ctx)}") # phải là 1, không phải 21

Cache:

from django.core.cache import cache
from django.views.decorators.cache import cache_page


@cache_page(60 * 15) # cache view 15 phút
def danh_sach_bai_viet(request):
...


def lay_bai_viet_noi_bat():
khoa = "bai_viet_noi_bat"
du_lieu = cache.get(khoa)
if du_lieu is None:
du_lieu = list(BaiViet.objects.filter(trang_thai="xuat_ban").order_by("-luot_xem")[:10])
cache.set(khoa, du_lieu, timeout=300)
return du_lieu

⏰ Tuần 13: Celery, tác vụ nền và signal​

pip install celery redis
# config/celery.py
import os
from celery import Celery

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
app = Celery("config")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
# blog/tasks.py
from celery import shared_task
from django.core.mail import send_mail
from django.utils import timezone
from datetime import timedelta


@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def gui_email_thong_bao(self, bai_viet_id: int):
from .models import BaiViet
try:
bv = BaiViet.objects.select_related("tac_gia").get(pk=bai_viet_id)
send_mail(
subject=f"Bài viết đã xuất bản: {bv.tieu_de}",
message=f"Bài viết của bạn đã được xuất bản: {bv.get_absolute_url()}",
from_email="noreply@example.com",
recipient_list=[bv.tac_gia.email],
fail_silently=False,
)
return f"Đã gửi email cho {bv.tac_gia.email}"
except Exception as exc:
raise self.retry(exc=exc)


@shared_task
def don_dep_ban_nhap_cu():
"""Xoá bản nháp không cập nhật trong 90 ngày."""
from .models import BaiViet
han = timezone.now() - timedelta(days=90)
so_luong, _ = BaiViet.objects.filter(trang_thai="nhap", ngay_cap_nhat__lt=han).delete()
return f"Đã xoá {so_luong} bản nháp cũ"
# Chạy worker (terminal riêng)
celery -A config worker -l info

# Chạy scheduler cho task định kỳ
celery -A config beat -l info

⚠️ Sai lầm phổ biến với Celery: truyền object model vào task thay vì id. Khi đó object bị serialize vào queue và có thể đã cũ hoặc không deserialize được. Luôn truyền id rồi truy vấn lại trong task.


🐳 Tuần 14–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 \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*

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

COPY . .

RUN python manage.py collectstatic --noinput
RUN useradd --create-home appuser && chown -R appuser:appuser /app
USER appuser

EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", \
"--workers", "4", "--timeout", "60", "--access-logfile", "-"]

settings.py cho production:

import os
from pathlib import Path

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

SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # bắt buộc, không có mặc định
DEBUG = os.environ.get("DJANGO_DEBUG", "False") == "True"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")

DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ["POSTGRES_DB"],
"USER": os.environ["POSTGRES_USER"],
"PASSWORD": os.environ["POSTGRES_PASSWORD"],
"HOST": os.environ.get("POSTGRES_HOST", "db"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
"CONN_MAX_AGE": 60, # tái sử dụng kết nối
"OPTIONS": {"connect_timeout": 10},
}
}

# Bảo mật production — bật hết
SECURE_SSL_REDIRECT = not DEBUG
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG
SECURE_HSTS_SECONDS = 31536000 if not DEBUG else 0
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"

CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")

CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.environ.get("REDIS_URL", "redis://localhost:6379/1"),
}
}

LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"day_du": {"format": "{asctime} | {levelname:<8} | {name} | {message}", "style": "{"},
},
"handlers": {
"console": {"class": "logging.StreamHandler", "formatter": "day_du"},
},
"root": {"handlers": ["console"], "level": "INFO"},
}
# Migration trong pipeline deploy — không chạy tay
python manage.py migrate --noinput
python manage.py collectstatic --noinput

Checklist bảo mật Django trước khi lên production:

python manage.py check --deploy
  • DEBUG = False
  • SECRET_KEY từ env, đủ dài và ngẫu nhiên
  • ALLOWED_HOSTS khai báo rõ ràng
  • HTTPS bắt buộc + HSTS
  • Cookie Secure, HttpOnly, SameSite
  • CSRF_TRUSTED_ORIGINS cấu hình đúng
  • Backup database tự động + test khả năng phục hồi
  • Log tập trung, có cảnh báo lỗi 500

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

Hệ thống quản lý nội dung có API — tận dụng tối đa thế mạnh của Django (admin) và DRF (API):

✅ Model đầy đủ quan hệ: FK, M2M, index, constraint
✅ Django Admin cấu hình đầy đủ (filter, search, action, badge)
✅ REST API với DRF: viewset, serializer lồng nhau, phân trang
✅ JWT auth + phân quyền theo vai trò
✅ Celery gửi email khi xuất bản bài
✅ Tối ưu: select_related, prefetch_related, cache Redis
✅ Test coverage > 70%
✅ Docker + docker-compose (web + postgres + redis + celery)
✅ Deploy có link thật, `check --deploy` sạch

🔍 Debug Django trong thực tế​

Debug là kỹ năng bạn dùng hàng ngày, nhưng hầu như không khoá học nào dạy.

Django shell — công cụ mạnh nhất​

python manage.py shell
>>> from blog.models import BaiViet
>>> from django.db import connection, reset_queries
>>> from django.conf import settings
>>> settings.DEBUG = True

>>> # Đếm số query của một đoạn code
>>> reset_queries()
>>> ds = list(BaiViet.objects.filter(trang_thai="xuat_ban"))
>>> [(b.tieu_de, b.tac_gia.username) for b in ds]
>>> print(f"Số query: {len(connection.queries)}")

>>> # Xem SQL thật
>>> print(BaiViet.objects.filter(trang_thai="xuat_ban").query)

>>> # Kiểm tra dữ liệu bẩn
>>> BaiViet.objects.filter(tom_tat="").count()
>>> BaiViet.objects.filter(ngay_xuat_ban__gt=timezone.now()).count()

💡 Mẹo: cài django-extensions rồi dùng python manage.py shell_plus — tự import toàn bộ model, tiết kiệm rất nhiều thời gian.

django-debug-toolbar — bắt N+1 ngay trên trình duyệt​

pip install django-debug-toolbar
# settings.py (chỉ bật khi DEBUG)
if DEBUG:
INSTALLED_APPS += ["debug_toolbar"]
MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = ["127.0.0.1"]

# urls.py
if settings.DEBUG:
import debug_toolbar
urlpatterns += [path("__debug__/", include(debug_toolbar.urls))]

Bảng SQL của toolbar hiển thị mọi truy vấn và thời gian. Nếu thấy 50 truy vấn giống nhau trên một trang, bạn đã gặp N+1.

Logging — biết chuyện gì đang xảy ra trên production​

import logging

logger = logging.getLogger(__name__)


def xuat_ban_bai_viet(bai_viet_id: int) -> bool:
from .models import BaiViet

logger.info("Bắt đầu xuất bản bài viết id=%s", bai_viet_id)

try:
bv = BaiViet.objects.get(pk=bai_viet_id)
except BaiViet.DoesNotExist:
logger.warning("Không tìm thấy bài viết id=%s", bai_viet_id)
return False

if bv.trang_thai == "xuat_ban":
logger.info("Bài viết id=%s đã xuất bản trước đó, bỏ qua", bai_viet_id)
return True

try:
with transaction.atomic():
bv.trang_thai = "xuat_ban"
bv.ngay_xuat_ban = timezone.now()
bv.save(update_fields=["trang_thai", "ngay_xuat_ban"])
except Exception:
logger.exception("Lỗi khi xuất bản bài viết id=%s", bai_viet_id)
raise

logger.info("Đã xuất bản bài viết id=%s", bai_viet_id)
return True

Ba cấp độ log và cách dùng đúng:

Cấp độDùng khiVí dụ
DEBUGChi tiết chỉ cần khi phát triểnGiá trị biến trung gian
INFOSự kiện bình thường, quan trọng"Đã xuất bản bài viết 42"
WARNINGBất thường nhưng hệ thống vẫn chạy"Không tìm thấy bài viết"
ERRORThao tác thất bại"Không gửi được email"
exception()Lỗi có tracebackTrong khối except

⚠️ Dùng logger.info("id=%s", id) thay vì logger.info(f"id={id}") — cách đầu chỉ format chuỗi khi log thực sự được ghi, tiết kiệm CPU khi log bị tắt.

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

LỗiNguyên nhânCách sửa
no such tableChưa migratepython manage.py migrate
Table already existsMigration lệch trạng thái--fake-initial rồi kiểm tra showmigrations
TemplateDoesNotExistSai cấu trúc thư mụcCần app/templates/app/file.html
AppRegistryNotReadyImport model quá sớmImport trong hàm
Reverse for 'x' not foundSai name= hoặc thiếu app_nameKiểm tra urls.py
RelatedObjectDoesNotExistTruy cập OneToOne chưa cóDùng hasattr() hoặc try/except
Migration conflict khi làm nhómHai người tạo migration cùng sốmakemigrations --merge
Maximum recursion depthSignal gọi save() trong save()Dùng update() hoặc cờ _dang_luu

📦 Quản lý phụ thuộc và cấu hình môi trường​

Tách requirements theo môi trường​

requirements/
├── base.txt ← dùng chung
├── dev.txt ← thêm cho phát triển
└── prod.txt ← chỉ cho production

base.txt:

Django==5.1.*
djangorestframework==3.15.*
django-filter==24.*
psycopg[binary]==3.2.*
python-dotenv==1.0.*

dev.txt:

-r base.txt
pytest==8.*
pytest-django==4.*
coverage==7.*
django-debug-toolbar==4.*
ruff==0.6.*
black==24.*
factory-boy==3.*

prod.txt:

-r base.txt
gunicorn==23.*
whitenoise==6.*
sentry-sdk==2.*
django-redis==5.*

Cách này giữ image production nhỏ (không mang theo pytest, debug toolbar) và giảm rủi ro bảo mật.

Đọc cấu hình bằng environ​

# settings.py
import os
from pathlib import Path

def env_bool(ten: str, mac_dinh: bool = False) -> bool:
return os.environ.get(ten, str(mac_dinh)).lower() in ("1", "true", "yes", "on")


def env_list(ten: str, mac_dinh: list[str] | None = None) -> list[str]:
gia_tri = os.environ.get(ten, "")
return [x.strip() for x in gia_tri.split(",") if x.strip()] or (mac_dinh or [])


SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = env_bool("DJANGO_DEBUG")
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", ["localhost"])
CSRF_TRUSTED_ORIGINS = env_list("CSRF_TRUSTED_ORIGINS")

if not DEBUG and SECRET_KEY.startswith("django-insecure"):
raise RuntimeError("SECRET_KEY không an toàn đang được dùng ở production!")

💡 Kiểm tra if not DEBUG này gây crash ngay khi khởi động. Đó là điều bạn muốn — tốt hơn nhiều so với việc phát hiện ra sau khi bị tấn công.

Quản lý file tĩnh và media​

LoạiLà gìPhục vụ bởi
staticCSS, JS, ảnh của bạnnginx, whitenoise, CDN
mediaFile người dùng uploadnginx, S3, Cloudinary
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"]

MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"

STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}

⚠️ Django không phục vụ file tĩnh ở production. runserver làm được điều đó, nhưng đó chỉ là tính năng của môi trường phát triển. Production phải dùng nginx/whitenoise/CDN — nếu không, hiệu năng sẽ rất tệ.


🤝 Làm việc nhóm trong dự án Django​

Quy ước code — bắt buộc phải tự động hoá​

# pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py312"

[tool.ruff.lint]
select = [
"E", "W", # pycodestyle
"F", # pyflakes
"I", # isort
"B", # bugbear — bắt lỗi logic phổ biến
"DJ", # flake8-django
"S", # bandit — bảo mật
]
ignore = ["E501"]

[tool.ruff.lint.per-file-ignores]
"**/settings*.py" = ["F405"]
"**/migrations/*" = ["E501", "S"]
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ["--maxkb=1000"]

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
args: ["--fix"]
- id: ruff-format
pip install pre-commit
pre-commit install

Từ giờ mọi commit đều tự động kiểm tra và format. Đây là việc quan trọng nhất bạn có thể làm trong ngày đầu tiên ở bất kỳ dự án nào.

Xử lý conflict migration khi làm nhóm​

Đây là vấn đề khiến nhiều người mất hàng giờ:

Hai người cùng tạo migration sau 0005_moi.py:
Người A: 0006_them_truong_a.py
Người B: 0006_them_truong_b.py ← trùng số!

Quy trình xử lý:

# 1. Pull code mới nhất
# 2. Django sẽ báo conflict hoặc tạo 2 migration cùng tên
# 3. Chạy merge để gộp thành một nhánh tuyến tính
python manage.py makemigrations --merge --no-input

# 4. Kiểm tra lại
python manage.py showmigrations app
python manage.py migrate --plan

Ba quy tắc phòng ngừa:

  1. Luôn tạo migration ngay sau khi sửa model, đừng để tích tụ.
  2. Test migration cả hai chiều trên database copy: migrate app 0001 rồi migrate app.
  3. Không bao giờ sửa migration đã được merge vào main. Nếu sai, tạo migration mới để sửa.

Migration có rủi ro mất dữ liệu — phải làm theo 2 bước​

Đổi tên cột trực tiếp sẽ khiến Django hiểu là xoá cột cũ và tạo cột mới:

# BƯỚC 1: thêm cột mới, giữ cột cũ
class BaiViet(models.Model):
tieu_de_moi = models.CharField(max_length=250, null=True) # cho phép null tạm

# BƯỚC 2: sau khi deploy và migrate, chạy data migration copy dữ liệu
# migrations/0008_copy_tieu_de.py
def copy_du_lieu(apps, schema_editor):
BaiViet = apps.get_model("blog", "BaiViet")
BaiViet.objects.update(tieu_de_moi=models.F("tieu_de"))

# BƯỚC 3 (deploy sau): xoá cột cũ

Với dự án lớn, mỗi bước là một lần deploy riêng. Với bảng 50 triệu dòng, ALTER TABLE có thể khoá bảng vài phút — cần kế hoạch bảo trì.


⚠️ 8 sai lầm khiến dự án Django khó bảo trì​

  1. Logic nghiệp vụ trong view. Tách ra services.py.
  2. Fat model với 2000 dòng. Chia thành mixin hoặc module riêng.
  3. Không tối ưu truy vấn. N+1 là nguyên nhân làm chậm số 1.
  4. Dùng FloatField cho tiền. Dùng DecimalField.
  5. Đọc rồi ghi thay vì dùng F(). Race condition.
  6. Không dùng migration cho thay đổi schema. create_all() chỉ có trong tutorial.
  7. Bỏ qua admin.py. Bạn đang vứt đi lợi thế lớn nhất của Django.
  8. Không viết test. Model phình ra, không ai dám sửa.

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

Django có phù hợp cho API thuần không? Có, DRF rất mạnh. Nhưng nếu bạn chỉ cần API và không dùng admin/ORM template, FastAPI thường gọn hơn.

Nên học Django hay DRF trước? Django trước. DRF dựa hoàn toàn trên ORM và model của Django. Học DRF khi chưa hiểu ORM là công thức của việc copy-paste.

Django có hỗ trợ async không? Có (từ 3.1) cho view và ORM (từ 4.1), nhưng hệ sinh thái chưa async hoàn toàn. Nếu async là yêu cầu trung tâm, FastAPI phù hợp hơn.

Django Admin có dùng cho khách hàng được không? Không nên. Admin là công cụ nội bộ. Khách hàng cần giao diện riêng. Dùng admin cho nhân viên vận hành, không cho người dùng cuối.

Bao lâu để đi làm với Django? Nếu đã biết Python + SQL: 4–6 tháng. Nếu học từ đầu: 9–12 tháng.


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

Lộ trình học Python để trở thành Backend Developer

· 26 phút để đọc

Backend developer là người xây dựng "phần chìm của tảng băng": API, database, xác thực, xử lý nghiệp vụ — tất cả những gì người dùng không nhìn thấy nhưng nếu hỏng thì cả sản phẩm sập.

Python là một trong những lựa chọn tốt nhất để bắt đầu, nhờ cú pháp rõ ràng và ba framework trưởng thành: Flask, Django, FastAPI.

Bài viết này là lộ trình 12 tháng thực tế, không phải danh sách từ khoá. Mỗi giai đoạn đều có: cần học gì, code mẫu, dự án phải làm, và tiêu chí để biết đã sẵn sàng bước tiếp.


🗺️ Bức tranh toàn cảnh​

Giai đoạn 1 (tuần 1–8)    Python nền tảng + OOP
Giai đoạn 2 (tuần 9–12) Git + Linux + công cụ
Giai đoạn 3 (tuần 13–20) Web cơ bản + Flask
Giai đoạn 4 (tuần 21–28) Database + SQL + ORM
Giai đoạn 5 (tuần 29–36) API chuyên nghiệp + Auth + Testing
Giai đoạn 6 (tuần 37–44) Django hoặc FastAPI (chọn 1)
Giai đoạn 7 (tuần 45–48) Docker + Deploy + CI/CD
Giai đoạn 8 (tuần 49–52) Dự án tổng hợp + phỏng vấn

Backend developer thực sự làm gì mỗi ngày?

Hoạt độngTỷ lệ thời gian
Viết code tính năng mới30%
Đọc và hiểu code cũ20%
Debug và sửa lỗi20%
Review code của đồng nghiệp10%
Viết test10%
Họp, trao đổi thiết kế, tài liệu10%

💡 Chú ý: chỉ 30% thời gian là "viết code mới". Vì vậy đọc code và debug là kỹ năng quan trọng ngang viết code.


🧱 Giai đoạn 1 (tuần 1–8): Python nền tảng và OOP​

Nội dung​

TuầnChủ đềKỹ năng đạt được
1Biến, kiểu dữ liệu, toán tử, input/printViết script tính toán
2if/elif/else, vòng lặpXử lý logic
3List, tuple, dict, setChọn cấu trúc dữ liệu đúng
4Hàm, tham số, *args/**kwargs, scopeChia nhỏ chương trình
5Xử lý file, CSV, JSON, pathlibĐọc/ghi dữ liệu
6Ngoại lệ, try/except/else/finallyCode không crash
7OOP: class, kế thừa, magic methodsThiết kế đối tượng
8Module, package, virtualenv, pipTổ chức dự án

Điều backend developer phải nắm chắc ở giai đoạn này​

1. Chọn đúng cấu trúc dữ liệu — đây là kỹ năng bị đánh giá thấp nhất:

Cấu trúcTruy cậpThêm/XoáDùng khi
listO(1) theo indexO(n) ở đầu, O(1) ở cuốiDữ liệu có thứ tự
dictO(1) theo keyO(1)Tra cứu theo khoá
setO(1) kiểm tra tồn tạiO(1)Loại trùng, kiểm tra membership
tupleO(1)Không đổiDữ liệu bất biến
# ❌ Tìm kiếm trong list — O(n), chậm khi dữ liệu lớn
danh_sach_email = ["a@x.com", "b@x.com", "c@x.com"]
if "c@x.com" in danh_sach_email: # quét từng phần tử
...

# ✅ Dùng set — O(1)
tap_email = {"a@x.com", "b@x.com", "c@x.com"}
if "c@x.com" in tap_email: # tra trực tiếp
...

Với 1 triệu bản ghi, khác biệt này là giây vs micro-giây.

2. try/except đúng cách — không nuốt lỗi:

# ❌ Bắt mọi lỗi, che giấu bug thật
try:
ket_qua = xu_ly_du_lieu(duong_dan)
except Exception:
pass

# ✅ Bắt lỗi cụ thể, log lại, xử lý đúng cách
import logging

logger = logging.getLogger(__name__)

try:
with open(duong_dan, encoding="utf-8") as f:
ket_qua = xu_ly(f.read())
except FileNotFoundError:
logger.warning("Không tìm thấy file %s, dùng cấu hình mặc định", duong_dan)
ket_qua = CAU_HINH_MAC_DINH
except json.JSONDecodeError as e:
logger.error("File %s không phải JSON hợp lệ: %s", duong_dan, e)
raise

3. OOP dùng cho việc thật, không phải class Animal:

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from decimal import Decimal


class PhuongThucThanhToan(ABC):
"""Interface cho mọi phương thức thanh toán."""

@abstractmethod
def thanh_toan(self, so_tien: Decimal) -> bool:
...

@property
@abstractmethod
def ten(self) -> str:
...


@dataclass
class ThanhToanThe(PhuongThucThanhToan):
so_the: str
han_dung: str

@property
def ten(self) -> str:
return f"Thẻ ****{self.so_the[-4:]}"

def thanh_toan(self, so_tien: Decimal) -> bool:
if so_tien <= 0:
raise ValueError("Số tiền phải dương")
# gọi cổng thanh toán...
return True


@dataclass
class ThanhToanViDienTu(PhuongThucThanhToan):
ma_vi: str
so_du: Decimal = field(default=Decimal("0"))

@property
def ten(self) -> str:
return f"Ví {self.ma_vi}"

def thanh_toan(self, so_tien: Decimal) -> bool:
if self.so_du < so_tien:
return False
self.so_du -= so_tien
return True


for pt in [ThanhToanThe("4111111111111234", "12/28"),
ThanhToanViDienTu("VI-001", Decimal("500000"))]:
ok = pt.thanh_toan(Decimal("200000"))
print(f"{pt.ten}: {'✅ thành công' if ok else '❌ thất bại'}")

Ở đây bạn học: ABC, @abstractmethod, @dataclass, Decimal cho tiền, @property — tất cả đều dùng hàng ngày ở backend.

Dự án giai đoạn 1​

Công cụ dòng lệnh quản lý chi tiêu — lưu vào JSON:

import json
from pathlib import Path
from datetime import date
from decimal import Decimal

FILE = Path("chi-tieu.json")


def doc_du_lieu() -> list[dict]:
if not FILE.exists():
return []
return json.loads(FILE.read_text(encoding="utf-8"))


def luu_du_lieu(ds: list[dict]) -> None:
FILE.write_text(json.dumps(ds, ensure_ascii=False, indent=2), encoding="utf-8")


def them(so_tien: str, danh_muc: str, ghi_chu: str = "") -> None:
ds = doc_du_lieu()
ds.append({
"ngay": date.today().isoformat(),
"so_tien": str(Decimal(so_tien)),
"danh_muc": danh_muc,
"ghi_chu": ghi_chu,
})
luu_du_lieu(ds)
print(f"✅ Đã thêm {so_tien} cho {danh_muc}")


def bao_cao_theo_danh_muc() -> None:
ds = doc_du_lieu()
if not ds:
print("Chưa có dữ liệu.")
return
tong: dict[str, Decimal] = {}
for item in ds:
tong[item["danh_muc"]] = tong.get(item["danh_muc"], Decimal(0)) + Decimal(item["so_tien"])
for danh_muc, so in sorted(tong.items(), key=lambda x: -x[1]):
print(f"{danh_muc:<16} {so:>12,.0f} đ")
print(f"{'TỔNG':<16} {sum(tong.values()):>12,.0f} đ")


if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Dùng: python chi_tieu.py them <số_tiền> <danh_mục>")
print(" python chi_tieu.py bao-cao")
sys.exit(1)

lenh = sys.argv[1]
if lenh == "them":
them(sys.argv[2], sys.argv[3], " ".join(sys.argv[4:]))
elif lenh == "bao-cao":
bao_cao_theo_danh_muc()
else:
print(f"Lệnh không hợp lệ: {lenh}")

Tiêu chí hoàn thành giai đoạn 1​

  • Viết được chương trình 150+ dòng chia thành nhiều hàm, không cần tra cú pháp
  • Giải thích được khi nào dùng list vs dict vs set
  • Dùng try/except đúng chỗ, không nuốt lỗi
  • Biết tạo venv và cài package

🛠️ Giai đoạn 2 (tuần 9–12): Git, Linux và công cụ​

Nhiều người bỏ qua giai đoạn này và trả giá suốt sự nghiệp.

Git — bắt buộc​

# Cấu hình lần đầu
git config --global user.name "Tên Bạn"
git config --global user.email "email@example.com"

# Workflow cơ bản hàng ngày
git status
git add .
git commit -m "feat: thêm chức năng đăng nhập"
git push origin main

# Làm việc nhánh — KỸ NĂNG QUAN TRỌNG NHẤT
git checkout -b feature/them-dang-nhap
# ... code ...
git commit -m "feat: hoàn thiện đăng nhập"
git push origin feature/them-dang-nhap
# rồi mở Pull Request trên GitHub

Conventional Commits — quy ước commit mà mọi team chuyên nghiệp dùng:

Tiền tốÝ nghĩa
feat:Thêm tính năng
fix:Sửa bug
docs:Sửa tài liệu
refactor:Tái cấu trúc, không đổi hành vi
test:Thêm/sửa test
chore:Việc lặt vặt (deps, config)

Viết commit rõ ràng giúp bạn được đánh giá cao hơn ngay từ ngày đầu đi làm.

Linux / command line cơ bản​

LệnhCông dụng
ls -laLiệt kê file, gồm file ẩn
cd, pwdDi chuyển, xem thư mục hiện tại
grep -rn "text" .Tìm text trong toàn bộ thư mục
find . -name "*.py"Tìm file theo mẫu
tail -f app.logXem log đang chạy (dùng hàng ngày!)
ps aux | grep pythonXem tiến trình Python
chmod +x script.shCấp quyền chạy
ssh user@serverKết nối server
curl -X POST ...Test API từ terminal

Công cụ nên cài​

Công cụVai trò
VS Code hoặc PyCharmIDE
Postman / InsomniaTest API trực quan
TablePlus / DBeaverXem database
Docker DesktopChạy database local dễ dàng

🌐 Giai đoạn 3 (tuần 13–20): Web hoạt động thế nào + Flask​

Kiến thức web bắt buộc​

HTTP request/response — nền tảng của mọi thứ:

GET /api/san-pham?page=2 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGc...
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json

{"data": [...], "page": 2, "total": 47}

Bảng mã trạng thái — phải thuộc lòng:

NhómMãÝ nghĩaKhi nào dùng
2xx200OKGET/PUT thành công
2xx201CreatedPOST tạo mới thành công
2xx204No ContentDELETE thành công
4xx400Bad RequestDữ liệu gửi lên sai định dạng
4xx401UnauthorizedChưa đăng nhập
4xx403ForbiddenĐã đăng nhập nhưng không có quyền
4xx404Not FoundKhông tìm thấy tài nguyên
4xx409ConflictTrùng dữ liệu (email đã tồn tại)
4xx422UnprocessableDữ liệu đúng định dạng nhưng sai logic
4xx429Too Many RequestsVượt rate limit
5xx500Internal Server ErrorLỗi phía server (bug)
5xx503Service UnavailableServer quá tải/bảo trì

⚠️ Lỗi kinh điển của người mới: trả về 200 OK kèm {"success": false, "error": "..."}. Điều này phá vỡ mọi thư viện client. Hãy dùng đúng mã trạng thái.

Flask — hiểu web từ gốc​

from flask import Flask, jsonify, request, abort

app = Flask(__name__)

# "Database" trong bộ nhớ
SACH = {
1: {"id": 1, "ten": "Lập trình Python", "gia": 250000},
2: {"id": 2, "ten": "Cấu trúc dữ liệu", "gia": 180000},
}
_bo_dem = 2


@app.get("/api/sach")
def danh_sach_sach():
return jsonify(list(SACH.values()))


@app.get("/api/sach/<int:sach_id>")
def chi_tiet_sach(sach_id):
sach = SACH.get(sach_id)
if sach is None:
abort(404, description=f"Không tìm thấy sách id={sach_id}")
return jsonify(sach)


@app.post("/api/sach")
def them_sach():
global _bo_dem
du_lieu = request.get_json(silent=True) or {}

if not du_lieu.get("ten"):
abort(400, description="Thiếu trường 'ten'")
if not isinstance(du_lieu.get("gia"), int) or du_lieu["gia"] <= 0:
abort(400, description="'gia' phải là số nguyên dương")

_bo_dem += 1
SACH[_bo_dem] = {"id": _bo_dem, **du_lieu}
return jsonify(SACH[_bo_dem]), 201


@app.put("/api/sach/<int:sach_id>")
def cap_nhat_sach(sach_id):
if sach_id not in SACH:
abort(404)
du_lieu = request.get_json(silent=True) or {}
SACH[sach_id] = {"id": sach_id, **du_lieu}
return jsonify(SACH[sach_id])


@app.delete("/api/sach/<int:sach_id>")
def xoa_sach(sach_id):
if SACH.pop(sach_id, None) is None:
abort(404)
return "", 204


@app.errorhandler(404)
def khong_tim_thay(e):
return jsonify({"loi": str(e.description)}), 404


@app.errorhandler(400)
def du_lieu_sai(e):
return jsonify({"loi": str(e.description)}), 400

Đọc kỹ đoạn này — nó chứa gần hết những gì cần biết về REST API cơ bản: routing, path param, validate, mã trạng thái, error handler.

Dự án giai đoạn 3​

API quản lý thư viện — CRUD đầy đủ cho 2 bảng (sách, người mượn), có validate và error handler. Đây là dự án bạn sẽ nâng cấp qua các giai đoạn sau.


🗄️ Giai đoạn 4 (tuần 21–28): Database và ORM​

SQL — không thể bỏ qua​

Cho dù dùng ORM, bạn phải hiểu SQL. Không hiểu SQL nghĩa là không debug được query chậm.

-- Tạo bảng với ràng buộc đầy đủ
CREATE TABLE tac_gia (
id SERIAL PRIMARY KEY,
ten VARCHAR(120) NOT NULL,
email VARCHAR(120) UNIQUE NOT NULL
);

CREATE TABLE sach (
id SERIAL PRIMARY KEY,
ten VARCHAR(200) NOT NULL,
gia NUMERIC(12,0) NOT NULL CHECK (gia > 0),
tac_gia_id INTEGER NOT NULL REFERENCES tac_gia(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW()
);

-- INDEX — thứ quyết định query nhanh hay chậm
CREATE INDEX idx_sach_tac_gia ON sach(tac_gia_id);

-- JOIN + GROUP BY + HAVING
SELECT t.ten,
COUNT(s.id) AS so_sach,
SUM(s.gia) AS tong_gia_tri
FROM tac_gia t
LEFT JOIN sach s ON s.tac_gia_id = t.id
GROUP BY t.id, t.ten
HAVING COUNT(s.id) > 1
ORDER BY tong_gia_tri DESC
LIMIT 10;

Bốn khái niệm SQL quan trọng nhất:

Khái niệmÝ nghĩaVì sao quan trọng
INDEXCấu trúc tăng tốc tra cứuQuery 5 giây → 5 mili-giây
JOINKết hợp nhiều bảng90% query thật cần JOIN
TRANSACTIONNhóm thao tác "tất cả hoặc không gì cả"Chuyển tiền: trừ A và cộng B phải cùng thành công
EXPLAINXem kế hoạch thực thi queryCông cụ debug hiệu năng số 1
-- Luôn dùng EXPLAIN khi query chậm
EXPLAIN ANALYZE SELECT * FROM sach WHERE tac_gia_id = 5;

ORM với SQLAlchemy​

from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, Numeric, ForeignKey, DateTime, func, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session


class Base(DeclarativeBase):
pass


class TacGia(Base):
__tablename__ = "tac_gia"

id: Mapped[int] = mapped_column(primary_key=True)
ten: Mapped[str] = mapped_column(String(120))
email: Mapped[str] = mapped_column(String(120), unique=True)

sach: Mapped[list["Sach"]] = relationship(back_populates="tac_gia", cascade="all, delete-orphan")

def __repr__(self) -> str:
return f"TacGia(id={self.id}, ten={self.ten!r})"


class Sach(Base):
__tablename__ = "sach"

id: Mapped[int] = mapped_column(primary_key=True)
ten: Mapped[str] = mapped_column(String(200))
gia: Mapped[Decimal] = mapped_column(Numeric(12, 0))
tac_gia_id: Mapped[int] = mapped_column(ForeignKey("tac_gia.id"))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

tac_gia: Mapped[TacGia] = relationship(back_populates="sach")


# Sử dụng
with Session(engine) as session:
tg = TacGia(ten="Nguyễn Văn A", email="a@example.com")
tg.sach = [Sach(ten="Python cơ bản", gia=Decimal("250000"))]
session.add(tg)
session.commit()

# Truy vấn với eager loading — tránh N+1 query
stmt = (
select(TacGia)
.join(TacGia.sach)
.where(Sach.gia > 200000)
.options(selectinload(TacGia.sach))
)
for tac_gia in session.scalars(stmt):
print(tac_gia.ten, [s.ten for s in tac_gia.sach])

🔥 Vấn đề N+1 query là lỗi hiệu năng phổ biến nhất với ORM. Nếu bạn lặp qua 100 tác giả và mỗi lần truy cập tac_gia.sach lại phát sinh 1 query → 101 query. selectinload/joinedload giải quyết việc này.

Dự án giai đoạn 4​

Nâng cấp API thư viện: chuyển từ dict trong bộ nhớ sang PostgreSQL + SQLAlchemy, thêm relationship, thêm index, viết 5 query phức tạp có JOIN + GROUP BY.


🔐 Giai đoạn 5 (tuần 29–36): API chuyên nghiệp​

Xác thực với JWT​

from datetime import datetime, timedelta, timezone
from passlib.context import CryptContext
import jwt

SECRET = "đọc-từ-biến-môi-trường-trong-thực-tế"
THUAT_TOAN = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


def bam_mat_khau(mat_khau: str) -> str:
return pwd_context.hash(mat_khau)


def kiem_tra_mat_khau(mat_khau: str, da_bam: str) -> bool:
return pwd_context.verify(mat_khau, da_bam)


def tao_token(user_id: int, vai_tro: str = "user") -> str:
payload = {
"sub": str(user_id),
"vai_tro": vai_tro,
"exp": datetime.now(timezone.utc) + timedelta(hours=24),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, SECRET, algorithm=THUAT_TOAN)


def giai_ma_token(token: str) -> dict:
return jwt.decode(token, SECRET, algorithms=[THUAT_TOAN])

Ba quy tắc bảo mật không được vi phạm:

  1. Không bao giờ lưu mật khẩu dạng thô — luôn băm bằng bcrypt/argon2 (không dùng MD5/SHA1).
  2. Không bao giờ để secret trong code — dùng biến môi trường.
  3. Luôn kiểm tra quyền — xác thực (bạn là ai) khác uỷ quyền (bạn được làm gì).

Phân quyền​

from functools import wraps
from flask import request, jsonify, g


def yeu_cau_dang_nhap(f):
@wraps(f)
def wrapper(*args, **kwargs):
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return jsonify({"loi": "Thiếu token"}), 401
try:
g.nguoi_dung = giai_ma_token(header[7:])
except jwt.ExpiredSignatureError:
return jsonify({"loi": "Token đã hết hạn"}), 401
except jwt.InvalidTokenError:
return jsonify({"loi": "Token không hợp lệ"}), 401
return f(*args, **kwargs)
return wrapper


def yeu_cau_quyen(vai_tro_can: str):
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
if g.nguoi_dung.get("vai_tro") != vai_tro_can:
return jsonify({"loi": "Không có quyền truy cập"}), 403
return f(*args, **kwargs)
return wrapper
return decorator


@app.delete("/api/sach/<int:sach_id>")
@yeu_cau_dang_nhap
@yeu_cau_quyen("admin")
def xoa_sach(sach_id):
...

Validation với Pydantic​

from pydantic import BaseModel, Field, EmailStr, field_validator
from decimal import Decimal


class SachVao(BaseModel):
ten: str = Field(min_length=1, max_length=200)
gia: Decimal = Field(gt=0, le=100_000_000)
tac_gia_email: EmailStr

@field_validator("ten")
@classmethod
def chuan_hoa_ten(cls, v: str) -> str:
return " ".join(v.split())


sach = SachVao.model_validate({"ten": " Python cơ bản ", "gia": "250000",
"tac_gia_email": "a@example.com"})
print(sach.ten) # "Python cơ bản" (đã chuẩn hoá)

Testing — kỹ năng phân biệt junior và mid​

import pytest
from app import app, SACH


@pytest.fixture
def client():
app.config["TESTING"] = True
with app.test_client() as c:
yield c


@pytest.fixture(autouse=True)
def reset_du_lieu():
"""Mỗi test chạy trên dữ liệu sạch."""
goc = SACH.copy()
yield
SACH.clear()
SACH.update(goc)


def test_lay_danh_sach(client):
r = client.get("/api/sach")
assert r.status_code == 200
assert len(r.get_json()) == 2


def test_lay_sach_khong_ton_tai(client):
r = client.get("/api/sach/9999")
assert r.status_code == 404
assert "loi" in r.get_json()


def test_them_sach_thieu_ten(client):
r = client.post("/api/sach", json={"gia": 100000})
assert r.status_code == 400


def test_them_sach_thanh_cong(client):
r = client.post("/api/sach", json={"ten": "Sách mới", "gia": 99000})
assert r.status_code == 201
assert r.get_json()["ten"] == "Sách mới"


@pytest.mark.parametrize("gia", [0, -100, "abc", None])
def test_them_sach_gia_khong_hop_le(client, gia):
r = client.post("/api/sach", json={"ten": "X", "gia": gia})
assert r.status_code == 400

Chạy: pytest -v --cov=app để xem độ phủ test.


🚀 Giai đoạn 6 (tuần 37–44): Chọn Django hoặc FastAPI​

Sau khi đã hiểu Flask và web từ gốc, hãy chọn một hướng chuyên sâu.

Chọn Django nếu bạn muốn​

  • Làm sản phẩm hoàn chỉnh nhanh: có admin, auth, ORM, form sẵn.
  • Làm việc ở công ty có nhiều dự án web truyền thống.
  • Cần CMS, ERP, hệ thống quản lý nội bộ.

Điểm mạnh độc nhất: Django Admin cho bạn giao diện quản trị hoàn chỉnh miễn phí.

Chọn FastAPI nếu bạn muốn​

  • Làm API cho mobile/frontend hoặc phục vụ model ML.
  • Cần hiệu năng cao, xử lý nhiều request đồng thời.
  • Thích code hiện đại với type hints và tự sinh tài liệu.

Điểm mạnh độc nhất: tài liệu Swagger tự sinh — bạn giao API cho team frontend và họ tự đọc.

Nếu chưa quyết được: học FastAPI trước (nhỏ hơn, học nhanh), rồi học Django sau (dễ hơn nhiều khi đã nắm web).


🐳 Giai đoạn 7 (tuần 45–48): Docker, deploy, CI/CD​

Docker — đóng gói để "chạy được ở mọi máy"​

FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1

WORKDIR /app

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

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

COPY . .

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

EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"

CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000", "--workers", "4"]
# docker-compose.yml — chạy app + database cùng lúc
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: mydb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s

api:
build: .
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://app:secret@db:5432/mydb
depends_on:
db:
condition: service_healthy

volumes:
pgdata:
docker compose up --build     # chạy toàn bộ hệ thống bằng 1 lệnh

CI/CD với GitHub Actions​

name: CI

on:
push:
branches: [main]
pull_request:

jobs:
kiem-tra:
runs-on: ubuntu-latest

services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 5

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Cài phụ thuộc
run: pip install -r requirements.txt -r requirements-dev.txt

- name: Kiểm tra định dạng
run: ruff check .

- name: Kiểm tra kiểu
run: mypy app

- name: Chạy test
run: pytest -v --cov=app --cov-report=term-missing
env:
DATABASE_URL: postgresql://postgres:test@localhost:5432/testdb

- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .

Checklist bảo mật trước khi deploy​

  • DEBUG = False trên production
  • Mọi secret đọc từ biến môi trường
  • HTTPS bắt buộc
  • CORS chỉ cho domain cụ thể (không *)
  • Rate limiting trên endpoint đăng nhập
  • Validate toàn bộ input, dùng tham số hoá SQL
  • Có logging và health check
  • Backup database tự động

🎯 Giai đoạn 8 (tuần 49–52): Dự án tổng hợp và phỏng vấn​

Dự án tổng hợp — chọn 1, làm thật sâu​

Ý tưởngCông nghệĐiểm mạnh khi phỏng vấn
Nền tảng blog có APIFastAPI + PostgreSQL + JWT + DockerThể hiện mọi kỹ năng
Hệ thống đặt hàngDjango + DRF + Celery + RedisThể hiện xử lý nghiệp vụ phức tạp
API phân tích dữ liệu công khaiFastAPI + pandas + cacheThể hiện xử lý dữ liệu + hiệu năng
Hệ thống quản lý nội bộDjango + Admin + PostgreSQLGần với công việc thật nhất

Checklist dự án đủ tốt:

  • Có README với ảnh chụp màn hình
  • Có Docker, chạy được bằng 1 lệnh
  • Có test với coverage > 70%
  • Có CI chạy tự động
  • Có tài liệu API (Swagger/OpenAPI)
  • Có deploy thật, có link truy cập được
  • Có migration database
  • Có xử lý lỗi và logging tử tế

Bộ câu hỏi phỏng vấn backend Python​

Python:

  1. List comprehension và generator expression khác nhau thế nào?
  2. *args và **kwargs — dùng khi nào?
  3. Vì sao def f(x=[]) là lỗi? Giải thích cơ chế.
  4. Shallow copy và deep copy — phân biệt và ví dụ.
  5. Decorator hoạt động ra sao? Viết decorator đo thời gian.
  6. GIL ảnh hưởng gì tới đa luồng? Khi nào dùng multiprocessing?
  7. __slots__ dùng để làm gì?
  8. Context manager là gì? Viết một cái.

Web & API:

  1. Khi nào dùng PUT và khi nào dùng PATCH?
  2. Làm sao chống spam/brute-force cho endpoint đăng nhập?
  3. Xử lý file upload lớn như thế nào?
  4. Idempotency trong API là gì? Vì sao quan trọng với thanh toán?
  5. CORS là gì? Vì sao browser chặn request?
  6. Làm sao versioning API?

Database:

  1. Index hoạt động thế nào? Khi nào index làm chậm?
  2. Transaction isolation level — có mấy mức?
  3. N+1 query là gì và cách phát hiện?
  4. Vì sao dùng connection pool?
  5. Optimistic vs pessimistic locking?

Kiến trúc:

  1. Khi nào cần cache? Cache invalidation ra sao?
  2. Xử lý tác vụ nặng (gửi email, xử lý ảnh) — dùng gì?
  3. Làm sao scale ứng dụng khi có 1 triệu người dùng?
  4. Monolith vs microservices — khi nào chọn cái nào?

📖 Kỹ năng đọc code — thứ không ai dạy bạn​

Trường học dạy bạn viết code. Không ai dạy bạn đọc code — nhưng đó là 50% công việc thật.

Khi vào công ty, việc đầu tiên của bạn gần như chắc chắn là: "Đọc codebase này đi, rồi sửa một bug nhỏ." Codebase có thể 50.000 dòng, do 20 người viết trong 5 năm, không có tài liệu.

Cách đọc một codebase lạ​

Bước 1 — Chạy được nó trước. Đừng đọc. Hãy chạy:

git clone <repo>
cd <repo>
python -m venv .venv && .venv\Scripts\activate
pip install -r requirements.txt
# đọc README để biết cách chạy
pytest

Nếu test chạy được, bạn đã có "lưới an toàn": sửa sai sẽ bị test phát hiện.

Bước 2 — Tìm điểm vào (entry point). Với web app, đó là nơi route được đăng ký:

# Tìm tất cả route
grep -rn "@app.route\|@app.get\|@app.post" app/
grep -rn "urlpatterns\|APIRouter" .

Bước 3 — Đi theo một luồng duy nhất. Đừng đọc lan man. Chọn một API endpoint, rồi lần theo:

Request → Route → Validation → Business logic → Database → Response

Ví dụ với POST /api/don-hang:

  1. Route nằm ở file nào?
  2. Hàm route gọi hàm nào tiếp?
  3. Dữ liệu được validate ở đâu?
  4. Logic nghiệp vụ nằm ở service nào, hay nằm luôn trong route?
  5. Truy vấn database nằm ở đâu?

Chỉ cần làm điều này với 3–5 endpoint là bạn đã hiểu kiến trúc dự án.

Bước 4 — Vẽ sơ đồ. Vẽ ra giấy cấu trúc thư mục và luồng dữ liệu. Nghe có vẻ trẻ con, nhưng nó giúp bạn nhớ và giúp bạn đặt câu hỏi đúng khi hỏi đồng nghiệp.

Bước 5 — Sửa bug nhỏ đầu tiên. Chọn bug nhỏ nhất trong issue tracker. Sửa nó, viết test cho nó, mở Pull Request. Đây là cách nhanh nhất để được coi là thành viên thật của team.

Đọc source của thư viện bạn dùng​

Đây là cách học nhanh nhất mà ít người làm. Khi bạn không hiểu Flask hoặc FastAPI xử lý một thứ gì đó, hãy mở source:

# Xem source ngay trong terminal
python -c "import flask, inspect; print(inspect.getsourcefile(flask))"

# Hoặc trong Python
import requests, inspect
print(inspect.getsource(requests.Session.request))

Đọc code của thư viện nổi tiếng giúp bạn thấy cách người giỏi viết code: cách họ đặt tên, cách họ xử lý lỗi, cách họ tổ chức module.


🧪 Cách luyện tập hiệu quả trong 12 tháng​

Quy tắc 70/20/10​

Tỷ lệHoạt độngGhi chú
70%Tự viết code, làm dự ánPhần quan trọng nhất
20%Đọc code người khác, đọc docs/sourceHọc từ người giỏi
10%Xem video, đọc sách, học lý thuyếtChỉ để gợi mở

Hầu hết người mới làm ngược lại: 80% xem video, 20% gõ code. Đó là lý do họ học mãi không tiến bộ.

Cách luyện "không copy-paste"​

Cách duy nhất để thực sự nhớ:

  1. Đọc ví dụ trong tài liệu.
  2. Đóng tài liệu lại.
  3. Viết lại từ đầu, không nhìn.
  4. Bí quá thì mở ra xem 5 giây rồi đóng lại.
  5. Sau khi chạy được, so sánh code bạn viết với code mẫu — khác chỗ nào, vì sao?

Cách này đau đớn hơn nhiều so với copy-paste. Nhưng nó là sự khác biệt giữa "biết" và "biết làm".

Lịch luyện tập mẫu theo tuần​

NgàyThời lượngNội dung
Thứ 290 phútHọc khái niệm mới + bài tập
Thứ 390 phútLuyện thuật toán (LeetCode easy)
Thứ 490 phútLàm dự án cá nhân
Thứ 590 phútĐọc code người khác hoặc đọc source thư viện
Thứ 690 phútLàm dự án cá nhân
Thứ 7120 phútHoàn thiện dự án + viết README
Chủ nhậtNghỉNão cần thời gian củng cố

Tổng: khoảng 10–11 giờ/tuần. Con số này thực tế hơn nhiều so với kế hoạch "học 4 giờ mỗi ngày" mà không ai duy trì được quá 2 tuần.

Ba câu hỏi tự kiểm tra mỗi tuần​

  1. Tuần này tôi viết được bao nhiêu dòng code tự nghĩ, không copy?
  2. Tôi có thể giải thích thứ tôi vừa học cho người khác không?
  3. Dự án tôi đang làm có tiến triển nhìn thấy được không?

Nếu cả ba câu đều "không", bạn đang lãng phí thời gian — hãy đổi cách học.


⚠️ 8 sai lầm khiến bạn tốn thêm 1 năm​

  1. Học 3 framework cùng lúc. Chọn 1, đi đến nơi đến chốn.
  2. Không học SQL vì "đã có ORM". ORM không cứu bạn khi query chạy 30 giây.
  3. Không viết test. Bạn sẽ không được nhận vào vị trí mid/senior.
  4. Không học Docker. Mọi team chuyên nghiệp đều dùng.
  5. Chỉ làm dự án todo list. Nhà tuyển dụng đã thấy 1000 cái như thế.
  6. Không đọc code của người khác. Đọc Django/FastAPI source dạy bạn nhiều hơn tutorial.
  7. Học mà không deploy. Dự án không deploy = dự án chưa xong.
  8. Không chuẩn bị phỏng vấn. Biết code mà không diễn đạt được thì vẫn trượt.

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

Backend Python có còn dễ xin việc không? Có, đặc biệt ở mảng API, data platform, automation. Nhu cầu cao nhưng yêu cầu cũng cao hơn 5 năm trước — biết CRUD cơ bản không còn đủ, cần Docker, testing, cloud.

Cần học frontend không? Không cần thành thạo, nhưng hiểu HTML/CSS/JS cơ bản giúp bạn phối hợp với frontend tốt hơn nhiều. Biết React là lợi thế lớn.

Có cần bằng đại học? Không bắt buộc, nhưng nếu không có, portfolio và kinh nghiệm phải thật nổi bật.

Bao lâu để thành mid-level? Thường 2–3 năm làm việc thật. Học 12 tháng đưa bạn tới junior; từ junior lên mid cần kinh nghiệm dự án thật, không phải học thêm khoá học.

Nên học async Python không? Có, nhưng chỉ sau khi vững sync. Hiểu async/await và khi nào không nên dùng async quan trọng hơn là biết cú pháp.


🎯 Tóm lại​

Lộ trình backend Python trong 12 tháng:

8 tuần  → Python + OOP vững
4 tuần → Git + Linux + công cụ
8 tuần → Web + Flask
8 tuần → Database + SQL + ORM
8 tuần → API chuyên nghiệp + Auth + Test
8 tuần → Django HOẶC FastAPI
4 tuần → Docker + Deploy + CI/CD
4 tuần → Dự án tổng hợp + phỏng vấn

Điều duy nhất quyết định thành công không phải là lộ trình này, mà là bạn có gõ code mỗi ngày hay không.


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