Рефакторинг в Actions

Рефакторинг в Ларавел

В наших новых проектах в 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
Перевод: Demiurge Ash