Lộ trình học Python Django Backend
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 khi | Nên chọn framework khác khi |
|---|---|
| Làm web app có giao diện + admin | Cần API siêu nhẹ, ít tính năng (→ Flask) |
| Cần CRUD nhanh cho nhiều bảng | Cần async native, WebSocket nhiều (→ FastAPI) |
| Team đông, cần quy ước chung | Muố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ẵn | Prototype 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ần | Cần hiểu |
|---|---|
| Method | GET, POST, PUT, PATCH, DELETE — và tính idempotent của từng cái |
| Mã trạng thái | 200, 201, 204, 400, 401, 403, 404, 500 |
| Header | Content-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ần | Vai trò | So với MVC |
|---|---|---|
| Model | Định nghĩa dữ liệu | Giống Model |
| Template | Hiển thị | Giống View |
| View | Xử lý logic | Giố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:
| Converter | Khớp với | Ví 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_404thay vìBaiViet.objects.get(...).get()némDoesNotExist→ lỗi 500.get_object_or_404mớ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 định | Lý 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ục | Xoá danh mục không nên xoá bài |
db_index=True cho slug, trang_thai, ngay_tao | Các trường hay lọc/sắp xếp |
indexes tổ hợp | Query thật luôn lọc theo nhiều cột |
CheckConstraint | Ràng buộc logic ngay ở tầng database |
⚠️ Tuyệt đối không dùng
FloatFieldcho tiền. LuônDecimalField. 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:
- Không bao giờ sửa file trong
migrations/bằng tay. - Luôn
sqlmigrateđể xem SQL trước khi migrate production. - Đổ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 view | Dùng cho | Ví dụ |
|---|---|---|
TemplateView | Trang tĩnh | Giới thiệu, liên hệ |
ListView | Danh sách có phân trang | Danh sách bài viết |
DetailView | Chi tiết một bản ghi | Chi tiết bài viết |
CreateView | Form tạo mới | Tạo bài viết |
UpdateView | Form cập nhật | Sửa bài viết |
DeleteView | Xá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/DetailViewvì 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àm | Dùng cho | Số query |
|---|---|---|
select_related | ForeignKey, OneToOne | 1 (JOIN) |
prefetch_related | ManyToMany, quan hệ ngược | 2 |
Prefetch | prefetch có lọc | 2 |
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
idrồ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_KEYtừ env, đủ dài và ngẫu nhiên -
ALLOWED_HOSTSkhai báo rõ ràng - HTTPS bắt buộc + HSTS
- Cookie
Secure,HttpOnly,SameSite -
CSRF_TRUSTED_ORIGINScấ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-extensionsrồi dùngpython 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 khi | Ví dụ |
|---|---|---|
DEBUG | Chi tiết chỉ cần khi phát triển | Giá trị biến trung gian |
INFO | Sự kiện bình thường, quan trọng | "Đã xuất bản bài viết 42" |
WARNING | Bất thường nhưng hệ thống vẫn chạy | "Không tìm thấy bài viết" |
ERROR | Thao tác thất bại | "Không gửi được email" |
exception() | Lỗi có traceback | Trong 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ỗi | Nguyên nhân | Cách sửa |
|---|---|---|
no such table | Chưa migrate | python manage.py migrate |
Table already exists | Migration lệch trạng thái | --fake-initial rồi kiểm tra showmigrations |
TemplateDoesNotExist | Sai cấu trúc thư mục | Cần app/templates/app/file.html |
AppRegistryNotReady | Import model quá sớm | Import trong hàm |
Reverse for 'x' not found | Sai name= hoặc thiếu app_name | Kiểm tra urls.py |
RelatedObjectDoesNotExist | Truy cập OneToOne chưa có | Dùng hasattr() hoặc try/except |
| Migration conflict khi làm nhóm | Hai người tạo migration cùng số | makemigrations --merge |
Maximum recursion depth | Signal 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 DEBUGnà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ại | Là gì | Phục vụ bởi |
|---|---|---|
| static | CSS, JS, ảnh của bạn | nginx, whitenoise, CDN |
| media | File người dùng upload | nginx, 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.
runserverlà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:
- Luôn tạo migration ngay sau khi sửa model, đừng để tích tụ.
- Test migration cả hai chiều trên database copy:
migrate app 0001rồimigrate app. - 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ì
- Logic nghiệp vụ trong view. Tách ra
services.py. - Fat model với 2000 dòng. Chia thành mixin hoặc module riêng.
- Không tối ưu truy vấn. N+1 là nguyên nhân làm chậm số 1.
- Dùng
FloatFieldcho tiền. DùngDecimalField. - Đọc rồi ghi thay vì dùng
F(). Race condition. - Không dùng migration cho thay đổi schema.
create_all()chỉ có trong tutorial. - Bỏ qua
admin.py. Bạn đang vứt đi lợi thế lớn nhất của Django. - 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.