跨境電商、金融應用、旅遊平台常需即時匯率兌換。本文實現一個生產級 CurrencyConverter 類,特性包括:
eurofxref-daily.xml)CREATE TABLE `currency_rates` (
`currency` CHAR(3) NOT NULL COMMENT 'ISO 4217 貨幣代碼',
`rate` DECIMAL(18, 8) NOT NULL COMMENT '相對 EUR 匯率 (1 EUR = X currency)',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`currency`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='匯率緩存表 (基準 EUR)';
-- 初始化基準貨幣
INSERT INTO `currency_rates` (`currency`, `rate`) VALUES ('EUR', 1.00000000)
ON DUPLICATE KEY UPDATE `rate` = 1.00000000;
<?php
namespace App\Services;
use PDO;
use PDOException;
use RuntimeException;
class CurrencyConverter {
private const ECB_XML_URL = 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml';
private const CACHE_TTL_SECONDS = 43200; // 12 小時
private const DEFAULT_DECIMALS = 2;
private PDO $pdo;
private string $table;
private array $rates = []; // 記憶體緩存
public function __construct(PDO $pdo, string $table = 'currency_rates') {
$this->pdo = $pdo;
$this->table = $table;
$this->ensureTableExists();
$this->loadRatesFromCache();
$this->maybeUpdateRates();
}
/** 確保資料表存在 */
private function ensureTableExists(): void {
$sql = "CREATE TABLE IF NOT EXISTS `{$this->table}` (
`currency` CHAR(3) NOT NULL,
`rate` DECIMAL(18,8) NOT NULL,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`currency`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
$this->pdo->exec($sql);
// 確保 EUR 存在
$this->pdo->exec("INSERT IGNORE INTO `{$this->table}` (`currency`, `rate`) VALUES ('EUR', 1.00000000)");
}
/** 從資料庫載入匯率到記憶體 */
private function loadRatesFromCache(): void {
$stmt = $this->pdo->query("SELECT `currency`, `rate` FROM `{$this->table}`");
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
$this->rates[$row['currency']] = (float)$row['rate'];
}
if (!isset($this->rates['EUR'])) $this->rates['EUR'] = 1.0;
}
/** 檢查是否需要更新 (超過 12 小時) */
private function maybeUpdateRates(): void {
$stmt = $this->pdo->query("SHOW TABLE STATUS LIKE '{$this->table}'");
$status = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$status) return; // 表不存在
$lastUpdate = strtotime($status['Update_time'] ?? '1970-01-01');
if (time() - $lastUpdate > self::CACHE_TTL_SECONDS) {
$this->fetchAndStoreRates();
}
}
/** 從 ECB 獲取 XML 並解析存入資料庫 */
private function fetchAndStoreRates(): void {
// 使用併發鎖防止多進程同時更新
$lockAcquired = $this->pdo->exec("SELECT GET_LOCK('currency_rates_update', 10)") === 1;
if (!$lockAcquired) {
// 獲取鎖失敗,說明有其他進程在更新,直接返回使用舊緩存
error_log('[CurrencyConverter] 無法獲取更新鎖,跳過本次更新');
return;
}
try {
$xml = $this->fetchEcbXml();
$rates = $this->parseEcbXml($xml);
if (empty($rates)) {
throw new RuntimeException('ECB XML 解析結果為空');
}
$this->upsertRates($rates);
$this->rates = $rates; // 更新記憶體緩存
$this->rates['EUR'] = 1.0;
error_log('[CurrencyConverter] 匯率更新成功,共 ' . count($rates) . ' 種貨幣');
} catch (\Throwable $e) {
error_log('[CurrencyConverter] 匯率更新失敗: ' . $e->getMessage());
} finally {
$this->pdo->exec("SELECT RELEASE_LOCK('currency_rates_update')");
}
}
/** 獲取 ECB XML 內容 */
private function fetchEcbXml(): string {
$ctx = stream_context_create([
'http' => [
'timeout' => 15,
'user_agent' => 'CurrencyConverter/1.0 (+https://example.com)',
'follow_location' => 1,
'max_redirects' => 3,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$content = @file_get_contents(self::ECB_XML_URL, false, $ctx);
if ($content === false) {
throw new RuntimeException('無法獲取 ECB XML: ' . error_get_last()['message'] ?? '未知錯誤');
}
return $content;
}
/** 解析 ECB XML (使用 XMLReader 節省記憶體) */
private function parseEcbXml(string $xml): array {
$reader = new \XMLReader();
$reader->xml($xml);
$rates = [];
while ($reader->read()) {
if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 'Cube') {
$currency = $reader->getAttribute('currency');
$rateStr = $reader->getAttribute('rate');
if ($currency && $rateStr !== null) {
$rate = (float)$rateStr;
if ($rate > 0) {
$rates[$currency] = $rate;
}
}
}
}
$reader->close();
return $rates;
}
/** 批量 UPSERT 匯率 */
private function upsertRates(array $rates): void {
$sql = "INSERT INTO `{$this->table}` (`currency`, `rate`) VALUES (:currency, :rate)
ON DUPLICATE KEY UPDATE `rate` = VALUES(`rate`), `updated_at` = CURRENT_TIMESTAMP";
$stmt = $this->pdo->prepare($sql);
$this->pdo->beginTransaction();
try {
foreach ($rates as $currency => $rate) {
$stmt->execute([':currency' => $currency, ':rate' => $rate]);
}
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
// ==================== 公開 API ====================
/**
* 貨幣兌換
* @param float $amount 金額
* @param string $from 來源貨幣 (ISO 4217, 如 USD, CNY, JPY)
* @param string $to 目標貨幣
* @param int $decimals 保留小數位 (預設 2)
* @return string 格式化後的金額字串
* @throws RuntimeException 貨幣不支援
*/
public function convert(float $amount, string $from, string $to, int $decimals = self::DEFAULT_DECIMALS): string {
$from = strtoupper($from);
$to = strtoupper($to);
if (!isset($this->rates[$from])) {
throw new RuntimeException("不支援的來源貨幣: {$from}");
}
if (!isset($this->rates[$to])) {
throw new RuntimeException("不支援的目標貨幣: {$to}");
}
// 公式: amount * (rate_to / rate_from) (因為兩者都是相對 EUR)
$rateFrom = $this->rates[$from];
$rateTo = $this->rates[$to];
$result = $amount * ($rateTo / $rateFrom);
return number_format($result, $decimals, '.', ',');
}
/** 獲取當前所有匯率 (相對 EUR) */
public function getAllRates(): array {
return $this->rates;
}
/** 獲取特定貨幣對 EUR 匯率 */
public function getRate(string $currency): ?float {
$currency = strtoupper($currency);
return $this->rates[$currency] ?? null;
}
/** 手動觸發更新 (管理後台用) */
public function forceUpdate(): bool {
try {
$this->fetchAndStoreRates();
return true;
} catch (\Throwable $e) {
error_log('[CurrencyConverter] 手動更新失敗: ' . $e->getMessage());
return false;
}
}
}
<?php
// config/container.php
return [
\App\Services\CurrencyConverter::class => function($container) {
$pdo = new PDO(
"mysql:host={$container->get('config')['db_host']};dbname={$container->get('config')['db_name']};charset=utf8mb4",
$container->get('config')['db_user'],
$container->get('config')['db_pass'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
return new \App\Services\CurrencyConverter($pdo);
},
];
<?php
class OrderService {
public function __construct(private \App\Services\CurrencyConverter $converter) {}
public function getOrderTotalInCurrency(int $orderId, string $targetCurrency): string {
$order = Order::find($orderId);
// 假設訂單儲存為 USD
return $this->converter->convert($order->total_amount, 'USD', $targetCurrency);
}
}
// 直接使用
$converter = app(\App\Services\CurrencyConverter::class);
echo $converter->convert(100, 'USD', 'CNY'); // 輸出如: 724.50
echo $converter->convert(50, 'EUR', 'JPY'); // 輸出如: 7,845.00
<?php
// bin/update_rates.php
require 'vendor/autoload.php';
$container = require 'bootstrap/container.php';
$converter = $container->get(\App\Services\CurrencyConverter::class);
if ($converter->forceUpdate()) {
echo "匯率更新成功\n";
} else {
echo "匯率更新失敗\n";
exit(1);
}
加入 Crontab:0 6 * * * /usr/bin/php /path/to/bin/update_rates.php >> /var/log/currency_update.log 2>&1 (每天早上 6 點更新,ECB 通常在 CET 16:00 發布)
| 決策點 | 選擇 | 理由 |
|---|---|---|
| 基準貨幣 | EUR (歐元) | ECB 官方數據基準即為 EUR,避免雙重轉換誤差 |
| 資料類型 | DECIMAL(18,8) | 精確表示匯率,避免浮點數精度丟失 (如 JPY 匯率 ~160) |
| 併發控制 | MySQL GET_LOCK | 跨進程/分布式安全,無需 Redis 等外部依賴 |
| XML 解析 | XMLReader (流式) | 記憶體常數級,適用於大型 XML (ECB 約 35KB,但良好習慣) |
| 錯誤策略 | 失敗靜默+日誌+回退緩存 | 不阻塞主業務,保證可用性優於一致性 |
| 更新頻率 | 12 小時 / 每日定時 | ECB 每工作日發布一次,12 小時檢查平衡及時性與流量 |
exchangerate.host、fixer.io、Open Exchange Rates 作為備選,實現 RateProviderInterface 策略模式。currency_rates_history (date, currency, rate),支援歷史訂單金額換算。/api/rates?base=USD 介面,前端定時輪詢更新顯示價格。CurrencyMetadata 表自動格式化。貨幣兌換看似簡單,實則涉及資料源可靠性、精度控制、併發安全、緩存策略、錯誤容錯等工程課題。本實現採用 ECB 官方權威數據源、MySQL 本地緩存、XMLReader 流式解析、GET_LOCK 分布式鎖、嚴格類型與異常處理,構建出可直接投入生產的 CurrencyConverter 服務。根據業務增長,可平滑擴展多資料源、歷史匯率、加密貨幣等能力。