◀ العودة إلى المدونة
PHP / Symfony

أنماط التصميم في PHP

نشر في 05 Sep 2024· 10 min قراءة
#PHP#Design Patterns#Architecture

أنماط أساسية لـ PHP الحديثة

أنماط التصميم (design patterns) حلول مُجرَّبة لمشكلات متكررة في تصميم البرمجيات. إليك أكثرها فائدة في PHP، مع أمثلة مكتوبة بـ PHP 8 وSymfony كما تُكتب اليوم.

النمط ليس مكتبة تُثبَّت ولا قاعدة تُطبَّق في كل مكان، بل هو مفردات مشتركة وشكل حلٍّ أثبت جدواه. فائدته الأساسية أنه يجعل الشيفرة متوقَّعة: حين يقرأ المطوّر PricingStrategy أو ProductRepositoryInterface يعرف فوراً دور الصنف وأين يبحث عن المنطق. الأنماط المعروضة هنا هي التي نصادفها فعلاً في تطبيق Symfony في بيئة الإنتاج: بعضها يوفّره إطار العمل نفسه، وبعضها يُكتب في أسطر قليلة بفضل ميزات اللغة الحديثة (ترقية الخصائص في المُنشئ، والسمات attributes، والأنواع الصارمة).

نمط Repository

يعزل Repository منطق الوصول إلى البيانات خلف واجهة موجَّهة نحو منطق الأعمال. يطلب باقي التطبيق «منتجات هذه الفئة» دون أن يعرف إن كانت قادمة من MySQL عبر Doctrine، أو من API خارجية، أو من مصفوفة في الذاكرة أثناء الاختبارات.

interface ProductRepositoryInterface
{
    public function findById(int $id): ?Product;
    public function findByCategory(string $category): array;
    public function save(Product $product): void;
}

class DoctrineProductRepository implements ProductRepositoryInterface
{
    public function __construct(
        private EntityManagerInterface $em,
    ) {}

    public function findById(int $id): ?Product
    {
        return $this->em->find(Product::class, $id);
    }

    public function findByCategory(string $category): array
    {
        return $this->em->getRepository(Product::class)
            ->findBy(['category' => $category], ['name' => 'ASC']);
    }

    public function save(Product $product): void
    {
        $this->em->persist($product);
        $this->em->flush();
    }
}

بعض النقاط المهمة في هذا التنفيذ:

  • تعتمد الخدمات على ProductRepositoryInterface وليس على صنف Doctrine أبداً. في Symfony، يربط الـ autowiring الواجهة تلقائياً بتنفيذها الوحيد.
  • تحمل الدوال أسماء من لغة الأعمال (findByCategory) بدلاً من كشف الـ QueryBuilder للخارج: يبقى الاستعلام داخل الـ repository.
  • نوع الإرجاع ?Product يُلزم المستدعي بمعالجة حالة «غير موجود» صراحةً.

تظهر الفائدة الأوضح في اختبارات الوحدة: تنفيذ في الذاكرة يحلّ محل قاعدة البيانات دون أي mock معقّد.

final class InMemoryProductRepository implements ProductRepositoryInterface
{
    /** @var array<int, Product> */
    private array $products = [];

    public function findById(int $id): ?Product
    {
        return $this->products[$id] ?? null;
    }

    public function findByCategory(string $category): array
    {
        return array_values(array_filter(
            $this->products,
            fn (Product $p): bool => $p->getCategory() === $category,
        ));
    }

    public function save(Product $product): void
    {
        $this->products[$product->getId()] = $product;
    }
}

فخ شائع: استدعاء flush() داخل كل save() بسيط، لكنه مكلف حين نحفظ مئات الكائنات داخل حلقة. للمعالجة على دفعات، وفّر دالة مخصصة أو اترك لطبقة التطبيق (معالج أوامر مثلاً) أن تقرر متى يُستدعى flush().

نمط Strategy

يغلّف نمط Strategy عائلة من الخوارزميات القابلة للتبديل خلف واجهة واحدة. إنه العلاج لسلاسل if/switch الطويلة التي تتضخم مع كل حالة أعمال جديدة: إضافة قاعدة تسعير تعني إضافة صنف دون المساس بالشيفرة الموجودة (مبدأ المفتوح/المغلق).

interface PricingStrategy
{
    public function calculate(float $basePrice): float;
}

class RegularPricing implements PricingStrategy
{
    public function calculate(float $basePrice): float
    {
        return $basePrice;
    }
}

class PremiumPricing implements PricingStrategy
{
    public function calculate(float $basePrice): float
    {
        return $basePrice * 0.8; // 20% discount
    }
}

يبقى اختيار الاستراتيجية المناسبة وقت التنفيذ. في Symfony، الطريقة الأنظف هي وسم كل التنفيذات تلقائياً وحقنها كمجموعة قابلة للتكرار (iterable) في صنف يتولى الاختيار. كل استراتيجية تحدد بنفسها إن كانت تنطبق:

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

#[AutoconfigureTag('app.pricing_strategy')]
interface CustomerPricingStrategy extends PricingStrategy
{
    public function supports(Customer $customer): bool;
}

final class PricingResolver
{
    /** @param iterable<CustomerPricingStrategy> $strategies */
    public function __construct(
        #[AutowireIterator('app.pricing_strategy')]
        private iterable $strategies,
    ) {}

    public function priceFor(Customer $customer, float $basePrice): float
    {
        foreach ($this->strategies as $strategy) {
            if ($strategy->supports($customer)) {
                return $strategy->calculate($basePrice);
            }
        }

        throw new \LogicException('No applicable pricing strategy.');
    }
}

السمة #[AutowireIterator] متاحة منذ Symfony 6.4؛ وفي الإصدارات السابقة تؤدي #[TaggedIterator] الدور نفسه. نصيحة عابرة: تستخدم الأمثلة النوع float للوضوح، لكن للمبالغ الحقيقية فضّل أعداداً صحيحة بالسنتيمات أو مكتبة مثل brick/money لتجنّب أخطاء التقريب.

نمط Observer مع أحداث Symfony

يسمح نمط Observer لكائن بإخطار كائنات أخرى لا يعرفها. في Symfony، الـ EventDispatcher تنفيذ كامل لهذا النمط: الشيفرة التي تنشئ طلباً تنشر حدثاً، وكل مستمع (listener) يتفاعل من جهته (بريد تأكيد، تحديث المخزون، إحصاءات). إضافة تفاعل جديد لا تتطلب أي تعديل على الشيفرة المُرسِلة.

#[AsEventListener(event: OrderCreatedEvent::class)]
class SendOrderConfirmation
{
    public function __construct(
        private MailerInterface $mailer,
    ) {}

    public function __invoke(OrderCreatedEvent $event): void
    {
        $this->mailer->send(
            new OrderConfirmationEmail($event->getOrder())
        );
    }
}

من جهة المُرسِل، يكفي إطلاق الحدث بعد حفظ الطلب:

final class OrderService
{
    public function __construct(
        private EventDispatcherInterface $dispatcher,
    ) {}

    public function place(Order $order): void
    {
        // ... persist the order
        $this->dispatcher->dispatch(new OrderCreatedEvent($order));
    }
}

انتبه: مستمعو الـ EventDispatcher يُنفَّذون بشكل متزامن داخل طلب HTTP نفسه. إن فشل إرسال البريد أو استغرق ثانيتين، فالمستخدم هو من ينتظر. للمعالجات البطيئة أو المعرّضة للفشل، انشر رسالة عبر Symfony Messenger وعالجها في عامل (worker) غير متزامن. فخ آخر: يجب ألا يعتمد مستمع على ترتيب تنفيذ المستمعين الآخرين؛ وإن لزم ذلك فاستخدم المعامل priority في السمة، لكنه غالباً علامة على اقتران خفي.

نمط Builder

يبني نمط Builder كائناً معقداً خطوة بخطوة عبر واجهة متسلسلة (fluent). يتجنب المُنشئات ذات المعاملات الاختيارية العشرة ويجعل الشيفرة المستدعية تُقرأ كجملة. يستخدمه Doctrine (QueryBuilder) وSymfony Mailer (Email) ومكوّن Form (FormBuilder) بكثرة.

class QueryBuilder
{
    private array $conditions = [];
    private ?int $limit = null;

    public function where(string $field, mixed $value): self
    {
        $this->conditions[$field] = $value;
        return $this;
    }

    public function limit(int $limit): self
    {
        $this->limit = $limit;
        return $this;
    }

    public function build(): Query
    {
        return new Query($this->conditions, $this->limit);
    }
}

الاستخدام:

$query = (new QueryBuilder())
    ->where('status', 'published')
    ->where('category', 'php')
    ->limit(10)
    ->build();

الدالة build() هي المكان المناسب للتحقق من تماسك المجموع (حقول إلزامية، تركيبات ممنوعة) ورمي استثناء قبل إنتاج كائن غير صالح. والأفضل أن يكون الكائن الناتج (Query) غير قابل للتعديل: الـ Builder قابل للتعديل، والنتيجة ليست كذلك.

إضافة: نمط Decorator مع Symfony

يضيف نمط Decorator سلوكاً إلى خدمة دون تعديلها، بتغليفها في صنف ينفّذ الواجهة نفسها. إنه مثالي للـ cache والسجلات والمقاييس. يدعمه Symfony أصلاً عبر السمة #[AsDecorator]:

use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

#[AsDecorator(decorates: ProductRepositoryInterface::class)]
final class CachedProductRepository implements ProductRepositoryInterface
{
    public function __construct(
        #[AutowireDecorated]
        private ProductRepositoryInterface $inner,
        private CacheInterface $cache,
    ) {}

    public function findById(int $id): ?Product
    {
        return $this->inner->findById($id);
    }

    public function findByCategory(string $category): array
    {
        return $this->cache->get(
            'products_category_'.md5($category),
            function (ItemInterface $item) use ($category): array {
                $item->expiresAfter(300);

                return $this->inner->findByCategory($category);
            },
        );
    }

    public function save(Product $product): void
    {
        $this->inner->save($product);
    }
}

كل الخدمات التي تعتمد على ProductRepositoryInterface تتلقى الآن النسخة المزوّدة بالـ cache، دون تغيير سطر واحد من شيفرتها. لكن احذر من تخزين كيانات Doctrine في الـ cache: فبعد إلغاء تسلسلها لا يعود الـ EntityManager يديرها. للـ cache، فضّل المعرّفات أو كائنات DTO للقراءة فقط.

ملخّص

  • Repository: تجريد طبقة التخزين
  • Strategy: خوارزميات قابلة للتبديل
  • Observer: فكّ الاقتران عبر الأحداث
  • Builder: بناء الكائنات المعقدة
  • Decorator: إضافة سلوك (cache، سجلات) دون تعديل الخدمة الأصلية

متى لا تستخدم نمطاً

الخطر الأساسي في أنماط التصميم ليس تجاهلها، بل تطبيقها في كل مكان. واجهة بتنفيذ وحيد لن يكون لها تنفيذ ثانٍ أبداً، أو Strategy لحالتين لن تتغيرا، أو Factory تكتفي باستدعاء new: كل ذلك يضيف ملفات وطبقات وسيطة دون أي فائدة. بعض المعالم:

  • ابدأ بأبسط شيفرة ممكنة؛ وأدخِل النمط حين تظهر حالة ملموسة ثانية، لا «احتياطاً».
  • تجنّب Singleton: في تطبيق Symfony، حاوية الخدمات تتشارك أصلاً نسخة وحيدة، والحالة العامة تجعل الاختبارات هشّة.
  • استخدم الأنماط التي يوفرها إطار العمل (الأحداث، التزيين، الخدمات الموسومة) بدلاً من إعادة تنفيذها.
  • سمِّ الأصناف وفق دورها في الأعمال؛ قد يظهر اسم النمط، لكن لا ينبغي أن يحلّ محلّ المعنى.

إذا أُحسن استخدامها، فهذه الأنماط القليلة تكفي لهيكلة الغالبية العظمى من تطبيقات PHP: التخزين خلف الـ repositories، والقواعد المتغيرة في الاستراتيجيات، والآثار الجانبية في المستمعين، والاهتمامات المشتركة في المُزيِّنات.