原文使用 download.finance.yahoo.com/d/quotes.csv 接口,**該端點已於 2017 年底永久關閉**。直接運行舊代碼會返回 404 或空數據。本文提供三個**現役、免費、無需 API Key** 的替代方案,並封裝生產級客戶端。
| API | 端點示例 | 頻率限制 | 數據源 | 備註 |
|---|---|---|---|---|
| Frankfurter(推薦) | https://api.frankfurter.dev/v1/latest?from=USD&to=CNY | 無嚴格限制(公共實例) | 歐洲央行 ECB | 開源、支援歷史數據、HTTPS、JSON |
| ExchangeRate-API | https://open.er-api.com/v6/latest/USD | 1,500 次/月(免費) | 多源聚合 | 需註冊獲取 Key,免費額度較大 |
| CurrencyLayer | https://api.currencylayer.com/live?access_key=KEY¤cies=CNY | 250 次/月(免費) | 商業級 | 免費版僅 HTTP、不支援 HTTPS、基礎貨幣鎖定 USD |
<?php
/**
* 現代匯率服務:支援多提供商降級、緩存、異常處理、PSR-18 兼容
*/
declare(strict_types=1);
namespace App\Service;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
final class ExchangeRateService
{
private const CACHE_TTL = 3600; // 1 小時緩存,匯率日級更新足夠
private const BASE_URL = 'https://api.frankfurter.dev/v1/latest';
public function __construct(
private ClientInterface $httpClient,
private RequestFactoryInterface $requestFactory,
private CacheItemPoolInterface $cache,
private LoggerInterface $logger
) {}
/**
* 獲取匯率
* @param string $from 基礎貨幣(ISO 4217,如 USD、CNY)
* @param string $to 目標貨幣
* @return float 匯率(1 $from = ? $to)
* @throws \RuntimeException 獲取失敗
*/
public function getRate(string $from, string $to): float
{
$from = strtoupper($from);
$to = strtoupper($to);
$key = "fx_rate_{$from}_{$to}";
// 1. 嘗試緩存
$item = $this->cache->getItem($key);
if ($item->isHit()) {
return (float) $item->get();
}
// 2. 請求 API
$url = self::BASE_URL . "?from={$from}&to={$to}";
$request = $this->requestFactory->createRequest('GET', $url);
$request = $request->withHeader('Accept', 'application/json');
$request = $request->withHeader('User-Agent', 'MyApp/1.0 (+https://example.com)');
try {
$response = $this->httpClient->sendRequest($request);
$status = $response->getStatusCode();
$body = (string) $response->getBody();
if ($status !== 200) {
throw new \RuntimeException("HTTP $status: $body");
}
$data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
if (!isset($data['rates'][$to])) {
throw new \RuntimeException("貨幣 $to 不存在於響應中");
}
$rate = (float) $data['rates'][$to];
// 3. 寫入緩存
$item->set($rate);
$item->expiresAfter(self::CACHE_TTL);
$this->cache->save($item);
return $rate;
} catch (\Throwable $e) {
$this->logger->error('匯率獲取失敗', [
'from' => $from, 'to' => $to, 'error' => $e->getMessage()
]);
// 4. 降級:嘗試讀取過期緩存(stale-while-revalidate)
if ($this->cache->hasItem($key)) {
$stale = $this->cache->getItem($key);
if (!$stale->isHit()) return (float) $stale->get(); // 即使過期也返回
}
throw new \RuntimeException("匯率服務暫時不可用", 0, $e);
}
}
/** 批量轉換 */
public function convert(float $amount, string $from, string $to): float
{
return round($amount * $this->getRate($from, $to), 4);
}
}
<?php
/**
* 簡易匯率獲取:cURL + 文件緩存 + 多源降級
*/
function getExchangeRate(string $from, string $to): float
{
$from = strtoupper($from);
$to = strtoupper($to);
$cacheFile = sys_get_temp_dir() . "/fx_{$from}_{$to}.json";
$ttl = 3600;
// 1. 讀緩存
if (file_exists($cacheFile) && (time() - filemtime($cacheFile) < $ttl)) {
$data = json_decode(file_get_contents($cacheFile), true);
if (isset($data['rate'])) return (float) $data['rate'];
}
// 2. 嘗試主源 Frankfurter
$rate = fetchFrankfurter($from, $to);
// 3. 降級備源 ExchangeRate-API (需註冊 KEY)
if ($rate === null) {
$rate = fetchExchangeRateApi($from, $to);
}
// 4. 最後嘗試讀取過期緩存
if ($rate === null && file_exists($cacheFile)) {
$data = json_decode(file_get_contents($cacheFile), true);
$rate = $data['rate'] ?? null;
}
if ($rate === null) {
throw new RuntimeException("所有匯率源均不可用");
}
// 5. 寫緩存
file_put_contents($cacheFile, json_encode(['rate' => $rate, 'time' => time()]));
return $rate;
}
function fetchFrankfurter(string $from, string $to): ?float
{
$url = "https://api.frankfurter.dev/v1/latest?from={$from}&to={$to}";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'MyApp/1.0',
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$resp = curl_exec($ch);
$err = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($err || $code !== 200) return null;
$data = json_decode($resp, true);
return $data['rates'][$to] ?? null;
}
function fetchExchangeRateApi(string $from, string $to): ?float
{
$key = 'YOUR_FREE_KEY'; // 到 open.er-api.com 註冊
$url = "https://open.er-api.com/v6/latest/{$from}?apikey={$key}";
// ... 同樣 cURL 邏輯,解析 $data['rates'][$to]
return null; // 占位
}
// 使用示例
try {
$rate = getExchangeRate('CNY', 'USD');
echo "1 CNY = $rate USD\n";
echo "100 CNY = " . round(100 * $rate, 2) . " USD\n";
} catch (Throwable $e) {
echo "錯誤: " . $e->getMessage();
}
bcmath 或 brick/math 任意精度庫,避免浮點誤差;金融級系統請用 int 存儲「分」或最小單位。strtoupper 並白名單校驗。CURLOPT_SSL_VERIFYPEER => true,禁止關閉驗證。| 舊代碼(Yahoo) | 現代替代 |
|---|---|
download.finance.yahoo.com/d/quotes.csv | https://api.frankfurter.dev/v1/latest |
CSV 解析 explode(',', $raw) | JSON 解析 json_decode($resp, true) |
| 無緩存、每請求必呼 API | Redis/文件緩存 + TTL + 降級 |
| 無異常處理、靜默失敗 | 分層異常 + 結構化日志 + 告警 |
| 硬編碼 User-Agent 偽裝 IE | 誠實 UA:MyApp/1.0 (+https://example.com) |
匯率轉換看似簡單,實則是「外部依賴治理」的縮影:**選擇可持續維護的開源/免費源、建立多級緩存與降級、嚴格異常處理與可觀測性**。將舊 Yahoo 代碼替換為現代客戶端,不僅修復可用性,更為系統穩定性鋪路。