如果你需要在 Go 中接入汇率 API,标准库几乎就够用了。net/http、encoding/json 和 context 这几个包,足以写出一个快速且无第三方依赖的客户端。真正的难点不在 HTTP 请求本身,而在它周围的一切:超时、错误处理、缓存、金额运算和测试。大多数教程写到 http.Get 和 fmt.Println 就结束了,做演示没问题,用在支付流程里却有风险。
本指南将带你为 Finexly API 文档中的接口构建一个小巧但面向生产的 Golang 客户端,再为它加上并发安全、能在上游故障时继续服务的缓存,编写以整数最小货币单位计算的换算函数,最后以带测试的 HTTP 服务形式交付。以下代码在 Go 1.22 及以上版本均可编译,且只使用标准库。
为什么 Go 适合处理汇率数据
汇率数据读多写少、体积小,并且在一次网页请求的时间尺度上变化缓慢。这种特征非常适合 Go。Goroutine 让你可以基于同一份内存快照以极低成本处理成千上万的并发请求;单个静态二进制文件部署简单;context.Context 则为每一次对外调用提供了一流的取消与截止时间机制。
设计目标很简单:请求处理函数永远不要直接调用汇率 API。它们只读取缓存,由唯一一条安全的刷新路径与 API 通信。这样延迟保持平稳,配额得到保护,上游的小故障也不会变成你自己的故障。想了解背后的思路,可以阅读我们的货币 API 缓存与错误处理指南;本文则把它落地实现。
第 1 步:先用 cURL 验证
在写 Go 之前,先确认你的密钥可用。免费注册,复制密钥,然后运行:
curl "https://api.finexly.com/v1/latest?base=USD&symbols=EUR,GBP,JPY&apikey=$FINEXLY_API_KEY"{
"base": "USD",
"timestamp": 1762761600,
"rates": { "EUR": 0.92, "GBP": 0.80, "JPY": 151.2 }
}The numbers above are illustrative.
响应包含三个字段:base(所有汇率所参照的基准货币)、timestamp(Unix 秒级时间戳)和 rates(ISO 4217 货币代码到汇率的映射)。rates 中的每个值表示一个单位基准货币可兑换多少单位该货币。请把密钥放在环境变量里,切勿提交到版本库:
export FINEXLY_API_KEY="your_key_here"第 2 步:带超时与安全错误的类型化客户端
用 go mod init fxdemo 创建模块,并把下面的代码放入 main.go。它定义了响应结构体、自定义的 APIError,以及 Latest 方法接受 context.Context 的 Client:
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
)
type RatesResponse struct {
Base string `json:"base"`
Timestamp int64 `json:"timestamp"`
Rates map[string]float64 `json:"rates"`
}
type APIError struct {
Status int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("finexly: HTTP %d: %s", e.Status, e.Body)
}
type Client struct {
apiKey string
baseURL string
http *http.Client
}
func NewClient(apiKey string) *Client {
return &Client{
apiKey: apiKey,
baseURL: "https://api.finexly.com/v1",
http: &http.Client{Timeout: 5 * time.Second},
}
}
func (c *Client) Latest(ctx context.Context, base string, symbols []string) (*RatesResponse, error) {
q := url.Values{}
q.Set("base", base)
if len(symbols) > 0 {
q.Set("symbols", strings.Join(symbols, ","))
}
q.Set("apikey", c.apiKey)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/latest?"+q.Encode(), nil)
if err != nil {
return nil, errors.New("finexly: build request failed")
}
resp, err := c.http.Do(req)
if err != nil {
var ue *url.Error
if errors.As(err, &ue) {
err = ue.Err // drop the URL, it contains the API key
}
return nil, fmt.Errorf("finexly: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, &APIError{Status: resp.StatusCode, Body: strings.TrimSpace(string(body))}
}
var out RatesResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("finexly: decode response: %w", err)
}
return &out, nil
}有几处细节正是演示代码与生产代码的区别:
- 硬性超时。 零值的
http.Client没有超时,一个卡住的连接可能让 goroutine 永远挂起。我们设置了5 * time.Second,同时传入调用方的 context,这样用户取消请求时,上游调用也会随之取消。 - 日志中不泄露密钥。 由于密钥通过
apikey查询参数传递,Go 的*url.Error会在错误信息中带出完整 URL(包括密钥)。我们用errors.As解包,只保留底层错误,这样记录该错误就安全了。 - 类型化的
APIError。 调用方可以用errors.As根据Status分支处理,例如遇到429时退避,遇到403时告警。响应体通过io.LimitReader读取,避免异常的上游让你缓冲数 MB 数据。 - 可配置的
baseURL。 几乎没有成本,却让客户端变得极易测试,第 6 步你会看到。
第 3 步:并发安全、带过期兜底的缓存
每次请求都调用 API 会迅速耗尽配额并增加延迟。对大多数应用来说,汇率在几分钟内不会有实质变化,因此应当缓存。下面的缓存具备三个简单的 map 加 Mutex 示例常常忽略的特性:
- 读取方不会被刷新阻塞。 网络请求在读写锁之外执行。
- 同一时刻只有一个 goroutine 刷新。 第二把互斥锁加上获取锁之后的再次检查,可避免 TTL 过期时在高负载下出现惊群效应。
- 过期数据好过没有数据。 如果刷新失败而我们手里有旧快照,就返回它并标记
stale=true,由调用方决定如何处理。
type Cache struct {
client *Client
base string
ttl time.Duration
mu sync.RWMutex
data *RatesResponse
fetchedAt time.Time
refreshMu sync.Mutex
}
func NewCache(c *Client, base string, ttl time.Duration) *Cache {
return &Cache{client: c, base: base, ttl: ttl}
}
func (c *Cache) fresh() (*RatesResponse, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if c.data != nil && time.Since(c.fetchedAt) < c.ttl {
return c.data, true
}
return nil, false
}
// Get returns rates, and stale=true if it had to fall back to old data.
func (c *Cache) Get(ctx context.Context) (rates *RatesResponse, stale bool, err error) {
if d, ok := c.fresh(); ok {
return d, false, nil
}
c.refreshMu.Lock() // only one goroutine hits the API
defer c.refreshMu.Unlock()
if d, ok := c.fresh(); ok { // refreshed while we waited
return d, false, nil
}
d, err := c.client.Latest(ctx, c.base, nil)
if err != nil {
c.mu.RLock()
old := c.data
c.mu.RUnlock()
if old != nil {
return old, true, nil
}
return nil, false, err
}
c.mu.Lock()
c.data, c.fetchedAt = d, time.Now()
c.mu.Unlock()
return d, false, nil
}请注意,我们一次请求就获取该基准货币的全部汇率(省略了 symbols)。这样每小时一次调用就能满足服务所需的所有货币对,一个常驻实例每月约 720 次调用,完全落在 1,000 次请求的免费额度之内。若 TTL 设为十分钟,则每月约 4,300 次调用,更短的刷新间隔需要付费方案,详见价格方案。
第 4 步:换算金额,避开浮点数陷阱
永远不要用 float64 存储金额。请存储整数最小货币单位(分、便士、日元),并只在最后用明确的舍入规则应用一次汇率:
// Convert works in minor units (cents). It assumes both currencies use the
// same number of decimal places; see the ISO 4217 note in the article.
func Convert(amountMinor int64, from, to string, r *RatesResponse) (int64, error) {
rate := func(code string) (float64, error) {
if code == r.Base {
return 1, nil
}
v, ok := r.Rates[code]
if !ok || v <= 0 {
return 0, fmt.Errorf("no rate for %q", code)
}
return v, nil
}
f, err := rate(from)
if err != nil {
return 0, err
}
t, err := rate(to)
if err != nil {
return 0, err
}
return int64(math.Round(float64(amountMinor) * t / f)), nil
}由于汇率是相对基准货币报价的,把 EUR 换算为 GBP 要经过基准货币:先除以源货币汇率,再乘以目标货币汇率。该函数也把基准货币自身视为恰好等于 1 的汇率。
这里有一个现实中的陷阱:上面的函数假定两种货币的小数位数相同。JPY 为 0 位,USD 为 2 位,KWD 为 3 位,因此生产系统必须按各自的指数进行缩放。我们的 ISO 4217 货币代码指南列出了每个代码的最小单位指数。想快速核对数字,可使用货币换算器。
第 5 步:通过 HTTP 对外提供服务
现在把所有部分接入 HTTP 处理函数。它解析请求,使用请求的 context 从缓存读取,并说明汇率是否已过期:
func convertHandler(cache *Cache) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
amount, err := strconv.ParseInt(q.Get("amount"), 10, 64)
if err != nil {
http.Error(w, "amount must be an integer in minor units", http.StatusBadRequest)
return
}
from, to := strings.ToUpper(q.Get("from")), strings.ToUpper(q.Get("to"))
rates, stale, err := cache.Get(r.Context())
if err != nil {
log.Printf("rates unavailable: %v", err)
http.Error(w, "rates unavailable", http.StatusBadGateway)
return
}
out, err := Convert(amount, from, to, rates)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"from": from, "to": to,
"amount_minor": amount, "converted_minor": out,
"rates_timestamp": rates.Timestamp, "stale": stale,
})
}
}
func main() {
apiKey := os.Getenv("FINEXLY_API_KEY")
if apiKey == "" {
log.Fatal("FINEXLY_API_KEY is not set")
}
cache := NewCache(NewClient(apiKey), "USD", time.Hour)
http.Handle("/convert", convertHandler(cache))
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}运行并试试把 100.00 美元换成欧元(10,000 美分):
go run . &
curl "http://localhost:8080/convert?amount=10000&from=USD&to=EUR"响应包含 rates_timestamp 和 stale 标志,下游系统可以据此显示“汇率更新于……”,或在数据过旧时拒绝报价。审计人员和财务团队往往最先要的就是这个字段。
第 6 步:不接触网络的测试
由于基准 URL 可以注入,你可以用 httptest.NewServer 测试完整的客户端与缓存逻辑。下面的测试验证了最重要的行为:当 API 开始失败时,缓存返回过期数据而不是错误。
func TestLatestAndStaleFallback(t *testing.T) {
healthy := true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !healthy {
http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError)
return
}
w.Write([]byte(`{"base":"USD","timestamp":1,"rates":{"EUR":0.92}}`))
}))
defer srv.Close()
c := NewClient("test-key")
c.baseURL = srv.URL
cache := NewCache(c, "USD", time.Millisecond)
if _, stale, err := cache.Get(context.Background()); err != nil || stale {
t.Fatalf("first call: err=%v stale=%v", err, stale)
}
healthy = false
time.Sleep(5 * time.Millisecond)
r, stale, err := cache.Get(context.Background())
if err != nil || !stale || r.Rates["EUR"] != 0.92 {
t.Fatalf("expected stale fallback, got err=%v stale=%v", err, stale)
}
}
func TestConvert(t *testing.T) {
r := &RatesResponse{Base: "USD", Rates: map[string]float64{"EUR": 0.92, "GBP": 0.80}}
got, err := Convert(10000, "EUR", "GBP", r)
if err != nil || got != 8696 {
t.Fatalf("got %d err %v", got, err)
}
}用 go test ./... 运行。这类测试只需几毫秒,不需要 API 密钥,可以直接放进 CI。你还可以用同样的写法为 429 响应、格式错误的 JSON 和响应缓慢的服务器(验证超时)补充用例。
上线前检查清单
发布之前,请逐项核对:
- 选择合理的 TTL。 一小时适合开票和电商展示价格,也能让单个实例保持在免费额度内。报价或交易类功能可能需要分钟甚至秒级。先按业务风险确定 TTL,再选择合适的方案。
- 展示汇率时间戳。 凡是客户能看到换算金额的地方都应展示,并在每笔交易中保存所用汇率,以便日后复现。
- 为过期状态设置告警。 当
stale=true持续超过阈值时发出指标。悄悄提供一天前数据的缓存比报错更糟糕。 - 启动时预热缓存,若首次获取失败则让就绪检查失败,这样错误的密钥会在部署阶段就被发现。
- 遇到
429时退避重试,不要立即重试。如果经常出现,说明 TTL 太短,或多个实例各自独立刷新。使用 Redis 等共享缓存可以解决后一种情况。 - 偶尔对比不同数据源。 如果你在评估数据源,我们的货币 API 对比页面和免费货币 API概述会告诉你应关注什么。
如果你使用多种语言,类似的模式也出现在我们的 Rust 教程和 Python 教程中。
常见问题
Go 有免费的汇率 API 吗?
有。Finexly 提供每月 1,000 次请求的免费方案,本文的客户端无需任何第三方包。使用一小时缓存时,一个常驻实例每月约使用 720 次请求,仍有余量。请记住,每多一个实例都会各自刷新。最新限制请查看价格方案。
在 Go 中汇率应该用 float64 吗?
汇率本身使用 float64 通常可以接受,因为汇率只是有少量有效数字的近似值。但金额不能这样做。请把金额存为整数最小货币单位,或使用十进制库,并只在最后舍入一次。
汇率应该多久刷新一次?
取决于你的风险承受能力。展示价格和报表通常 15 到 60 分钟即可。开票通常按单据锁定汇率。凡是实时转移资金的场景,无论刷新间隔多长,都应保存所用的确切汇率和时间戳。
如何避免 API 密钥泄露到 Go 日志中?
当密钥作为查询参数发送时,http.Client 返回的错误会嵌入完整 URL。请像本文客户端那样,用 errors.As 解包 *url.Error,只记录其 Err 字段。同时避免在中间件中记录完整的请求 URL。
可以并发获取多个基准货币吗?
可以。使用带 SetLimit 的 golang.org/x/sync/errgroup 限制并行度,并共享同一个 Client(它对并发使用是安全的)。多数情况下,使用单一基准货币加第 4 步的交叉汇率计算更简单、成本也更低。
下一步
准备好把实时汇率集成到你的 Go 服务了吗?获取你的免费 Finexly API 密钥,无需信用卡。每月先享 1,000 次免费请求,随业务增长再升级。
Explore More
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 →