Laravel المتقدم

مفاهيم توريد الأحداث

18 دقيقة الدرس 10 من 40

مفاهيم توريد الأحداث

فهم هندسة توريد الأحداث وتنفيذ مخازن الأحداث وإنشاء الإسقاطات وإعادة تشغيل الأحداث وتطبيق أنماط CQRS (فصل مسؤولية الأمر والاستعلام) باستخدام حزمة Spatie Event Sourcing.

ما هو توريد الأحداث؟

توريد الأحداث هو نمط معماري حيث يتم تخزين تغييرات حالة التطبيق كتسلسل من الأحداث، بدلاً من مجرد الحالة الحالية.

// النهج التقليدي - يخزن الحالة الحالية User::where('id', 1)->update(['balance' => 1000]); // توريد الأحداث - يخزن الأحداث التي أدت إلى الحالة MoneyAdded::dispatch($user, 500); // الحدث 1: +500 MoneySubtracted::dispatch($user, 200); // الحدث 2: -200 MoneyAdded::dispatch($user, 700); // الحدث 3: +700 // الرصيد الحالي = 1000 (مشتق من الأحداث)
نصيحة احترافية: يوفر توريد الأحداث مسار تدقيق كامل ويمكّن من السفر عبر الزمن (عرض الحالات السابقة) ويسمح بإعادة بناء الحالة من الصفر بإعادة تشغيل الأحداث.

الفوائد وحالات الاستخدام

يتفوق توريد الأحداث في سيناريوهات محددة:

الفوائد: ✓ مسار تدقيق كامل - يتم تسجيل كل تغيير في الحالة ✓ السفر عبر الزمن - إعادة بناء الحالة في أي نقطة زمنية ✓ إعادة تشغيل الأحداث - إعادة بناء الإسقاطات من الأحداث ✓ تصحيح الأخطاء - رؤية التسلسل الدقيق للأحداث التي تسببت في المشاكل ✓ التحليلات - تحليل الأنماط التاريخية ✓ قابلية التوسع - فصل نماذج القراءة والكتابة (CQRS) حالات الاستخدام: • الأنظمة المالية (البنوك، المحاسبة) • التجارة الإلكترونية (معالجة الطلبات، المخزون) • الأنظمة التعاونية (تحرير المستندات) • الألعاب (حالة اللعبة، لوحات المتصدرين) • الرعاية الصحية (سجلات المرضى) • إنترنت الأشياء (بيانات الحساسات، حالات الأجهزة) متى لا يجب استخدامه: ✗ تطبيقات CRUD البسيطة ✗ الأنظمة بدون متطلبات التدقيق ✗ سيناريوهات الكتابة العالية والقراءة المنخفضة بدون احتياجات التحليلات ✗ الفرق غير المألوفة بالنمط

تثبيت Spatie Event Sourcing

إعداد توريد الأحداث بحزمة Spatie:

// تثبيت الحزمة composer require spatie/laravel-event-sourcing // نشر التكوين والترحيلات php artisan vendor:publish --provider="Spatie\EventSourcing\EventSourcingServiceProvider" // تشغيل الترحيلات php artisan migrate // الجداول الرئيسية المنشأة: // - stored_events: يخزن جميع الأحداث // - snapshots: يخزن لقطات التجميع // - event_sourcing_jobs: يتتبع مهام إعادة التشغيل

إنشاء الأحداث

تعريف أحداث المجال التي تمثل تغييرات الحالة:

// app/Domain/Account/Events/AccountCreated.php namespace App\Domain\Account\Events; use Spatie\EventSourcing\StoredEvents\ShouldBeStored; class AccountCreated extends ShouldBeStored { public function __construct( public string $accountUuid, public string $name, public string $userId ) {} } // app/Domain/Account/Events/MoneyAdded.php class MoneyAdded extends ShouldBeStored { public function __construct( public string $accountUuid, public int $amount ) {} } // app/Domain/Account/Events/MoneySubtracted.php class MoneySubtracted extends ShouldBeStored { public function __construct( public string $accountUuid, public int $amount ) {} } // app/Domain/Account/Events/AccountLimitReached.php class AccountLimitReached extends ShouldBeStored { public function __construct( public string $accountUuid, public int $currentBalance ) {} }
ملاحظة: يجب أن تكون الأحداث غير قابلة للتغيير ومسماة بصيغة الماضي وتحتوي على جميع البيانات اللازمة لإعادة بناء الحالة. لا تعدل بنية الحدث أبداً بعد الاستخدام في الإنتاج.

إنشاء التجميعات

التجميعات هي الكيانات التي تطبق الأحداث وتحافظ على قواعد الأعمال:

// app/Domain/Account/AccountAggregateRoot.php namespace App\Domain\Account; use Spatie\EventSourcing\AggregateRoots\AggregateRoot; class AccountAggregateRoot extends AggregateRoot { protected int $balance = 0; protected int $balanceLimit = 10000; // تطبيق الأحداث لتحديث الحالة الداخلية protected function applyAccountCreated(AccountCreated $event): void { $this->balance = 0; } protected function applyMoneyAdded(MoneyAdded $event): void { $this->balance += $event->amount; } protected function applyMoneySubtracted(MoneySubtracted $event): void { $this->balance -= $event->amount; } // طرق منطق الأعمال public function createAccount(string $name, string $userId): self { $this->recordThat(new AccountCreated( accountUuid: $this->uuid(), name: $name, userId: $userId )); return $this; } public function addMoney(int $amount): self { if ($amount <= 0) { throw new InvalidAmountException('يجب أن يكون المبلغ موجباً'); } $newBalance = $this->balance + $amount; if ($newBalance > $this->balanceLimit) { $this->recordThat(new AccountLimitReached( accountUuid: $this->uuid(), currentBalance: $this->balance )); throw new AccountLimitException('تم تجاوز حد الرصيد'); } $this->recordThat(new MoneyAdded( accountUuid: $this->uuid(), amount: $amount )); return $this; } public function subtractMoney(int $amount): self { if ($amount <= 0) { throw new InvalidAmountException('يجب أن يكون المبلغ موجباً'); } if ($this->balance - $amount < 0) { throw new InsufficientFundsException('رصيد غير كافٍ'); } $this->recordThat(new MoneySubtracted( accountUuid: $this->uuid(), amount: $amount )); return $this; } }

استخدام التجميعات

التفاعل مع التجميعات في وحدات التحكم والخدمات:

// app/Http/Controllers/AccountController.php namespace App\Http\Controllers; use App\Domain\Account\AccountAggregateRoot; use Illuminate\Support\Str; class AccountController extends Controller { public function create(Request $request) { $accountUuid = Str::uuid()->toString(); AccountAggregateRoot::retrieve($accountUuid) ->createAccount( name: $request->name, userId: auth()->id() ) ->persist(); return response()->json([ 'account_uuid' => $accountUuid, 'message' => 'تم إنشاء الحساب بنجاح' ]); } public function deposit(Request $request, string $accountUuid) { try { AccountAggregateRoot::retrieve($accountUuid) ->addMoney($request->amount) ->persist(); return response()->json(['message' => 'نجح الإيداع']); } catch (AccountLimitException $e) { return response()->json(['error' => $e->getMessage()], 422); } } public function withdraw(Request $request, string $accountUuid) { try { AccountAggregateRoot::retrieve($accountUuid) ->subtractMoney($request->amount) ->persist(); return response()->json(['message' => 'نجح السحب']); } catch (InsufficientFundsException $e) { return response()->json(['error' => $e->getMessage()], 422); } } }

إنشاء الإسقاطات

الإسقاطات هي نماذج القراءة المبنية من الأحداث:

// app/Domain/Account/Projections/Account.php namespace App\Domain\Account\Projections; use Illuminate\Database\Eloquent\Model; class Account extends Model { protected $guarded = []; protected $casts = [ 'balance' => 'integer', ]; } // app/Domain/Account/Projectors/AccountProjector.php namespace App\Domain\Account\Projectors; use Spatie\EventSourcing\EventHandlers\Projectors\Projector; class AccountProjector extends Projector { public function onAccountCreated(AccountCreated $event): void { Account::create([ 'uuid' => $event->accountUuid, 'name' => $event->name, 'user_id' => $event->userId, 'balance' => 0, ]); } public function onMoneyAdded(MoneyAdded $event): void { $account = Account::where('uuid', $event->accountUuid)->first(); $account->balance += $event->amount; $account->save(); } public function onMoneySubtracted(MoneySubtracted $event): void { $account = Account::where('uuid', $event->accountUuid)->first(); $account->balance -= $event->amount; $account->save(); } } // تسجيل الإسقاط في EventSourcingServiceProvider public function boot() { Projectionist::addProjector(AccountProjector::class); }
تحذير: يجب أن تكون الإسقاطات متماثلة (تطبيق نفس الحدث عدة مرات ينتج نفس النتيجة) ومتسقة في نهاية المطاف مع مخزن الأحداث.

إنشاء المفاعلات

تتعامل المفاعلات مع الآثار الجانبية عند حدوث الأحداث:

// app/Domain/Account/Reactors/SendAccountLimitNotification.php namespace App\Domain\Account\Reactors; use Spatie\EventSourcing\EventHandlers\Reactors\Reactor; class SendAccountLimitNotification extends Reactor { public function onAccountLimitReached(AccountLimitReached $event): void { $account = Account::where('uuid', $event->accountUuid)->first(); Mail::to($account->user) ->send(new AccountLimitReachedMail($account)); Log::warning('تم الوصول إلى حد الحساب', [ 'account_uuid' => $event->accountUuid, 'balance' => $event->currentBalance, ]); } } // app/Domain/Account/Reactors/TransactionLogger.php class TransactionLogger extends Reactor { public function onMoneyAdded(MoneyAdded $event): void { TransactionLog::create([ 'account_uuid' => $event->accountUuid, 'type' => 'إيداع', 'amount' => $event->amount, 'timestamp' => now(), ]); } public function onMoneySubtracted(MoneySubtracted $event): void { TransactionLog::create([ 'account_uuid' => $event->accountUuid, 'type' => 'سحب', 'amount' => $event->amount, 'timestamp' => now(), ]); } } // تسجيل المفاعلات public function boot() { Projectionist::addReactor(SendAccountLimitNotification::class); Projectionist::addReactor(TransactionLogger::class); }

إعادة تشغيل الأحداث

إعادة بناء الإسقاطات بإعادة تشغيل جميع الأحداث:

// إعادة تشغيل جميع الأحداث لجميع الإسقاطات php artisan event-sourcing:replay // إعادة التشغيل لإسقاط محدد php artisan event-sourcing:replay --projector=AccountProjector // إعادة التشغيل من معرف حدث محدد php artisan event-sourcing:replay --from=1000 // مسح الإسقاط قبل إعادة التشغيل Account::truncate(); php artisan event-sourcing:replay --projector=AccountProjector // إعادة التشغيل البرمجي use Spatie\EventSourcing\Facades\Projectionist; Projectionist::replay( AccountProjector::class, startingFromEventId: 0 );

اللقطات للأداء

تحسين إعادة بناء التجميع باللقطات:

class AccountAggregateRoot extends AggregateRoot { // التقاط لقطة كل 100 حدث protected int $snapshotEvery = 100; // إنشاء لقطة للحالة الحالية protected function snapshot(): array { return [ 'balance' => $this->balance, 'balance_limit' => $this->balanceLimit, ]; } // الاستعادة من اللقطة protected function restoreFromSnapshot(array $state): void { $this->balance = $state['balance']; $this->balanceLimit = $state['balance_limit']; } } // إنشاء لقطة يدوياً $aggregate = AccountAggregateRoot::retrieve($uuid); $aggregate->snapshot(); // بدون لقطات: إعادة تشغيل 10,000 حدث // مع لقطات (كل 100): إعادة تشغيل 100 حدث من آخر لقطة

نمط CQRS

فصل مسؤوليات الأمر والاستعلام:

// الأوامر - تعديل الحالة (كتابة) class DepositMoneyCommand { public function __construct( public string $accountUuid, public int $amount ) {} } class DepositMoneyHandler { public function handle(DepositMoneyCommand $command): void { AccountAggregateRoot::retrieve($command->accountUuid) ->addMoney($command->amount) ->persist(); } } // الاستعلامات - قراءة الحالة (قراءة) class GetAccountBalanceQuery { public function __construct( public string $accountUuid ) {} } class GetAccountBalanceHandler { public function handle(GetAccountBalanceQuery $query): int { // القراءة من الإسقاط، وليس الأحداث $account = Account::where('uuid', $query->accountUuid)->first(); return $account->balance; } } // الاستخدام Bus::dispatch(new DepositMoneyCommand($uuid, 500)); $balance = Bus::dispatch(new GetAccountBalanceQuery($uuid)); // قواعد بيانات منفصلة للقراءات والكتابات // config/database.php 'connections' => [ 'write' => [ 'driver' => 'mysql', 'host' => env('DB_WRITE_HOST'), // اتصال مخزن الأحداث ], 'read' => [ 'driver' => 'mysql', 'host' => env('DB_READ_HOST'), // اتصال الإسقاط (يمكن نسخه) ], ],

اختبار الأنظمة المبنية على توريد الأحداث

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

// tests/Feature/AccountTest.php use Spatie\EventSourcing\StoredEvents\Models\EloquentStoredEvent; class AccountTest extends TestCase { public function test_account_creation_records_event() { $uuid = Str::uuid()->toString(); AccountAggregateRoot::retrieve($uuid) ->createAccount('حساب تجريبي', 1) ->persist(); $this->assertDatabaseHas('stored_events', [ 'aggregate_uuid' => $uuid, 'event_class' => AccountCreated::class, ]); } public function test_adding_money_increases_balance() { $uuid = $this->createAccount(); AccountAggregateRoot::retrieve($uuid) ->addMoney(500) ->persist(); $account = Account::where('uuid', $uuid)->first(); $this->assertEquals(500, $account->balance); } public function test_exceeding_limit_throws_exception() { $uuid = $this->createAccount(); $this->expectException(AccountLimitException::class); AccountAggregateRoot::retrieve($uuid) ->addMoney(15000) ->persist(); } public function test_projection_can_be_rebuilt_from_events() { $uuid = $this->createAccountWithTransactions(); // حذف الإسقاط Account::where('uuid', $uuid)->delete(); // إعادة تشغيل الأحداث Projectionist::replay(AccountProjector::class); // التحقق من إعادة بناء الإسقاط بشكل صحيح $account = Account::where('uuid', $uuid)->first(); $this->assertEquals(1200, $account->balance); } }
تمرين 1: ابن نظام إدارة الطلبات باستخدام توريد الأحداث:
1. أنشئ OrderAggregateRoot بأحداث: تم وضع الطلب، تم دفع الطلب، تم شحن الطلب، تم تسليم الطلب، تم إلغاء الطلب
2. نفذ قواعد الأعمال: لا يمكن شحن الطلبات غير المدفوعة، لا يمكن إلغاء الطلبات المشحونة
3. أنشئ إسقاطات لـ: حالة الطلب الحالية، الجدول الزمني لتاريخ الطلب
4. أضف مفاعلات لـ: إرسال رسائل البريد الإلكتروني، تحديث المخزون
5. نفذ لقطات كل 50 حدث
اختبر جميع انتقالات الحالة وسيناريوهات إعادة التشغيل.
تمرين 2: أنشئ محرر مستندات تعاوني مع توريد الأحداث:
1. الأحداث: تم إنشاء المستند، تمت إضافة المحتوى، تم حذف المحتوى، تم تعديل المحتوى، انضم المستخدم، غادر المستخدم
2. تتبع جميع التغييرات مع إسناد المستخدم والطوابع الزمنية
3. ابن إسقاط يظهر حالة المستند الحالية
4. نفذ ميزة "السفر عبر الزمن" لعرض المستند في أي نقطة في التاريخ
5. أنشئ إسقاط تحليلي لـ: تكرار التحرير، المستخدمون النشطون، الأقسام الشائعة
ادعم التحرير المتزامن وحل التعارض.
تمرين 3: نفذ نظام لوحة متصدري الألعاب مع توريد الأحداث و CQRS:
1. الأحداث: تم تسجيل اللاعب، تمت إضافة النقاط، تم فتح الإنجاز، تم إكمال المستوى
2. الأوامر: تسجيل اللاعب، إضافة النقاط، فتح الإنجاز
3. الاستعلامات: الحصول على رتبة اللاعب، الحصول على أفضل اللاعبين، الحصول على إحصائيات اللاعب
4. افصل قواعد بيانات الكتابة (مخزن الأحداث) والقراءة (الإسقاطات)
5. أنشئ إسقاطات متعددة: لوحة المتصدرين لجميع الأوقات، لوحة المتصدرين الشهرية، إحصائيات اللاعبين
تعامل مع التزامن العالي ونفذ حساب الرتبة الفعال.