Terjemahan artikel disiapkan untuk siswa dari kursus profesional "Framework Laravel"

Pendahuluan
Artikel ini dikhususkan untuk dasar-dasar menciptakan sistem acara CQRS dalam bahasa PHP dan dalam kerangka Laravel. Diasumsikan bahwa Anda terbiasa dengan skema pengembangan menggunakan bus perintah dan memiliki gagasan tentang peristiwa (khususnya, publikasi acara untuk berbagai pendengar). Untuk menyegarkan kembali pengetahuan ini, Anda dapat menggunakan layanan Laracasts. Selain itu, diasumsikan bahwa Anda memiliki pemahaman tertentu tentang prinsip CQRS. Jika tidak, saya sangat merekomendasikan mendengarkan dua ceramah:
Mathias Verraes Workshop Generasi Acara dan
CQRS dan Generasi Acara Greg Young .
Jangan gunakan kode yang diberikan di sini di proyek Anda! Ini adalah platform pembelajaran untuk memahami ide-ide di balik CQRS. Kode ini tidak dapat disebut andal, tidak diuji dengan baik, dan selain itu, saya jarang memprogram antarmuka, jadi akan jauh lebih sulit untuk mengubah bagian-bagian individual dari kode. Contoh yang jauh lebih baik dari paket CQRS yang dapat Anda gunakan adalah
Broadway, yang dikembangkan oleh Qandidate Lab . Ini adalah kode yang bersih dan longgar, namun, beberapa abstraksi membuatnya tidak sepenuhnya jelas jika Anda belum pernah menemukan sistem acara.
Dan yang terakhir - kode saya terhubung dengan acara dan bus perintah Laravel. Saya ingin melihat bagaimana kode akan terlihat di Laravel (saya biasanya menggunakan kerangka kerja ini untuk proyek-proyek agen kecil), namun, melihat ke belakang, saya pikir saya harus membuat implementasi saya sendiri. Saya berharap kode saya akan jelas bahkan bagi mereka yang tidak menggunakan kerangka kerja.
Di github, kode ini terletak di
https://github.com/scazz/cqrs-tutorial.git , dan dalam panduan kami, kami akan mempertimbangkan komponen-komponennya untuk meningkatkan logika.
Kami akan membuat sistem pendaftaran awal untuk sekolah selancar. Dengan bantuannya, klien sekolah dapat mendaftar untuk kelas. Untuk proses perekaman, kami merumuskan aturan berikut:
- Setiap pelajaran harus memiliki setidaknya satu klien ...
- ... tapi tidak lebih dari tiga.
Salah satu fitur yang paling mengesankan dari sistem CQRS berbasis acara adalah pembuatan model bacaan khusus untuk setiap metrik yang diperlukan dari sistem. Anda akan menemukan contoh proyeksi model membaca di ElasticSearch, dan Greg Young telah menerapkan bahasa berorientasi subjek di toko acaranya untuk menangani acara yang kompleks. Namun, untuk kesederhanaan, proyeksi baca kami akan menjadi database SQL standar untuk digunakan dengan Eloquent. Sebagai hasilnya, kami akan memiliki satu tabel untuk kelas dan satu untuk klien.
Konsep pembuatan acara juga memungkinkan Anda memproses acara secara offline. Tetapi dalam artikel ini saya akan mematuhi model pengembangan "tradisional" secara maksimal (sekali lagi, untuk menyederhanakan), dan proyeksi bacaan kami akan diperbarui secara real time, segera setelah peristiwa disimpan dalam repositori.
Pengaturan proyek dan tes pertama
git clone https://github.com/scazz/cqrs-tutorial
Buat proyek Laravel 5 baru
$> laravel new cqrs-tutorial
Sebagai permulaan, kita perlu tes. Kami akan menggunakan tes integrasi, yang akan memastikan bahwa pendaftaran klien untuk kelas mengarah pada fakta bahwa pelajaran dibuat dalam model Eloquent kami.
Tes daftar / 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 ); } }
Kami menugaskan aktivitas baru ke ID, membuat tim untuk mendaftar untuk aktivitas baru, dan memberi tahu Laravel untuk mengirimkannya. Di tabel pelajaran, kita perlu membuat catatan baru yang bisa kita baca menggunakan model Eloquent. Kami membutuhkan database, jadi isilah file .env Anda dengan benar.
Setiap acara yang direkam di toko acara kami menempel pada akar agregat, yang kami sebut entitas (Entity) - sebuah abstraksi untuk tujuan pendidikan hanya menambah kebingungan. ID adalah pengidentifikasi unik universal (UUID). Toko acara tidak peduli jika acara tersebut berlaku untuk pelajaran atau klien. Dia hanya tahu bahwa itu terkait dengan ID.
Berdasarkan kesalahan yang diidentifikasi selama pengujian, kami dapat membuat kelas yang hilang. Pertama, kita akan membuat kelas LessonId, lalu perintah BookLesson (jangan khawatir tentang metode handler, terus jalankan tes). Kelas Pelajaran adalah model membaca di luar namespace Pelajaran. Model bacaan eksklusif - logika area subjek tidak akan pernah disimpan di sini. Sebagai kesimpulan, kita perlu membuat migrasi untuk tabel pelajaran.
Untuk menjaga kejelasan kode, saya akan menggunakan pustaka verifikasi pernyataan. Itu dapat ditambahkan dengan perintah berikut:
$> composer require beberlei/assert
Pertimbangkan proses yang harus dimulai oleh perintah ini:
- Validasi: perintah imperatif mungkin gagal, dan acara telah terjadi dan karenanya tidak boleh gagal.
- Buat acara LessonWasBooked baru (mendaftar untuk pelajaran).
- Perbarui status aktivitas. (Model catatan harus menyadari keadaan model sehingga dapat melakukan validasi.)
- Tambahkan acara ini ke aliran acara yang tidak terikat yang disimpan dalam model catatan aktivitas.
- Simpan aliran acara yang tidak dikomit ke repositori.
- Angkat acara LessonWasBooked secara global untuk memberi tahu semua proyektor membaca untuk memperbarui tabel pelajaran.
Anda harus terlebih dahulu membuat model rekaman untuk pelajaran. Kami akan menggunakan metode pabrik statis
Lesson::bookClientOntoNewLesson()
. Ini menghasilkan acara
LessonWasOpened
baru (pelajaran terbuka), menerapkan acara ini untuk dirinya sendiri (hanya menetapkan ID-nya), menambahkan acara baru ke daftar acara yang tidak terikat dalam bentuk
DomainEventMessage
(acara ditambah beberapa metadata yang kami gunakan saat menyimpan ke toko acara).
Proses ini diulangi untuk menambahkan klien ke acara tersebut. Saat menerapkan acara
ClientWasBookedOntoLesson
(klien terdaftar dalam pelajaran), model rekaman tidak melacak nama klien, tetapi hanya jumlah klien terdaftar. Model rekaman tidak perlu tahu nama pelanggan untuk memastikan konsistensi.
Metode
applyLessonWasOpened
dan
applyClientWasBookedOntoLesson
mungkin tampak agak aneh sekarang. Kami akan menggunakannya nanti ketika kami perlu mereproduksi acara lama untuk membentuk keadaan model rekaman. Tidak mudah untuk dijelaskan, jadi saya akan memberikan kode yang akan membantu Anda memahami proses ini. Nanti kita akan mengekstrak kode yang memproses uncommittedEvents dan menghasilkan pesan peristiwa domain.
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++; }
Kita dapat mengekstrak komponen CQRS dari model rekaman kami - fragmen kelas yang terlibat dalam pemrosesan acara yang tidak dikomit. Kami juga dapat menghapus API untuk entitas yang dihasilkan acara dengan membuat fungsi
apply()
aman yang menerima acara, memanggil metode
applyEventName()
sesuai, dan menambahkan acara
DomainEventMessage
baru ke daftar acara yang tidak dikomit. Kelas yang diekstrak adalah detail dari implementasi CQRS dan tidak mengandung logika domain, sehingga kami dapat membuat namespace baru: App \ CQRS:
Perhatikan app/CQRS/EventSourcedEntity.php
kode app/CQRS/EventSourcedEntity.php
Agar kode berfungsi, kita perlu menambahkan kelas DomainEventMessage
, yang merupakan DTO sederhana - dapat ditemukan di app/CQRS/DomainEventMessage.php
Dengan demikian, kami mendapatkan sistem yang menghasilkan acara untuk setiap upaya penulisan dan menggunakan acara untuk mencatat perubahan yang diperlukan untuk mencegah invarian. Langkah selanjutnya adalah menyimpan acara ini di toko (EventStore). Pertama-tama, repositori acara ini perlu dibuat. Untuk menyederhanakan, kita akan menggunakan model Eloquent, tabel SQL sederhana dengan bidang-bidang berikut: *
UUID
(untuk mengetahui entitas mana yang menerapkan acara tersebut) *
event_payload
(pesan berseri berisi semua yang diperlukan untuk membuat ulang acara) *
event_payload
- stempel waktu untuk mengetahui kapan acara terjadi Jika Anda hati-hati meninjau kode, Anda akan melihat bahwa saya membuat dua perintah - untuk membuat dan menghancurkan tabel penyimpanan acara kami:
- php artisan eloquenteventstore: create (App \ CQRS \ EloquentEventStore \ CreateEloquentEventStore)
- php artisan eloquenteventstore: drop (App \ CQRS \ EloquentEventStore \ DropEloquentEventStore) (jangan lupa menambahkannya ke App \ Console \ Kernel.php sehingga mereka memuat).
Ada dua alasan yang sangat baik untuk tidak menggunakan SQL sebagai event store: tidak menerapkan model append-only (hanya menambahkan data, peristiwa harus tidak berubah), dan juga karena SQL bukan bahasa query yang ideal untuk database temporal. Kami memprogram antarmuka untuk memfasilitasi penggantian toko acara dalam publikasi berikutnya.
Untuk menyimpan acara, gunakan repositori. Setiap kali
save()
dipanggil untuk model rekaman, kami menyimpan daftar uncommittedEvents di event store. Untuk menyimpan acara, kita memerlukan mekanisme untuk serialisasi dan deserialisasi mereka. Buat Serializer untuk ini. Kita akan membutuhkan metadata, seperti kelas acara (misalnya,
App\School\Lesson\Events\LessonWasOpened
) dan payload acara (data yang diperlukan untuk merekonstruksi suatu peristiwa).
Semua ini akan dikodekan dalam format JSON, dan kemudian ditulis ke basis data kami bersama dengan entitas UUID dan timestamp. Kami ingin memperbarui model membaca kami setelah menangkap peristiwa, sehingga repositori akan memicu setiap peristiwa setelah menyimpan. Serializer akan bertanggung jawab untuk menulis kelas acara, sedangkan acara tersebut akan bertanggung jawab untuk membuat serial muatannya. Acara berseri lengkap akan terlihat seperti ini:
{ class: "App\\School\\Lesson\\Events\\", event: $event->serialize() }
Karena semua acara memerlukan metode serialisasi dan deserialisasi, kita dapat membuat antarmuka
SerializableEvent
dan menambahkan indikasi jenis nilai yang diharapkan. Perbarui acara
LessonWasOpened
kami:
app/School/Lesson/Events/LessonWasOpened.php class LessonWasOpened implements SerializableEvent { public function serialize() { return array( 'lessonId'=> (string) $this->getLessonId() ); } }
Buat repositori
LessonRepository
. Kita dapat melakukan refactor dan mengekstraksi komponen CQRS inti nanti.
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()); } } }
Jika Anda menjalankan tes integrasi lagi dan kemudian memeriksa tabel SQL
domain_events
, Anda akan melihat dua peristiwa dalam database.
Langkah terakhir kami dalam berhasil lulus tes adalah mendengarkan acara siaran dan memperbarui proyeksi model membaca Pelajaran. Acara siaran Lesson akan dicegat oleh
LessonProjector
, yang akan menerapkan perubahan yang diperlukan untuk
LessonProjection
(model
LessonProjection
dari tabel pelajaran):
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"; }
Jika Anda menjalankan tes, Anda akan melihat bahwa kesalahan SQL telah terjadi:
Unknown column 'clientName' in 'field list'
Segera setelah kami membuat migrasi untuk menambahkan
clientName
ke tabel pelajaran, kami akan berhasil lulus tes. Kami telah menerapkan fungsionalitas dasar CQRS: tim membuat acara yang digunakan untuk menghasilkan model membaca.
Memperbaiki model membaca dengan tautan
Kami telah mencapai tonggak penting, tetapi itu belum semuanya! Sejauh ini, model membaca hanya mendukung satu klien (kami menetapkan tiga dalam aturan domain kami). Perubahan yang kami buat pada model bacaan cukup sederhana: kami hanya membuat model proyeksi Klien dan
ClientProjector
yang menangkap acara
ClientBookedOntoLesson
. Pertama, kami memperbarui tes kami untuk mencerminkan perubahan yang ingin kami lihat dalam model bacaan kami:
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); }
Ini adalah demonstrasi yang jelas tentang betapa mudahnya mengubah model membaca. Semuanya, sampai ke penyimpanan acara, tetap tidak berubah. Sebagai bonus, saat menggunakan sistem acara, kami mendapatkan data untuk tes dasar - saat mengganti proyektor model bacaan, kami mendengarkan setiap peristiwa yang pernah terjadi dalam sistem kami.
Kami mereproduksi peristiwa ini menggunakan proyektor baru, memeriksa pengecualian, dan membandingkan hasilnya dengan proyeksi sebelumnya. Setelah sistem bekerja selama beberapa waktu, kami akan memiliki pilihan acara yang cukup representatif untuk menguji proyektor kami.
Model rekaman kami saat ini tidak memiliki kemampuan memuat keadaan saat ini. Jika kami ingin menambahkan klien kedua ke pelajaran, kami dapat dengan mudah membuat acara ClientWasAddedToLesson yang kedua, tetapi kami tidak dapat memberikan perlindungan terhadap invarian. Untuk kejelasan yang lebih besar, saya mengusulkan untuk menulis tes kedua yang mensimulasikan rekaman dua klien per pelajaran.
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); }
Untuk model perekaman kami, kami perlu menerapkan metode "memuat" entitas yang sudah menerapkan peristiwa di dalamnya di event store. Kita dapat mencapai ini dengan memutar ulang setiap peristiwa yang mengacu pada UUID entitas. Secara umum, prosesnya adalah sebagai berikut:
- Kami menerima semua pesan acara yang relevan dari toko acara.
- Untuk setiap pesan, kami membuat ulang acara terkait.
- Kami membuat model catatan entitas baru dan memutar ulang setiap acara.
Saat ini, tes kami mengeluarkan pengecualian, jadi kami akan mulai dengan membuat
BookClientOntoLesson
diperlukan (daftarkan klien untuk pelajaran) menggunakan perintah
BookLesson
sebagai templat. Metode handler akan terlihat seperti ini:
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; }
Fungsi beban repositori mengembalikan array peristiwa yang dibuat kembali. Untuk melakukan ini, ia pertama kali menemukan pesan tentang peristiwa di repositori, dan kemudian meneruskannya ke
Serializer
untuk mengonversi setiap pesan menjadi suatu peristiwa.
Serializer
membuat pesan dari acara, jadi kita perlu menambahkan metode
deserialize()
untuk melakukan transformasi terbalik. Ingat bahwa
Serializer
diteruskan ke setiap peristiwa untuk membuat serialisasi data acara (misalnya, nama klien). Kami akan melakukan hal yang sama untuk melakukan transformasi terbalik, sementara antarmuka
SerializableEvent
kami harus diperbarui menggunakan metode
deserialize()
. Mari kita lihat kodenya sehingga semuanya jatuh pada tempatnya. Pertama adalah
EventStoreRepository
memuat
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; }
Menggunakan fungsi deserialisasi yang sesuai di
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); }
Kesimpulannya, kita akan menggunakan metode pabrik statis
deserialize()
di
LessonWasOpened
(kita perlu menambahkan metode ini ke setiap peristiwa)
app/School/Lesson/Events/LessonWasOpened.php public static function deserialize($data) { $lessonId = new LessonId($data->lessonId); return new self($lessonId); }
Sekarang kami memiliki larik semua peristiwa yang baru saja direproduksi relatif terhadap model catatan Entitas kami untuk menginisialisasi keadaan dalam metode
initializeState
di
app/CQRS/EventSouredEntity.php
Sekarang jalankan pengujian kami. Bingo!
Faktanya, saat ini kami tidak memiliki tes untuk memverifikasi kepatuhan dengan aturan domain kami, jadi mari kita tulis:
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") ); }
Harap dicatat bahwa kita hanya perlu
lessonId
- tes ini menginisialisasi ulang kondisi pelajaran selama setiap perintah.
Saat ini, kami hanya mentransfer
UUID
dibuat secara manual, sedangkan pada kenyataannya kami ingin membuatnya secara otomatis. Saya akan menggunakan paket
Ramsy\UUID
, jadi mari kita instal dengan
composer
:
$> composer require ramsey/uuid
Sekarang perbarui tes kami untuk menggunakan paket baru:
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) ); }
Sekarang pengembang proyek baru dapat melihat kode, lihat
App\School\ReadModels
, yang berisi sekumpulan model Eloquent, dan menggunakan model ini untuk menulis perubahan pada tabel pelajaran. Kita bisa mencegah ini dengan membuat kelas
ImmutableModel
yang memperluas kelas Eloquent Model dan menimpa metode simpan di
app/CQRS/ReadModelImmutableModel.php
.