В наших новых проектах в Spatie мы начали использовать концепцию под названием «actions». Она позволяет держать наши контроллеры и модели в чистоте. Очевидная практика. Сейчас я объясню подробнее.
Из логики контроллеров и моделей…
Предположим, у вас есть блог на Laravel, где вы публикуете свои записи. Когда запись опубликована, то приложение твиттит заголовок и ссылку на него.
Контроллер, который делает это, может выглядеть так:
class PostsController { public function create() { // ... } public function store() { // ... } public function edit() { // ... } public function update() { // ... } public function delete() { // ... } public function publish(Post $post, TwitterApi $twitterApi) { $post->markAsPublished(); $twitterApi->tweet($post->title . PHP_EOL . $post->url); flash()->success('Your post has been published!'); return back(); } }
Если вам интересно, почему этот контроллер не расширяет дефолтный контроллер, то об этом рассказано в статье об упрощении контроллеров.
Для меня немного грязно, когда не-crud методы находятся в crud-контроллере. Давайте последуем совету Адама и поместим метод publish в свой собственный контроллер.
class PublishPostController { public function __invoke(Post $post, TwitterApi $twitter) { $post->markAsPublished(); $twitter->tweet($post->title . PHP_EOL . $post->url); flash()->success('Your post has been published!'); return back(); } }
Уже немного лучше, но мы можем пойти еще дальше. Представьте, что вы хотите создать artisan команду для публикации записи в блоге. Сейчас это невозможно, потому что логика для этого находится внутри контроллера.
Чтобы сделать логику вызываемой из командной строки (или откуда-либо еще в приложении), эта логика не должна находиться в контроллере. В идеале, единственный код, который помещается в контроллер — это код, который обрабатывает слой HTTP.
У вас может возникнуть соблазн переместить весь этот код в метод publish модели Post. Для небольших проектов это нормально. Но представьте, что у вас есть куда больше видов действий для записи, например архивирование или дублирование. Всё это сильно раздует вашу модель.
…в action-логику!
Вместо того, чтобы оставить эту логику в контроллере или поместить ее в модель, давайте переместим ее в выделенный класс. В Spatie мы называем эти классы «actions».
Action — это очень простой класс. У него есть только один публичный метод: execute. Вы можете назвать этот метод как хотите.
namespace App\Actions; use App\Services\TwitterApi; class PublishPostAction { /** @var \App\Services\TwitterApi */ private $twitter; public function __construct(TwitterApi $twitter) { $this->twitter = $twitter; } public function execute(Post $post) { $post->markAsPublished(); $this->tweet($post->title . PHP_EOL . $post->url); } private function tweet(string $text) { $this->twitter->tweet($text); } }
Заметили, что метод markAsPublished вызывается через $post? Поскольку в нашем приложении теперь есть специальное место для публикации записей, эту логика можно перенести в PublishPostAction, что сделает модель Post немного полегче.
// in PublishPostAction public function execute(Post $post) { $this->markAsPublished($post); $this->tweet($post->title . PHP_EOL . $post->url); } private function markAsPublished(Post $post) { $post->published_at = now(); $post->save(); } private function tweet(string $text) { $this->twitter->tweet($text); }
В контроллере вы можете вызвать action следующим образом:
namespace App\Http\Controllers; use App\Actions\PublishPostAction; class PublishPostController { public function __invoke(Post $post, PublishPostAction $publishPostAction) { $publishPostAction->execute($post); flash()->success('Hurray, your post has been published!'); return back(); } }
Мы используем установку зависимостей с помощью вызова метода, поэтому контейнер Laravel автоматически внедряет экземпляр TwitterApi в сам PublishPostAction.
Команда artisan теперь тоже может использовать этот action.
namespace App\Console\Commands; use Illuminate\Console\Command; use App\Actions\PublishPostAction; use App\Models\Post; class PublishPostCommand extends Command { protected $signature = 'blog:publish-post {postId}'; protected $description = 'Publish a post'; public function handle(PublishPostAction $publishPostAction) { $post = Post::findOrFail($this->argument('postId')); $publishPostAction->execute($post); $this->comment('The post has been published!'); } }
Еще одно преимущество, которое мы получаем при переходе к actions, состоит в том, что код теперь проще тестировать, так как он больше не привязан к слою HTTP.
class PublishPostActionTest extends TestCase { public function setUp(): void { parent::setUp(); Carbon::setTestNow(Carbon::createFromFormat('Y-m-d H:i:s', '2019-01-01 01:23:45')); TwitterApi::fake(); } /** @test */ public function it_can_publish_a_post() { $post = factory(Post::class)->state('unpublished')->create(); (new PublishPostAction())->execute($post); $this->assertEquals('2019-01-01 01:23:45', $post->published_at->format('Y-m-d H:i:s')); TweetterApi::assertTweetSent(); } }
Actions и Очереди.
Представьте, что у вас есть action и работа, которую он выполняет, которая занимает много времени. Простым решением было бы создать задачу в очереди и отправлять эту задачу из action.
Давайте используем очередь в PublishPostAction для отправки твита.
// in PublishPostAction public function execute(Post $post) { $this->markAsPublished($post); $this->tweet($post->title . PHP_EOL . $post->url); } private function markAsPublished(Post $post) { $post->published_at = now(); $post->save(); } private function tweet(string $text) { dispatch(new SendTweetJob($text)); }
Теперь если вы можете отправить твиты из разных мест приложения. используя Задачи:
namespace App\Http\Controllers class SendTweetController { public function __invoke(SendTweetRequest $request) { dispatch(new TweetJob($request->text); flash()->success('The tweet has been sent'); return back(); } }
Отлично работает. Но, было бы неплохо, если бы могли использовать actions для всего, включая асинхронные задачи.
Посмотрите наш пакет laravel-queueable-action. Он позволяет легко ставить actions в очередь, просто применяя к нему предоставленное QueueableAction. Этот трейт добавляет метод onQueue.
use Spatie\QueueableAction\QueueableAction; namespace App\Actions; class SendTweetAction { use QueueableAction; /** @var \App\Services\TwitterApi */ private $twitter; public function __construct(TwitterApi $twitter) { $this->twitter = $twitter; } public function execute(string $text) { $this->twitter->tweet($text); } }
Теперь мы можем вызвать action и он выполнит свою работу в очереди.
class SendTweetController { public function __invoke(SendTweetRequest $request, SendTweetAction $sendTweetAction) { $sendTweetAction->onQueue()->execute($request->text); flash()->success('The tweet will be sent very shortly!'); return back(); } }
Вы также можете указать очередь, в которой должна выполнится задача, передав ее имя в onQueue.
$sendTweetAction->onQueue('tweets')->execute($request->text);
Если вы хотите узнать больше об actions в очередях, обязательно ознакомьтесь с этой записью в блоге моего коллеги и создателя пакета.
В заключение
Извлечение логики в action, позволяет вызывать этот action из множества мест вашего приложения. Это облегчает тестирование кода. Если action становится большим, то вы можете разделить его на более мелкие части.
В Spatie мы назвали эту концепцию actions и используем в ней метод execute. Вы можете называть концепцию и метод как хотите. Не мы изобрели эту практику. Есть множество разработчиков, которые используют её. Если вы пришли из мира DDD, то, вероятно, заметили, что actions — это просто объединенные команда и обработчик.
Автор: Freek Van der Herten
Перевод: Алексей Широков
Наш Телеграм-канал — следите за новостями о Laravel.