返回博客

Go 语言汇率 API 接入教程:可用于生产环境的 Golang 实战指南(2026)

V
Vlado Grigirov
November 10, 2026
Currency API Golang Tutorial Exchange Rates Finexly Backend

如果你需要在 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 示例常常忽略的特性:

  1. 读取方不会被刷新阻塞。 网络请求在读写锁之外执行。
  2. 同一时刻只有一个 goroutine 刷新。 第二把互斥锁加上获取锁之后的再次检查,可避免 TTL 过期时在高负载下出现惊群效应。
  3. 过期数据好过没有数据。 如果刷新失败而我们手里有旧快照,就返回它并标记 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 次免费请求,随业务增长再升级。

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 →