Goで為替レートAPIを使いたいなら、標準ライブラリだけでほぼ十分です。net/http、encoding/json、context の3つのパッケージがあれば、高速で依存関係のないクライアントを作れます。難しいのはHTTPリクエストそのものではなく、その周辺です。タイムアウト、エラー処理、キャッシュ、金額計算、そしてテストです。多くのチュートリアルは http.Get と fmt.Println で終わります。デモなら問題ありませんが、決済フローでは危険です。
このガイドでは、Finexly APIドキュメントのエンドポイント向けに、小さいながら本番運用を意識したGolangクライアントを作ります。さらに、提供元の障害を乗り越えられる並行処理安全なキャッシュで包み、整数の最小通貨単位で動く換算関数を追加し、テスト付きのHTTPサービスとして仕上げます。コードはすべてGo 1.22以降でコンパイルでき、標準ライブラリのみを使用します。
為替データにGoが向いている理由
為替レートは読み取りが非常に多く、データが小さく、Webリクエストの時間スケールではゆっくりとしか変化しません。この特性はGoに最適です。ゴルーチンを使えば、1つのメモリ上のスナップショットから何千もの同時リクエストを低コストで処理できます。単一の静的バイナリはデプロイが簡単で、context.Context はすべての外部呼び出しに対してキャンセルと期限を第一級でサポートします。
設計の目標はシンプルです。リクエストハンドラは為替レートAPIを直接呼び出さないこと。ハンドラはキャッシュから読み取り、更新用の安全な経路が1本だけ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.
レスポンスには3つのフィールドがあります。base(すべてのレートの基準通貨)、timestamp(Unix秒)、rates(ISO 4217コードからレートへのマップ)です。rates の各値は、基準通貨1単位がその通貨の何単位に相当するかを示します。キーは環境変数に保存し、バージョン管理には決して含めないでください。
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にはタイムアウトがなく、固まった接続がゴルーチンを永久に保持する可能性があります。ここでは5 * time.Secondを設定し、呼び出し元のコンテキストも渡しているので、ユーザーがキャンセルしたリクエストは上流の呼び出しもキャンセルします。 - ログへのキー漏えいを防止。 キーは
apikeyクエリパラメータで送られるため、Goの*url.Errorはメッセージにキーを含む完全なURLを含めてしまいます。errors.Asで取り出し、根本のエラーだけを残しているので、このエラーをログに出しても安全です。 - 型付きの
APIError。 呼び出し側はerrors.AsでStatusに応じて分岐できます。たとえば429なら待機し、403ならアラートを出します。レスポンスボディはio.LimitReader経由で読み込むため、異常な上流サーバーが数MBを送ってきてもバッファしません。 - 設定可能な
baseURL。 コストはゼロで、ステップ6で見るようにクライアントを簡単にテストできるようになります。
ステップ3:古いデータへのフォールバック付き、並行処理安全なキャッシュ
リクエストのたびにAPIを呼ぶと、クォータを使い果たし、レスポンスも遅くなります。ほとんどのアプリでは、為替レートは数分では実質的に変わらないのでキャッシュします。以下のキャッシュには、単純な map と Mutex の例では見落とされがちな3つの特性があります。
- 更新によって読み取りがブロックされない。 ネットワーク呼び出しは読み書きロックの外で行われます。
- 同時に更新するのは1つのゴルーチンだけ。 2つ目のミューテックスと、取得後の再チェックにより、負荷時に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
}基準通貨のすべてのレートを1回のリクエストで取得している点に注目してください(symbols を省略)。1時間に1回の呼び出しで、サービスが必要とするあらゆる通貨ペアをまかなえます。常時稼働の1インスタンスなら月約720回で、月1,000リクエストの無料プランに収まります。TTLを10分にすると月約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のレートとして扱います。
現実的な落とし穴が1つあります。上の関数は両方の通貨の小数桁数が同じと仮定しています。JPYは0桁、USDは2桁、KWDは3桁なので、本番システムでは通貨ごとの指数でスケーリングする必要があります。ISO 4217通貨コードガイドに、各コードの最小単位の指数を一覧にしています。数値を手早く対話的に確認するには通貨換算ツールをご利用ください。
ステップ5:HTTPで公開する
ここまでをHTTPハンドラにつなぎます。ハンドラはリクエストを解析し、リクエストのコンテキストでキャッシュから読み取り、レートが古かったかどうかを返します。
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 USDからEUR(10,000セント)への換算を試しましょう。
go run . &
curl "http://localhost:8080/convert?amount=10000&from=USD&to=EUR"レスポンスには rates_timestamp と stale フラグが含まれるため、下流のシステムは「レート時点:...」と表示したり、データが古すぎる場合に見積りを拒否したりできます。監査担当や経理チームが最初に求めるのは、たいていこの1つのフィールドです。
ステップ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を選ぶ。 請求書やECの表示価格には1時間が適しており、1インスタンスなら無料プランに収まります。見積りやトレーディング機能では数分から数秒が必要な場合もあります。ビジネス上のリスクに合わせてTTLを決め、そのうえでプランを選びましょう。
- レートのタイムスタンプを表示する。 顧客が換算後の金額を目にする場所にはすべて表示し、取引ごとに使用したレートを保存して後から再現できるようにします。
- 古さをアラート対象にする。
stale=trueがしきい値を超えて続いたらメトリクスを送出します。1日前のデータを黙って返すキャッシュは、エラーよりたちが悪いです。 - 起動時にキャッシュを温める。 最初の取得に失敗したらレディネスチェックを失敗させ、誤ったキーをデプロイ時に発見できるようにします。
429にはバックオフで対応する。 すぐに再試行してはいけません。頻発するなら、TTLが短すぎるか、複数インスタンスがそれぞれ独立に更新しています。後者はRedisなどの共有キャッシュで解決できます。- ときどきプロバイダーを比較する。 データソースを検討するなら、通貨APIの比較ページと無料の通貨APIの解説が見るべき点を示しています。
複数の言語を使う場合も、同じパターンがRustチュートリアルやPythonチュートリアルに登場します。
よくある質問
Go向けの無料の為替レートAPIはありますか?
はい。Finexlyには月1,000リクエストの無料プランがあり、上のクライアントはサードパーティのパッケージを必要としません。1時間のキャッシュなら、常時稼働の1インスタンスで月約720リクエストとなり、余裕があります。ただしインスタンスを増やすと、それぞれが独自に更新する点に注意してください。最新の上限は料金プランで確認できます。
Goで為替レートにfloat64を使うべきですか?
レートに float64 を使うのは一般に許容されます。レートは有効数字の少ない近似値だからです。一方、金額には使えません。金額は整数の最小通貨単位で保持するか、10進数ライブラリを使い、最後に一度だけ丸めてください。
為替レートはどのくらいの頻度で更新すべきですか?
リスク許容度によります。表示価格やレポートなら15分から60分で通常は十分です。請求書では書類ごとにレートを固定することが多いです。リアルタイムで資金を動かす処理では、更新間隔にかかわらず、使用した正確なレートとタイムスタンプを保存してください。
GoのログにAPIキーが漏れないようにするには?
キーをクエリパラメータで送ると、http.Client が返すエラーに完全なURLが埋め込まれます。このガイドのクライアントのように、errors.As で *url.Error を取り出し、その Err フィールドだけをログに出してください。ミドルウェアで完全なリクエストURLをログに残さないことも大切です。
複数の基準通貨を並行して取得できますか?
はい。golang.org/x/sync/errgroup と SetLimit で並列度を制限し、並行利用に対して安全な同じ Client を共有します。ただし多くの場合、基準通貨を1つにしてステップ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 →