Event dan listener

Dispatch event, listener sinkron, dan listener yang dijalankan pada antrian.

Daftar isi

Satu kejadian pada aplikasi sering perlu ditindaklanjuti oleh beberapa bagian aplikasi sekaligus, misalnya pembaruan statistik, pengiriman pengingat, dan pencatatan log. Memanggil semuanya langsung dari kode pemicunya menyatukan bagian-bagian yang seharusnya terpisah. Pola event dan listener memisahkan pemicu dari tindak lanjutnya, yaitu pemicu cukup melepas event tanpa mengetahui listener mana yang akan merespons. Topik ini membahas definisi event dan listener, cara keduanya didaftarkan pada aplikasi catatan, serta perbedaan listener sinkron dan listener yang dijalankan pada antrian. Setiap contoh dijalankan langsung sehingga perbedaan waktunya dapat diamati dari keluaran asli.

Peran event dan listener#

Event adalah objek yang menandai kejadian tertentu sekaligus membawa datanya, misalnya kategori yang baru saja dibuat beserta namanya. Listener adalah kelas atau fungsi yang terdaftar untuk merespons event tertentu, sedangkan penghubung keduanya disebut dispatcher, yaitu komponen yang mencatat pasangan event dan listener lalu menjalankan listener pada saat event dilepas. Pemicu yang melepas event tidak memanggil satu pun listener secara langsung, sehingga listener baru dapat ditambahkan tanpa menyentuh kode pemicu. Alur dari dispatch hingga respons listener digambarkan pada diagram berikut.

Dispatch eventCatatanDiarsipkan::dispatch($note)Dispatchercocokkan eventdengan listenerListener sinkrondijalankan seketikaListener antrianjob pada tabel jobsWorkermengerjakanListener sinkron berbagi proses dengan permintaan, sedangkan listener antrian menunggu worker.
Alur event menuju listener sinkron dan listener antrian
Perilaku tersebut dapat dibuktikan tanpa membuat berkas apa pun. Event didefinisikan sebagai kelas sederhana, dua listener didaftarkan dengan Event::listen(), kemudian event dilepas dengan dispatch(). Urutan pemanggilan listener mengikuti urutan pendaftarannya.

bash
# mendaftarkan dua listener lalu melepas satu event
cd ~/aplikasi-catatan
php artisan tinker --execute='
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Support\Facades\Event;

class KategoriDibuat {
    use Dispatchable;
    public function __construct(public string $nama) {}
}

$jejak = [];
Event::listen(KategoriDibuat::class, function (KategoriDibuat $event) use (&$jejak) {
    $jejak[] = "log: kategori {$event->nama} dicatat";
});
Event::listen(KategoriDibuat::class, function (KategoriDibuat $event) use (&$jejak) {
    $jejak[] = "cache: entri kategori {$event->nama} diperbarui";
});

KategoriDibuat::dispatch("Riset");
echo implode(PHP_EOL, $jejak) . PHP_EOL;
'
text Keluaran
log: kategori Riset dicatat
cache: entri kategori Riset diperbarui

Kedua listener dieksekusi berurutan dalam proses yang sama dengan kode pemicunya, sedangkan pemicu sendiri hanya menuliskan satu baris dispatch. Apabila nanti ada listener ketiga yang ditambahkan, baris dispatch itu tidak perlu diubah sama sekali. Sifat inilah yang membuat event cocok untuk tindak lanjut yang memang harus dijalankan, tetapi tidak menjadi tanggung jawab pemicunya.

Membuat event dan listener#

Event dan listener milik aplikasi dibuat dengan perintah make:event dan make:listener. Contoh berikut membuat event CatatanDiarsipkan, yaitu kejadian sebuah catatan yang telah diarsipkan, beserta listener PerbaruiStatistikArsip yang meresponsnya.

bash
# tidak-dijalankan
# membuat event CatatanDiarsipkan
cd ~/aplikasi-catatan
php artisan make:event CatatanDiarsipkan

Perintah pertama menghasilkan berkas app/Events/CatatanDiarsipkan.php dengan keluaran berikut.

text Hasil perintah make:event

   INFO  Event [app/Events/CatatanDiarsipkan.php] created successfully.  
bash
# tidak-dijalankan
# membuat listener PerbaruiStatistikArsip untuk event tersebut
cd ~/aplikasi-catatan
php artisan make:listener PerbaruiStatistikArsip --event=CatatanDiarsipkan

Perintah kedua menghasilkan berkas app/Listeners/PerbaruiStatistikArsip.php dengan keluaran serupa.

text Hasil perintah make:listener

   INFO  Listener [app/Listeners/PerbaruiStatistikArsip.php] created successfully.  

Kerangka bawaan event memuat metode broadcastOn() untuk kebutuhan broadcasting. Karena aplikasi catatan tidak menggunakan broadcasting, metode tersebut dihapus dan isi event disesuaikan menjadi berikut.

php CatatanDiarsipkan.php
// tidak-dijalankan
<?php

namespace App\Events;

use App\Models\Note;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class CatatanDiarsipkan
{
    use Dispatchable, SerializesModels;

    public function __construct(public Note $note)
    {
    }
}

Trait Dispatchable menyediakan metode statis dispatch(), sedangkan trait SerializesModels memastikan model yang dibawa event dapat diserialisasi ketika eventnya masuk antrian. Listener hasil make:listener diisi pada metode handle() yang menerima event sebagai satu-satunya parameter. Listener dibentuk oleh container, sehingga dependensi listener juga dapat di-inject melalui constructor-nya.

php PerbaruiStatistikArsip.php
// tidak-dijalankan
<?php

namespace App\Listeners;

use App\Events\CatatanDiarsipkan;
use Illuminate\Support\Facades\Cache;

class PerbaruiStatistikArsip
{
    public function handle(CatatanDiarsipkan $event): void
    {
        Cache::increment("jumlah-catatan-arsip");
    }
}

Pendaftaran listener tidak perlu dituliskan sama sekali karena listener yang berada pada app/Listeners ditemukan otomatis melalui event discovery, yaitu pemindaian direktori listener yang dilakukan framework. Pendaftaran manual tetap tersedia apabila dibutuhkan, misalnya untuk listener yang berada di luar direktori tersebut, yaitu Event::listen(CatatanDiarsipkan::class, PerbaruiStatistikArsip::class) pada metode boot() service provider.

Listener pada antrian#

Listener sinkron berbagi proses dengan permintaan yang melepas event, sehingga seluruh pekerjaan listenernya harus selesai sebelum respons dikirim. Pekerjaan yang berat seperti pengiriman surel membuat pengguna menunggu tanpa keuntungan yang terlihat. Laravel menyediakan ShouldQueue, yaitu interface penanda. Ketika diimplementasikan oleh listener, pemanggilannya diubah menjadi job yang ditulis ke antrian, kemudian dikerjakan oleh worker pada proses terpisah.

php KirimPengingatArsip.php
// tidak-dijalankan
<?php

namespace App\Listeners;

use App\Events\CatatanDiarsipkan;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Cache;

class KirimPengingatArsip implements ShouldQueue
{
    use InteractsWithQueue;

    public $queue = "pengingat";

    public function handle(CatatanDiarsipkan $event): void
    {
        // pekerjaan berat digantikan penanda cache agar mudah diukur
        Cache::put("pengingat-arsip-{$event->note->id}", true, now()->addDay());
    }
}

Properti $queue menempatkan job pada antrian bernama pengingat agar terpisah dari antrian bawaan. Pada aplikasi nyata, isi handle() diganti dengan pengiriman surel atau notifikasi, sedangkan contoh menggunakan penanda cache agar hasilnya dapat diperiksa. Perbedaan waktunya dapat disimulasikan pada demo berikut. Jalankan simulasi, matikan pilihan worker, kemudian dispatch ulang dan amati bahwa job tetap menunggu di antrian.

Perilaku yang sama dibuktikan pada aplikasi catatan. Data catatan disiapkan dengan firstOrCreate, event didefinisikan sebagai kelas uji, dan worker dijalankan sekali dengan opsi --stop-when-empty agar prosesnya berhenti setelah antrian habis.

bash
# menguji listener antrian dari dispatch hingga dikerjakan worker
cd ~/aplikasi-catatan
php artisan tinker --execute='
use App\Models\Note;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;

class CatatanDiarsipkanUji {
    use Dispatchable, SerializesModels;
    public function __construct(public Note $note) {}
}

class KirimPengingatUji implements ShouldQueue {
    use InteractsWithQueue;
    public $queue = "uji-pengingat";
    public function handle(CatatanDiarsipkanUji $event): void {
        Cache::put("pengingat-arsip-{$event->note->id}", "terkirim", 300);
    }
}

$note = Note::firstOrCreate(["judul" => "Rencana pengingat arsip"], ["isi" => "catatan uji listener antrian"]);
Event::listen(CatatanDiarsipkanUji::class, KirimPengingatUji::class);
CatatanDiarsipkanUji::dispatch($note);
echo "job pada antrian: " . Queue::size("uji-pengingat") . PHP_EOL;
Artisan::call("queue:work", ["--stop-when-empty" => true, "--queue" => "uji-pengingat"]);
echo "job tersisa: " . Queue::size("uji-pengingat") . PHP_EOL;
echo "pengingat: " . Cache::get("pengingat-arsip-" . $note->id) . PHP_EOL;
'
text Keluaran
job pada antrian: 1
job tersisa: 0
pengingat: terkirim

Keluaran tersebut memperlihatkan tiga tahap yang terpisah. Pada saat dispatch, listener belum dijalankan sama sekali, tetapi satu job sudah menunggu pada antrian uji-pengingat. Worker kemudian menghabiskan antrian hingga kosong, dan pada saat itulah handle() benar-benar dieksekusi sehingga penanda cache muncul. Jeda antara dispatch dan eksekusi adalah jeda yang tidak dialami listener sinkron.

Kesalahan umum#

Kesalahan pertama adalah menempatkan pekerjaan berat pada listener sinkron, misalnya pengiriman surel atau panggilan jaringan ke layanan lain. Respons permintaan yang melepas event ikut menunggu seluruh pekerjaan itu selesai, padahal hasilnya tidak diperlukan oleh respons. Tandai listener semacam itu dengan ShouldQueue agar pekerjaannya berpindah ke antrian.

Kesalahan kedua adalah menjadikan urutan listener sebagai bagian dari logika bisnis, misalnya listener kedua yang hanya benar apabila listener pertama sudah berjalan. Urutan memang mengikuti pendaftaran, tetapi kontrak tersebut rapuh ketika listener ditambahkan atau disunting oleh pengembang lain. Gabungkan langkah-langkah yang saling bergantung ke dalam satu listener yang memanggil kelas service secara berurutan.

Kesalahan ketiga adalah menghapus catatan yang masih dirujuk oleh job yang belum dikerjakan. Event membawa model melalui SerializesModels, dan worker mengambil ulang barisnya dari database pada saat job dijalankan, sehingga baris yang sudah terhapus membuat job gagal dengan ModelNotFoundException. Tunda penghapusannya sampai antrian terkait kosong, atau tangani exception tersebut di dalam listener.

Ringkasan#

  • Event adalah objek yang membawa data kejadian dan listener adalah responsnya, sedangkan dispatcher mencocokkan keduanya, sehingga pemicu tidak perlu mengenal listener mana pun.
  • Event dan listener dibuat dengan make:event dan make:listener, listener pada app/Listeners ditemukan otomatis, dan pendaftaran manual dilakukan dengan Event::listen() pada boot() provider.
  • Listener sinkron berbagi proses dengan permintaan, sedangkan listener ShouldQueue diubah menjadi job pada antrian yang dikerjakan worker belakangan, dan properti $queue menentukan nama antriannya.
  • Urutan listener sinkron mengikuti urutan pendaftaran, dan pekerjaan berat sebaiknya tidak pernah berada pada listener sinkron.

Referensi#

  • Events — Laravel — Dokumentasi event, listener, event discovery, dan listener antrian.
  • Queues — Laravel — Dokumentasi worker beserta opsi --stop-when-empty yang dipakai contoh.