انتقال منطق از services.py به templatetags؛ مرز درست نمایش و کسبوکار کجاست؟
آیا منطق آماری و کسبوکار را میشود داخل templatetags نوشت؟ مرز درست لایهی نمایش و لایهی دامنه در جنگو کجاست؟
وقتی برای اولین بار کدی مینویسی که در چند قالب تکرار میشود، وسوسهی بزرگ این است که آن را به یک templatetag منتقل کنی؛ سریع، تمیز و بدون دستزدن به ویو. همین یک تصمیم کوچک، در طول چند ماه میتواند معماری یک پروژهی جنگو را به لبهی فروپاشی ببرد.
چرا انتقال منطق به templatetags وسوسهانگیز است؟
در یکی از پروژههای فروشگاهی که سالها پیش روی آن کار میکردم، تیم تصمیم گرفت قیمت نهایی سبد خرید را مستقیماً در یک templatetag محاسبه کند. منطق این بود: «چون فقط در قالب سبد خرید و قالب پرداخت استفاده میشود، پس چرا باید یک سرویس جدا برایش بسازیم؟» سه ماه بعد، همان منطق در پنل ادمین، در ایمیل تراکنشی و در یک API موبایل هم لازم شد. ناگهان شش نقطه از سیستم همان فرمول را با کمی تفاوت پیاده کرده بودند و هیچکدام از آنها هم با تست پوشش داده نشده بود.
این داستان تکراری است، چون templatetagها در نگاه اول یک راهحل ارزان به نظر میرسند. نیازی به تغییر ویو ندارند، در قالب قابل استفادهاند، با یک {% load %} فعال میشوند و در همان لحظه احساس میکنی کاری حرفهای انجام دادهای. مشکل اصلی اینجاست که لایهی نمایش و لایهی دامنه در ذهن ما بهسرعت قاطی میشوند. اگر با ساختار تمپلت تگ در جنگو آشنا نیستی، توصیه میکنم اول راهنمای تعریف تمپلت تگ در جنگو را مطالعه کنی تا بدانی این ابزار در چه سطحی از انتزاع طراحی شده است.
هر بار که منطق کسبوکار به لایهی نمایش نشت میکند، یک بدهی معماری پنهان تولید میشود که نرخ بهرهاش هر ماه بالاتر میرود.
templatetag در جنگو دقیقاً چیست؟
templatetag در جنگو یک قطعه کد است که در فایلهای تمپلیت با سینتکس {% %} یا {{ }} قابل استفاده میشود. این کد در فایلهای templatetags/ داخل اپهای پروژه قرار میگیرد و در زمان رندر قالب اجرا میشود. سه نوع اصلی وجود دارد:
۱. simple_tag. برای توابع ساده که یک مقدار برمیگردانند. مثال: تعداد بازدیدکنندگان آنلاین.
۲. inclusion_tag. برای توابعی که یک زیرقالب را رندر میکنند. مثال: نمایش لیست آخرین سفارشها.
۳. filter. برای تغییر شکل یک مقدار. مثال: تبدیل تاریخ میلادی به شمسی یا کوتاهکردن یک متن.
نکتهی کلیدی اینجاست: templatetag برای نمایش طراحی شده، نه برای تصمیمگیری کسبوکار. این ابزار در لحظهی رندر قالب اجرا میشود، جایی که هیچ تراکنشی وجود ندارد، هیچ کنترل سطح دسترسی سطح پایینی وجود ندارد و هیچ بازخوردی از شکست به کاربر داده نمیشود. اگر یک templatetag خطا پرتاب کند، تنها چیزی که کاربر میبیند یک صفحهی خطای 500 است.
وقتی با استفاده از تمپلت تگ بهعنوان متغیر با کلمهی کلیدی as آشنا باشی، ظاهراً قدرت بیشتری به دست میآوری؛ میتوانی نتیجه را در قالب نگه داری و در شرطها استفاده کنی. همین انعطاف، دام پنهان است: هرچه templatetagهایت پیچیدهتر شوند، فاصلهشان از «نمایش ساده» بیشتر و از «منطق کسبوکار» کمتر میشود.
services.py دقیقاً چیست؟
در مقابل، services.py یک کنوانسیون جامعه برای نگهداری منطق کسبوکار است. سرویس، جایی است که یک use case مشخص پیاده میشود: ثبت سفارش، محاسبهی تخفیف، ارسال اعلان، هماهنگی چند مدل. سرویس نه به request وابسته است، نه به response، و نه به قالب. اگر میخواهی تفاوت دقیقتر را ببینی، مقالهی تعریف و کاربرد services.py در جنگو را کامل بخوان، ولی خلاصهاش این است:
سرویس، مرز تراکنش است. سرویس، مرز تست است. سرویس، جایی است که exceptionهای دامنه پرتاب میشوند. اگر یک تصمیم کسبوکار در قالب گرفته شود، هیچکدام از این سه ویژگی را نداری.
سرویس، به تو میگوید «چه کاری انجام شد». templatetag به تو میگوید «چطور نمایش داده شود». این دو، دو دنیای متفاوتند.
مرز درست لایهی نمایش و لایهی دامنه
یک قاعدهی ساده برای تشخیص مرز وجود دارد که در سالها کار با پروژههای مختلف به آن رسیدهام: اگر منطق به دیتابیس، به شبکه، به فایل یا به تصمیم کسبوکار وابسته است، جای آن سرویس است. اگر منطق فقط به دادهای که از قبل آماده شده شکل میدهد، جای آن templatetag است.
| نشانه | templatetag | services.py |
|---|---|---|
| تراکنش دیتابیس | غیرممکن | طبیعی و ضروری |
| exception دامنه | خطای 500 | ValidationError قابل کنترل |
| تستپذیری مستقل | نیاز به رندر قالب | تست واحد ساده |
| استفاده در API | محدود | مستقیم |
| استفاده در Celery | بدون معنا | کاملاً طبیعی |
| کش کردن نتیجه | دشوار | ساده |
پس میتوان گفت: خدمات (services) در لایهی دامنه زندگی میکنند، templatetagها در لایهی ارائه. بین این دو، یک قاعدهی ساده وجود دارد که همیشه جواب میدهد: هرگز templatetag نباید بنویسد، فقط بخواند و نمایش دهد. اگر templatetag تو دیتابیس را تغییر میدهد، یا ایمیل میفرستد، یا تراکنش باز میکند، از مرز گذشتهای.
چه زمانی انتقال منطق به templatetag کاملاً درست است؟
با وجود همهی هشدارها، مواردی هستند که templatetag انتخاب درست است:
۱. قالببندی داده. مثل نمایش قیمت با جداکنندهی هزارگان، تبدیل عدد به حروف فارسی، یا کوتاهکردن عنوانهای بلند. اینها منطق خالص نمایش هستند.
۲. دسترسی به context. مثل نمایش نام کاربر جاری یا نمایش تنظیمات سراسری سایت. جنگو از قبل این کار را با context processor انجام میدهد، ولی گاهی یک templatetag سبک کافی است. اگر میخواهی تفاوت این دو را دقیقتر بفهمی، تفاوت Middleware و Context Processor را مطالعه کن.
۳. خواندنهای ساده و کششده. اگر میخواهی در فوتر قالب تعداد کاربران آنلاین را نشان بدهی، یک templatetag که از کش میخواند، کاملاً قابل قبول است. ولی به شرطی که این خواندن هرگز از کش عبور نکند و روی هر رندر صفحه یک کوئری جدید نزند. مثالی از همین الگو در ساخت پنل ادمین با کارتهای quick view دیده میشود که در آن آمار از کش خوانده میشود.
۴. ترجمه و بومیسازی. تغییرات زبانی، تبدیل تاریخ، تبدیل واحد پول. اینها منطق نمایش خالص هستند.
در همهی این موارد، ویژگی مشترک این است که templatetag فقط دادهای که از قبل در اختیار دارد را شکل میدهد. هیچ تصمیم جدیدی نمیگیرد.
چه زمانی انتقال یک اشتباه پرهزینه است؟
در مقابل، مواردی وجود دارند که انتقال به templatetag بهنظر معصومانه میآید ولی در عمل فاجعه است:
۱. محاسبهی قیمت یا تخفیف. هر محاسبهای که پول در آن دخیل است، باید در لایهی سرویس انجام شود. چون تراکنش لازم دارد، چون باید idempotent باشد، چون باید audit log داشته باشد، و چون در آینده ممکن است در گزارشهای مالی هم استفاده شود.
۲. تشخیص دسترسی. نگو در templatetag چک کن که کاربر به این سفارش دسترسی دارد یا نه. این تصمیم، تصمیم دامنه است. اگر کاربر دسترسی ندارد، نباید قالب رندر شود، نه اینکه محتوای قالب مخفی شود.
۳. ارسال ایمیل یا پیام. هیچوقت templatetag نباید side effect داشته باشد. اگر با یک بار رندر قالب، ایمیلی فرستاده شود، در تستها و در فراخوانیهای مکرر فاجعه میسازد.
۴. فراخوانی API خارجی. اگر templatetag تو به یک وبسرویس خارجی وصل شود، هر رندر صفحه به یک تأخیر شبکهای گره میخورد. این کار در زمان کندی سرویس خارجی، سایت تو را هم از کار میاندازد.
۵. مدیریت state سشن. اگر templatetag تو دادهای را در session بنویسد، در حالتهای موازی و async رفتار غیرقابل پیشبینی خواهد داشت. این نوع تصمیمها باید در لایهی middleware یا سرویس انجام شوند. الگوی درست برای بهروزرسانی session در نوشتن Middleware سفارشی در جنگو نشان داده شده است.
۶. ثبت آمار و رویداد. اگر میخواهی هر بار نمایش یک بخش را بهعنوان رویداد ثبت کنی، این کار را در templatetag نکن. دلایلش را در بخش هزینهی پنهان کوئری توضیح میدهم.
الگوهای عملی refactoring
حالا که مرز را میشناسیم، بیایید یک سناریوی واقعی را بررسی کنیم. فرض کن در قالب صفحهی محصول این کد را داری:
{% load shop_tags %}
{% product_final_price product user as price %}
{{ price }}
و داخل shop_tags.py این منطق را نوشتهای:
@register.simple_tag
def product_final_price(product, user):
discount = Discount.objects.filter(
product=product,
user=user,
is_active=True,
).first()
if not discount:
return product.price
return product.price - (product.price * discount.percent / 100)
سه مشکل جدی اینجا وجود دارد. اول، هر رندر صفحه یک کوئری اضافه میزند. دوم، منطق تخفیف از منطق سبد خرید جدا شده و ممکن است با آن تفاوت داشته باشد. سوم، تستکردن این تابع بدون ساخت یک کاربر و محصول و تخفیف غیرممکن است.
نسخهی درست این است که منطق در سرویس باشد:
# shop/services/pricing.py
from decimal import Decimal
from shop.models import Discount
def resolve_price(product, user) -> Decimal:
if not user.is_authenticated:
return product.price
discount = Discount.objects.filter(
product=product,
user=user,
is_active=True,
).first()
if not discount:
return product.price
return product.price - (product.price * discount.percent / 100)
و در ویو، قبل از رندر قالب، قیمت را در context بگذاری:
from shop.services.pricing import resolve_price
def product_detail(request, slug):
product = get_object_or_404(Product, slug=slug)
final_price = resolve_price(product, request.user)
return render(request, "shop/product_detail.html", {
"product": product,
"final_price": final_price,
})
قالب هم ساده میشود:
{{ final_price }}
سه مزیت بزرگ این تغییر: اول، سرویس قابل تست است و میتوانی برای حالتهای مختلف تست بنویسی. دوم، همان سرویس در API و در ایمیل و در Celery task بدون تغییر استفاده میشود. سوم، اگر بعداً قواعد تخفیف پیچیدهتر شد (تخفیف پلهای، کد تخفیف انباشته، محدودیت زمانی)، فقط یک فایل تغییر میکند.
یک نکتهی ظریف اینجاست: ممکن است الان بگویی «خوب، این هم که همان کار را میکند، فقط جای کد عوض شده». تفاوت واقعی در مقیاس پروژه مشخص میشود. اگر ساختار یک اپ کامل ردیابی بازدیدکننده را در ذهن داشته باشی که در آن چندین مدل و ویو با هم کار میکنند، میبینی چطور یک تصمیم کوچک در لایهی نمایش، در نهایت به تکرار منطق در چند نقطه میانجامد.
هزینهی پنهان کوئری در قالبها
یکی از بزرگترین اشتباهات فنی که در پروژهها دیدهام، کوئری زدن داخل قالب است. این کار از سه جهت مشکلساز است.
۱. N+1 query. اگر در یک templatetag روی یک لیست ۵۰ تایی حلقه بزنی و هر بار یک کوئری بزنی، ۵۰ کوئری اضافه داری. این نوع خطا در debug toolbar دیده میشود، ولی در production معمولاً فقط به شکل «سایت کند است» خودش را نشان میدهد.
۲. کش ناپذیری. کوئریهای داخل قالب بهسختی کش میشوند، چون context قالب معمولاً یکتاست. اگر میخواهی کوئری مشترک را کش کنی، باید آن را از قالب بیرون بیاوری.
۳. ناپایداری زمان رندر. زمان رندر قالب تو به زمان پاسخ دیتابیس گره میخورد. هر کندی گذرا در دیتابیس، مستقیم به زمان بارگذاری صفحه منتقل میشود.
راهحل این مشکلات ساده است: هر چیزی که کوئری میزند، از قالب بیرون بیاید. اگر قرار است در چند ویو مشترک باشد، در middleware یا در یک context processor با کش قرار بگیرد. اگر همان منطق آماری است، از کامند مدیریتی پاکسازی داده الگو بگیر و آن را به یک task زمانبند بسپار.
تستنویسی؛ جایی که تفاوت معلوم میشود
شاید بزرگترین تفاوت خدمات و templatetagها، در تست مشخص شود. تست یک templatetag به این شکل است:
from django.test import TestCase
from django.template import Template, Context
class ProductPriceTagTest(TestCase):
def test_discount_applied(self):
user = User.objects.create_user("u", "u@example.com", "p")
product = Product.objects.create(name="p", price=Decimal("100000"))
Discount.objects.create(
product=product, user=user, percent=10, is_active=True,
)
tpl = Template("{% load shop_tags %}{% product_final_price product user %}")
rendered = tpl.render(Context({"product": product, "user": user}))
self.assertEqual(rendered.strip(), "90000")
این تست، حداقل چهار آبجکت در دیتابیس میسازد، قالب را رندر میکند و به تگ وابسته است. اگر فردا نام تگ عوض شود، تست میشکند. اگر منطق تخفیف تغییر کند، ولی تگ همان باشد، تست بازنویسی میشود.
در مقابل، تست سرویس این است:
from shop.services.pricing import resolve_price
def test_resolve_price_applies_discount(user_factory, product_factory, discount_factory):
user = user_factory()
product = product_factory(price=Decimal("100000"))
discount_factory(product=product, user=user, percent=10, is_active=True)
assert resolve_price(product, user) == Decimal("90000")
سریعتر، مستقلتر، و کاملاً قابل خواندن. مهمتر از همه، این تست روی قرارداد سرویس تمرکز دارد، نه روی جزئیات پیادهسازی templatetag. اگر فردا تگ را از قالب حذف کنی و از ویو استفاده کنی، این تست بدون تغییر پاس میشود.
الگوی مشابهی در تست رویدادهای کاربر دیده میشود. اگر رویدادهای اسکرول و کلیک را در یک سرویس ثبت کنی، تست کردنش ساده است. اگر در یک templatetag ثبت کنی، باید قالب را رندر کنی.
بهینهسازی و کش در سمت تمپلیت
در پروژههای پربازدید، ممکن است وسوسه شوی templatetag را با کش ترکیب کنی تا مشکل کوئری حل شود. این کار ممکن است، ولی سه ریسک دارد:
۱. کلید کش وابسته به کاربر. اگر کش را برای همه مشترک بگذاری، کاربر A قیمت کاربر B را میبیند. اگر برای هر کاربر جدا بگذاری، با ۱۰۰ هزار کاربر ۱۰۰ هزار کلید داری.
۲. invalidation. وقتی تخفیف تغییر میکند، باید کل کش مربوطه پاک شود. اگر این کار را در templatetag انجام دهی، کد invalidation هم به لایهی نمایش منتقل میشود و این یعنی آشفتگی بیشتر.
۳. کش لایهی نمایش، مشکل معماری را حل نمیکند. فقط علائم را میپوشاند. اگر منطق در لایهی سرویس باشد و آن لایه کش شود، هم تگ و هم API و هم task از همان کش استفاده میکنند.
اگر واقعاً به بهینهسازی سمت قالب نیاز داری، راه بهتر این است که محاسبه را در ویو انجام دهی و نتیجه را در context بگذاری. برای آمار زنده هم از API JSON برای آمار زنده استفاده کن و قالب را از کوئری سنگین آزاد کن.
anti-patternهای رایج
در بازبینی کد پروژههای مختلف، این anti-patternها را زیاد دیدهام:
۱. templatetag بهعنوان wrapper روی مدل. تگی که فقط Model.objects.get(pk=id) را صدا میزند. این کار هیچ ارزشی اضافه نمیکند و فقط یک لایهی اضافی روی ORM میسازد.
۲. منطق شرطی پیچیده در قالب. اگر در قالب بیش از سه شرط تودرتو داری، منطق باید از قالب بیرون بیاید. قالب جای شرط ساده است، نه جای درخت تصمیم.
۳. exception خفهشده. templatetag با try/except Exception که خطا را میخورد و None برمیگرداند. این کار دیباگ را غیرممکن میکند. اگر خطایی ممکن است، آن را در سرویس مدیریت کن و تصمیم بگیر چه چیزی برگردانی.
۴. وابستگی templatetag به session. اگر تگی مستقیماً به request.session دسترسی دارد، در حالتهای موازی و async رفتار غیرقابل پیشبینی خواهد داشت.
۵. قاطیکردن منطق آماری با نمایش. در پروژههای آماری، این خطا خیلی شایع است. اگر تگی میخواهی که بازدید جدید را ثبت کند، بگذار همان کار در middleware بماند. الگوی درست را در تشخیص بات از کاربر انسانی میبینی که در آن تصمیمگیری در middleware است، نه در قالب.
پرسشهای پرتکرار دربارهی templatetags و services
آیا میتوان templatetag را از سرویس صدا زد یا بالعکس؟ سرویس را میتوان از templatetag صدا زد، ولی معمولاً علامت خوبی نیست. اگر تگ تو به یک سرویس نیاز دارد، به احتمال زیاد ویو باید آن سرویس را صدا بزند و نتیجه را در context بگذارد. جهت معکوس (سرویس templatetag را صدا بزند) منطقی نیست، چون سرویس به لایهی نمایش وابسته میشود.
تعداد templatetagهای یک پروژه چه محدودیتی دارد؟ محدودیت عددی وجود ندارد، ولی معیار مهمتر این است: templatetagهایی که فقط نمایش میدهند، مشکلی ندارند. templatetagهایی که منطق دامنه را میسازند، بدهی معماری محسوب میشوند.
آیا services.py میتواند در فوتر قالب استفاده شود؟ بله، ولی مسیر درست این است که در context processor یا در ویو، سرویس را صدا بزنی و نتیجه را در context بگذاری. هرگز سرویس را مستقیم از قالب صدا نزن، چون آنموقع هر رندر یک تماس سرویس داری.
templatetagها چطور با inlines و admin کار میکنند؟ templatetagها در قالبهای admin هم کار میکنند، ولی برای منطق کسبوکار پنل ادمین باید از admin action یا از override متدهای admin استفاده کنی. الگوی درست در پنل ادمین سفارشی نشان داده شده است.
آیا templatetagها در async views کار میکنند؟ بله، ولی بهصورت sync اجرا میشوند. اگر داخل تگ کوئری بزنی، در یک async view بلاک میشود. این نکته در پروژههای پربازدید اهمیت دارد.
templatetag با filter چه تفاوتی دارد؟ filter برای تغییر یک مقدار ورودی طراحی شده و باید بدون side effect باشد. templatetag میتواند چند ورودی بگیرد و منطق پیچیدهتری داشته باشد. در هر دو حالت، اگر منطق به دیتابیس وابسته است، جای اشتباهی است.
آیا کش کردن templatetag کافی است؟ کش، علائم را میپوشاند، نه مشکل را. اگر منطق کسبوکار در تگ باشد، کش فقط تأخیر را کم میکند و بدهی معماری را حل نمیکند.
چطور یک templatetag را به سرویس تبدیل کنیم؟ چهار مرحله: اول، منطق تگ را به یک فایل سرویس منتقل کن. دوم، پارامترها را به ورودیهای سرویس تبدیل کن. سوم، exceptionهای دامنه پرتاب کن. چهارم، ویو را بهروز کن که سرویس را صدا بزند و نتیجه را در context بگذارد.
templatetagها روی performance چقدر تأثیر دارند؟ در حالت عادی ناچیز. ولی اگر داخل تگ کوئری بزنی، با N+1 query میتوانی زمان رندر را چند برابر کنی. برای این موضوع، همیشه از بهینهسازی جنگو برای ترافیک بالا بهعنوان چکلیست استفاده کن.
آیا services.py در پروژههای کوچک هم ارزش دارد؟ اگر پروژه فقط CRUD ساده است، سرویس اضافه است. ولی بهمحض اینکه یک use case واقعی با چند مدل و چند مرحله شکل گرفت، سرویس لازم میشود. مرز معمولاً جایی است که تستنویسی سخت میشود.
تصمیم نهایی؛ کدام را انتخاب کنیم؟
پاسخ کوتاه این است: بستگی به جنس منطق دارد. اگر منطق تو دادهای که از قبل در اختیار دارد را شکل میدهد، templatetag انتخاب درست است. اگر منطق تو تصمیم کسبوکار میگیرد، تراکنش باز میکند، یا side effect دارد، سرویس انتخاب درست است.
اما پاسخی که در پروژههای واقعی مفیدتر است، این است: بهطور پیشفرض، منطق را در سرویس بنویس. فقط وقتی که ثابت شد منطق خالص نمایش است، آن را به templatetag منتقل کن. این ترتیب، تو را در برابر بدهی معماری محافظت میکند. چرا؟ چون انتقال از سرویس به templatetag ساده است، ولی برعکسش پرهزینه.
نکتهی آخر که در طول این سالها یاد گرفتهام: هر پروژهای که در آن لایهی نمایش با لایهی دامنه قاطی شده بود، در نهایت مجبور شدیم بازسازیاش کنیم. هیچ پروژهای ندیدم که این اختلاط را بهعنوان یک ویژگی مثبت حفظ کند. templatetag ابزار خوبی است، ولی نه بهعنوان مخفیگاه منطق کسبوکار.
اگر در پروژهای این انتقال را انجام دادی و به مشکل خوردی — مثلاً جایی که exception مدیریتنشده داشتی یا با کوئریهای N+1 غافلگیر شدی — برایم جالب است بدانی کدام بخشش بیشترین زمان را از تو گرفت. تجربهات را در دیدگاهها بنویس؛ مخصوصاً اگر در نهایت به یک الگوی متفاوت از آنچه در این مقاله توضیح دادم رسیدی، چون همان تجربههای عملی هستند که این بحث را برای خوانندهی بعدی مفیدتر میکنند.