返回博客

用 Rust 对接货币汇率 API —— 完整集成教程

V
Vlado Grigirov
August 04, 2026
Rust Currency API Exchange Rates Tutorial reqwest Tokio Fintech

如果你正在构建金融科技基础设施、定价引擎、CLI 工具或高吞吐量后端,迟早都会需要实时的外汇数据。用 Rust 对接货币汇率 API 是这门语言所能提供的最简洁的网络任务之一:借助 reqwesttokioserde,你可以得到一个异步 HTTP 客户端、自动的 JSON 反序列化,以及在代码运行之前就能保证汇率处理逻辑正确的编译期保障。本教程会带你走完整条路径——从第一个 GET 请求,到一个具备生产形态的货币转换器,其中包含可复用的客户端、类型化的模型、自定义错误处理、十进制安全的金额运算,以及一个让你始终处于免费额度之内的内存缓存。

读完之后,你将拥有一个基于 Finexly API 文档 构建的小巧、可复用的 Rust 模块,能够在 170 多种货币之间以实时汇率进行转换。如果你已经读过我们的 Go 货币 API 教程,那么这就是它的 Rust 对应版本,采用相同的架构——类型化的客户端、缓存以及干净的错误传播。

为什么 Rust 非常适合处理货币数据

货币数据是 I/O 密集型的、对延迟敏感的,而且——在任何涉及金钱的系统中——对正确性有着极高要求。Rust 在这三个方面都异常合适:

  • 默认异步。 reqwest 建立在 hypertokio 之上,因此并发的汇率获取既廉价又非阻塞。你可以并行请求数十个货币对,而无需创建操作系统线程。
  • 类型系统能尽早发现错误。 一个由 serde 派生出的 struct 意味着格式错误的响应或缺失的字段会成为编译期或反序列化时的错误,而不是在生产环境中埋藏三层深的神秘 null
  • 没有垃圾回收器,延迟可预测。 对于一个在高负载下报出外汇汇率的服务而言,没有 GC 停顿这一点至关重要。
  • 十进制安全的金额。 借助 rust_decimal crate,你可以进行定点运算,从而永远不会发布一个把 €100.00 变成 €99.999999 的舍入错误。

代价是相比脚本语言略微更陡峭的初始配置,但换来的是一个你可以放心塞进服务中并加以信任的货币客户端。

你将构建什么

一个可复用的 FinexlyClient,它能够:

  1. 从货币汇率 API 获取某个基准货币的最新汇率
  2. serde 将 JSON 反序列化为类型化的 Rust struct。
  3. 任意两种货币之间转换,即使两者都不是基准货币(交叉汇率)。
  4. 用自定义错误类型处理错误——网络故障、非 200 响应、速率限制。
  5. 带 TTL 地在内存中缓存响应,让你稳稳地待在免费套餐之内。

前置条件与项目搭建

你需要一个较新的稳定版 Rust 工具链(通过 rustup 安装)以及一个免费的 Finexly API key。你可以在一分钟内 免费注册——无需信用卡,免费套餐每月 1,000 次请求。

创建一个新项目并添加依赖:

cargo new finexly-rates
cd finexly-rates
cargo add tokio --features full
cargo add reqwest --features json
cargo add serde --features derive
cargo add thiserror
cargo add rust_decimal rust_decimal_macros

现在你的 Cargo.toml 依赖应该看起来像这样:

[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
thiserror = "2"
rust_decimal = "1"
rust_decimal_macros = "1"

关于这些 crate 的简要说明:reqwest 是 Rust 事实上的异步 HTTP 客户端;json 特性会引入 serde_json 并启用 .json() 辅助方法。带 deriveserde 让你能够使用 #[derive(Deserialize)]thiserror 让你能够优雅地定义自定义错误枚举。rust_decimal 为金额提供定点小数。

把你的 key 存放在环境变量中,这样它就绝不会进入源代码管理:

export FINEXLY_API_KEY="your_api_key_here"

Finexly API 的响应结构

在写任何代码之前,先看看这个端点返回什么。对最新汇率端点发起一个请求:

curl "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY&apikey=YOUR_KEY"

会返回一个小巧、可预测的 JSON 对象:

{
  "success": true,
  "base": "USD",
  "timestamp": 1753660800,
  "rates": {
    "EUR": 0.9213,
    "GBP": 0.7847,
    "JPY": 161.42
  }
}

有两点决定了下面的代码。第一,rates 是一个扁平的映射,从 ISO 4217 货币代码映射到一个浮点数——这可以干净地映射为 Rust 的 HashMap<String, f64>。第二,base 告诉你这些汇率是相对于什么而言的。要把 USD → EUR 转换,你就乘以 rates["EUR"]。要在两种非基准货币之间转换,你需要经由基准货币:先除掉一个,再乘上另一个。

用 reqwest 发起你的第一个请求

从尽可能简单的异步请求开始。把 src/main.rs 替换为:

#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
    let api_key = std::env::var("FINEXLY_API_KEY")
        .expect("Set FINEXLY_API_KEY in your environment");

    let url = format!(
        "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY&apikey={api_key}"
    );

    let body = reqwest::get(&url).await?.text().await?;
    println!("{body}");

    Ok(())
}

cargo run 运行它,你就会看到打印出来的原始 JSON。#[tokio::main] 宏搭建了异步运行时,reqwest::get 执行请求,而每个 .await? 都会挂起任务直到网络响应,同时传播任何错误。这能工作,但 reqwest::get 在每次调用时都会创建一个全新的客户端——对于一次性用途没问题,对真实应用则是错误的。我们很快就会修正它。

用 Serde 为 JSON 建模

打印一个字符串没什么用;你想要的是类型化的数据。定义与响应对应的 struct,让 serde 来做解析:

use serde::Deserialize;
use std::collections::HashMap;

#[derive(Debug, Deserialize)]
pub struct RatesResponse {
    pub success: bool,
    pub base: String,
    pub timestamp: i64,
    pub rates: HashMap<String, f64>,
}

现在把 .text() 换成 .json(),它会把响应体直接反序列化为你的 struct:

let data: RatesResponse = reqwest::get(&url).await?.json().await?;
println!("1 USD = {} EUR", data.rates["EUR"]);

json() 方法读取响应体,并在底层运行 serde_json。如果某个字段缺失或类型不对,你会得到一个清晰的反序列化错误,而不是一个悄无声息的 null。因为 rates 是一个 HashMap,你可以按 ISO 4217 代码查找任何返回的货币。

构建一个可复用的客户端

最常见的 reqwest 错误就是为每个请求都创建一个新的 Client。每个客户端都拥有自己的连接池,因此反复重建它会丢弃掉 keep-alive 连接和 TLS 握手。创建一个 Client,廉价地克隆它(它内部是一个 Arc),然后到处复用它。 把它包裹进一个隐藏了 URL 和 key 的小 struct 里:

use reqwest::Client;

#[derive(Clone)]
pub struct FinexlyClient {
    http: Client,
    api_key: String,
    base_url: String,
}

impl FinexlyClient {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self {
            http: Client::builder()
                .timeout(std::time::Duration::from_secs(10))
                .build()
                .expect("failed to build HTTP client"),
            api_key: api_key.into(),
            base_url: "https://api.finexly.com/v1".to_string(),
        }
    }

    pub async fn latest(
        &self,
        base: &str,
        symbols: &[&str],
    ) -> Result<RatesResponse, FinexlyError> {
        let url = format!("{}/latest", self.base_url);
        let symbols_csv = symbols.join(",");

        let resp = self
            .http
            .get(&url)
            .query(&[
                ("base", base),
                ("symbols", symbols_csv.as_str()),
                ("apikey", self.api_key.as_str()),
            ])
            .send()
            .await?;

        let resp = resp.error_for_status()?;
        let data = resp.json::<RatesResponse>().await?;
        Ok(data)
    }
}

有几点值得指出。.query(&[...]) 构造器会替你处理 URL 编码,因此你永远不用手动拼接查询字符串。构造器上的 .timeout(...) 可防止一个挂起的连接拖住你的服务。而 error_for_status() 会把任何非 2xx 的 HTTP 响应变成一个 Err——这就引出了错误处理。

健壮的错误处理

生产代码必须回答:当网络中断、key 无效(403)或者你触发了速率限制(429)时会发生什么?用 thiserror 通过一个自定义错误枚举来为这些情况建模:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum FinexlyError {
    #[error("HTTP request failed: {0}")]
    Http(#[from] reqwest::Error),

    #[error("API returned an unsuccessful response")]
    Unsuccessful,

    #[error("currency '{0}' was not present in the response")]
    MissingCurrency(String),
}

#[from] reqwest::Error 这一行意味着任何 reqwest 失败——DNS、TLS、超时,或被 error_for_status() 捕获的非 2xx 状态——都会通过 ? 运算符自动转换为 FinexlyError::Http。这就是为什么 latest() 中的每个 .await? 都能直接工作。

当你想要定制化的行为时,也可以显式地检查状态码——例如,在遇到 429 时退避:

if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
    // sleep and retry, or fall back to a cached rate
}

关于重试、退避和缓存策略的更深入探讨,请参阅我们的 货币 API 缓存与错误处理最佳实践 指南。

在任意两种货币之间转换

只有当你的源货币就是基准货币时,乘以单一汇率才行得通。真实的应用需要任意的货币对——EUR → JPYGBP → CAD——其中两边都不是基准货币。诀窍是经由基准货币来路由:先用源汇率去除金额,得到基准货币等值,然后再乘以目标汇率。

impl FinexlyClient {
    /// Convert `amount` from `from` to `to`, using `from` as the request base.
    pub async fn convert(
        &self,
        amount: f64,
        from: &str,
        to: &str,
    ) -> Result<f64, FinexlyError> {
        let data = self.latest(from, &[to]).await?;

        if !data.success {
            return Err(FinexlyError::Unsuccessful);
        }

        let rate = data
            .rates
            .get(to)
            .ok_or_else(|| FinexlyError::MissingCurrency(to.to_string()))?;

        Ok(amount * rate)
    }
}

这里我们只是以 from 作为基准货币来请求汇率,因此返回的汇率已经是直接的 from → to 转换。如果你缓存了一种基准货币(比如 USD)而又需要交叉汇率,那就改在客户端一侧计算它们:amount / rates[from] * rates[to]。从头到尾试一遍:

#[tokio::main]
async fn main() -> Result<(), FinexlyError> {
    let client = FinexlyClient::new(
        std::env::var("FINEXLY_API_KEY").expect("set FINEXLY_API_KEY"),
    );

    let usd = client.convert(250.0, "EUR", "USD").await?;
    println!("250 EUR = {usd:.2} USD");

    Ok(())
}

用 rust_decimal 处理金额

f64 用于展示型转换没问题,但它对于存储余额、开具发票或任何必须精确对账到分的场景都是错误的类型。二进制浮点数无法精确表示 0.1,而这些误差会不断累积。对于金额,请使用定点小数:

use rust_decimal::Decimal;
use rust_decimal::prelude::FromPrimitive;
use rust_decimal_macros::dec;

pub fn convert_decimal(amount: Decimal, rate: f64) -> Decimal {
    let rate = Decimal::from_f64(rate).unwrap_or_default();
    (amount * rate).round_dp(2)
}

// usage
let total = convert_decimal(dec!(1999.99), 0.9213);
println!("{total}"); // rounded to 2 decimal places

有一条重要的告诫:并非每种货币都有两位小数。JPY 和 KRW 有零位小数;一些货币如 BHD 和 KWD 有三位。请按每种货币舍入到正确的小数位数(由 ISO 4217 的最小单位表决定),而不是把 2 硬编码进去。我们的 货币转换器API 文档 都反映了这些逐货币的规则。

通过缓存待在免费套餐之内

汇率并不会一秒一秒地发生有意义的变动。对于大多数展示、结账和报表场景,每小时刷新一次就绰绰有余了——而且无论你的流量有多大,缓存都能让你舒舒服服地待在免费套餐之内。加一个由 Mutex 守护的简单内存 TTL 缓存:

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

struct CacheEntry {
    data: RatesResponse,
    fetched_at: Instant,
}

pub struct CachedClient {
    inner: FinexlyClient,
    ttl: Duration,
    cache: Mutex<HashMap<String, CacheEntry>>,
}

impl CachedClient {
    pub fn new(api_key: impl Into<String>) -> Self {
        Self {
            inner: FinexlyClient::new(api_key),
            ttl: Duration::from_secs(3600), // 1 hour
            cache: Mutex::new(HashMap::new()),
        }
    }

    pub async fn latest(&self, base: &str) -> Result<RatesResponse, FinexlyError> {
        {
            let cache = self.cache.lock().unwrap();
            if let Some(entry) = cache.get(base) {
                if entry.fetched_at.elapsed() < self.ttl {
                    return Ok(entry.data.clone());
                }
            }
        }

        let fresh = self.inner.latest(base, &[]).await?;
        let mut cache = self.cache.lock().unwrap();
        cache.insert(
            base.to_string(),
            CacheEntry { data: fresh.clone(), fetched_at: Instant::now() },
        );
        Ok(fresh)
    }
}

为了让它能编译,你会想在 RatesResponse 上加 #[derive(Clone)]。有了一小时的 TTL,无论你的用户执行多少次转换,一种基准货币每天最多也只花费你 24 次请求——相对于 1,000 次请求的免费套餐而言只是一个舍入误差。如果你超出了它,价格方案 会线性扩展。关于诸如 stale-while-revalidate 以及跨实例共享缓存之类的模式,请参阅我们的 缓存与错误处理指南

获取历史汇率

倒填日期的发票、报表和图表都需要历史数据。Finexly 暴露了一个历史端点,你可以通过在同一个客户端上添加一个访问 /v1/historical 的方法来调用它:

impl FinexlyClient {
    pub async fn historical(
        &self,
        date: &str, // "YYYY-MM-DD"
        base: &str,
        symbols: &[&str],
    ) -> Result<RatesResponse, FinexlyError> {
        let url = format!("{}/historical", self.base_url);
        let symbols_csv = symbols.join(",");

        let data = self
            .http
            .get(&url)
            .query(&[
                ("date", date),
                ("base", base),
                ("symbols", symbols_csv.as_str()),
                ("apikey", self.api_key.as_str()),
            ])
            .send()
            .await?
            .error_for_status()?
            .json::<RatesResponse>()
            .await?;

        Ok(data)
    }
}

响应结构与最新汇率端点完全相同,因此你现有的所有解析和转换代码都能直接工作。完整的参数列表以及用于日期区间的 /v1/timeseries 端点,请参阅 API 文档

需要避免的常见陷阱

  • 为每个请求都创建一个 Client 只构建一个,克隆它(这很廉价),然后复用它,从而让连接池保持温热。
  • f64 存储余额。 用于展示没问题,用于账本则是错误的——凡是必须对账的都请使用 rust_decimal
  • 硬编码两位小数。 JPY 和 KRW 是零位;少数货币是三位。请使用 ISO 4217 的最小单位按每种货币来舍入。
  • 跳过状态检查。 把一个 403429 的响应体当作汇率数据来反序列化会产生令人困惑的 bug。error_for_status() 一行就能处理这件事。
  • 在每次转换时都去获取。 用 TTL 缓存并对用户输入做防抖,这样你就不会烧掉自己的配额。
  • 提交你的 API key。 从环境变量或密钥管理器中读取它,绝不要在 main.rs 里写成字符串字面量。

如果你还在比较各家提供商,我们的 货币 API 对比 拆解了准确性、货币覆盖范围和免费套餐限制,而 免费货币 API 指南 则更深入地涵盖了零成本的选项。

常见问题

在 Rust 中调用货币 API 最好的 HTTP 客户端是什么?

reqwest 是异步 Rust 的标准选择。它建立在 hypertokio 之上,开箱即用地处理连接池、TLS 和 JSON,并且与 serde 天然搭配以获得类型化的响应。启用 json 特性,并在整个应用中复用单个 Client 实例。

在 Rust 中我该如何解析一个 JSON 汇率响应?

定义一个与响应对应的 struct,并在其上派生 serde::Deserialize。把 rates 字段建模为 HashMap<String, f64>,因为它是一个从货币代码到汇率的扁平映射,然后对响应调用 .json::<RatesResponse>()reqwest 就会替你反序列化它。

在 Rust 中进行货币转换我该用 f64 还是 Decimal?

f64 做快速的展示型转换,但凡是你要存储或对账的东西——余额、发票、账本——都请使用 rust_decimal::Decimal。二进制浮点数无法精确表示十进制小数,因此舍入误差会累积。rust_decimal 为你提供定点精度。

在一个 Rust 服务中我该多久获取一次汇率?

对于大多数结账、展示和报表场景,每小时一次就绰绰有余了——汇率在一小时之内的变动不足以产生影响。用一小时的 TTL 缓存响应,这会把你控制在每种基准货币每天大约 24 次调用,并舒舒服服地待在免费套餐之内。

在 Rust 中我能获取历史汇率吗?

可以。添加一个在同一个客户端上调用 /v1/historical?date=YYYY-MM-DD 端点的方法。其 JSON 结构与最新汇率端点相匹配,因此你现有的 struct 和转换逻辑无需改动即可工作。这对于倒填日期的发票、报表和图表都很有用。

开始构建

准备好把实时汇率集成进你的 Rust 项目了吗?获取你的免费 Finexly API key——无需信用卡。从每月 1,000 次免费请求开始,随着你的成长再升级。完整的端点参考请查看 API 文档,当你准备好扩展你的多货币功能时,再去探索 价格方案

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →