Узнайте, являются ли Model Observers в Laravel плохой практикой
11 ноября 2022 г.Laravel предоставляет интересный способ автоматизации общих событий модели внутри вашего приложения с помощью отправляемых событий, событий закрытия и наблюдателей< /а>.
Несмотря на то, что такое готовое решение звучит круто, в некоторых случаях это будет иметь неприятные последствия для вашего проекта, если вы склонны перегружать эту функцию бизнес-логикой.
TL;DR
- Я думаю, что наблюдатели и модельные события подходят для MVP и/или небольших проектов.
- Если у вас работает более двух разработчиков и/или более 100 тестовых случаев, они могут стать проблемой (но не обязательно).
- Для очень крупных проектов это наверняка будет проблемой. Вам нужно будет потратить много времени на рефакторинг, контроль качества и регресс-тестирование вашего приложения. Так что думайте заранее и выполняйте рефакторинг как можно раньше.
- Причина: модельные события создают скрытые побочные эффекты, иногда неожиданные и не требуемые выполняемым действием.
Наиболее распространенные побочные эффекты можно наблюдать при написании и запуске модульных и функциональных тестов для вашего приложения Laravel. Эта статья продемонстрирует этот сценарий.
Наш пример
Обработка измерений температуры с устройств Интернета вещей, сохранение их в базе данных и выполнение дополнительных расчетов после использования каждого образца.
Наши бизнес-требования:
- сохранить образец, использованный через открытый API
- для каждого сохраненного обновления образца и средней температуры за последние 10 измерений
Это наш пример модели и миграции:
<?php
declare(strict_types=1);
use IlluminateDatabaseMigrationsMigration;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateSupportFacadesSchema;
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 AppModels;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateDatabaseEloquentModel;
class Sample extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'device_id',
'temp',
'created_at',
];
}
Теперь каждый раз, когда мы сохраняем образец, мы хотим сохранить среднюю температуру для последних 10 образцов в другой модели, Avg Temperature
:
<?php
declare(strict_types=1);
use IlluminateDatabaseMigrationsMigration;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateSupportFacadesSchema;
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 AppModels;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateDatabaseEloquentModel;
class AvgTemperature extends Model
{
use HasFactory;
protected $fillable = [
'device_id',
'temp',
];
}
Мы можем добиться этого, просто присоединив событие к состоянию created
модели Sample
:
<?php
declare(strict_types=1);
namespace AppModels;
use AppEventsSampleCreatedEvent;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateDatabaseEloquentModel;
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,
];
}
Теперь добавим прослушиватель с логикой пересчета среднего:
class EventServiceProvider extends ServiceProvider
{
/**
* @var array<string, array<string>>
*/
protected $listen = [
SampleCreatedEvent::class => [
RecalcAvgTemperatureListener::class,
],
];
}
<?php
declare(strict_types=1);
namespace AppListeners;
use AppEventsSampleCreatedEvent;
use AppModelsAvgTemperature;
use AppModelsSample;
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,
]);
}
}
Теперь наша наивная реализация контроллера, **пропускающая проверку и все хорошие шаблоны разработки**, будет выглядеть так:
<?php
declare(strict_types=1);
namespace AppHttpControllers;
use AppModelsSample;
use IlluminateHttpRequest;
class SampleController extends Controller
{
public function store(Request $request): void
{
Sample::create(
array_merge($request->all(), ['created_at' => now()])
);
}
}
Мы также можем написать тест функций, который подтвердит, что наш маршрут API работает должным образом — образец сохраняется и средний образец сохраняется:
<?php
declare(strict_types=1);
namespace TestsOriginal;
use AppModelsAvgTemperature;
use AppModelsSample;
use TestsTestCase;
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);
}
}
Выглядит прекрасно, правда?
Теперь, когда что-то пойдет не так
Представьте, что второй разработчик в вашей команде собирается написать модульный тест, в котором он хочет проверить расчеты средней температуры.
Для выполнения этой работы он извлекает сервис из класса слушателя:
<?php
declare(strict_types=1);
namespace AppListeners;
use AppEventsSampleCreatedEvent;
use AppServicesAvgTemperatureRecalcService;
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 AppServices;
use AppModelsAvgTemperature;
use AppModelsRefactoredSample;
use AppModelsSample;
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,
]);
}
}
У него есть написанный модульный тест, в котором он хочет заполнить 100 образцов одновременно с интервалом в 1 минуту:
<?php
declare(strict_types=1);
namespace TestsOriginal;
use AppModelsAvgTemperature;
use AppModelsSample;
use AppServicesAvgTemperatureRecalcService;
use TestsTestCase;
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);
}
}
Это довольно простой пример, и его можно исправить, отключив событие модели или сымитировав весь фасад события на специальной основе.
Event::fake();
// or
Sample::unsetEventDispatcher();
Для любого более-менее крупного проекта такие варианты болезненны — всегда нужно помнить, что ваша модель создает побочные эффекты.
Представьте, что такое событие создает побочные эффекты в другой базе данных или внешней службе через вызов API. Каждый раз, когда вы создаете образец с фабрикой, вам приходится иметь дело с имитацией внешних вызовов.
У нас есть сочетание плохого шаблона разработки событий модели и недостаточного разделения кода.
Рефакторинг и разделение нашего примера
Для лучшей видимости мы создадим второй набор моделей в нашем проекте и новый маршрут.
Во-первых, мы удаляем событие модели из нашей модели Sample, теперь она выглядит так:
<?php
declare(strict_types=1);
namespace AppModels;
use IlluminateDatabaseEloquentFactoriesHasFactory;
use IlluminateDatabaseEloquentModel;
class RefactoredSample extends Model
{
use HasFactory;
protected $table = 'samples';
public $timestamps = false;
protected $fillable = [
'device_id',
'temp',
'created_at',
];
}
Затем мы создаем службу, которая будет отвечать за потребление новых образцов:
<?php
declare(strict_types=1);
namespace AppServices;
use AppEventsSampleCreatedEvent;
use AppModelsDataTransferObjectsSampleDto;
use AppModelsRefactoredSample;
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;
}
}
Обратите внимание, что наша служба теперь отвечает за запуск события в случае успеха.
Наш новый обработчик маршрута будет выглядеть так:
<?php
declare(strict_types=1);
namespace AppHttpControllers;
use AppHttpRequestsStoreSampleRequest;
use AppModelsDataTransferObjectsSampleDto;
use AppServicesSampleConsumeService;
class SampleController extends Controller
{
public function storeRefactored(StoreSampleRequest $request, SampleConsumeService $service): void
{
$service->newSample(SampleDto::fromRequest($request));
}
}
Класс запроса:
<?php
declare(strict_types=1);
namespace AppHttpRequests;
use IlluminateFoundationHttpFormRequest;
/**
* @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'],
];
}
}
Теперь мы повторяем тесты нашего второго разработчика с новым маршрутом и можем подтвердить, что он проходит:
<?php
declare(strict_types=1);
namespace TestsRefactored;
use AppModelsAvgTemperature;
use AppModelsRefactoredSample;
use AppServicesRefactoredAvgTemperatureRecalcService;
use TestsTestCase;
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);
}
}
Заключение
Что было улучшено:
- Мы отделили наш контроллер от модели базы данных.
- Мы отделили обработку примеров (бизнес-логику) от платформы.
- Инициирование события
SampleCreatedEvent
стало более контролируемым и не срабатывает, когда его не ожидают.
Как это помогает:
- Разработчики получают больше удовольствия от работы с вашим кодом.
- Теперь вы можете имитировать обработку сэмпла при тестировании контроллера сэмпла.
- CI/CD работает быстрее и стоит меньше, поскольку мы не делаем ненужной работы (действительно для крупных проектов).
Репозиторий с кодом можно найти здесь: https://github.com/dkhorev/model-observers-bad-practice .
Также опубликовано здесь
Оригинал