iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Software Development

一套真實運作中的 Laravel 系統,拆解它的原生機制系列 第 14

Day 14:一支 Job 該做多少事?從 16 行 vs 120 行的 Job 看職責邊界

  • 分享至 

  • xImage
  •  

前言:Job 越寫越長,是什麼時候開始不對勁的?

「這支 Job 一開始只是抓資料寫進資料庫,後來陸續加了同步關聯、處理附件、呼叫另一支 Job……現在已經 100 多行了,這樣正常嗎?」

Job 會長大,通常不是一次設計錯誤造成的,是一次一次「反正加在這裡最快」的小決定累積出來的。今天要對照同一個系統裡兩支長度差非常多的 Job——一支 16 行、一支超過 120 行——具體看看「一支 Job 該做多少事」這個問題,在真實程式碼裡是怎麼被回答(或沒被回答)的。

今日目標

  • 看一支 16 行、職責單一到一眼看完的 Job 長什麼樣
  • 看一支 120 多行、混了好幾種節奏的 Job,具體混了哪些事
  • 認識「一支非同步 Job 裡呼叫同步派發」這種矛盾寫法該怎麼看待
  • 建立「Job 該不該拆」的具體判斷依據,不是憑感覺

本文主體

16 行版本:職責單一到一眼看完

public function handle(DataGatewayClient $client): void
{
    $assigners = $client->getAssigners();

    foreach ($assigners as $assigner) {
        NewsAssigner::query()->firstOrCreate([
            'code' => (string) $assigner->UNIT_CODE,
        ], [
            'name' => (string) $assigner->UNIT_CODE_DESC,
            'eng_name' => (string) $assigner->UNIT_ENAME,
            'raw_data' => $assigner,
        ]);
    }
}

這支 Job 只做一件事:呼叫外部系統的 SOAP 介面拿到一份清單,逐筆用 firstOrCreate 寫進資料表。沒有分頁、沒有複雜的欄位轉換、沒有呼叫其他 Job。讀這支 Job 完全不需要理解上下文,handle() 方法本身就是完整的說明文件。

120 多行版本:一支 Job 混了幾種不同節奏的事

另一支負責同步新聞資料的 Job,職責描述聽起來也很單純:「抓新聞、寫進資料庫」。但攤開 handle() 之後,實際發生的事情遠不只這樣:

  1. 抓分頁資料:呼叫外部系統的分頁 API,逐頁把資料收集起來
  2. 組出 30 多個欄位的 attributes:把外部系統回傳的原始欄位,轉換、拼接、格式化成站內資料表要的欄位結構
  3. 寫入 News,並同步兩個多對多關聯assigners()->sync()identities()->sync()
  4. handle() 裡呼叫 dispatch_sync(new FetchNewsFile(...))——同步派發另一支負責下載附件的 Job

第 4 點是這支 Job 裡最值得停下來想一想的地方。這支 Job 本身實作了 ShouldQueue,代表它是設計成要被排進佇列、非同步執行的。但它內部呼叫附件下載邏輯的方式,用的是 dispatch_sync()——這個方法會讓被呼叫的 Job 立刻在當下的 process 裡同步執行完,不會真的排進佇列

換句話說:外層這支 Job 已經享受了「非同步、背景執行」帶來的好處(不阻塞使用者請求),但它內部呼叫附件下載的方式,卻是同步、阻塞的——附件下載要花多久,這支 Job 的整體執行時間就要多久,「非同步」的好處在這一段完全沒發揮出來。

為什麼會變成這樣:一個合理的猜測

dispatch_sync() 通常出現在兩種情境:一是開發階段圖方便,先同步跑確認邏輯對不對,之後忘了改回 dispatch();二是刻意這樣寫,因為附件下載的結果會影響到後續某個判斷,必須等它跑完才能繼續。不管是哪一種,這支 Job 目前的寫法沒有留下任何說明——沒有註解解釋「為什麼這裡故意同步呼叫」,讀程式碼的人只能自己猜。

這支 Job 裡還藏著一段被整段註解掉、沒有刪除的舊邏輯(一個查找關聯用的變數計算),留在那裡但沒有人動它。這是一個很適合套用「重構前先問這段還要不要,而不是直接刪掉」這條紀律的具體案例——死碼留著不動,跟直接刪掉,都不是無腦就能下的決定,要先搞清楚它是不是還在被依賴、或者只是單純被遺忘。

對照:職責單一 vs 職責混合

一支 Job 混了「抓資料」「轉換欄位」「寫入」「同步派發下載」四種節奏

public function handle(): void
{
    $data = $this->fetchPaginatedData();       // 抓分頁
    $attributes = $this->transform($data);      // 轉換 30+ 欄位
    $news = News::create($attributes);           // 寫入
    $news->assigners()->sync(...);
    $news->identities()->sync(...);
    dispatch_sync(new FetchNewsFile($news));     // 同步呼叫另一個 Job,失去非同步的意義
}

拆成職責單一的多支 Job,各自可以獨立測試、獨立重試

public function handle(): void
{
    $data = $this->fetchPaginatedData();
    $attributes = $this->transform($data);
    $news = News::create($attributes);
    $news->assigners()->sync(...);
    $news->identities()->sync(...);
    FetchNewsFile::dispatch($news);   // 真正非同步排隊,失敗可以獨立重試
}

真實程式碼目前是前者。列出後者不是說「這支 Job 一定要立刻重寫」,而是提供一個具體的判斷基準:當一支 Job 內部出現「呼叫另一支本該非同步的 Job,卻用同步方式呼叫」的寫法時,這通常是職責邊界模糊的訊號,值得回頭問一句「這裡為什麼不能真的排隊」。

今日思考題

你的專案裡有沒有一支 Job,一開始很單純,後來陸續被加了很多事?回想一下它現在的長度,如果要你憑印象講出它「到底在做什麼」,你講得出幾件事?如果超過三件,可能已經到了該問「這些事該不該拆開」的時候。

今日重點回顧

  • 16 行版本的 Job:職責單一,一眼看完,不需要額外的上下文才能理解
  • 120 多行版本的 Job:混了抓分頁、轉換欄位、寫入、同步派發下載四種不同節奏的事
  • dispatch_sync() 出現在一支 ShouldQueue 的 Job 裡,是職責邊界模糊的訊號,值得停下來問為什麼
  • 死碼(被註解掉但沒刪除的邏輯)留著不動,要先確認它是不是還被依賴,不是無腦刪掉或無腦保留

明日預告

明天要看兩種不同的 Observer 註冊方式——一種用 PHP 8 的 Attribute 語法、一種用傳統的 ServiceProvider 手動註冊,同一個系統裡兩種寫法並存,背後代表什麼。


上一篇
Day 13:Queue Job 基礎——用 Generator 安全迭代日期區間去抓外部資料
系列文
一套真實運作中的 Laravel 系統,拆解它的原生機制14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言