الحدث الجيل ، CQRS ولارافيل

تم إعداد ترجمة المقال لطلاب الدورة المهنية "Framework Laravel"





مقدمة


هذه المقالة مخصصة لأساسيات إنشاء أنظمة CQRS للحدث في لغة PHP وفي إطار Laravel. من المفترض أن تكون على دراية بمخطط التطوير باستخدام ناقل الأوامر ولديك فكرة عن الأحداث (على وجه الخصوص ، نشر الأحداث لمجموعة من المستمعين). لتحديث هذه المعرفة ، يمكنك استخدام خدمة Laracasts. بالإضافة إلى ذلك ، من المفترض أن لديك فهمًا معينًا لمبدأ CQRS. إذا لم يكن الأمر كذلك ، فإنني أوصي بشدة بالاستماع إلى محاضرتين: ورشة عمل Mathias Verraes حول إنشاء الأحداث و GQ Young's CQRS وحدث الجيل .

لا تستخدم الشفرة الواردة هنا في مشاريعك! إنها منصة تعليمية لفهم الأفكار الكامنة وراء CQRS. لا يمكن تسمية هذه الشفرة بالموثوقية ، حيث يتم اختبارها بشكل سيء ، وبالإضافة إلى ذلك ، نادراً ما أقوم ببرمجة واجهات ، لذلك سيكون من الصعب تغيير أجزاء فردية من الكود. أفضل مثال لحزمة CQRS التي يمكنك استخدامها هو Broadway ، الذي طورته شركة Qandidate Lab . هذا رمز نظيف ومرتبط بشكل فضفاض ، ومع ذلك ، فإن بعض التجريدات تجعل من غير الواضح تمامًا ما إذا كنت لم تصادف أنظمة أحداث أبدًا.

والأخير - الكود الخاص بي مرتبط بالأحداث وحافلة قيادة Laravel. أردت أن أرى كيف سيبدو الرمز في Laravel (عادةً ما أستخدم هذا الإطار لمشاريع الوكالات الصغيرة) ، ومع ذلك ، بالنظر إلى الوراء ، أعتقد أنه ينبغي علي إنشاء تطبيقاتي الخاصة. آمل أن تكون الشفرة الخاصة بي واضحة حتى لأولئك الذين لا يستخدمون الأطر.

على github ، يوجد الكود في https://github.com/scazz/cqrs-tutorial.git ، وفي دليلنا سننظر في مكوناته لزيادة المنطق.

سنقوم بإنشاء نظام التسجيل الأولي لمدرسة ركوب الأمواج. من خلال مساعدتها ، يمكن لعملاء المدارس التسجيل في الفصول الدراسية. بالنسبة لعملية التسجيل ، نقوم بصياغة القواعد التالية:

  • يجب أن يكون لكل درس عميل واحد على الأقل ...
  • ... ولكن ليس أكثر من ثلاثة.

واحدة من الميزات الأكثر إثارة للإعجاب لأنظمة CQRS المستندة إلى الحدث هو إنشاء نماذج القراءة الخاصة بكل قياس مطلوب من النظام. ستجد أمثلة لإسقاط نماذج القراءة في ElasticSearch ، ونفذ Greg Young لغة موجهة نحو الموضوع في متجر الأحداث الخاص به للتعامل مع الأحداث المعقدة. ومع ذلك ، من أجل البساطة ، سيكون عرض القراءة الخاص بنا بمثابة قاعدة بيانات SQL قياسية للاستخدام مع Eloquent. نتيجة لذلك ، سيكون لدينا جدول واحد للفصول وآخر للعملاء.

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

إعداد المشروع والاختبار الأول


git clone https://github.com/scazz/cqrs-tutorial 

إنشاء مشروع جديد لارافيل 5

 $> laravel new cqrs-tutorial 

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

قائمة الاختبارات / CQRSTest.php:

 use Illuminate\Foundation\Bus\DispatchesCommands; class CQRSTest extends TestCase { use DispatchesCommands; 

 /** *  ,   BookLesson       * @return void */ public function testFiringEventUpdatesReadModel() { $testLessonId = '123e4567-e89b-12d3-a456-426655440000'; $clientName = "George"; $lessonId = new LessonId($testLessonId); $command = new BookLesson($lessonId, $clientName); $this->dispatch($command); $this->assertNotNull(Lesson::find($testLessonId)); $this->assertEquals( Lesson::find($testLessonId)->clientName, $clientName ); } } 

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

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

بناءً على الأخطاء التي تم تحديدها أثناء الاختبار ، يمكننا إنشاء فصول مفقودة. أولاً ، سنقوم بإنشاء فئة LessonId ، ثم أمر BookLesson (لا تقلق بشأن طريقة المعالج حتى الآن ، فقط قم بإجراء الاختبار). فئة الدرس هي نموذج للقراءة خارج مساحة اسم الدرس. نموذج قراءة حصري - لن يتم تخزين منطق منطقة الموضوع هنا مطلقًا. في الختام ، سنحتاج إلى إنشاء ترحيل لجدول الدرس.

للحفاظ على وضوح الشفرة ، سأستخدم مكتبة التحقق من التأكيد. يمكن إضافته باستخدام الأمر التالي:

 $> composer require beberlei/assert 

النظر في العملية التي ينبغي أن تبدأ من قبل هذا الأمر:

  1. التحقق من الصحة: ​​قد تفشل الأوامر الضرورية ، وقد وقعت الأحداث بالفعل وبالتالي لا ينبغي أن تفشل.
  2. قم بإنشاء حدث LessonWasBooked جديد (اشترك في درس).
  3. تحديث حالة النشاط. (يجب أن يكون طراز السجل على دراية بحالة النموذج حتى يتمكن من إجراء التحقق من الصحة.)
  4. أضف هذا الحدث إلى مجموعة الأحداث غير الملتزم بها المخزنة في نموذج سجل النشاط.
  5. احفظ مجموعة الأحداث غير الملتزم بها في المستودع.
  6. ارفع حدث LessonWasBooked على مستوى العالم لإعلام جميع أجهزة قراءة القراءة بتحديث جدول الدرس.

تحتاج أولاً إلى إنشاء نموذج تسجيل للدرس. سوف نستخدم طريقة المصنع الثابت Lesson::bookClientOntoNewLesson() . يقوم بإنشاء حدث LessonWasOpened جديد (الدرس مفتوح) ، ويطبق هذا الحدث على نفسه (فقط يحدد هويته) ، ويضيف الحدث الجديد إلى قائمة الأحداث غير الملتزم بها في شكل DomainEventMessage (الحدث بالإضافة إلى بعض البيانات التعريفية التي نستخدمها عند الحفظ في مخزن الأحداث).

تتكرر العملية لإضافة عميل إلى الحدث. عند تطبيق حدث ClientWasBookedOntoLesson (تم تسجيل العميل في الدرس) ، فإن نموذج التسجيل لا يتتبع أسماء العملاء ، ولكن فقط عدد العملاء المسجلين. لا تحتاج نماذج السجلات إلى معرفة أسماء العملاء لضمان الاتساق.

قد يبدو أن أساليب applyClientWasBookedOntoLesson و applyClientWasBookedOntoLesson غريبة بعض الشيء في applyClientWasBookedOntoLesson الحالي. سنستخدمها لاحقًا عندما نحتاج إلى إعادة إنتاج الأحداث القديمة من أجل تشكيل حالة نموذج التسجيل. ليس من السهل شرح ذلك ، لذلك سأقدم لك كودًا يساعدك على فهم هذه العملية. في وقت لاحق ، سنقوم باستخراج الكود الذي يعالج uncommittedEvents ويقوم بإنشاء رسائل أحداث المجال.

 app/School/Lesson/Lesson.php public function openLesson( LessonId $lessonId ) { /*      ,      ,       */ $this->apply( new LessonWasOpened( $lessonId) ); } protected function applyLessonWasOpened( LessonWasOpened $event ) { $this->lessonId = $event->getLessonId(); $this->numberOfClients = 0; } public function bookClient( $clientName ) { if ($this->numberOfClients >= 3) { throw new TooManyClientsAddedToLesson(); } $this->apply( new ClientBookedOntoLesson( $this->lessonId, $clientName) ); } /** *       — *  ,       *      ,       , *      . */ protected function applyClientBookedOntoLesson( ClientBookedOntoLesson $event ) { $this->numberOfClients++; } 

يمكننا استخراج مكونات CQRS من نموذج التسجيل الخاص بنا - أجزاء من الفصل تشارك في معالجة الأحداث غير الملتزم بها. يمكننا أيضًا مسح واجهة برمجة التطبيقات (API) لكيان تم إنشاؤه من خلال إنشاء وظيفة applyEventName() آمنة تقبل الحدث ، وتدعو applyEventName() المطابق ، وتضيف حدث DomainEventMessage جديدًا إلى قائمة الأحداث غير الملتزم بها. الفئة المستخرجة عبارة عن تفاصيل لتنفيذ CQRS ولا تحتوي على منطق المجال ، لذلك يمكننا إنشاء مساحة اسم جديدة: App \ CQRS:

انتبه إلى app/CQRS/EventSourcedEntity.php الكود app/CQRS/EventSourcedEntity.php
لكي تعمل التعليمات البرمجية ، نحتاج إلى إضافة فئة DomainEventMessage ، وهي DTO بسيطة - يمكن العثور عليها في app/CQRS/DomainEventMessage.php

وبالتالي ، حصلنا على نظام يقوم بإنشاء أحداث لكل محاولة كتابة ويستخدم الأحداث لتسجيل التغييرات اللازمة لمنع المتغيرات. الخطوة التالية هي حفظ هذه الأحداث في المتجر (EventStore). بادئ ذي بدء ، يجب إنشاء مستودع الحدث هذا. لتبسيط ، سوف نستخدم نموذج Eloquent ، وهو جدول SQL بسيط مع الحقول التالية: * UUID (لمعرفة الكيان الذي سيتم تطبيق الحدث عليه) * event_payload (رسالة متسلسلة تحتوي على كل شيء ضروري لإعادة إنشاء الحدث) * event_payload - الطابع الزمني لمعرفة متى حدث حدث ما حدث. إذا قمت بمراجعة الكود بعناية ، فسترى أنني قمت بإنشاء أمرين - لإنشاء جدول تخزين الأحداث الخاص بنا وتدميره:

  • php الحرفي eloquenteventstore: إنشاء (App \ CQRS \ EloquentEventStore \ CreateEloquentEventStore)
  • الحرفي php eloquenteventstore: drop (App \ CQRS \ EloquentEventStore \ DropEloquentEventStore) (لا تنس إضافتها إلى App \ Console \ Kernel.php بحيث يتم تحميلها).

هناك سببان جيدان لعدم استخدام SQL كمخزن أحداث: لا يتم تطبيق طراز الإلحاق فقط (فقط إضافة البيانات ، ويجب أن تكون الأحداث غير قابلة للتغيير) ، وأيضًا لأن SQL ليست لغة استعلام مثالية لقواعد البيانات الزمنية. نبرمج الواجهة لتسهيل استبدال مخزن الأحداث في المنشورات اللاحقة.

لحفظ الأحداث ، استخدم المستودع. عندما يتم استدعاء save() لنموذج التسجيل ، فإننا نحفظ قائمة uncommittedEvents في مخزن الأحداث. لتخزين الأحداث ، نحتاج إلى آلية لتسلسلها وإلغاء التسلسل. إنشاء Serializer لهذا. سنحتاج إلى بيانات وصفية ، مثل فئة الحدث (على سبيل المثال ، App\School\Lesson\Events\LessonWasOpened ) App\School\Lesson\Events\LessonWasOpened حدث (البيانات اللازمة لإعادة بناء الحدث).

سيتم تشفير كل هذا بتنسيق JSON ، ثم كتابته إلى قاعدة البيانات الخاصة بنا إلى جانب الكيان UUID والطابع الزمني. نريد تحديث نماذج القراءة الخاصة بنا بعد التقاط الأحداث ، وبالتالي فإن المخزون سيشغل كل حدث بعد الحفظ. سيكون Serializer مسؤولاً عن كتابة فئة الحدث ، في حين سيكون الحدث مسؤولاً عن إجراء تسلسل لحملته. سيبدو الحدث المتسلسل بالكامل مثل هذا:

  { class: "App\\School\\Lesson\\Events\\", event: $event->serialize() } 

نظرًا لأن جميع الأحداث تتطلب أسلوبًا للتسلسل وإلغاء التسلسل ، يمكننا إنشاء واجهة SerializableEvent وإضافة مؤشر لنوع القيمة المتوقعة. تحديث حدث LessonWasOpened بنا:

 app/School/Lesson/Events/LessonWasOpened.php class LessonWasOpened implements SerializableEvent { public function serialize() { return array( 'lessonId'=> (string) $this->getLessonId() ); } } 

إنشاء مستودع LessonRepository . يمكننا refactor واستخراج مكونات CQRS الأساسية في وقت لاحق.

app/School/Lesson/LessonRepository.php
 eventStoreRepository = new EloquentEventStoreRepository( new EventSerializer() ); } public function save(Lesson $lesson) { /** @var DomainEventMessage $domainEventMessage */ foreach( $lesson->getUncommittedDomainEvents() as $domainEventMessage ) { $this->eventStoreRepository->append( $domainEventMessage->getId(), $domainEventMessage->getEvent(), $domainEventMessage->getRecordedAt() ); Event::fire($domainEventMessage->getEvent()); } } } 

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

الخطوة الأخيرة في اجتياز الاختبار بنجاح هي الاستماع إلى أحداث البث وتحديث إسقاط نموذج قراءة الدرس. سيتم اعتراض أحداث بث الدرس من قبل LessonProjector ، والتي ستطبق التغييرات اللازمة على LessonProjection (نماذج بليغة من جدول الدرس):

  app/School/Lesson/Projections/LessonProjector.php class LessonProjector { public function applyLessonWasOpened( LessonWasOpened $event ) { $lessonProjection = new LessonProjection(); $lessonProjection->id = $event->getLessonId(); $lessonProjection->save(); } public function subscribe(Dispatcher $events) { $fullClassName = self::class; $events->listen( LessonWasOpened::class, $fullClassName.'@applyLessonWasOpened'); } }  app/School/Lesson/Projections/LessonProjection.php class LessonProjection extends Model { public $timestamps = false; protected $table = "lessons"; } 

إذا قمت بإجراء الاختبار ، سترى أن خطأ SQL قد حدث:

 Unknown column 'clientName' in 'field list' 

بمجرد إنشاء عملية ترحيل لإضافة clientName إلى جدول الدرس ، clientName الاختبار بنجاح. قمنا بتطبيق الوظيفة الأساسية لـ CQRS: تقوم الفرق بإنشاء أحداث تستخدم لإنشاء نماذج القراءة.

تحسين نموذج القراءة مع الروابط


لقد وصلنا إلى معلم هام ، لكن هذا ليس كل شيء! حتى الآن ، يدعم نموذج القراءة عميلًا واحدًا فقط (حددنا ثلاثة في قواعد مجالنا). التغييرات التي نجريها على نموذج القراءة بسيطة للغاية: نقوم فقط بإنشاء نموذج الإسقاط Client و ClientProjector الذي يمسك الحدث ClientBookedOntoLesson . أولاً ، نقوم بتحديث اختبارنا ليعكس التغييرات التي نريد أن نراها في نموذج القراءة لدينا:

 tests/CQRSTest.php public function testFiringEventUpdatesReadModel() { $lessonId = new LessonId( (string) \Rhumsaa\Uuid\Uuid::uuid1() ); $clientName = "George"; $command = new BookLesson($lessonId, $clientName); $this->dispatch($command); $lesson = Lesson::find( (string) $lessonId); $this->assertEquals( $lesson->id, (string) $lessonId ); $client = $lesson->clients()->first(); $this->assertEquals($client->name, $clientName); } 

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

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

نموذج التسجيل لدينا حاليًا لا يملك القدرة على تحميل الحالة الحالية. إذا أردنا إضافة عميل ثانٍ إلى الدرس ، فيمكننا ببساطة إنشاء حدث ClientWasidedToLesson آخر ، لكن لا يمكننا توفير الحماية ضد المتحولين. لمزيد من الوضوح ، أقترح كتابة اختبار ثانٍ يحاكي تسجيل عميلين لكل درس.

 tests/CQRSTest.php public function testLoadingWriteModel() { $lessonId = new LessonId( (string) \Rhumsaa\Uuid\Uuid::uuid1() ); $clientName_1 = "George"; $clientName_2 = "Fred"; $command = new BookLesson($lessonId, $clientName_1); $this->dispatch($command); $command = new BookClientOntoLesson($lessonId, $clientName_2); $this->dispatch($command); $lesson = Lesson::find( (string) $lessonId ); $this->assertClientCollectionContains($lesson->clients, $clientName_1); $this->assertClientCollectionContains($lesson->clients, $clientName_2); } 

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

  1. نتلقى جميع رسائل الأحداث ذات الصلة من متجر الأحداث.
  2. لكل رسالة ، نقوم بإعادة إنشاء الحدث المقابل.
  3. نقوم بإنشاء نموذج سجل كيان جديد وإعادة تشغيل كل حدث.

في الوقت الحالي ، BookClientOntoLesson اختباراتنا استثناءات ، لذلك سنبدأ بإنشاء BookClientOntoLesson الضروري (تسجيل عميل للدرس) ، باستخدام أمر BookLesson كقالب. ستبدو طريقة المعالج كما يلي:

  app/School/Lesson/Commands/BookClientOntoLesson.php public function handle(LessonRepository $repository) { /** @var Lesson $lesson */ $lesson = $repository->load($this->lessonId); $lesson->bookClient($this->clientName); $repository->save($lesson); }      : app/School/Lesson/LessonRepository.php public function load(LessonId $id) { $events = $this->eventStoreRepository->load($id); $lesson = new Lesson(); $lesson->initializeState($events); return $lesson; } 

ترجع دالة تحميل المستودع مجموعة من الأحداث التي تم إعادة إنشائها. للقيام بذلك ، تعثر أولاً على رسائل حول الأحداث في المستودع ، ثم تقوم بتمريرها إلى Serializer لتحويل كل رسالة إلى حدث. ينشئ Serializer رسائل من الأحداث ، لذلك نحتاج إلى إضافة طريقة deserialize() لإجراء التحويل العكسي. تذكر أنه يتم تمرير Serializer إلى كل حدث لإجراء تسلسل لبيانات الحدث (على سبيل المثال ، اسم العميل). سنفعل نفس الشيء لإجراء التحويل العكسي ، بينما يجب تحديث واجهة SerializableEvent باستخدام طريقة deserialize() . دعونا نلقي نظرة على الكود بحيث يقع كل شيء في مكانه. الأول هو EventStoreRepository تحميل EventStoreRepository :

 app/CQRS/EloquentEventStore/EloquentEventStoreRepository.php public function load($uuid) { $eventMessages = EloquentEventStoreModel::where('uuid', $uuid)->get(); $events = []; foreach($eventMessages as $eventMessage) { /*       event_payload,        . */ $events[] = $this->eventSerializer->deserialize( json_decode($eventMessage->event_payload)); } return $events; } 

باستخدام وظيفة إلغاء التسلسل المناسبة في eventSerializer :

 app/CQRS/Serializer/EventSerializer.php public function serialize( SerializableEvent $event ) { return array( 'class' => get_class($event), 'payload' => $event->serialize() ); } public function deserialize( $serializedEvent ) { $eventClass = $serializedEvent->class; $eventPayload = $serializedEvent->payload; return $eventClass::deserialize($eventPayload); } 

في الختام ، سوف نستخدم أسلوب المصنع الثابت deserialize() في LessonWasOpened (نحتاج إلى إضافة هذه الطريقة إلى كل حدث)

 app/School/Lesson/Events/LessonWasOpened.php public static function deserialize($data) { $lessonId = new LessonId($data->lessonId); return new self($lessonId); } 

الآن لدينا مجموعة من جميع الأحداث التي app/CQRS/EventSouredEntity.php للتو نسبةً إلى نموذج سجل الكيان الخاص بنا لتهيئة الحالة في طريقة initializeState في app/CQRS/EventSouredEntity.php

الآن تشغيل اختبارنا. البنغو!
في الواقع ، ليس لدينا في الوقت الحالي اختبار للتحقق من الامتثال لقواعد النطاق لدينا ، لذلك دعونا نكتبها:

 tests/CQRSTest.php public function testMoreThan3ClientsCannotBeAddedToALesson() { $lessonId = new LessonId( (string) \Rhumsaa\Uuid\Uuid::uuid1() ); $this->dispatch( new BookLesson($lessonId, "bob") ); $this->dispatch( new BookClientOntoLesson($lessonId, "george") ); $this->dispatch( new BookClientOntoLesson($lessonId, "fred") ); $this->setExpectedException( TooManyClientsAddedToLesson::class ); $this->dispatch( new BookClientOntoLesson($lessonId, "emma") ); } 

يرجى ملاحظة أننا نحتاج فقط إلى lessonId - هذا الاختبار lessonId تهيئة حالة الدرس خلال كل أمر.

في الوقت الحالي ، نقوم ببساطة بنقل UUID تم إنشاؤها يدويًا ، بينما في الواقع نريد إنشاءها تلقائيًا. Ramsy\UUID حزمة Ramsy\UUID ، لذلك دعونا تثبيتها مع composer :

 $> composer require ramsey/uuid 

الآن قم بتحديث اختباراتنا لاستخدام الحزمة الجديدة:

 tests/CQRSTest.php public function testEntityCreationWithUUIDGenerator() { $lessonId = new LessonId( (string) \Rhumsaa\Uuid\Uuid::uuid1() ); $this->dispatch( new BookLesson($lessonId, "bob") ); $this->assertInstanceOf( Lesson::class, Lesson::find( (string) $lessonId) ); } 

الآن يمكن لمطور المشروع الجديد الاطلاع على الكود ، راجع App\School\ReadModels ، والذي يحتوي على مجموعة من النماذج App\School\ReadModels ، واستخدام هذه النماذج لكتابة التغييرات على جدول الدرس. يمكننا منع ذلك عن طريق إنشاء فئة ImmutableModel التي تمتد فئة app/CQRS/ReadModelImmutableModel.php Model وتتجاوز طريقة الحفظ في app/CQRS/ReadModelImmutableModel.php .

Source: https://habr.com/ru/post/ar461899/


All Articles