iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Modern Web

我推的Laravel S2!系列 第 27

我推的Laravel S2|Day 26:HTTP Client:呼叫外部 API

  • 分享至 

  • xImage
  •  

一句話破題

串接第三方服務,現代 Laravel 專案已經很少有理由手寫原生 curl——Http facade 提供的是一套鏈式呼叫、對測試友善的完整 API,而且在近幾個大版本間相當穩定,是這系列少數幾乎不用處理版本差異的章節。今天整理的內容也是 Day 29 LINE Bot/OpenAI 整合實戰的先修知識。

基本用法

use Illuminate\Support\Facades\Http;

$response = Http::get('https://api.example.com/posts');

$response->body();          // 原始字串
$response->json();          // 解析成陣列
$response->object();        // 解析成 stdClass 物件
$response->collect();       // 解析成 Collection
$response->status();        // HTTP 狀態碼

$response->successful();    // 200-299
$response->ok();            // 剛好 200
$response->created();       // 201
$response->notFound();      // 404
$response->serverError();   // 500 以上

傳送資料

// GET 帶查詢參數
Http::get('https://api.example.com/posts', ['page' => 1, 'limit' => 10]);

// POST 帶 JSON body(預設行為)
Http::post('https://api.example.com/posts', [
    'title' => '透過 API 建立的文章',
    'body' => '內容...',
]);

// 以表單格式傳送(application/x-www-form-urlencoded)
Http::asForm()->post('https://api.example.com/posts', [
    'title' => '表單格式的請求',
]);

// 上傳檔案
Http::attach('cover', file_get_contents($path), 'cover.jpg')
    ->post('https://api.example.com/posts');

URI 模板

Http::withUrlParameters([
    'endpoint' => 'https://api.example.com',
    'postId' => 42,
])->get('{+endpoint}/posts/{postId}');

附加 Header 與認證

Http::withHeaders([
    'X-Custom-Header' => 'value',
])->acceptJson()->post(/* ... */);

Http::withToken($apiToken)->get('https://api.example.com/posts');
Http::withBasicAuth($username, $password)->get(/* ... */);

逾時與重試

Http::timeout(10)->connectTimeout(3)->get('https://api.example.com/posts');

// 失敗時自動重試 3 次,每次間隔 100 毫秒
Http::retry(3, 100)->post('https://api.example.com/posts', $data);

// 重試間隔可以用閉包做指數退避
Http::retry(3, function (int $attempt, Exception $exception) {
    return $attempt * 100;
})->post(/* ... */);

併發請求:Pool

如果 blog-app 需要同時呼叫多個獨立的外部端點(例如同時查詢多個第三方服務的狀態),逐一 await 每個請求會浪費大量等待時間,Http::pool() 讓多個請求並發送出:

$responses = Http::pool(fn (Pool $pool) => [
    $pool->get('https://api.example.com/posts/1'),
    $pool->get('https://api.example.com/posts/2'),
    $pool->as('weather')->get('https://weather-api.example.com/current'),
]);

$responses[0]->json();               // 第一個請求的結果
$responses['weather']->json();       // 用 as() 命名過的請求可以用名稱取結果,比數字索引更清楚

底層是用非同步 I/O 讓多個請求同時進行,而不是依序一個個發送等待,總耗時大致等於「最慢的那個請求」而不是「所有請求時間加總」——這在需要整合多個外部資料源的頁面(例如同時顯示天氣、匯率、第三方統計)特別有價值。

可重用的客戶端設定:巨集(Macro)

如果 blog-app 需要頻繁呼叫同一個外部服務、每次都要重複設定相同的 base URL、Header、逾時時間,可以定義一個巨集封裝這些共用設定:

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Http;

public function boot(): void
{
    Http::macro('lineApi', function () {
        return Http::withToken(config('services.line.channel_access_token'))
            ->baseUrl('https://api.line.me/v2/bot')
            ->timeout(10);
    });
}
// 之後任何地方都能這樣呼叫,不需要重複設定 Token/baseUrl/timeout
Http::lineApi()->post('/message/push', $payload);

這個模式在 Day 29 實際串接 LINE Bot 時會直接用到,把「怎麼建立一個已經設定好認證資訊的客戶端」集中定義一次,呼叫端不需要每次都重複貼上同一段設定樣板。

錯誤處理

Laravel 的 HTTP Client 有個容易讓人意外的預設行為:就算收到 404 或 500,Http::get() 本身不會拋出例外,你要主動判斷或呼叫 throw()

$response = Http::get('https://api.example.com/posts');

if ($response->failed()) {
    Log::error('API 呼叫失敗', ['status' => $response->status()]);
}

// 或直接要求失敗時拋出例外
$response = Http::get('https://api.example.com/posts')->throw();

// 只在特定條件才拋出
$response->throwIf($response->status() === 429);
$response->throwUnless($response->successful());

// 統一處理錯誤而不拋例外
Http::get('https://api.example.com/posts')->onError(function ($response) {
    Log::warning('外部 API 回應異常', ['status' => $response->status()]);
});

Laravel 13 對 throw()/throwIf() 的方法簽名做了一個很小的調整:這兩個方法現在明確在簽名裡宣告 callback 參數,如果你的專案有繼承 Response 類別並覆寫這兩個方法,需要確認簽名相容;一般專案直接呼叫 throw()/throwIf() 不會有感。

把外部 API 的失敗轉換成自己的例外類型

呼應 Day 18 提過的自訂例外階層架構,呼叫外部服務失敗時,建議轉換成自己專案定義的例外,而不是讓呼叫端直接處理 Guzzle/HTTP Client 底層的例外類型:

class LineApiException extends BlogAppException
{
    public function userMessage(): string
    {
        return 'LINE 訊息發送失敗,請稍後再試。';
    }
}
try {
    Http::lineApi()->post('/message/push', $payload)->throw();
} catch (RequestException $e) {
    throw new LineApiException(previous: $e);
}

這樣呼叫端(例如 Controller)只需要知道「可能會拋出 LineApiException」,不需要認識 Guzzle 底層的例外類型細節——這是 Day 22 依賴反轉原則的另一個實際應用:高層邏輯依賴自己定義的抽象(LineApiException),不直接依賴第三方函式庫的實作細節。

快取外部 API 的回應

如果呼叫的外部端點資料不會頻繁變動(例如匯率、每日一句),每次都重新呼叫既浪費時間也可能觸及對方的速率限制,搭配 Laravel 的快取系統:

$rate = Cache::remember('exchange-rate:usd-twd', now()->addHours(1), function () {
    return Http::get('https://exchange-api.example.com/usd-twd')->json('rate');
});

Cache::remember() 會先檢查快取有沒有值,有就直接回傳,沒有才真的呼叫外部 API 並把結果存進快取——這個模式能大幅減少對外部服務的呼叫次數,也讓 blog-app 自身的回應速度不受外部服務延遲拖累(快取命中時完全不需要等待網路往返)。

測試:Http::fake()

呼叫外部 API 的程式碼,測試時不該真的打到外部服務,Http::fake() 讓你模擬回應:

Http::fake([
    'api.example.com/*' => Http::response(['id' => 1, 'title' => '假資料'], 200),
]);

// 依序回傳不同回應,適合模擬「先失敗、重試後成功」的情境
Http::fake([
    'api.example.com/*' => Http::sequence()
        ->push(['error' => 'timeout'], 500)
        ->push(['id' => 1], 200),
]);

$response = Http::get('https://api.example.com/posts');

Http::assertSent(function ($request) {
    return $request->url() === 'https://api.example.com/posts';
});
Http::assertSentCount(1);

想避免測試意外真的打出網路請求(例如忘記 fake 某個服務),可以在測試設定裡開啟:

Http::preventStrayRequests();

開啟後,任何沒有被 Http::fake() 涵蓋到的請求都會直接拋出例外,而不是真的發出網路請求,是撰寫測試時很推薦的防呆設定,Day 27 講 Testing 時會再提到。

檢查實際發出的請求

測試或除錯時想確認「到底送出了什麼、收到了什麼」,Http::recorded() 會回傳所有請求/回應配對:

Http::recorded()->each(function ($pair) {
    [$request, $response] = $pair;
    Log::debug('HTTP 呼叫紀錄', [
        'url' => $request->url(),
        'status' => $response->status(),
    ]);
});

開發階段也可以直接在鏈式呼叫裡插入 dd(),檢視實際送出的請求內容,不需要另外寫記錄邏輯:

Http::withHeaders([...])->dd()->post('https://api.example.com/posts', $data);   // 印出即將送出的請求內容並中斷執行

常見錯誤與踩雷點

  • 誤以為 4xx/5xx 會自動拋出例外:這是最容易踩的坑,Http::get() 預設對任何狀態碼都「成功」回傳一個 Response 物件,不主動判斷 successful()/呼叫 throw() 的話,程式碼可能在外部服務回錯誤時還繼續往下跑,操作到不完整的資料。
  • 測試沒有 Http::fake() 就跑,意外打到真實外部服務:除了測試變慢、變得不穩定(依賴外部服務可用性),還可能不小心對正式的第三方 API 產生實際副作用(例如真的建立了一筆測試資料)。搭配 preventStrayRequests() 能提早在開發階段抓到這類疏漏。
  • 忘記設定逾時,外部服務掛掉時整個請求被卡住:沒有 timeout() 的話,Laravel 會沿用底層 Guzzle 的預設值(通常很長甚至無限等待),外部服務異常時容易連帶拖垮你自己的應用程式回應時間,正式環境的外部 API 呼叫建議都明確設定合理的逾時時間。
  • 呼叫端直接依賴 Guzzle 底層的例外類型:這會讓你的業務邏輯程式碼跟第三方函式庫的實作細節綁死,前面提過的「轉換成自己的例外類型」能避免這個耦合。
  • 快取外部 API 結果卻沒設定合理的過期時間:資料變動頻率不同的端點,快取時間應該分開評估,把所有外部 API 回應都用同一個很長的快取時間,可能導致使用者看到過時的資料而不自知。

小結

Http facade 提供了一套完整、鏈式呼叫、對測試友善的 API,用來取代手寫原生 curl。今天除了核心用法,也補上了 Http::pool() 併發請求、巨集封裝常用設定、把外部 API 錯誤轉換成自訂例外、以及搭配快取降低外部服務呼叫頻率這幾個實務上處理第三方整合時很有價值的技巧。這個章節本身沒有版本相關的破壞性變動,今天建立的知識會直接用在 Day 29 的 LINE Bot/OpenAI 整合實戰。

海內存知己,天涯若比鄰 — 王勃《送杜少府之任蜀州》

明日預告

Day 27 進入 Testing 測試:TEST CASE 基本組成、HTTP 斷言精選、資料庫測試,把這系列建立的 Post 相關功能一一驗證起來。


上一篇
我推的Laravel S2|Day 25:Schedule 排程與自訂 Artisan Command
系列文
我推的Laravel S2!27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言