الحقن بالاعتماد: طريقة بايثون

أنماط حقن التبعيات في بايثون لكتابة كود نظيف وقابل للاختبار

Page content

الحقول المُستخدمة (DI) هي نمط تصميم أساسي يعزز كتابة الكود النظيف، القابل للاختبار، والمُحافظ عليه في تطبيقات بايثون.

سواء كنت تبني واجهات برمجية REST مع FastAPI، أو تُنفِّذ اختبارات وحدة، أو تعمل مع وظائف AWS Lambda، فإن فهم الحقول المُستخدمة سيُحسِّن جودة كودك بشكل كبير.

حزم بايثون

ما هي الحقول المُستخدمة؟

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

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

لماذا استخدام الحقول المُستخدمة في بايثون؟

تحسين قابلية الاختبار: من خلال حقول المُستخدمة، يمكنك بسهولة استبدال التنفيذات الفعلية بمُحاكاتها أو نماذج الاختبار. هذا يسمح لك بكتابة اختبارات وحدة سريعة ومُستقلة لا تتطلب خدمات خارجية مثل قواعد البيانات أو الواجهات.

عند كتابة اختبارات وحدة شاملة، تجعل الحقول المُستخدمة من السهل أن تُبادل الاعتماديات الفعلية بنماذج اختبار.

تحسين الصيانة: تصبح الاعتماديات واضحة في كودك. عند النظر إلى المُنشئ، ترى فورًا ما تحتاجه المكونة. هذا يجعل قاعدة الكود أسهل لفهمها وتعديلها.

الربط المرن: تعتمد المكونات على المجرات (البروتوكولات أو ABCs) بدلًا من التنفيذات المحددة. هذا يعني أنك تستطيع تغيير التنفيذات دون التأثير على الكود المعتمد.

المرونة: يمكنك تكوين تنفيذات مختلفة لكل بيئات (التطوير، الاختبار، الإنتاج) دون تغيير منطق العمل. هذا مفيد بشكل خاص عند نشر تطبيقات بايثون على منصات مختلفة، سواء كانت AWS Lambda أو الخوادم التقليدية.

حقول المُستخدمة عبر المُنشئ: الطريقة البايثونية

أفضل طريقة واسطة لتنفيذ حقول المُستخدمة في بايثون هي عبر حقول المُنشئ—تقبل الاعتماديات كمعلمات في طريقة __init__.

مثال بسيط

إليك مثال بسيط يوضح حقول المُستخدمة عبر المُنشئ:

from typing import Protocol
from abc import ABC, abstractmethod

# تعريف بروتوكول للمخزن
class UserRepository(Protocol):
    def find_by_id(self, user_id: int) -> 'User | None':
        ...
    
    def save(self, user: 'User') -> 'User':
        ...

# الخدمة تعتمد على بروتوكول المخزن
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
    
    def get_user(self, user_id: int) -> 'User | None':
        return self.repo.find_by_id(user_id)

هذا النمط يجعل من الواضح أن UserService يحتاج إلى UserRepository. لا يمكنك إنشاء UserService دون توفير مخزن، مما يمنع الأخطاء عند غياب الاعتماديات.

عدة اعتماديات

عندما يكون لدى المكون عدة اعتماديات، أضفها كمعلمات للمُنشئ:

class EmailService(Protocol):
    def send(self, to: str, subject: str, body: str) -> None:
        ...

class Logger(Protocol):
    def info(self, msg: str) -> None:
        ...
    
    def error(self, msg: str, err: Exception) -> None:
        ...

class OrderService:
    def __init__(
        self,
        repo: OrderRepository,
        email_svc: EmailService,
        logger: Logger,
        payment_svc: PaymentService,
    ):
        self.repo = repo
        self.email_svc = email_svc
        self.logger = logger
        self.payment_svc = payment_svc

استخدام البروتوكولات والصفات الأساسية

إحدى المبادئ الرئيسية عند تنفيذ الحقول المُستخدمة هي مبدأ انعكاس الاعتماد (DIP): يجب ألا تعتمد الوحدات العليا على الوحدات الدنيا؛ بل يجب أن تعتمد كل منهما على المجرات.

في بايثون، يمكنك تعريف المجرات باستخدام البروتوكولات (التصنيف الهيكلي) أو الصفات الأساسية (ABCs) (التصنيف الاسمي).

البروتوكولات (Python 3.8+)

تستخدم البروتوكولات التصنيف الهيكلي—إذا كان الكائن يحتوي على الأساليب المطلوبة، فإنه يحقق البروتوكول:

from typing import Protocol

class PaymentProcessor(Protocol):
    def process_payment(self, amount: float) -> bool:
        ...

# أي فئة تحتوي على طريقة process_payment تحقق هذا البروتوكول
class CreditCardProcessor:
    def process_payment(self, amount: float) -> bool:
        # منطق بطاقة الائتمان
        return True

class PayPalProcessor:
    def process_payment(self, amount: float) -> bool:
        # منطق باي بال
        return True

# الخدمة تقبل أي PaymentProcessor
class OrderService:
    def __init__(self, payment_processor: PaymentProcessor):
        self.payment_processor = payment_processor

الصفات الأساسية

تستخدم الصفات الأساسية التصنيف الاسمي—يجب أن ترث الفئات صراحة من الصفات الأساسية:

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> bool:
        pass

class CreditCardProcessor(PaymentProcessor):
    def process_payment(self, amount: float) -> bool:
        return True

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

مثال عملي: تأسيس قاعدة بيانات

عند العمل مع قواعد بيانات في تطبيقات بايثون، ستحتاج غالبًا إلى تأسيس عمليات قاعدة البيانات. إليك كيف تساعدك الحقول المُستخدمة:

from typing import Protocol, Optional
from contextlib import contextmanager

class Database(Protocol):
    @contextmanager
    def transaction(self):
        ...
    
    def execute(self, query: str, params: dict) -> None:
        ...
    
    def fetch_one(self, query: str, params: dict) -> Optional[dict]:
        ...

# المخزن يعتمد على المجرة
class UserRepository:
    def __init__(self, db: Database):
        self.db = db
    
    def find_by_id(self, user_id: int) -> Optional['User']:
        result = self.db.fetch_one(
            "SELECT * FROM users WHERE id = :id",
            {"id": user_id}
        )
        if result:
            return User(**result)
        return None

هذا النمط يسمح لك بتغيير تنفيذات قاعدة البيانات (PostgreSQL، SQLite، MongoDB) دون تغيير كود المخزن.

نمط الجذر التكويني

الجذر التكويني هو المكان الذي تُكوِّن فيه جميع الاعتماديات عند نقطة دخول التطبيق (عادة main.py أو مصنع التطبيق). هذا يركز تكوين الاعتماديات ويجعل الرسم البياني للاعتماديات واضحًا.

def create_app() -> FastAPI:
    app = FastAPI()
    
    # تهيئة الاعتماديات الأساسية
    db = init_database()
    logger = init_logger()
    
    # تهيئة المخازن
    user_repo = UserRepository(db)
    order_repo = OrderRepository(db)
    
    # تهيئة الخدمة مع الاعتماديات
    email_svc = EmailService(logger)
    payment_svc = PaymentService(logger)
    user_svc = UserService(user_repo, logger)
    order_svc = OrderService(order_repo, email_svc, logger, payment_svc)
    
    # تهيئة معالجات HTTP
    user_handler = UserHandler(user_svc)
    order_handler = OrderHandler(order_svc)
    
    # ربط الطرق
    app.include_router(user_handler.router)
    app.include_router(order_handler.router)
    
    return app

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

الإطارات للحقول المُستخدمة

لتطبيقات أكبر مع رسم بياني معقد للاعتماديات، قد يصبح إدارة الاعتماديات يدويًا مرهقًا. لدى بايثون عدة إطارات للحقول المُستخدمة يمكن أن تساعد:

Dependency Injector

Dependency Injector هي إطارة شهيرة توفر نهجًا قائمًا على الحاويات للحقول المُستخدمة.

التركيب:

pip install dependency-injector

مثال:

from dependency_injector import containers, providers
from dependency_injector.wiring import inject, Provide

class Container(containers.DeclarativeContainer):
    # التكوين
    config = providers.Configuration()
    
    # قاعدة البيانات
    db = providers.Singleton(
        Database,
        connection_string=config.database.url
    )
    
    # المخازن
    user_repository = providers.Factory(
        UserRepository,
        db=db
    )
    
    # الخدمة
    user_service = providers.Factory(
        UserService,
        repo=user_repository
    )

# الاستخدام
container = Container()
container.config.database.url.from_env("DATABASE_URL")
user_service = container.user_service()

Injector

Injector هي مكتبة خفيفة مستوحاة من Guice من Google، تركز على البساطة.

التركيب:

pip install injector

مثال:

from injector import Injector, inject, Module, provider

class DatabaseModule(Module):
    @provider
    def provide_db(self) -> Database:
        return Database(connection_string="...")

class UserModule(Module):
    @inject
    def __init__(self, repo: UserRepository):
        self.repo = repo

injector = Injector([DatabaseModule()])
user_service = injector.get(UserService)

متى تستخدم الإطارات

استخدم إطارة عندما:

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

استخدم الحقول المُستخدمة يدويًا عندما:

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

اختبار مع الحقول المُستخدمة

أحد الفوائد الرئيسية للحقول المُستخدمة هو تحسين قابلية الاختبار. إليك كيف تجعل الحقول المُستخدمة اختبارات أسهل:

مثال على اختبار الوحدة

from unittest.mock import Mock
import pytest

# تنفيذ مُحاكي للاختبار
class MockUserRepository:
    def __init__(self):
        self.users = {}
        self.error = None
    
    def find_by_id(self, user_id: int) -> Optional['User']:
        if self.error:
            raise self.error
        return self.users.get(user_id)
    
    def save(self, user: 'User') -> 'User':
        if self.error:
            raise self.error
        self.users[user.id] = user
        return user

# اختبار باستخدام المُحاكي
def test_user_service_get_user():
    mock_repo = MockUserRepository()
    mock_repo.users[1] = User(id=1, name="John", email="john@example.com")
    
    service = UserService(mock_repo)
    
    user = service.get_user(1)
    assert user is not None
    assert user.name == "John"

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

استخدام fixtures pytest

تعمل fixtures pytest بشكل ممتاز مع الحقول المُستخدمة:

@pytest.fixture
def mock_user_repository():
    return MockUserRepository()

@pytest.fixture
def user_service(mock_user_repository):
    return UserService(mock_user_repository)

def test_user_service_get_user(user_service, mock_user_repository):
    user = User(id=1, name="John", email="john@example.com")
    mock_user_repository.users[1] = user
    
    result = user_service.get_user(1)
    assert result.name == "John"

الأنماط الشائعة والممارسات المثلى

1. استخدام تقسيم الواجهات

احتفظ بالبروتوكولات والواجهات صغيرة ومُركزة على ما يحتاجه العميل فعليًا:

# جيد: العميل يحتاج فقط إلى قراءة المستخدمين
class UserReader(Protocol):
    def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    def find_by_email(self, email: str) -> Optional['User']:
        ...

# واجهة منفصلة للكتابة
class UserWriter(Protocol):
    def save(self, user: 'User') -> 'User':
        ...
    
    def delete(self, user_id: int) -> bool:
        ...

2. تحقق من الاعتماديات في المُنشئ

يجب أن يتحقق المُنشئ من الاعتماديات ويُثير أخطاء واضحة إذا فشل التهيئة:

class UserService:
    def __init__(self, repo: UserRepository):
        if repo is None:
            raise ValueError("user repository cannot be None")
        self.repo = repo

3. استخدام العلامات النوعية

تُساعد العلامات النوعية في جعل الاعتماديات واضحة وتُساعد في دعم IDE والفحص النوعي الثابت:

from typing import Protocol, Optional

class UserService:
    def __init__(
        self,
        repo: UserRepository,
        logger: Logger,
        email_service: EmailService,
    ) -> None:
        self.repo = repo
        self.logger = logger
        self.email_service = email_service

4. تجنب الحقول المُستخدمة الزائدة

لا تحقِّل الاعتماديات التي تُعتبر تفاصيل تنفيذ داخلية. إذا أنشأ المكون وحدات مساعدة الخاصة به، فهذا مناسب:

# جيد: لا يحتاج المُساعِد الداخلي إلى الحقول المُستخدمة
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
        # ذاكرة ت高速 - لا تحتاج إلى الحقول المُستخدمة
        self._cache: dict[int, User] = {}

5. وثّق الاعتماديات

استخدم الوثائق لشرح سبب الحاجة إلى الاعتماديات وجميع القيود:

class UserService:
    """UserService يتعامل مع منطق العمل المرتبط بالمستخدمين.
    
    Args:
        repo: UserRepository للاستعلام عن البيانات. يجب أن يكون آمنًا من التحديد
            إذا تم استخدامه في السياقات المتزامنة.
        logger: Logger لتعقب الأخطاء وتصحيحها.
    """
    def __init__(self, repo: UserRepository, logger: Logger):
        self.repo = repo
        self.logger = logger

الحقول المُستخدمة مع FastAPI

تُوفر FastAPI دعمًا داخليًا للحقول المُستخدمة عبر آلية Depends:

from fastapi import FastAPI, Depends
from typing import Annotated

app = FastAPI()

def get_user_repository() -> UserRepository:
    db = get_database()
    return UserRepository(db)

def get_user_service(
    repo: Annotated[UserRepository, Depends(get_user_repository)]
) -> UserService:
    return UserService(repo)

@app.get("/users/{user_id}")
def get_user(
    user_id: int,
    service: Annotated[UserService, Depends(get_user_service)]
):
    user = service.get_user(user_id)
    if not user:
        raise HTTPException(status_code=404)
    return user

نظام الحقول المُستخدمة في FastAPI يتعامل مع رسم بياني الاعتماديات تلقائيًا، مما يجعل من السهل بناء واجهات API نظيفة ومُحافظة.

متى لا تستخدم الحقول المُستخدمة

الحقول المُستخدمة هي أداة قوية، ولكنها ليست ضرورية دائمًا:

تخطي DI عند:

  • القيم البسيطة أو الفئات الخاصة بالبيانات
  • الوظائف المساعدة الداخلية أو الأدوات
  • النصوص أو الأدوات البسيطة
  • عندما يكون التهيئة المباشرة أكثر وضوحًا وبساطة

مثال على متى لا تستخدم DI:

# فئة بيانات بسيطة - لا حاجة للحقول المُستخدمة
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

# أداة بسيطة - لا حاجة للحقول المُستخدمة
def format_currency(amount: float) -> str:
    return f"${amount:.2f}"

التكامل مع بيئة بايثون

تعمل الحقول المُستخدمة بسلاسة مع أنماط بايثون الأخرى والأدوات. عند بناء تطبيقات تستخدم حزم بايثون أو إطارات اختبار الوحدة، يمكنك حقِّل هذه الخدمات في منطق العمل الخاص بك:

class ReportService:
    def __init__(
        self,
        pdf_generator: PDFGenerator,
        repo: ReportRepository,
        logger: Logger,
    ):
        self.pdf_generator = pdf_generator
        self.repo = repo
        self.logger = logger
    
    def generate_report(self, report_id: int) -> bytes:
        report_data = self.repo.get_by_id(report_id)
        pdf = self.pdf_generator.generate(report_data)
        self.logger.info(f"تم إنشاء التقرير {report_id}")
        return pdf

هذا يسمح لك بتغيير تنفيذات أو استخدام نماذج اختبار أثناء الاختبار.

الحقول المُستخدمة المُزامنة

تعمل تركيبات async/await في بايثون بشكل جيد مع الحقول المُستخدمة:

from typing import Protocol
import asyncio

class AsyncUserRepository(Protocol):
    async def find_by_id(self, user_id: int) -> Optional['User']:
        ...
    
    async def save(self, user: 'User') -> 'User':
        ...

class AsyncUserService:
    def __init__(self, repo: AsyncUserRepository):
        self.repo = repo
    
    async def get_user(self, user_id: int) -> Optional['User']:
        return await self.repo.find_by_id(user_id)
    
    async def get_users_batch(self, user_ids: list[int]) -> list['User']:
        tasks = [self.repo.find_by_id(uid) for uid in user_ids]
        results = await asyncio.gather(*tasks)
        return [u for u in results if u is not None]

الخاتمة

الحقول المُستخدمة هي ركيزة أساسية لكتابة كود بايثون قابل للصيانة والاختبار. من خلال اتباع الأنماط المذكورة في هذه المقالة—حقول المُنشئ، التصميم القائم على البروتوكولات، ونمط الجذر التكويني—ستبني تطبيقات أسهل لفهمها، اختبارها، وتعديلها.

ابدأ بحقول المُنشئ اليدوية لتطبيقات صغيرة إلى متوسطة الحجم، وفكر في الإطارات مثل dependency-injector أو injector عند نمو رسم بياني الاعتماديات. تذكّر أن الهدف هو الوضوح والاختبار، وليس التعقيد من أجله نفسه.

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

روابط مفيدة

موارد خارجية