معماری بک‌اند

ماژول‌های اختیاری در پروژهٔ پایهٔ جنگو: یک پوشه، یک کلید در .env و هسته‌ای که اسمشان را نمی‌برد

چطور قابلیت‌های اختیاری پروژهٔ پایهٔ جنگو را به ماژول تبدیل کردم: پوشه‌ای با module.py که با ast خوانده می‌شود، یک کلید در .env، ستون‌هایی که فقط وقتی ماژول روشن است وجود دارند و تست‌هایی که نمی‌گذارند هسته اسم ماژولی را بیاورد.

Django, Docker and Python

همهٔ پروژه‌هایی که می‌سازم از یک پروژهٔ پایهٔ جنگو شروع می‌شوند: یک سامانهٔ نوبت‌دهی آرایشگاه، چند سایت پرداخت و چند سایت رمزارز. این پایه کم‌کم قابلیت‌هایی جمع کرد که فقط بعضی از پروژه‌ها لازمشان داشتند: کیف پول، پرداخت آنلاین، تسویه، پیامک، کیف پول رمزارز، REST API و تیکت پشتیبانی. و این قابلیت‌ها به همه نشت می‌کردند. پروژه‌ای که هیچ‌وقت پیامکی نمی‌فرستاد هم اپ پیامک را در INSTALLED_APPS داشت، ستونی برای کلید پیامک در جدول تنظیماتش داشت و پکیجش هم در ایمیجش بود؛ و هر merge از پایه همان کد را برمی‌گرداند.

چیزی که در نهایت به آن رسیدم یک قاعده است و کمی سازوکار که آن را سر پا نگه می‌دارد.

قاعده

هر قابلیت اختیاری یک ماژول اختیاری است: یک پوشه (گاهی چند پوشه) با یک فایل module.py، پشت یک کلید در .env مثل USE_SMS_SERVICE. خاموش که باشد وجود ندارد: نه اپ، نه مسیر، نه جدول و ستون، نه کار beat، نه بلوکی روی صفحه، نه میزبانی در CSP و نه پکیجی. و هیچ فایلی از هسته اسمش را نمی‌آورد. هسته نمی‌داند پیامک یا کیف پولی وجود دارد؛ فقط جاهایی دارد که در آن‌ها از ماژول‌های روشن می‌پرسد چه اضافه می‌کنند.

الان دوازده ماژول این‌طورند، از wallet و payment تا sms، api و compliance. همه به‌طور پیش‌فرض خاموش‌اند جز کیف پول: پروژه‌هایی که پیش از سوئیچ‌دار شدنش ساخته شده بودند کیف پول داشتند و نگهش می‌دارند.

module.py داده است، نه کد

هر ماژول خودش را در یک دیکشنری به اسم MODULE معرفی می‌کند. نسخهٔ کوتاه‌شدهٔ ماژول پیامک:

MODULE = {
    "name": "sms",
    "switch": "USE_SMS_SERVICE",
    "default": False,
    "order": 70,
    "apps": ["apps.sms"],
    "requires": [],
    "paths": ["apps/sms"],
    # its pages: /vN/panel-admin/sms/
    "urls": {
        "panel_admin": {
            "prefix": "sms/",
            "v1": "apps.sms.urls.admin.v1.urls_i18n",
            "v3": "apps.sms.urls.admin.v3.urls_i18n",
        },
    },
    # "Send SMS" and SMS.ir's key, columns of SystemSetting
    "system_setting": "apps.sms.setting_models.SmsSettingMixin",
    "sections": {
        "delivery": ["apps.sms.forms.setting_forms.SmsDeliverySection"],
    },
    # what it draws on the core's pages ({version}: v1 or v3)
    "slots": {
        "admin_rail": "sms/slots/{version}/admin_rail.html",
        "delivery_detail": "sms/slots/{version}/delivery_detail.html",
        # ...
    },
    "words": "apps.sms.locales",
    "wizard": {...},  # setup_project.py's question
}

این فایل هیچ‌وقت import نمی‌شود. MODULES_CONFIG آن را با ast تجزیه می‌کند و دیکشنری را با ast.literal_eval برمی‌دارد:

# _0_config/configs/MODULES_CONFIG.py
def read_manifest(path):
    """`MODULE` of a module.py, without running it."""
    tree = ast.parse(Path(path).read_text(encoding="utf-8"))
    for node in tree.body:
        if (
            isinstance(node, ast.Assign)
            and isinstance(node.targets[0], ast.Name)
            and node.targets[0].id == "MODULE"
        ):
            return ast.literal_eval(node.value)


MODULES = tuple({**manifest, "on": _switched_on(manifest)} for manifest in discover())
SWITCHES = {module["switch"]: module["on"] for module in MODULES}
INSTALLED_APPS = [app for module in MODULES if module["on"] for app in module["apps"]]

دلیلش این است که چه کسی و کِی آن را می‌خواند. یکی خود settings.py است، پیش از اینکه جنگو setup شود: import کردن apps.sms.module یعنی اول import شدن پکیج خود اپ، با کدی که شاید انتظار داشته باشد جنگو آماده باشد، یا وابستگی‌ای که وقتی ماژول خاموش است اصلاً نصب نیست. دیگری setup_project.py است، ویزاردی که پروژه می‌سازد و به‌روزش می‌کند؛ روی خود میزبان و بیرون از کانتینرها اجرا می‌شود و هیچ خطی از کد پروژه را اجرا نمی‌کند. و چون literal_eval فقط literal قبول می‌کند، module.py نمی‌تواند بی‌صدا منطق پیدا کند. هر خواننده parser خودش را دارد و یک تست مطمئن می‌شود هر دو ماژول‌ها را یکسان می‌بینند.

# _0_config/settings.py
MODULES = MODULES_CONFIG.MODULES
globals().update(MODULES_CONFIG.SWITCHES)
# ... the core's settings ...
MODULES_CONFIG.contribute(globals())  # last, so a module sees them whole

در انتهای تنظیمات، contribute به هر ماژول روشن اجازه می‌دهد بقیه را اضافه کند: اپ‌ها و middlewareهای یک پکیج، میزبان‌های CSP (add_csp)، یا بررسی‌ای که جلوی start-up را می‌گیرد؛ ماژول پرداخت بدون پکیجش، یا با ارزی جز تومان و ریال، بالا نمی‌آید. وابستگی‌ها هم همین‌جا بررسی می‌شوند: payout روشن بدون wallet یعنی ImproperlyConfigured.

هسته کجا می‌پرسد

هسته برای هر نوع چیزی که ماژولی ممکن است اضافه کند یک جا دارد و آنجا فقط از ماژول‌های روشن می‌پرسد (module_function.py):

جای هستهکلید در MODULE
INSTALLED_APPS و settings.<switch>apps، switch
بقیهٔ تنظیمات: middleware، CSP، بررسی‌های start-upsettings
URLها: module_urls(place, version)urls
ستون‌های SystemSetting: service_mixins()system_setting
فرم صفحه‌های تنظیمات: module_sections(page)sections
بلوکی در صفحه‌ای از هسته: {% module_slot "user_detail" %}slots
واژه‌نامهٔ dic_languagewords
اجازهٔ جابه‌جایی پول: money_refusal(user, request=None)money_refusal

پس URLconf ریشه دربارهٔ ماژول‌ها فقط urlpatterns += module_urls("root") را دارد و صفحهٔ تنظیمات امنیت، جایی که بلوک ماژولی ممکن است بیاید، فقط {% module_slot "security_detail" %} را. اسلاتی که هیچ ماژولی پرش نکند چیزی رسم نمی‌کند، و برای همین قالب‌های هسته اسم هیچ ماژولی را نمی‌آورند.

money_refusal نشان می‌دهد این الگو تا کجا می‌رود: هر جریانی که پول جابه‌جا می‌کند اول همین را می‌پرسد. ماژول compliance اگر روشن باشد می‌تواند با جمله‌ای جواب بدهد که می‌گوید چرا نه و کجا باید اقدام کرد؛ اگر هیچ ماژولی جواب ندهد هر حسابی پول جابه‌جا می‌کند، و کد پرداخت هیچ‌وقت نمی‌فهمد چنین ماژولی وجود دارد.

ستون‌هایی که فقط وقتی ماژول روشن است وجود دارند

تنظیمات قابل‌ویرایش سایت ستون‌های SystemSetting هستند، ولی پروژه‌ای که هیچ‌وقت پیامک را روشن نمی‌کند نباید ستون کلید پیامک را داشته باشد. پس ستون‌های هر ماژول یک mixin انتزاعی در پوشهٔ خود آن ماژول است و مدل از mixinهای ماژول‌های روشن ساخته می‌شود:

# _1_system_setting/models/models.py
def service_mixins():
    """The mixins of the modules switched on, in their order."""
    return module_setting_mixins()


class SystemSetting(mb.BasicModelHistorical, mx.SitePreferenceMixin, *service_mixins()):
    ...


# apps/sms/setting_models.py
class SmsSettingMixin(models.Model):
    use_sms_service = models.BooleanField(default=False, db_default=False, ...)
    sms_api_key_encrypted = models.TextField(
        blank=True, default="", db_default="", ...
    )

    SENSITIVE_FIELDS = ("sms_api_key_encrypted",)

    class Meta:
        abstract = True

کلاس در هر پروسه یک بار ساخته می‌شود، با همان سوئیچ‌هایی که استقرار روشن کرده است؛ و چون migrationها در هر پروژه جداگانه ساخته می‌شوند، migrationی که با سوئیچ روشن ساخته شود ستون‌های آن ماژول را دارد. روشن کردن یعنی سوئیچ، docker compose up -d، makemigrations و migrate؛ خاموش کردن هم همین است و آن migrate ستون‌ها را با مقدارشان حذف می‌کند، جدول‌های خود ماژول را بی‌استفاده باقی می‌گذارد و کارهای beat آن را پاک می‌کند. دو قاعده این را امن نگه می‌دارد:

  • هر ستون کنار default یک db_default هم دارد. از لحظهٔ خاموش شدن تا migrate بعدی، مدل ستون‌ها را فراموش کرده ولی جدول نه. ردیفی که در این فاصله نوشته شود، مثلاً ردیف تاریخچه که هر ذخیره می‌نویسد، مقدار پیش‌فرض دیتابیس را می‌گیرد و روی NOT NULL شکست نمی‌خورد.
  • هیچ ستونی به کدی از ماژولش اشاره نمی‌کند: نه default قابل‌فراخوانی، نه validator و نه کلاس فیلد. migrationهای SystemSetting مال هسته‌اند؛ اگر یکی از آن‌ها ماژول را import کند، پروژه‌ای که بعداً ماژول را حذف کرده دیگر نمی‌تواند migrate کند. پیش‌فرضی هم که فهرست است، functools.partial(list, …) روی دادهٔ ساده است.

در یک پروژه: حذف و برگرداندن

خاموش بودن کافی نیست؛ پروژه‌ای که از ماژولی استفاده نمی‌کند نباید پوشه‌اش را هم داشته باشد. ویزارد پوشهٔ ماژول‌هایی را که پروژهٔ تازه روشن نکرده در یک commit جدا حذف می‌کند و سوئیچ، کلیدهای .env و مسیرهایشان را در _0_deploy/removed_modules.json ثبت می‌کند. از آن به بعد:

python3 src/core/setup_project.py --modules
python3 src/core/setup_project.py --remove-module sms --dry-run
python3 src/core/setup_project.py --remove-module sms
python3 src/core/setup_project.py --add-module sms
python3 src/core/setup_project.py --update
  • --update آخرین نسخهٔ پایه را merge می‌کند و هر چه از ماژول حذف‌شده برگشته باشد، چه conflict از نوع modify/delete و چه فایل تازه، دوباره پاک می‌کند تا فقط conflictهای واقعی بمانند.
  • --add-module ماژول را از همان commit پایه برمی‌گرداند که پروژه آخرین بار merge کرده، تا با هستهٔ اطرافش جور باشد.
  • --remove-module اول می‌خواهد سوئیچ خاموش باشد، جدول‌ها را تا migrate <app> zero نگه می‌دارد و ماژولی را که ماژول دیگری لازمش دارد حذف نمی‌کند؛ مثلاً کیف پول را تا وقتی تسویه هست.
  • سوئیچ ماژول حذف‌شده‌ای که روشن شود، start-up را با پیامی متوقف می‌کند که راه برگرداندنش را می‌گوید.

تست‌هایی که قاعده را نگه می‌دارند

قاعده‌ای که فقط در مستندات باشد دوام نمی‌آورد. تست‌های هر ماژول در پوشهٔ خودش هستند و فقط وقتی ماژول روشن است جمع‌آوری می‌شوند: conftest.py ریشه پوشهٔ ماژول‌های خاموش را رد می‌کند، چون بیشتر آن تست‌ها آن وقت حتی import هم نمی‌شوند؛ جز test_*_switch_off.py که حالت خاموش را نگه می‌دارند.

# conftest.py, at the root
OFF_MODULES = [module for module in MODULES if not module["on"]]
OFF_PATHS = [path.rstrip("/") for module in OFF_MODULES for path in module["paths"]]
SWITCH_OFF_TESTS = "test_*_switch_off.py"


def pytest_ignore_collect(collection_path, config):
    ...  # not inside an off module's folder: None
    if Path(collection_path).is_dir():
        return True if not any(Path(collection_path).rglob(SWITCH_OFF_TESTS)) else None
    return None if fnmatch(Path(collection_path).name, SWITCH_OFF_TESTS) else True

هزینه‌اش این است که در تست نمی‌شود با override_settings ماژولی را روشن کرد، چون SystemSetting از قبل ساخته شده است. در اجرای معمول همه جز کیف پول خاموش‌اند؛ برای حالت روشن، کل مجموعه را با سوئیچ‌های روشن و --nomigrations اجرا می‌کنم، چون migrationهای محلی با سوئیچ‌های خاموش ساخته شده‌اند. بقیه را test_optional_modules.py نگه می‌دارد:

  • settings و ویزارد هر module.py را یکسان می‌خوانند؛
  • ماژول خاموش هیچ اپ، URL، ستون یا واژه‌ای باقی نمی‌گذارد؛
  • ستون‌های هر ماژول بدون هیچ کدی از آن سریالایز می‌شوند؛
  • هیچ فایلی از هسته اسم ماژول را نمی‌آورد (پکیج، پوشه‌ها، سوئیچ، قالب‌ها، فایل‌های static، اسم URLها) و هر ماژول فقط اسم ماژول‌هایی را می‌آورد که لازمشان دارد؛
  • کپی‌ای از پروژه بدون پوشه‌های یک ماژول، و کپی‌ای بدون همهٔ آن‌ها، check جنگو را پاس می‌کنند.
@pytest.mark.parametrize("module", MODULES, ids=IDS)
def test_nothing_of_the_core_names_a_module(module):
    core = [(rel, text) for rel, text in _files() if not _owners(rel)]
    named = _named(module["name"], core)
    assert not named, (
        f"{module['name']} is named outside its folders — removed, it would "
        f"leave these behind: {named}"
    )

این تست الگوهایش را از module.py خود هر ماژول می‌سازد و کد هسته را می‌گردد. اگر اسم URL یک ماژول در قالبی از هسته نوشته شود، تست شکست می‌خورد و اسم فایل را می‌گوید و اینکه پروژه‌ای بدون آن ماژول با چه چیزی جا می‌ماند.

هزینه و دستاورد

این طراحی مجانی نیست: دو parser که باید هماهنگ بمانند، جایی در هسته که باید پیش از استفادهٔ ماژول ساخته شود، و مجموعهٔ تستی که باید در بیش از یک پیکربندی اجرا شود. در عوض، پروژهٔ تازه دقیقاً همان پوشه‌هایی را دارد که استفاده می‌کند، به‌روزرسانی از پایه بقیه را برنمی‌گرداند و اضافه کردن یک قابلیت در آینده یک فرمان است، یک سوئیچ و یک migration. قابلیتی که پروژه‌های دیگر هم ممکن است لازم داشته باشند، اول به شکل ماژول در پایه ساخته می‌شود و بعد در پروژه merge می‌شود.