iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Software Development

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

Day 21:定期爬取外部網頁——Generator 分頁 + PSR-18 discovery + retry plugin 組合技

  • 分享至 

  • xImage
  •  

前言:一支只有 38 行的類別,可以塞進多少個技巧?

「爬取外部資料的邏輯,不就是發個請求、解析回應嗎?能有多複雜?」

今天要看的這個 Crawler 類別,全部加起來只有 38 行,論長度稱不上複雜。但這 38 行裡塞進的技巧密度相當高:分頁迭代不預先展開、HTTP client 不寫死實作、重試邏輯用套件疊加而不是自己刻。今天要把這幾個技巧一個一個拆開看。

今日目標

  • 看一支短小但技巧密度高的 Crawler 類別
  • 理解 Generator 怎麼處理「不知道總共有幾頁」的分頁 API
  • 認識 PSR-18 discovery 機制,跟「不寫死 client 實作」原則的延伸應用
  • 看重試邏輯怎麼用套件的 Plugin 疊加,而不是自己刻一段 retry 迴圈

本文主體

分頁迭代:不預先知道總頁數,也能安全迭代

這個 Crawler 要爬取的外部 API 是分頁式的——每次請求回傳一頁資料,回應裡帶著目前頁碼跟總頁數。核心方法用 do...while 搭配 Generator:

public function fetchNews(?Carbon $date = null, $perPage = 30): Generator
{
    $page = 1;

    do {
        $response = $this->client->sendRequest(/* 組出帶頁碼的請求 */);
        $data = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);

        if (! isset($data['content'])) {
            break;
        }

        foreach ($data['content'] as $row) {
            yield $row;
        }

        $page++;
    } while ((int) $data['pageNumber'] < (int) $data['totalPages']);
}

這裡用 yield 一筆一筆吐出資料,而不是把所有分頁資料先收集進一個陣列再整批回傳。跟 Day 13 看到的 dateRange() 是同一個原則:呼叫端要一筆、才真的去要一筆,不會因為總筆數多就佔用大量記憶體。這在爬取分頁 API 時特別重要——分頁數量不是自己可以控制的,外部系統回傳幾頁,就要迭代幾次,沒有上限保證。

PSR-18 Discovery:連 HTTP client 都不用寫死

建構子的預設值特別值得注意:

public function __construct(
    ?ClientInterface $client = null,
    ?RequestFactoryInterface $requestFactory = null,
    ?string $baseUrl = null
) {
    $this->client = new PluginClient($client ?? Psr18ClientDiscovery::find(), [
        new RetryPlugin(['retries' => 3]),
    ]);
    $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory();
    // ...
}

如果呼叫端沒有明確傳入 ClientInterface 實例,就用 Psr18ClientDiscovery::find() 自動探索當前專案裡裝了哪個 PSR-18 相容的 HTTP client 套件,自動找出來使用。這是「不寫死實作」原則的延伸版本——連「要用哪個 HTTP client」這件事,都不寫死在程式碼裡,而是讓套件自己去偵測環境裡實際裝了什麼。這種寫法常見於設計成要被廣泛複用的函式庫,不特別綁定某個框架或某個 HTTP client 品牌。

重試邏輯:用套件疊加,不自己刻迴圈

PluginClient 把原始的 HTTP client 包了一層,加上 RetryPlugin(['retries' => 3])——這代表這支 Crawler 送出的每一個請求,如果失敗(連線異常、逾時等),會自動重試最多 3 次,完全不需要自己寫一段 for 迴圈手動處理重試邏輯。

這是很值得記住的一個習慣:重試、逾時、快取這類橫切關注點,通常已經有現成的 middleware/plugin 可以疊加使用,不需要自己刻一套。自己刻的重試邏輯,容易漏掉一些邊角情況(例如指數退避、哪些錯誤該重試哪些不該),用套件提供的 Plugin,通常已經考慮過這些細節。

一個需要誠實提醒的地方:真實網址不能照抄

這支 Crawler 的預設 baseUrl,在真實程式碼裡是一個寫死的內部網路位址。這是一個值得停下來提醒的地方:任何程式碼範例裡出現的網址、IP,如果是內部系統的真實位址,公開分享程式碼片段時一定要換成範例用的假位址(例如 http://10.0.0.1:3000 這種明顯不是真實可連線位址的範例值),這不是這篇文章特有的顧慮,是任何團隊在公開分享內部系統程式碼時都該養成的習慣。

對照:自己刻重試邏輯 vs 用套件的 Plugin 疊加

自己手動寫一段 retry 迴圈

$attempts = 0;
while ($attempts < 3) {
    try {
        return $client->sendRequest($request);
    } catch (ClientExceptionInterface $e) {
        $attempts++;
        if ($attempts >= 3) throw $e;
    }
}
// 容易漏掉指數退避、哪些例外該重試等細節

用 PluginClient 疊加 RetryPlugin

$client = new PluginClient($baseClient, [
    new RetryPlugin(['retries' => 3]),
]);
$client->sendRequest($request);
// 重試邏輯交給套件處理,呼叫端程式碼保持乾淨

今日思考題

你的專案裡有沒有自己手動寫的重試/逾時邏輯?回想一下,有沒有現成的套件/middleware 可以取代它,讓這段橫切關注點不用自己維護?

今日重點回顧

  • 分頁爬取用 Generator yield 逐筆吐出資料,不用預先知道總頁數也能安全迭代
  • PSR-18 discovery 讓連 HTTP client 的具體實作都不用寫死,套件自動偵測環境裝了什麼
  • 重試邏輯用 PluginClient + RetryPlugin 疊加,不用自己刻一段 retry 迴圈
  • 任何內部系統的真實網址/IP,公開分享程式碼片段前一定要換成假的範例值

明日預告

明天要進入資安相關的主題:一次真實發生過的檔案上傳攻擊事件,事後才補上的 webshell 防護,是怎麼一回事。


上一篇
Day 20:XML 容錯——抄一篇公開文章的解法,也要懂它在防什麼
下一篇
Day 22:檔案上傳安全——一次真實資安事件後,才補上的 webshell 防護
系列文
一套真實運作中的 Laravel 系統,拆解它的原生機制22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言