Middleware سفارشی در جنگو؛ چرا ردیابی خودکار بدون آن غیرممکن است؟
چطور یک middleware سبک و امن بنویسید که روی هر درخواست اجرا شود، بدون اینکه سرعت سایت را فدا کند یا در استثناها بشکند؟
وقتی برای اولین بار نیاز پیدا میکنید روی هر درخواست سایتتان چیزی ثبت کنید، اولین گزینهای که به ذهن میرسد تغییر تمام ویوهاست؛ ولی Middleware در جنگو دقیقاً برای همین لحظه طراحی شده است: اجرای خودکار یک منطق، روی هر درخواست و هر پاسخ، بدون دستزدن به ویوها.
Middleware در جنگو دقیقاً چیست؟
Middleware در جنگو یک لایهی نازک است که بین وبسرور (یا WSGI/ASGI container) و سیستم مسیریابی جنگو قرار میگیرد. این لایه، در قالب یک یا چند کلاس پایتونی پیادهسازی میشود و به شما اجازه میدهد در نقاط مشخصی از چرخهی درخواست-پاسخ، کد خود را اجرا کنید.
جنگو خودش چند middleware پیشفرض دارد که هر کدام مسئولیت مشخصی دارند: SecurityMiddleware برای هدرهای امنیتی، SessionMiddleware برای مدیریت نشست، CsrfViewMiddleware برای محافظت CSRF (Cross-Site Request Forgery)، AuthenticationMiddleware برای شناسایی کاربر جاری و MessageMiddleware برای پیامهای موقت. هر یک از اینها، در یک نقطهی دقیق از چرخه اجرا میشوند و ترتیبشان تصادفی نیست.
مفهوم Middleware از حوزهی معماری نرمافزار میآید و در مستندات آکادمیک آن، بهعنوان الگویی برای جداکردن دغدغههای عرضی (cross-cutting concerns) شناخته میشود. برای مطالعهی مفهوم پایهای آن میتوانید به مقالهی Middleware در ویکیپدیا مراجعه کنید.
Middleware یک جعبهابزار برای اجرای منطق تکراری است، نه یک محل مناسب برای منطق کسبوکار. اگر بتوانید منطق را در یک لایهی دیگر قرار بدهید، آن کار را بکنید.
نکتهی مهم اینجاست که جنگو خودش از middleware برای کارهای زیرساختی استفاده میکند. اگر شما هم یک middleware ردیابی بنویسید، دقیقاً در همان سطح از انتزاع کار میکنید. این یعنی باید همان دقت و انضباطی که در middlewareهای هستهی جنگو دیده میشود را در middleware خودتان هم رعایت کنید. برای اینکه تفاوت این ابزار با سایر ابزارهای مشابه را بشناسید، پیشنهاد میکنم تفاوت Middleware و Context Processor در جنگو را مطالعه کنید.
چرخهی کامل درخواست و پاسخ در جنگو
برای اینکه middleware را درست بنویسید، ابتدا باید چرخهی درخواست را دقیق بشناسید. ترتیب وقایع به این شکل است:
مرحلهی اول، ورود درخواست. سرور WSGI یا ASGI درخواست HTTP را دریافت میکند و آن را به یک آبجکت HttpRequest تبدیل میکند.
مرحلهی دوم، عبور از middlewareها به سمت پایین. درخواست از بالا به پایین در زنجیرهی middleware حرکت میکند. هر middleware در متد process_request یا در متد __call__ (در سبک جدید) میتواند درخواست را دستکاری کند یا آن را متوقف کند.
مرحلهی سوم، انتخاب ویو. سیستم مسیریابی جنگو، ویوی مناسب را انتخاب میکند.
مرحلهی چهارم، اجرای ویو. ویو، درخواست را پردازش میکند و یک HttpResponse برمیگرداند.
مرحلهی پنجم، عبور از middlewareها به سمت بالا. پاسخ از پایین به بالا در زنجیره حرکت میکند. هر middleware در متد process_response میتواند پاسخ را دستکاری کند.
مرحلهی ششم، بازگشت به سرور. پاسخ نهایی به وبسرور برگردانده میشود.
Client Request
↓
SecurityMiddleware (process_request)
↓
SessionMiddleware (process_request)
↓
CommonMiddleware (process_request)
↓
CsrfViewMiddleware (process_request)
↓
AuthenticationMiddleware (process_request)
↓
MessageMiddleware (process_request)
↓
[Your Tracking Middleware] (process_request) ← اینجا وارد میشوید
↓
URL Resolver
↓
View
↓
[Your Tracking Middleware] (process_response) ← اینجا خارج میشوید
↑
MessageMiddleware (process_response)
↑
AuthenticationMiddleware (process_response)
↑
CsrfViewMiddleware (process_response)
↑
CommonMiddleware (process_response)
↑
SessionMiddleware (process_response)
↑
SecurityMiddleware (process_response)
↑
Client Response
نکتهی کلیدی این است که یک middleware، هم در مسیر رفت و هم در مسیر برگشت اجرا میشود. اگر در process_request یک متغیر روی request بگذارید، در process_response قابل دسترسی است. این ویژگی، پایهی اصلی پیادهسازی ردیابی خودکار است.
چرا ردیابی خودکار بدون middleware ممکن نیست؟
تصور کنید میخواهید هر بازدید از سایت را ثبت کنید. سه راه پیشرو دارید.
راه اول، ویرایش تمام ویوها. در ابتدای هر ویو، یک تابع track_visit(request) صدا بزنید. این رویکرد سه مشکل جدی دارد: اول، با اضافهشدن هر ویو جدید باید یادتان باشد که این تابع را صدا بزنید. دوم، در ویوهای class-based باید این را در متد dispatch بنویسید. سوم، ویوهای شخص ثالث (مثل پنل ادمین یا کتابخانههای خارجی) از دست شما خارجند.
راه دوم، استفاده از decorator. هر ویو را با یک decorator مخصوص ردیابی تزئین کنید. این رویکرد کمی بهتر از راه اول است، ولی همان مشکل را دارد: باید همهجا یادتان باشد که decorator را اضافه کنید.
راه سوم، استفاده از middleware. یک کلاس بنویسید که بهطور خودکار روی تمام درخواستها اجرا شود. این رویکرد، هم جامع است، هم قابل نگهداری.
در ساختار پروژههای واقعی، این تفاوت خیلی زود خودش را نشان میدهد. اگر با الگوی ساخت اپ جنگو برای ردیابی بازدیدکننده کار کرده باشید، میدانید که در آن ساختار، middleware قلب سیستم است. اگر با services.py در جنگو آشنا هستید، منطق کسبوکار را در سرویس میگذارید، ولی middleware، لایهی بالاتری است که آن سرویس را صدا میزند.
| رویکرد | جامعیت | نگهداشت | پوشش ویوهای خارجی |
|---|---|---|---|
| ویرایش ویوها | پایین | دشوار | خیر |
| Decorator | متوسط | متوسط | خیر |
| Middleware | کامل | ساده | بله |
| Signal | محدود | دشوار | خیر |
ساخت اولین middleware سفارشی
حالا بیایید یک middleware ردیابی واقعی بنویسیم. ساختار پوشهها در اپ analytics به این شکل است:
analytics/
├── middleware.py
├── models.py
├── utils.py
└── services/
└── tracking.py
فایل middleware.py، پوستهی نازکی است که منطق را به سرویس واگذار میکند. چرا؟ چون middleware باید تا جای ممکن سبک بماند و منطق قابل تست در جای دیگری زندگی کند. اگر با الگوی انتقال منطق از services.py به templatetags آشنا هستید، این تفکیک برایتان منطقی است.
# analytics/middleware.py
from django.conf import settings
from django.utils.deprecation import MiddlewareMixin
from .services.tracking import (
should_track_request,
track_page_view,
update_last_activity,
)
class VisitorTrackingMiddleware(MiddlewareMixin):
"""
middleware ردیابی خودکار بازدیدکنندگان.
مسئولیت: تصمیمگیری سبک + واگذاری به سرویس.
"""
def process_request(self, request):
if not should_track_request(request):
return None
try:
context = track_page_view(request)
request._analytics_visit = context["visit"]
request._analytics_page_view = context["page_view"]
except Exception:
# هرگز نباید باعث 500 شود
request._analytics_visit = None
request._analytics_page_view = None
return None
def process_response(self, request, response):
visit = getattr(request, "_analytics_visit", None)
if visit is None:
return response
try:
update_last_activity(visit)
except Exception:
pass
return response
و در فایل سرویس، منطق واقعی را مینویسیم:
# analytics/services/tracking.py
from django.conf import settings
from django.utils import timezone
from analytics.models import Visitor, Visit, PageView
from analytics.utils import (
get_client_ip,
is_bot,
make_fingerprint,
detect_device_type,
parse_user_agent,
classify_referrer,
)
SKIP_PATHS = getattr(settings, "ANALYTICS_SKIP_PATHS", ())
EXCLUDE_IPS = getattr(settings, "ANALYTICS_EXCLUDE_IPS", ())
TRACK_BOTS = getattr(settings, "ANALYTICS_TRACK_BOTS", False)
SESSION_TIMEOUT = getattr(settings, "ANALYTICS_SESSION_TIMEOUT", 1800)
def should_track_request(request) -> bool:
if request.method != "GET":
return False
path = request.path
if path.startswith(tuple(SKIP_PATHS)):
return False
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
return False
ip = get_client_ip(request)
if ip in EXCLUDE_IPS:
return False
ua = request.META.get("HTTP_USER_AGENT", "")
if not TRACK_BOTS and is_bot(ua):
return False
return True
def track_page_view(request) -> dict:
now = timezone.now()
ip = get_client_ip(request)
ua = request.META.get("HTTP_USER_AGENT", "")
visitor = _get_or_create_visitor(ip, ua)
visit = _get_or_create_visit(request, visitor, now)
page_view = _open_page_view(request, visit, visitor, now)
return {"visit": visit, "page_view": page_view}
def _get_or_create_visitor(ip, ua):
fingerprint = make_fingerprint(ip, ua)
defaults = {
"ip_address": ip,
"user_agent": (ua or "")[:2000],
"device_type": detect_device_type(ua),
**parse_user_agent(ua),
}
visitor, created = Visitor.objects.get_or_create(
fingerprint=fingerprint, defaults=defaults,
)
if not created:
Visitor.objects.filter(pk=visitor.pk).update(last_seen=timezone.now())
return visitor
def _get_or_create_visit(request, visitor, now):
visit_id = request.session.get("_analytics_visit_id")
if visit_id:
visit = Visit.objects.filter(
pk=visit_id, visitor=visitor, is_active=True,
).first()
if visit and not _is_expired(visit, now):
return visit
if visit:
_close_visit(visit, now)
referrer = request.META.get("HTTP_REFERER", "")
current_host = request.get_host().split(":")[0]
ref_info = classify_referrer(referrer, current_host)
visit = Visit.objects.create(
visitor=visitor,
entry_time=now,
entry_url=request.build_absolute_uri()[:2000],
entry_path=request.path[:500],
referrer_url=referrer[:2000],
referrer_domain=ref_info["referrer_domain"],
referrer_type=ref_info["referrer_type"],
last_activity=now,
is_active=True,
)
request.session["_analytics_visit_id"] = visit.pk
return visit
def _open_page_view(request, visit, visitor, now):
prev_id = request.session.get("_analytics_page_view_id")
if prev_id:
PageView.objects.filter(
pk=prev_id, left_at__isnull=True,
).update(
left_at=now,
duration_seconds=0,
)
pv = PageView.objects.create(
visit=visit,
visitor=visitor,
path=request.path[:500],
url=request.build_absolute_uri()[:2000],
entered_at=now,
is_entry=(visit.pages_count == 0),
)
request.session["_analytics_page_view_id"] = pv.pk
Visit.objects.filter(pk=visit.pk).update(
pages_count=visit.pages_count + 1,
last_activity=now,
)
return pv
def _is_expired(visit, now):
return (now - visit.entry_time).total_seconds() > SESSION_TIMEOUT
def _close_visit(visit, now):
duration = int((now - visit.entry_time).total_seconds())
Visit.objects.filter(pk=visit.pk).update(
is_active=False,
exit_time=now,
duration_seconds=duration,
)
def update_last_activity(visit):
Visit.objects.filter(pk=visit.pk).update(last_activity=timezone.now())
این ساختار، سه مزیت کلیدی دارد. اول، تمام منطق تستپذیر در سرویس است. دوم، middleware فقط مسئولیت تصمیمگیری سبک و واگذاری را دارد. سوم، اگر روزی بهجای middleware خواستید از یک رویکرد دیگر استفاده کنید، منطق از بین نمیرود. برای درک عمیقتر این جداسازی، طراحی مدل Visitor و Visit در جنگو دیدگاه کاملی از پایینترین لایه ارائه میدهد.
Middleware باید مثل یک دربان باشد، نه مثل یک انبار. کارش این است که تصمیم بگیرد چه کسی وارد شود و بعد در را باز کند، نه اینکه خودش همهی کارهای داخلی را انجام دهد.
process_request در برابر process_response
در جنگو مدرن، دو سبک برای نوشتن middleware وجود دارد: سبک قدیمی با MiddlewareMixin و متدهای process_request/process_response، و سبک جدید با متد __call__. هر دو معتبرند، ولی توصیه میکنم سبک جدید را انتخاب کنید چون صریحتر است و با ASGI سازگاری بهتری دارد.
# سبک جدید
class VisitorTrackingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# منطق قبل از ویو (معادل process_request)
self._start_tracking(request)
response = self.get_response(request)
# منطق بعد از ویو (معادل process_response)
self._finalize_tracking(request, response)
return response
def _start_tracking(self, request):
...
def _finalize_tracking(self, request, response):
...
این سبک، اجازه میدهد منطق را در قالب متدهای خصوصی سازماندهی کنید و خوانایی کد بالاتر برود.
نکتهی حیاتی اینجاست: در سبک جدید، شما باید حتماً response = self.get_response(request) را صدا بزنید، وگرنه زنجیرهی middleware قطع میشود و بقیهی لایهها اجرا نمیشوند. اگر یک middleware را بنویسید که این خط را نگذارد، ویو هیچوقت اجرا نمیشود و سایت شما با خطای عجیبی مواجه میشود که دیباگش سخت است.
در سبک قدیمی، تفاوت بین process_request و process_response صریحتر است. اگر در process_request یک HttpResponse برگردانید، زنجیره متوقف میشود و ویو اجرا نمیشود. اگر None برگردانید، درخواست ادامه مییابد. این رفتار کوتاهسازی (short-circuit) در پیادهسازیهای خاص بسیار مفید است، ولی در middleware ردیابی، معمولاً شما همیشه None برمیگردانید.
مدیریت استثناها؛ جایی که پروژهها میشکنند
بزرگترین خطای middlewareهای ردیابی در پروژههای واقعی این است که استثناها مدیریت نمیشوند. تصور کنید در تابع track_page_view یک خطای دیتابیس رخ دهد. اگر این خطا مدیریت نشود، بهجای یک بازدید ثبتنشده، کاربر شما یک صفحهی 500 میبیند. سایت شما از کار میافتد، فقط بهخاطر یک خطای آماری.
سه سطح محافظت را در نظر بگیرید:
سطح اول، try/except در middleware. هر بلوک منطقی را در try/except بپیچید و در except، خطا را لاگ کنید ولی اجازه بدهید درخواست ادامه یابد.
import logging
logger = logging.getLogger(__name__)
class VisitorTrackingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
try:
self._start_tracking(request)
except Exception as exc:
logger.warning(
"analytics.tracking_failed_start",
exc_info=exc,
)
response = self.get_response(request)
try:
self._finalize_tracking(request, response)
except Exception as exc:
logger.warning(
"analytics.tracking_failed_end",
exc_info=exc,
)
return response
سطح دوم، محدودیت زمانی. اگر منطق ردیابی شما وابسته به یک تماس شبکهای یا یک کوئری سنگین است، برای آن یک timeout بگذارید. پایتون بهطور ذاتی از timeout در کوئریهای دیتابیس پشتیبانی نمیکند، ولی میتوانید در لایهی دیتابیس (مثلاً با تنظیم statement_timeout در PostgreSQL) این کار را انجام دهید.
سطح سوم، circuit breaker. اگر دیتابیس آماری شما بهطور موقت از کار میافتد، هر درخواست کاربر تا زمان ریکاوری، یک خطای دیتابیس میگیرد. با یک circuit breaker ساده، میتوانید بعد از N شکست متوالی، ردیابی را برای چند دقیقه غیرفعال کنید. این الگو در سیستمهای توزیعشده استاندارد است، ولی در middleware جنگو کمتر دیده میشود.
هر middleware که مینویسید، باید این پرسش را در ذهن داشته باشد: اگر این کد شکست بخورد، آیا سایت من از کار میافتد؟ اگر پاسخ بله است، طراحی شما اشتباه است.
دسترسی به session و کوکی در middleware
Middleware ردیابی معمولاً نیاز دارد که در session کاربر چیزی ذخیره کند: شناسهی Visit، شناسهی PageView، یا هر وضعیت دیگری. برای این کار، دو نکتهی مهم را باید رعایت کنید.
نکتهی اول، ترتیب middleware. SessionMiddleware باید قبل از middleware شما اجرا شود. در غیر این صورت، request.session در دسترس نیست و خطای AttributeError میگیرید. بهطور پیشفرض، SessionMiddleware بعد از SecurityMiddleware و قبل از بقیه قرار میگیرد، ولی این ترتیب را همیشه در settings.py چک کنید.
نکتهی دوم، نوشتن در session هزینه دارد. هر بار که request.session["key"] = value مینویسید، جنگو در پایان درخواست، session را در دیتابیس یا cache ذخیره میکند. اگر در middleware خود چندین بار بنویسید، ممکن است چند بار ذخیره شود. راهحل: یکبار بنویسید و در طول درخواست فقط بخوانید.
اگر کاربر شما از یک دستگاه خاص بازدید میکند و کوکیها را رد میکند، session ساخته نمیشود و شما نمیتوانید Visit را بهدرستی ردیابی کنید. برای این حالت، یک fallback ساده داشته باشید: اگر session در دسترس نبود، فقط یک رکورد PageView بسازید و آن را به Visitor وصل کنید، ولی آن را به یک Visit خاص نسبت ندهید. اگر میخواهید بفهمید چطور دستگاه کاربر را تشخیص دهید، تشخیص دستگاه کاربر در جنگو راهنمای مفیدی است.
ترتیب middleware و اهمیت حیاتی آن
ترتیب middleware در settings.py، تنها تنظیمی است که اگر اشتباه باشد، ممکن است ماهها بعد خودش را نشان دهد. چند قاعدهی کلی وجود دارد:
قاعدهی اول، SecurityMiddleware همیشه اول. این middleware مسئول افزودن هدرهای امنیتی و HTTPS redirect است. اگر بعد از middlewareهای دیگر باشد، ممکن است بعضی درخواستها بدون هدر امنیتی پردازش شوند.
قاعدهی دوم، SessionMiddleware باید قبل از هر چیزی باشد که به session نیاز دارد. شامل AuthenticationMiddleware، MessageMiddleware و middleware ردیابی شما.
قاعدهی سوم، AuthenticationMiddleware باید قبل از middlewareهایی باشد که به request.user نیاز دارند. اگر middleware ردیابی شما میخواهد بداند کاربر لاگین کرده یا نه، باید بعد از AuthenticationMiddleware باشد.
برای ردیابی، ترتیب پیشنهادی این است:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"analytics.middleware.VisitorTrackingMiddleware", # آخرین
]
چرا آخرین؟ چون در process_request، آخرین middleware اولین کسی است که درخواست را میبیند (بعد از عبور از بقیه) و در process_response، آخرین middleware آخرین کسی است که پاسخ را میبیند. این ترتیب اجازه میدهد که middleware ردیابی شما، هم به session و هم به user دسترسی داشته باشد.
نکتهی ظریف دیگر: اگر از cache برای session استفاده میکنید، ترتیب middleware تغییری نمیکند، ولی رفتار آن ممکن است بهخاطر latency شبکه متفاوت باشد. اگر میخواهید رویکرد کاملتری برای بهینهسازی داشته باشید، بهینهسازی جنگو برای ترافیک بالا راهنمای دقیقی است.
کارایی؛ هزینهی هر کوئری اضافه
Middleware شما روی هر درخواست اجرا میشود. اگر درون آن یک کوئری اضافه بزنید، این کوئری بهازای هر بازدید صفحه تکرار میشود. در سایتهایی با ترافیک متوسط، این تفاوت ناچیز است. در سایتهای پربازدید، میتواند به گلوگاه تبدیل شود.
محاسبهی سریع: در سایت با ۱۰۰ کاربر آنلاین، هر کاربر هر ۶۰ ثانیه یک صفحه میبیند. این یعنی حدود ۱.۷ بازدید صفحه در ثانیه. اگر middleware شما ۸ کوئری بزند، شما ۱۴ کوئری اضافه در ثانیه دارید. اگر ۱۰٬۰۰۰ کاربر آنلاین داشته باشید، ۱۴۰۰ کوئری اضافه در ثانیه.
| تعداد کاربران آنلاین | کوئری اضافه در ثانیه | وضعیت |
|---|---|---|
| ۵۰ | ~۷ | بیمسئله |
| ۵۰۰ | ~۷۰ | قابل مدیریت |
| ۵٬۰۰۰ | ~۷۰۰ | نیاز به بهینهسازی |
| ۵۰٬۰۰۰ | ~۷٬۰۰۰ | نیاز به معماری توزیعشده |
سه راهبرد عملی برای کاهش هزینه:
راهبرد اول، ادغام کوئریها. بهجای سه کوئری جدا برای Visitor، Visit و PageView، میتوانید از bulk_create و update استفاده کنید. اگر با الگوی ذخیره PageView و Interaction آشنا هستید، میدانید که این ادغام چقدر مؤثر است.
راهبرد دوم، کش کردن visitor. اگر از یک cache محلی (در حافظهی پروسه) برای نگهداشتن Visitorهای اخیر استفاده کنید، میتوانید از یک کوئری SELECT در هر درخواست صرفنظر کنید. این کار در ترافیک بالا بسیار مؤثر است.
راهبرد سوم، نوشتن غیرهمگام. بهجای نوشتن مستقیم در دیتابیس، رویدادها را در Redis بگذارید و یک worker مستقل آنها را در بچهای بزرگ پردازش کند. این الگو، تأخیر middleware را از چند میلیثانیه به کمتر از یک میلیثانیه کاهش میدهد.
تستنویسی middleware
Middleware ردیابی را میتوان با سه سطح تست پوشش داد.
سطح اول، تست واحد سرویس. چون منطق در سرویس است، میتوانید بدون ساخت request واقعی، سرویس را تست کنید:
import pytest
from django.test import RequestFactory
from analytics.services.tracking import should_track_request
@pytest.fixture
def rf():
return RequestFactory()
def test_should_track_get_request(rf):
request = rf.get("/")
request.session = {}
assert should_track_request(request) is True
def test_should_not_track_admin_path(rf):
request = rf.get("/admin/")
request.session = {}
assert should_track_request(request) is False
def test_should_not_track_bot(rf):
request = rf.get("/", HTTP_USER_AGENT="Googlebot/2.1")
request.session = {}
assert should_track_request(request) is False
سطح دوم، تست یکپارچه middleware. با django.test.Client، یک درخواست واقعی بفرستید و بررسی کنید که آیا رکوردها ساخته میشوند:
@pytest.mark.django_db
def test_middleware_creates_visit(client):
from analytics.models import Visit, PageView
client.get("/", HTTP_USER_AGENT="Mozilla/5.0 Chrome/120")
assert Visit.objects.count() == 1
assert PageView.objects.count() == 1
سطح سوم، تست کارایی. با CaptureQueriesContext، تعداد کوئریهای middleware را اندازه بگیرید:
from django.test.utils import CaptureQueriesContext
from django.db import connection
@pytest.mark.django_db
def test_middleware_query_count(client):
with CaptureQueriesContext(connection) as ctx:
client.get("/", HTTP_USER_AGENT="Mozilla/5.0 Chrome/120")
# اجازهی حداکثر ۱۰ کوئری برای کل درخواست
assert len(ctx) <= 10
این تستها، بهویژه تست کارایی، به شما اجازه میدهند که در طول توسعه، از رگرسیونهای پنهان جلوگیری کنید. اگر با الگوی طراحی مدل Visitor و Visit شروع کرده باشید، میدانید که هر کوئری اضافه، مستقیماً روی latency سایت اثر میگذارد.
Middleware در جنگوی async
از جنگو ۴.۱ به بعد، میتوانید middleware را بهصورت async بنویسید. این قابلیت در پروژههای با بار بالا، مزیتهای واقعی دارد، ولی پیچیدگیهای خاص خودش را هم دارد.
class AsyncVisitorTrackingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
async def __call__(self, request):
try:
await self._start_tracking(request)
except Exception:
pass
response = await self.get_response(request)
try:
await self._finalize_tracking(request, response)
except Exception:
pass
return response
async def _start_tracking(self, request):
from asgiref.sync import sync_to_async
await sync_to_async(track_page_view)(request)
نکتهی حیاتی: ORM جنگو در نسخههای اخیر از async پشتیبانی میکند، ولی نه همهی بخشها. برای اطمینان، از sync_to_async استفاده کنید تا مطمئن شوید عملیات دیتابیس در یک thread جداگانه اجرا میشود.
یک تصمیم عملی: تا وقتی جنگو بهطور کامل به async مهاجرت نکرده و اکثر کتابخانههای جانبی async نیستند، middleware ردیابی خود را sync بنویسید. این کار، از باگهای ظریف جلوگیری میکند. اگر میخواهید یک نمونهی async در کنار sync ببینید، الگوی ساخت API Endpoint برای Beacon نمونهی خوبی است که در آن، endpoint سبک است ولی middleware همان sync میماند.
ملاحظات امنیتی
Middleware ردیابی، بهخاطر اینکه روی تمام درخواستها اجرا میشود، سطح حملهی جدیدی ایجاد میکند. سه نکتهی مهم را در نظر بگیرید.
نکتهی اول، عدم ذخیرهی اطلاعات حساس. هرگز توکنهای احراز هویت، رمزهای عبور یا session IDها را در دیتابیس آماری ذخیره نکنید. حتی ذخیرهی User-Agent خام، در بعضی حوزههای قضایی بهعنوان دادهی شخصی تلقی میشود.
نکتهی دوم، محدودیت rate. اگر middleware شما به یک endpoint عمومی (مثل beacon) داده میفرستد، rate limiting ضروری است. بدون آن، یک مهاجم میتواند با هزاران درخواست در ثانیه، دیتابیس شما را پر کند.
نکتهی سوم، نبود نقطهی تزریق. هر ورودی از کاربر (بهخصوص User-Agent، Referer و X-Forwarded-For) باید قبل از ذخیره، اعتبارسنجی و محدود شود. برای مثال، طول User-Agent را به ۲۰۰۰ کاراکتر محدود کنید، دامنهی Referer را چک کنید و IP را با یک regex ساده اعتبارسنجی کنید.
هر middleware که درخواست کاربر را میخواند، یک سطح حملهی جدید به پروژه اضافه میکند. آن را با همان جدیتی بنویسید که یک ویوی احراز هویت را مینویسید.
در پروژههای واقعی، مهمترین حملهای که دیدهام، پر کردن دیتابیس با رکوردهای جعلی است. اگر مهاجم بتواند بهسادگی در دیتابیس شما ردیف بسازد، در چند ساعت حجم جدول را چند برابر میکند. برای مقابله با این مشکل، از rate limiting در سطح IP استفاده کنید و حتماً یک سقف برای طول فیلدها در نظر بگیرید. اگر میخواهید رویکرد تشخیص بات را دقیقتر کنید، تشخیص بات از کاربر انسانی راهنمای مفیدی است.
anti-patternهای رایج در middlewareهای ردیابی
در بازبینی پروژههای مختلف، این اشتباهات تکرارشونده را دیدهام:
۱. اجرای منطق کسبوکار در middleware. Middleware جای تصمیمگیری سبک است، نه محاسبهی تخفیف یا ارسال ایمیل. اگر منطق شما بیش از ۵۰ خط است، آن را به سرویس منتقل کنید. الگوی درستش در تعریف و کاربرد services.py در جنگو آمده است.
۲. کوئری زدن در هر درخواست بدون کش. اگر در هر صفحه یک کوئری برای خواندن تنظیمات یا Visitor میزنید، در ترافیک بالا این کار به گلوگاه تبدیل میشود.
۳. نداشتن try/except. اگر middleware شما هرگز خطا نگیرد، پس شما یک پروژهی واقعی را تجربه نکردهاید. همیشه استثناها را مدیریت کنید.
۴. کوتاهسازی ناخواستهی زنجیره. اگر در process_request اشتباهاً یک HttpResponse برگردانید، ویو اجرا نمیشود و سایت شما به کاربر یک پاسخ اشتباه میدهد.
۵. نادیدهگرفتن درخواستهای OPTIONS و HEAD. اگر روی همهی متدها ردیابی میکنید، درخواستهای preflight که مرورگر برای CORS میفرستد، بهعنوان بازدید ثبت میشوند. این خطا در سایتهایی که از APIهای cross-origin استفاده میکنند، آمار را آلوده میکند.
۶. ذخیرهی کامل request در دیتابیس. بعضی تیمها برای دیباگ، request.META را بهطور کامل ذخیره میکنند. این کار حجم دیتابیس را چندین برابر میکند و شامل اطلاعات حساس میشود.
۷. عدم تفکیک staging از production. اگر در محیط staging هم ردیابی فعال باشد، آمار سایت شما با دادههای تستی قاطی میشود. راهحل: در staging، مقدار ANALYTICS_TRACK_BOTS و EXCLUDE_IPS را طوری تنظیم کنید که هیچچیز ثبت نشود.
۸. عدم هماهنگی با CDN. اگر سایت شما پشت CDN است، IP واقعی کاربر در X-Forwarded-For میآید، نه در REMOTE_ADDR. اگر این نکته را نادیده بگیرید، IP همهی کاربران یکی است. برای پیادهسازی درست، تابع get_client_ip باید هدرها را بهترتیب اولویت بخواند.
۹. ذخیرهی User-Agent در CharField(max_length=255). بعضی User-Agentهای مدرن بیش از ۵۰۰ کاراکتر هستند و باعث truncation میشوند. از TextField استفاده کنید.
۱۰. نداشتن ایندکس روی last_activity. کوئری «کاربران آنلاین» بدون این ایندکس، یک full scan روی جدول Visit میشود.
پرسشهای پرتکرار دربارهی middleware ردیابی
آیا میتوانم چند middleware ردیابی داشته باشم؟ بله، ولی توصیه میکنم فقط یکی داشته باشید. اگر میخواهید منطق را به چند بخش تقسیم کنید، از سرویسهای مختلف درون یک middleware استفاده کنید. چند middleware ردیابی، ترتیب را پیچیده و دیباگ را دشوار میکند.
آیا middleware روی درخواستهای static file اجرا میشود؟ در حالت development، اگر django.contrib.staticfiles فعال باشد و runserver اجرا شود، بله. در production که Nginx یا CDN مسیرهای static را سرو میکند، خیر. برای اطمینان، مسیر /static/ و /media/ را در ANALYTICS_SKIP_PATHS بگذارید.
چطور middleware را برای APIها غیرفعال کنم؟ میتوانید در should_track_request یک شرط اضافه کنید که اگر مسیر با /api/ شروع شد، ردیابی نشود. یا اینکه برای درخواستهایی که Authorization header دارند و برای API هستند، شرط جداگانه بگذارید.
آیا middleware میتواند روی WebSocket اجرا شود؟ در جنگو، middleware برای WebSocket وجود ندارد، چون WebSocket از ASGI استفاده میکند و چرخهی متفاوتی دارد. برای ردیابی WebSocket باید یک ASGI middleware جداگانه بنویسید.
چطور از middleware در تستها صرفنظر کنم؟ در settings.py میتوانید یک تنظیم داشته باشید که وقتی TESTING = True بود، middleware را غیرفعال کند. یا در تستهای خاص، از override_settings(MIDDLEWARE=[...]) استفاده کنید.
آیا باید در middleware به request.user دسترسی داشته باشم؟ فقط اگر نیاز دارید بین کاربر لاگین و مهمان تفکیک کنید. توجه کنید که request.user ممکن است در حین دسترسی، یک کوئری به دیتابیس بزند. برای بهینهسازی، از request.user.is_authenticated استفاده کنید که این کوئری را نمیزند.
چطور میتوانم middleware را برای بعضی کاربران غیرفعال کنم؟ اگر میخواهید کاربران خودی یا ادمینها ردیابی نشوند، در should_track_request یک شرط اضافه کنید: if request.user.is_staff: return False. این کار، از ثبت بازدیدهای خودتان جلوگیری میکند.
آیا middleware میتواند درخواست را کند کند؟ بله. اگر منطق middleware شما سنگین باشد، هر درخواست کند میشود. قاعدهی تجربی من: middleware ردیابی نباید بیش از ۲۰ میلیثانیه به زمان پاسخ اضافه کند. اگر بیشتر شد، منطق باید به صف منتقل شود.
آیا باید از MiddlewareMixin استفاده کنم یا __call__؟ در پروژههای جدید، از __call__ استفاده کنید. MiddlewareMixin برای سازگاری با کد قدیمی نگه داشته شده و در آینده ممکن است منسوخ شود.
چطور از middleware برای ردیابی فایلهای دانلودی استفاده کنم؟ اگر فایلها از طریق یک ویو سرو میشوند، middleware آنها را ردیابی میکند. اگر فایلها را Nginx مستقیماً سرو میکند (که توصیه میشود)، middleware اجرا نمیشود و باید از یک endpoint اختصاصی برای ثبت دانلود استفاده کنید. الگوی این کار در ثبت اسکرول و کلیک کاربر آمده است که رویدادهای سفارشی را پوشش میدهد.
آیا باید از middleware برای CSRF استفاده کنم؟ نه. جنگو خودش CsrfViewMiddleware دارد که این کار را بهدرستی انجام میدهد. اگر در middleware خود با CSRF تداخل دارید، بهاحتمال زیاد مشکل از تنظیمات CSRF_TRUSTED_ORIGINS است.
آیا middleware روی درخواستهای مدیریت جنگو اجرا میشود؟ بله، مگر اینکه مسیر /admin/ را در ANALYTICS_SKIP_PATHS بگذارید. توصیه میکنم این کار را انجام دهید، وگرنه تمام بازدیدهای خودتان از پنل ادمین هم ثبت میشود.
نگاه دقیقتر به مرز واقعی لایهی middleware
در نگاه مهندسی، middleware یک لایهی زیرساختی است، نه یک لایهی کاربردی. این تفکیک، در مقیاس بزرگ اهمیت حیاتی پیدا میکند. اگر با الگوی انتقال منطق از services.py به templatetags آشنا باشید، میدانید که مرز بین لایهها، در نهایت تعیین میکند که پروژه چقدر قابل نگهداری است.
مرز اول: تکرارپذیری. هر چیزی که در middleware اجرا میشود، در تمام درخواستها اجرا میشود. اگر منطق شما فقط برای بعضی درخواستها معنا دارد، آن را از middleware بیرون بیاورید.
مرز دوم: قابلیت تست. اگر منطق middleware شما بدون ساخت یک request واقعی قابل تست نیست، طراحی شما بیش از حد به جنگو گره خورده است. منطق را در سرویس بگذارید و middleware را یک wrapper نازک کنید.
مرز سوم: انعطافپذیری. اگر روزی تصمیم گرفتید جنگو را ترک کنید، منطق سرویس شما بدون تغییر در فریمورک جدید کار میکند. ولی middleware، مخصوص جنگو است و باید بازنویسی شود.
مرز چهارم: ردیابی خطا. در سیستمهای تولیدی، هر خطا در middleware باید در یک سیستم متمرکز لاگ شود (Sentry، ELK، Loki). بدون این، خطاها در سکوت اتفاق میافتند و ماهها بعد خودشان را نشان میدهند.
مرز پنجم: بار. در پروژههای پربازدید، middleware ردیابی باید idle باشد. یعنی هر چه سریعتر، کار را به یک صف یا یک task بسپارد. اگر منطق سنگین در middleware باشد، در peak traffic، سایت شما از کار میافتد. اگر میخواهید این معماری را در کل پروژه پیاده کنید، ساخت اپ جنگو برای ردیابی بازدیدکننده چارچوب کاملی ارائه میدهد.
یک درس از مهندسی داده
در پروژههای بزرگ آماری، یک الگوی تکرارشونده دیدهام: هرچه middleware نازکتر باشد، سیستم آماری مقیاسپذیرتر است. تیمهایی که middleware خود را با منطق پیچیده پر میکنند، در نهایت مجبور میشوند آن را بازنویسی کنند. تیمهایی که از ابتدا middleware را یک نقطهی گذر میبینند، نه یک محل پردازش، سریعتر رشد میکنند.
بهعنوان یک قاعدهی سرانگشتی، هر middleware ردیابی باید کمتر از ۱۵۰ خط کد باشد. اگر بیشتر شد، یعنی منطق باید جابجا شود. اگر با الگوهای مشابه در پروژههای دیگر آشنا هستید، استفاده از تمپلت تگ بهعنوان متغیر مثال خوبی از همین اصل در لایهی نمایش است.
در نهایت، یک middleware خوب، مثل یک لایهی بیصدا عمل میکند. کاربر متوجه وجود آن نمیشود، توسعهدهنده نیازی به یادآوری آن ندارد و سیستم بهطور یکپارچه دادههای لازم را تولید میکند. اگر این ویژگیها در middleware شما وجود دارد، پس در مسیر درستی هستید.
اگر این ساختار را در پروژهی خودتان پیاده کردید و به چالشهایی برخوردید — مثلاً کندی در زمان peak ترافیک یا خطاهای پنهان در session — خوشحال میشوم تجربهتان را بشنوم. مخصوصاً اگر راهحلی پیدا کردهاید که با الگوهای استاندارد متفاوت است، چون همان راهحلها میتوانند به خوانندهی بعدی کمک کنند. تجربهی خودتان را در دیدگاهها بنویسید.