В наших новых проектах в 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. Для небольших проектов это нормально. Но представьте, что у вас есть куда больше видов действий для записи, например архивирование или дублирование. Всё это сильно раздует вашу модель.
Вместо того, чтобы оставить эту логику в контроллере или поместить ее в модель, давайте переместим ее в выделенный класс. В 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();
}
}
Представьте, что у вас есть 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
Перевод: Demiurge Ash