Laravel tái cấu trúc hóa theo cách chia nhỏ xử lý hướng actions

Bài Học

Cách chia nhỏ xử lý hướng actions là phương pháp giữ cho lớp controllers và models trở nên nhỏ gọn. Đây là 1 phương pháp hết sức đơn giản. Và trong bài viết này, tôi sẽ trình bày rõ nó cho bạn.

Từ suy nghĩ logic nằm trong lớp controllers và models …

Giả sử bạn có 1 ứng dụng blog được phát triển dựa trên Laravel, ứng dụng này giúp bạn xuất bản ra các bài viết posts. Khi 1 bài post được xuất bản, ứng dụng sẽ thực hiện tweet (chia sẻ lên twitter) tiêu đề và link của bài post.

Xử lý nằm trong lớp controller như sau:

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();
    }
}

Nếu bạn thấy ngạc nhiên là vì sao controller này lại không kế thừa controller mặc định trong Laravel, thì bạn có thể xem giải thích về nó tại bài viết: https://freek.dev/1324-simplifying-controllers.

Đối với cá nhân tôi, thì thật là rối rắm khi xuất hiện 1 thao tác action mà không phải action liên quan tới crud (thêm/đọc/sửa/xóa) trong 1 crud controller.

Ta nên đặt publish action vào 1 controller khác chỉ chứa mình nó.

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();
    }
}

Đoạn code đã trở nên sáng sủa hơn, tuy nhiên chúng ta vẫn có thể cải tiến nó tốt hơn nữa. Giả sử, bạn sẽ muốn tạo ra 1 lệnh artisan command  dùng để xuất bản các bài viết blog posts, thì đoạn code nằm trong lớp controller phía trên sẽ không tái sử dụng lại được.

Để đoạn logic xử lý có thể gọi được từ cửa sổ dòng lệnh command (hay bất cứ nơi nào khác trong app) thì đoạn logic này không nên nằm trong 1 controller. Về lý thuyết thì chỉ có đoạn code nào nằm trong 1 controller mới có thể tương tác được với lớp HTTP.

Bạn có thể tư duy theo thiên hướng chuyển toàn bộ đoạn code này xuống phương thức publish nằm tại Post model. Đối với các dự án nhỏ thì xử lý theo cách này cũng có thể chấp nhận được. Nhưng bạn có thể hình dung rằng sẽ ngày càng có nhiều hơn nữa các actions dạng này trên 1 bài post, giống như lưu trữ hay nhân bản. Tất cả các actions này sẽ khiến lớp model của bạn phình to ra.

… tới suy nghĩ logic nằm trong actions!

Thay vì giữ logic nằm trong controller hay chuyển chúng xuống model, ta chuyển dời logic vào bên trong 1 lớp class dành riêng. Chúng ta gọi các lớp classes kiểu này là “actions”.

1 action là 1 lớp class rất đơn giản. Nó là lớp class chỉ có duy nhất 1 phương thức public method là execute. Bạn có thể đặt tên name cho phương thức này là bất cứ thứ gì mà bạn muốn.

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);
    }
}

Chú ý rằng phương thức markAsPublished vẫn đang được gọi từ $post model? Bởi vì ứng dụng hiện đã có 1 chỗ dành riêng cho việc xuất bản bài post, nên logic này nên chuyển vào trong PublishPostAction này, do đó điều này sẽ khiến Post model trở nên nhỏ gọn hơn.

// 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);
}

Trong controller, ta có thể gọi vào action như sau:

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();
    }
}

Chúng ta sử dụng method injection để resolve PublishPostAction do vậy container của Laravel sẽ tự động nạp vào TwitterApi instance vào trongPublishPostAction

1 artisan command cũng có thể sử dụng action này

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!');
    }
}

1 ưu điểm khác mà ta có được từ việc trích xuất logic vào trong actions là nó không còn bị ràng buộc với lớp HTTP nữa, và nó trở nên dễ test hơn.

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 có tính queue hóa

Giả sử bạn có 1 action cần thực hiện xử lý công việc mà có thể mất tương đối thời gian. 1 giải pháp đơn giản là bạn có thể tạo ra 1 queued job và thực hiện kích hoạt job này trong phạm vi của action.

Giả sử ta sử dụng 1 hàng đợi queue trong PublishPostAction để gửi tweet

// 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));
}

Giờ, nếu bạn muốn gửi tweets từ 1 vài chỗ khác trong ứng dụng. Bạn có thể sử dụng 1 job như sau:

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();
    }
}

Việc thực hiện gửi tweet giống như 2 ví dụ trên trông có vẻ khá tốt. Nhưng nó vẫn chưa đủ tốt nếu chúng ta sử dụng actions cho mọi thứ, bao gồm cả các xử lý bất đồng bộ?

Sử dụng laravel-queueable-action package cho phép bạn có thể queue hóa action 1 cách rất dễ dàng. Bạn có thể tạo ra 1 action có tính queue hóa bằng cách áp dụng QueueableAction được cung cấp sẵn trong package vào nó. Trait này sẽ thêm vào phương thức 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);
    }
}

Giờ ta có thể gọi action này, và nó sẽ thực hiện xử lý theo hàng đợi queue.

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();
    }
}

Bạn có thể định nghĩa 1 queue xác định bằng cách truyền thêm tên name vào onQueue.

$sendTweetAction->onQueue('tweets')->execute($request->text);

Kết luận

Việc trích xuất logic thành các actions có thể khiến các action này được gọi từ nhiều nơi trong app. Nó cũng khiến cho code trở nên dễ dàng để test hơn. Nếu actions trở nên phình to hơn, bạn có thể chia chúng ra thành các actions nhỏ hơn.

Bài gốc: https://freek.dev/1371-refactoring-to-actions



Comments

Trả lời

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *