Laravel Queue 與 Event 的比較

在使用 Laravel 的時候,可能會把 Queue 跟 Event 搞混,這篇文章將會比較兩種功能,並說明適用的情境。

簡介一下 Event 與 Queue 兩個功能:

  • Event 搭配 Listener 後,即可讓主要流程和監聽後觸發的流程分離,搭配 Queue 的機制還可以做到非同步執行。
  • Queue 搭配 Invokable Job 的寫法,因 Job 本身就是可以自己獨立運行的任務,因此本質就是流程分離,而 Queue 機制可以讓 Job 非同步執行。

因為只要搭配 Queue,就都能做到非同步執行,因此有遇過一些人會搞混這兩個工具的使用情境。

理解工具的本質

設計是為了解決問題,因此理解問題的本質是很重要的。因此這裡需要先清楚三個工具:非同步執行(asynchronous)、Queue、Event,它們能解決的問題為何。

非同步執行能解決的問題很廣泛,但它不是銀彈,所以這個方案有副作用需要考慮。最大的問題是:後續執行失敗的回滾(rollback)複雜操作,造成的資料不一致問題,所以它只是系統架構的一種「方案」。

然而,要記得一個很重要的事:程式是工具,我們寫程式都是為了業務需求。而資料不一致對業務需求來說,通常都是致命的問題(critical issue)。以我最熟悉的登入驗證需求來說:

  1. 會員註冊完帳號後,資料沒同步完成,造成會員無法登入,這是致命的問題。
  2. 登入的過程寄送的登入驗證碼沒有馬上送達,這也是致命的問題。
  3. 註冊後的註冊通知信沒有馬上發送,雖然業務的期待是馬上送達,但這個問題就不是那麼嚴重。

從這幾個情境可以理解,資料一致性也是有需求等級,但有一點絕對不會變的:會希望最後一定要一致,也就是最終一致性(eventual consistency)。

因此,要不要非同步,這件事是因需求而異的,最好的狀況是所有事都同步,非同步其實是替代方案(alternatives)。

理解完非同步執行的原理後,再說明另一個更重要的資訊:Queue 和 Event 都能靠設定調整成同步或非同步,這意味著,這兩個的使用情境,其實跟底層機制是不是非同步無關,因此反而從「同步」的角度去思考這兩個工具的使用情境,會更好理解。

Queue 與 Event 的差別

Queue 和 Event 的底層概念就不細說了,未來有機會再寫文章說明。這裡主要著墨的是使用它們的樣子為何。

Laravel Queues 的文件說明裡,提到用法是寫一個 Invokable Job ,然後透過 Bus Dispatcher 即可將 Job 分配給 Worker 執行。

class ProcessPodcast implements ShouldQueue
{
use Queueable;

public function __construct(
public Podcast $podcast,
) {}

public function handle(AudioProcessor $processor): void
{
// Process uploaded podcast...
}
}

ProcessPodcast::dispatch($podcast);

從這個樣貌可以理解,Job 是設計成獨立要做的任務,而它的樣子就是「一個 function」,因此它只是一個 function 的另一個樣子。像常見的 Service Repository Pattern 可能會出現下面的寫法:

class ProcessPodcastService
{
public function __construct(
private AudioProcessor $processor,
) {}

public function upload(Podcast $podcast): void
{
// Process uploaded podcast...
}
}

app()->make(ProcessPodcastService::class)->upload($podcast);

再來就是很重要的概念:當 Queue 設定是同步時,這兩個寫法執行結果是一樣的。雖然結果一樣,但設計是不大一樣的。Service 的寫法代表上傳是該服務的一個功能,而 Job 的寫法代表上傳是一個獨立的功能。這個結果就類似 Controller 改寫成 Invokable Action 一樣,事實上有些場景下,這個寫法更具備可讀性。

簡單來說:Queue Job 的寫法,只是 Service 的另一種樣貌,所以應該要先設計 Service,再設計 Job。

Laravel Events 則是完全不一樣的設計。官網的範例如下:

class PodcastProcessed
{
}

class SendPodcastNotification
{
public function handle(PodcastProcessed $event): void
{
// ...
}
}

Event::listen(PodcastProcessed::class, SendPodcastNotification::class);

event(new PodcastProcessed());

先寫一個 Class PodcastProcessed 代表某件事發生,而再寫另一個 Class SendPodcastNotification 代表發生某件事要做另一個對應的事,接著再透過 Event::listen() 綁定兩者。

而大多數人都會把 SendPodcastNotification::handle() 跟剛剛 Queue ProcessPodcast::handle() 兩個當成是一樣的事,所以概念就是在這裡搞混的。

這裡講一個很重要的概念:其實沒有 SendPodcastNotification 也沒關係,所以 PodcastProcessed 的意義或設計正確才是重要的。因此簡單來說,在設計 Event 的時候,是要先設計 Event,而不是先設計 Listener。像 PodcastProcessed 的命名,或是裡面帶的參數,都應該要跟發出事件的功能一致才是正確的設計。反過來說,如果針對 Listener 的功能設計 Event 的命名和參數,這樣的設計會跟 Laravel Event 的設計不合,最終都會有非常多問題。

總結

以我見過的例子,通常都是為了「非同步執行某件事」而在考慮到底要 Queue 或 Event。而單純針對這句話的意義來說,「某件事」是已經被設計好的,所以應該要優先考慮 Queue,而非 Listener。目前還沒看到要設計成 Event 的例子,還無法舉例。

最後,上面兩個例子剛好是可以整合的:

class ProcessPodcast implements ShouldQueue
{
use Queueable;

public function __construct(
public Podcast $podcast,
) {}

public function handle(AudioProcessor $processor): void
{
// Process uploaded podcast...

event(new PodcastProcessed());
}
}

class PodcastProcessed
{
}

class SendPodcastNotification
{
public function handle(PodcastProcessed $event): void
{
// ...
}
}

因為 PodcastProcessed 的意義,剛好是 ProcessPodcast 執行完的意思,這個就是上面提的「命名或參數,要跟發出事件的功能一致」的意思。