Alasan Kenapa Menggunakan Model Observer Di Laravel Termasuk Bad Practice
Laravel menyediakan cara yang menarik untuk mengotomatiskan kejadian model umum di dalam aplikasi Anda dengan kejadian terkirim, kejadian penutupan, dan pengamat.
Meskipun kedengarannya keren untuk memiliki solusi plug-and-play semacam ini — ada kasus-kasus tertentu ketika ini akan menjadi bumerang pada proyek Anda jika Anda cenderung membebani fitur ini dengan logika bisnis.
TL;DR
- Saya rasa pengamat dan kejadian model baik untuk MVP dan/atau proyek yang lebih kecil.
- Bila Anda memiliki 2+ pengembang yang bekerja dan/atau 100+ kasus uji — mereka dapat menjadi masalah (tidak sepenuhnya akan).
- Untuk proyek yang sangat besar yang akan menjadi masalah pasti. Anda perlu menghabiskan banyak waktu untuk refactoring, QAing, dan regress-testing aplikasi Anda. Jadi pikirkan ke depan dan perbaiki lebih awal.
- Alasan: kejadian model menciptakan efek samping tersembunyi, terkadang tidak terduga dan tidak diperlukan oleh tindakan yang dijalankan.
Efek samping yang paling umum dapat diamati saat menulis dan menjalankan pengujian Unit dan pengujian Fitur untuk aplikasi Laravel Anda. Artikel ini akan menunjukkan skenario ini.
Contoh kita
Memproses pengukuran suhu dari perangkat IoT, menyimpannya dalam database, dan melakukan beberapa perhitungan tambahan setelah setiap sampel dikonsumsi.
Persyaratan bisnis kami:
- simpan sampel yang dikonsumsi melalui API yang terbuka
- untuk setiap pembaruan sampel yang disimpan dan suhu rata-rata untuk 10 pengukuran terakhir
Ini adalah contoh model dan migrasi kami:
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('samples', static function (Blueprint $table) {
$table->id();
$table->string('device_id');
$table->float('temp');
$table->timestamp('created_at')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('samples');
}
};
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Sample extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'device_id',
'temp',
'created_at',
];
}
Sekarang setiap kali kami menyimpan sampel, kami ingin menyimpan suhu rata-rata untuk 10 sampel terakhir dalam model lain, Avg Temperatur:
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('avg_temperatures', static function (Blueprint $table) {
$table->id();
$table->string('device_id');
$table->float('temp');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('avg_temperatures');
}
};
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AvgTemperature extends Model
{
use HasFactory;
protected $fillable = [
'device_id',
'temp',
];
}
Kita dapat mencapai ini hanya dengan melampirkan acara ke status model Sampel yang dibuat:
<?php
declare(strict_types=1);
namespace App\Models;
use App\Events\SampleCreatedEvent;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Sample extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'device_id',
'temp',
'created_at',
];
/**
* @var array<string, string>
*/
protected $dispatchesEvents = [
'created' => SampleCreatedEvent::class,
];
}
Sekarang kami menambahkan pendengar dengan logika perhitungan ulang rata-rata:
class EventServiceProvider extends ServiceProvider
{
/**
* @var array<string, array<string>>
*/
protected $listen = [
SampleCreatedEvent::class => [
RecalcAvgTemperatureListener::class,
],
];
}
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Events\SampleCreatedEvent;
use App\Models\AvgTemperature;
use App\Models\Sample;
class RecalcAvgTemperatureListener
{
public function handle(SampleCreatedEvent $event): void
{
$average = Sample::orderByDesc('created_at')
->limit(10)
->avg('temp');
AvgTemperature::updateOrCreate([
'device_id' => $event->sample->device_id,
], [
'temp' => $average ?? 0,
]);
}
}
Sekarang, implementasi pengontrol naif kami, **melewati validasi dan pola pengembangan yang baik**, akan terlihat seperti ini:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Sample;
use Illuminate\Http\Request;
class SampleController extends Controller
{
public function store(Request $request): void
{
Sample::create(
array_merge($request->all(), ['created_at' => now()])
);
}
}
Kami juga dapat menulis uji fitur yang mengonfirmasi bahwa rute API kami berfungsi seperti yang diharapkan — sampel disimpan dan sampel rata-rata disimpan:
<?php
declare(strict_types=1);
namespace Tests\Original;
use App\Models\AvgTemperature;
use App\Models\Sample;
use Tests\TestCase;
class SampleControllerTest extends TestCase
{
/** @test */
public function when_sample_is_sent_then_model_is_stored(): void
{
// act
$this->post('/sample', [
'device_id' => 'xyz',
'temp' => 10.5,
]);
// assert
$sample = Sample::first();
$this->assertSame('xyz', $sample->device_id);
$this->assertSame(10.5, $sample->temp);
}
/** @test */
public function when_sample_is_sent_then_avg_model_is_stored(): void
{
Sample::factory()->create(['device_id' => 'xyz', 'temp' => 20]);
// act
$this->post('/sample', [
'device_id' => 'xyz',
'temp' => 10,
]);
// assert
$sample = AvgTemperature::first();
$this->assertSame('xyz', $sample->device_id);
$this->assertSame(15.0, $sample->temp);
}
}
Itu terlihat baik-baik saja, bukan?
Sekarang ketika ada yang salah
Bayangkan pengembang kedua di tim Anda akan menulis tes Unit di mana dia ingin memeriksa perhitungan suhu rata-rata.
Dia mengekstrak layanan dari kelas pendengar untuk melakukan pekerjaan ini:
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Events\SampleCreatedEvent;
use App\Services\AvgTemperatureRecalcService;
class RefactoredRecalcAvgTemperatureListener
{
public function __construct(protected AvgTemperatureRecalcService $recalcAvg)
{
}
public function handle(SampleCreatedEvent $event): void
{
$this->recalcAvg->withLatestTenSamples($event->sample);
}
}
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\AvgTemperature;
use App\Models\RefactoredSample;
use App\Models\Sample;
class AvgTemperatureRecalcService
{
public function withLatestTenSamples(Sample|RefactoredSample $sample): void
{
$average = Sample::where('device_id', $sample->device_id)
->orderByDesc('created_at')
->limit(10)
->pluck('temp')
->avg();
AvgTemperature::updateOrCreate([
'device_id' => $sample->device_id,
], [
'temp' => $average ?? 0,
]);
}
}
Dia memiliki unit test yang tertulis di mana dia ingin menyemai 100 sampel sekaligus pada interval 1 menit:
<?php
declare(strict_types=1);
namespace Tests\Original;
use App\Models\AvgTemperature;
use App\Models\Sample;
use App\Services\AvgTemperatureRecalcService;
use Tests\TestCase;
class AvgTemperatureRecalcServiceTest extends TestCase
{
/** @test */
public function when_has_existing_100_samples_then_10_last_average_is_correct(): void
{
for ($i = 0; $i < 100; $i++) {
Sample::factory()->create([
'device_id' => 'xyz',
'temp' => 1,
'created_at' => now()->subMinutes($i),
]);
}
$sample = Sample::factory()->create(['device_id' => 'xyz', 'temp' => 11, 'created_at' => now()]);
// pre assert
// this will FAIL because average was already recounted 100x times when factory was creating 100x samples
$this->assertCount(0, AvgTemperature::all());
// act
$service = new AvgTemperatureRecalcService();
$service->withLatestTenSamples($sample);
// assert
$avgTemp = AvgTemperature::where('device_id', 'xyz')->first();
$this->assertSame((float)((9 + 11) / 10), $avgTemp->temp);
}
}
Ini adalah contoh yang cukup sederhana dan dapat diperbaiki dengan menonaktifkan model event atau memalsukan seluruh fasad Event secara ad-hoc.
Event::fake();
or
Sample:: unsetEventDispatcher();
Untuk proyek yang kurang lebih besar, opsi seperti itu menyakitkan — Anda harus selalu ingat bahwa model Anda menciptakan efek samping.
Bayangkan peristiwa seperti itu menciptakan efek samping di database lain atau layanan eksternal melalui panggilan API. Setiap kali Anda membuat sampel dengan pabrik, Anda harus berurusan dengan panggilan eksternal yang mengejek.
Apa yang kita miliki di sini adalah kombinasi dari pola pengembangan yang buruk dari peristiwa model dan decoupling kode yang tidak cukup.
Memfaktorkan ulang dan memisahkan contoh kami
Untuk visibilitas yang lebih baik, kami akan membuat set model kedua dalam proyek kami dan rute baru.
Pertama, kami menghapus model event dari model Sample kami, sekarang terlihat seperti ini:
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class RefactoredSample extends Model
{
use HasFactory;
protected $table = 'samples';
public $timestamps = false;
protected $fillable = [
'device_id',
'temp',
'created_at',
];
}
Kemudian kami membuat layanan yang akan bertanggung jawab untuk mengkonsumsi sampel baru:
<?php
declare(strict_types=1);
namespace App\Services;
use App\Events\SampleCreatedEvent;
use App\Models\DataTransferObjects\SampleDto;
use App\Models\RefactoredSample;
class SampleConsumeService
{
public function newSample(SampleDto $sample): RefactoredSample
{
$sample = RefactoredSample::create([
'device_id' => $sample->device_id,
'temp' => $sample->temp,
'created_at' => now(),
]);
event(new SampleCreatedEvent($sample));
return $sample;
}
}
Perhatikan bahwa layanan kami sekarang bertanggung jawab untuk menjalankan acara jika berhasil.
Handler rute baru kami akan terlihat seperti ini:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\StoreSampleRequest;
use App\Models\DataTransferObjects\SampleDto;
use App\Services\SampleConsumeService;
class SampleController extends Controller
{
public function storeRefactored(StoreSampleRequest $request, SampleConsumeService $service): void
{
$service->newSample(SampleDto::fromRequest($request));
}
}
Kelas permintaan:
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property-read string $device_id
* @property-read string|float|int $temp
*/
class StoreSampleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<string>>
*/
public function rules(): array
{
return [
'device_id' => ['required', 'string'],
'temp' => ['required', 'numeric'],
];
}
}
Sekarang kami mereplikasi pengujian pengembang kedua kami dengan rute baru dan dapat mengonfirmasi bahwa itu lolos:
<?php
declare(strict_types=1);
namespace Tests\Refactored;
use App\Models\AvgTemperature;
use App\Models\RefactoredSample;
use App\Services\RefactoredAvgTemperatureRecalcService;
use Tests\TestCase;
class AvgTemperatureRecalcServiceTest extends TestCase
{
/** @test */
public function when_has_existing_100_samples_then_10_last_average_is_correct(): void
{
for ($i = 0; $i < 100; $i++) {
RefactoredSample::factory()->create([
'device_id' => 'xyz',
'temp' => 1,
'created_at' => now()->subMinutes($i),
]);
}
$sample = RefactoredSample::factory()->create(['device_id' => 'xyz', 'temp' => 11, 'created_at' => now()]);
// pre assert
$this->assertCount(0, AvgTemperature::all());
// act
$service = new RefactoredAvgTemperatureRecalcService();
$service->withLatestTenSamples($sample);
// assert
$avgTemp = AvgTemperature::where('device_id', 'xyz')->first();
$this->assertSame((float)((9 + 11) / 10), $avgTemp->temp);
}
}
Kesimpulan
Apa saja yang sudah ditingkatkan:
- Kami memisahkan pengontrol kami dari model basis data.
- Kami memisahkan pemrosesan sampel (logika bisnis) dari kerangka kerja.
- Pengaktifan SampleCreatedEvent lebih dapat dikontrol dan tidak akan dipicu saat tidak diharapkan.
Sekian pembahasan kita kali ini semoga artikel ini dapat membantu dan bermanfaat. Dan jika ada yang kurang paham silahkan tinggalkan pertanyaan anda di kolom komentar ya!