Understanding Design Patterns in Python: تطبيق واقعي على Factory و Singleton

Understanding Design Patterns in Python: تطبيق واقعي على Factory و Singleton

في المشاريع الصغيرة في بايثون غالباً نكتب الكود بشكل مباشر وبسيط، لكن مع توسّع المشروع وازدياد عدد المطورين والموديولات، يبدأ الكود في التعقيد ويصبح من الصعب صيانته أو توسيعه بدون “تكسير” أجزاء أخرى من النظام. هنا تظهر أهمية Design Patterns.

في هذا المقال سنشرح مفهوم أنماط التصميم (Design Patterns) في بايثون مع التركيز على نمطين من الأكثر استخداماً في التطبيقات الكبيرة: Factory و Singleton، وسنرى:

  • لماذا نحتاج Design Patterns في Python رغم بساطة اللغة.
  • فكرة Factory Pattern وما يحلّه من مشاكل.
  • فكرة Singleton Pattern ومتى يكون مفيداً أو خطيراً.
  • تطبيقات عملية واقعية لكل نمط.
  • كيفية اختبار هذه الأنماط بوحدات اختبار (Unit Tests).

إذا كنت مهتماً بالتصميم الجيد للكود، فهذا المقال موجه لك، خاصة إن كنت تعمل على مشاريع Web مثل Django أو FastAPI، أو مشاريع تستخدم البرمجة المتزامنة وغير المتزامنة كما شرحنا في مقال البرمجة غير المتزامنة في بايثون.

ما هي Design Patterns؟ ولماذا نحتاجها في Python؟

Design Patterns ليست كود جاهز للنسخ، بل هي حلول عامة لمشاكل متكررة في التصميم. يمكنك اعتباره “قالب تفكير” يساعدك على تنظيم الكود، تقليل التكرار، وجعل المشروع قابلاً للتوسع.

من أشهر تصنيفات أنماط التصميم:

  • Creational Patterns: تهتم بطريقة إنشاء الكائنات (Objects) مثل Factory و Singleton.
  • Structural Patterns: تهتم بكيفية ربط الكائنات ببعضها مثل Adapter و Facade.
  • Behavioral Patterns: تهتم بسلوك الكائنات وتفاعلها مثل Observer و Strategy.

رغم أن بايثون لغة ديناميكية وبسيطة، إلا أن المشاريع الكبيرة (خاصة في مجال الـ Web أو الـ Microservices أو أدوات تحليل البيانات كبيرة الحجم) تستفيد كثيراً من الأنماط، لأنها:

  • تجعل الكود مقروءاً ومفهوماً لأي مطور يعرف هذه الأنماط.
  • تساعد على فصل المسؤوليات (Separation of Concerns).
  • تسهل الاختبار (Testing) واستبدال المكونات.

مقدمة سريعة عن Factory Pattern و Singleton Pattern

1. Factory Pattern

Factory هو نمط من نوع Creational، فكرته باختصار: بدلاً من أن تنشئ الكائنات (Objects) بشكل مباشر في كل مكان، تنشئها في مكان مركزي مسؤول عن هذا الإنشاء.

لماذا؟ لأنك أحياناً:

  • تحتاج إلى إنشاء أنواع مختلفة من الكائنات حسب إعدادات أو نوع بيانات.
  • لا تريد أن يعتمد الكود في كل مكان على التفاصيل الدقيقة لكيفية إنشاء هذا الكائن.
  • تريد القدرة على تغيير طريقة الإنشاء لاحقاً بدون تعديل عشرات الملفات.

النتيجة: كود أسهل في التعديل والاختبار، وأكثر تنظيماً.

2. Singleton Pattern

Singleton أيضاً نمط من نوع Creational، فكرته: التأكد أنه لا يوجد إلا نسخة واحدة فقط من كائن معيّن في التطبيق، مع توفير نقطة وصول عالمية له.

هذا مفيد في حالات مثل:

  • مُسجِّل واحد للّوغ (Logger) على مستوى التطبيق.
  • مدير إعدادات (Configuration Manager) موحّد.
  • اتصال واحد مشترك بقاعدة بيانات (بحذر شديد).

لكن Singleton يحتاج حذراً في الاستخدام، خاصة في التطبيقات متعددة الخيوط (Threading في بايثون) أو في الأنظمة الكبيرة التي تحتاج إلى عزل واضح بين المكوّنات.

تطبيق عملي على Factory Pattern في Python

المشكلة الواقعية

تخيل أنك تبني نظام دفع (Payment System) في تطبيق Python/Backend يدعم أكثر من مزوّد دفع:

  • دفع عن طريق بطاقة ائتمان.
  • دفع عن طريق PayPal.
  • دفع عن طريق Apple Pay أو غيره.

مقاربة ساذجة قد تكون هكذا:


payment_method = input("Enter method: ")

if payment_method == "credit_card":
    # إنشاء كائن وتعامل مع الدفع بالبطاقة
elif payment_method == "paypal":
    # إنشاء كائن وتعامل مع PayPal
elif payment_method == "apple_pay":
    # ...

كلما أضفت وسيلة دفع جديدة تضطر لتعديل هذا الكود وغيره. هذا يكسر مبدأ Open/Closed Principle (الكود يجب أن يكون مفتوحاً للإضافة، مغلقاً للتعديل).

تصميم أفضل باستخدام Factory Pattern

لنعرّف أولاً واجهة (Interface) موحّدة لوسائل الدفع باستخدام Class أساسي:


from abc import ABC, abstractmethod


class PaymentGateway(ABC):
    @abstractmethod
    def pay(self, amount: float) -> None:
        pass


class CreditCardPayment(PaymentGateway):
    def pay(self, amount: float) -> None:
        print(f"Processing credit card payment of {amount} USD")


class PayPalPayment(PaymentGateway):
    def pay(self, amount: float) -> None:
        print(f"Processing PayPal payment of {amount} USD")


class ApplePayPayment(PaymentGateway):
    def pay(self, amount: float) -> None:
        print(f"Processing Apple Pay payment of {amount} USD")

الآن نعرّف Factory مسؤولة عن إنشاء الكائن المناسب:


class PaymentFactory:
    @staticmethod
    def create_payment(method: str) -> PaymentGateway:
        method = method.lower()

        if method == "credit_card":
            return CreditCardPayment()
        elif method == "paypal":
            return PayPalPayment()
        elif method == "apple_pay":
            return ApplePayPayment()
        else:
            raise ValueError(f"Unsupported payment method: {method}")

استخدام الـ Factory في كود الأعمال (Business Logic):


def process_order(amount: float, method: str) -> None:
    payment_gateway = PaymentFactory.create_payment(method)
    payment_gateway.pay(amount)


if __name__ == "__main__":
    process_order(100.0, "credit_card")
    process_order(50.0, "paypal")

بهذا الشكل:

  • كل منطق اختيار نوع الدفع موجود في مكان واحد فقط (PaymentFactory).
  • دالة process_order لا تعرف تفاصيل إنشاء الكائنات ولا تحتاج لتعديلها مع كل وسيلة دفع جديدة.

تحسين Factory بإزالة سلسلة if/elif

يمكنك جعل الـ Factory أكثر مرونة باستخدام قاموس (Dictionary) لتسجيل الكلاسات:


class PaymentFactory:
    _registry = {
        "credit_card": CreditCardPayment,
        "paypal": PayPalPayment,
        "apple_pay": ApplePayPayment,
    }

    @classmethod
    def register(cls, method: str, gateway_cls: type[PaymentGateway]) -> None:
        cls._registry[method.lower()] = gateway_cls

    @classmethod
    def create_payment(cls, method: str) -> PaymentGateway:
        method = method.lower()
        gateway_cls = cls._registry.get(method)
        if not gateway_cls:
            raise ValueError(f"Unsupported payment method: {method}")
        return gateway_cls()

بهذه الطريقة يمكنك تسجيل وسيلة دفع جديدة في ملف مستقل بدون تعديل الكود الأصلي:


class StripePayment(PaymentGateway):
    def pay(self, amount: float) -> None:
        print(f"Processing Stripe payment of {amount} USD")


PaymentFactory.register("stripe", StripePayment)

كيفية اختبار Factory Pattern في Python

الاختبار يصبح أسهل لأنك تختبر كلاس واحد مسؤول عن الإنشاء:


import unittest


class TestPaymentFactory(unittest.TestCase):
    def test_create_credit_card_payment(self):
        gateway = PaymentFactory.create_payment("credit_card")
        self.assertIsInstance(gateway, CreditCardPayment)

    def test_unsupported_method_raises_error(self):
        with self.assertRaises(ValueError):
            PaymentFactory.create_payment("unknown")


if __name__ == "__main__":
    unittest.main()

يمكنك أيضاً استخدام mocks لاختبار مناداة pay() بدون تنفيذ فعلي (مثلاً عدم الاتصال بـ API خارجي حقيقي).

تطبيق عملي على Singleton Pattern في Python

المشكلة الواقعية

تخيل أن لديك تطبيق Web كبير يحتاج إلى:

  • مُسجِّل Log موحّد يكتب في ملف أو في نظام تتبّع مركزي.
  • أو مُدير إعدادات (Config) يقرأ من ملف واحد أو من متغيرات البيئة.

لا تريد أن تنشئ أكثر من نسخة من هذا الكائن في كل مرة، بل تريد نسخة واحدة مشتركة.

أبسط طريقة لـ Singleton في Python

واحدة من أبسط الطرق هي باستخدام متغيّر Module-Level، لكن غالباً نستخدم نمطاً عاماً قابلاً لإعادة الاستخدام، مثل استخدام MetaClass:


class SingletonMeta(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            instance = super().__call__(*args, **kwargs)
            cls._instances[cls] = instance
        return cls._instances[cls]

نستخدم هذه الميتاكلاس لإنشاء أي Singleton:


class AppConfig(metaclass=SingletonMeta):
    def __init__(self):
        print("Initializing config...")
        self._settings = {
            "db_url": "postgres://user:pass@localhost:5432/mydb",
            "debug": True,
        }

    def get(self, key: str, default=None):
        return self._settings.get(key, default)


if __name__ == "__main__":
    c1 = AppConfig()
    c2 = AppConfig()

    print(c1 is c2)  # True

هنا:

  • أول استدعاء لـ AppConfig() ينشئ كائناً جديداً.
  • الاستدعاءات التالية تعيد نفس النسخة المخزّنة في _instances.

Singleton في بيئة متعددة الخيوط (Thread-Safe Singleton)

في تطبيقات تستخدم threading، يمكن أن يحدث “Race Condition” عند إنشاء أول نسخة. لتفادي ذلك نستخدم Lock:


import threading


class ThreadSafeSingletonMeta(type):
    _instances = {}
    _lock: threading.Lock = threading.Lock()

    def __call__(cls, *args, **kwargs):
        # Double-checked locking
        if cls not in cls._instances:
            with cls._lock:
                if cls not in cls._instances:
                    instance = super().__call__(*args, **kwargs)
                    cls._instances[cls] = instance
        return cls._instances[cls]

نستخدمها كالتالي:


class Logger(metaclass=ThreadSafeSingletonMeta):
    def __init__(self):
        self.logs = []

    def log(self, message: str):
        self.logs.append(message)
        print(f"[LOG] {message}")

هذا التصميم يضمن أن Logger يملك نسخة واحدة حتى عند وجود أكثر من Thread في التطبيق كما هو الحال في بعض خوادم الويب.

كيفية اختبار Singleton Pattern

الاختبار الأساسي هو التأكد من أن كل الاستدعاءات تعيد نفس الكائن:


import unittest


class TestSingleton(unittest.TestCase):
    def test_singleton_instance(self):
        c1 = AppConfig()
        c2 = AppConfig()
        self.assertIs(c1, c2)

    def test_singleton_state_shared(self):
        c1 = AppConfig()
        c2 = AppConfig()

        c1._settings["debug"] = False
        self.assertFalse(c2.get("debug"))


if __name__ == "__main__":
    unittest.main()

بهذا الشكل نضمن أن:

  • هناك نسخة واحدة فقط من الكلاس.
  • الحالة (State) مشتركة بين كل المراجع.

متى نستخدم Factory و Singleton في المشاريع الواقعية؟

متى تستخدم Factory Pattern؟

استخدم Factory عندما:

  • تحتاج إلى إنشاء أنواع متعددة من الكائنات تشترك في واجهة واحدة.
  • لا تريد أن يعتمد الكود في كل مكان على تفاصيل الكلاسات الفرعية.
  • تريد أن تتمكن من إضافة نوع جديد بدون تعديل كود المنطق الأساسي.

أمثلة شائعة:

  • إنشاء Connectors لمصادر بيانات مختلفة (MySQL، PostgreSQL، SQLite).
  • إنشاء Clients لخدمات خارجية (Stripe، PayPal، Twilio).
  • اختيار Parser مختلف حسب نوع الملف (JSON، XML، CSV) في أدوات تحليل البيانات، مع ربطه بأدوات مثل Pandas في Python.

متى تستخدم Singleton Pattern؟

استخدم Singleton عندما:

  • تحتاج إلى كائن واحد فقط على مستوى التطبيق (Global-like) لكن مع تنظيم أفضل.
  • هذا الكائن يقرأ إعدادات أو يمثّل مورد مشترك.

لكن تجنّبه عندما:

  • يؤدي إلى تشابك (Coupling) عالي بين المكوّنات.
  • يجعل الاختبار صعباً، لأن الحالة تكون مشتركة على مستوى المشروع.
  • تحتاج إلى نسخ مستقلة في كل اختبار أو في كل جزء من النظام.

بديل أفضل في كثير من الحالات هو حقن التبعية (Dependency Injection) حيث تمرّر الكائن المطلوب صراحةً للدوال أو الكلاسات بدلاً من الوصول له كـ Singleton عالمي.

اختبار الأنماط في سياق مشاريع Web (Django / FastAPI)

في مشاريع Web الكبيرة، غالباً تستخدم هذه الأنماط مع:

للاختبار، غالباً:

  • تختبر Factory بشكل معزول، وتتأكد أنها تعيد الكائنات الصحيحة بناءً على الإعدادات.
  • تختبر Singleton بأن تبقي حالته محدودة، ويمكنك إعادة ضبط الحالة في setUp() لكل اختبار حتى لا تؤثر الاختبارات على بعضها.
  • تستخدم Mocks/Dependency Injection للتخلص من التأثيرات الجانبية (Side Effects) عند الاختبار.

خلاصة: كيف تستفيد من Python Design Patterns Factory Singleton؟

عند تصميم مشروع Python متوسط أو كبير، لا تتعامل مع Factory و Singleton كقواعد إلزامية، بل كأدوات:

  • Factory Pattern يساعدك في تنظيم منطق إنشاء الكائنات، خاصة عندما:
    • يكون لديك عدة أنواع تشترك في واجهة واحدة.
    • تريد مرونة في إضافة أنواع جديدة بدون تعديل كل مكان في الكود.
  • Singleton Pattern مفيد عندما تحتاج إلى نسخة واحدة فقط من كائن معين (Config، Logger، Cache)، لكن:
    • استخدمه بحذر، ولا تحوّل كل شيء إلى Singleton.
    • انتبه للمشاكل في بيئات متعددة الخيوط والاختبارات.

إذا فهمت لماذا تم تصميم هذه الأنماط وما المشاكل التي تحلّها، سيكون تطبيقك لها في Python أكثر وعياً وفعالية، وستلاحظ فرقاً كبيراً في قابلية الكود للصيانة والتوسّع والاختبار.

حول المحتوى:

شرح أنماط التصميم الأكثر استخداماً في المشاريع الكبيرة، مع أمثلة عملية وشرح سبب الحاجة إليها وكيفية اختبارها.

هل كان هذا مفيدًا لك؟

أضف تعليقك