إذا كنت تحتاج إلى واجهة API لأسعار الصرف في Go، فإن المكتبة القياسية تكاد تكفيك. تكفي حزم net/http وencoding/json وcontext لبناء عميل سريع بلا اعتماديات خارجية. الجزء الصعب ليس طلب HTTP نفسه، بل كل ما يحيط به: المهلات الزمنية ومعالجة الأخطاء والتخزين المؤقت وحساب الأموال والاختبارات. تتوقف معظم الشروحات عند http.Get وfmt.Println، وهذا مقبول في العروض التجريبية لكنه محفوف بالمخاطر في مسارات الدفع.
في هذا الدليل ستبني عميل Golang صغيرًا لكنه مصمم للإنتاج لنقاط النهاية الموثقة في وثائق واجهة Finexly، وستغلّفه بذاكرة مؤقتة آمنة للتزامن تصمد أمام انقطاعات المزوّد، وتضيف دالة تحويل تعمل بالوحدات الصغرى الصحيحة، ثم تنشره كخدمة HTTP مع اختبارات. كل الشيفرة تُترجم مع Go 1.22 أو أحدث وتستخدم المكتبة القياسية فقط.
لماذا تناسب Go بيانات أسعار الصرف
تُقرأ أسعار الصرف كثيرًا، وحجمها صغير، وتتغير ببطء على مقياس طلب ويب واحد. وهذه الخصائص تلائم Go تمامًا. تتيح goroutines خدمة آلاف الطلبات المتزامنة من لقطة واحدة في الذاكرة بكلفة زهيدة، ويسهل نشر ملف تنفيذي ثابت واحد، ويوفر context.Context إلغاءً ومواعيد نهائية من الدرجة الأولى لكل استدعاء صادر.
هدف التصميم بسيط: يجب ألا تستدعي معالجات الطلبات واجهة الأسعار مباشرة. إنها تقرأ من ذاكرة مؤقتة، ويتولى مسار تحديث آمن واحد فقط التواصل مع الواجهة. بهذا يبقى زمن الاستجابة ثابتًا، وتُحمى حصتك، ولا يتحول تعثر المزوّد إلى عطل في خدمتك. للاطلاع على الجانب النظري راجع دليلنا حول التخزين المؤقت ومعالجة الأخطاء في واجهات العملات، أما هنا فننفذه عمليًا.
الخطوة 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 مخصصًا وClient تقبل دالته Latest معامل context.Context:
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ونمرّر أيضًا سياق المستدعي، فيؤدي إلغاء المستخدم لطلبه إلى إلغاء الاستدعاء الخارجي. - عدم تسريب المفتاح في السجلات. لأن المفتاح يُرسل في معامل الاستعلام
apikey، فإن*url.Errorفي Go يضمّن العنوان الكامل بما فيه المفتاح في رسالته. نفكّه بـerrors.Asونحتفظ بالخطأ الأساسي فقط، فيصبح تسجيل هذا الخطأ آمنًا. APIErrorمُنمَّط. يستطيع المستدعي استخدامerrors.Asللتفرع حسبStatus، مثل التمهل عند429أو إطلاق تنبيه عند403. يُقرأ الجسم عبرio.LimitReaderحتى لا يجبرك خادم معطوب على تخزين ميغابايتات.baseURLقابل للضبط. لا يكلّف شيئًا ويجعل اختبار العميل بسيطًا للغاية، كما سترى في الخطوة 6.
الخطوة 3: ذاكرة مؤقتة آمنة للتزامن مع بديل بيانات قديمة
استدعاء الواجهة عند كل طلب سيستنزف حصتك ويضيف زمن استجابة. في معظم التطبيقات لا تتغير الأسعار بشكل ملموس خلال دقائق، لذا خزّنها مؤقتًا. تتميز الذاكرة المؤقتة أدناه بثلاث خصائص تغفلها الأمثلة البسيطة المعتمدة على map وMutex:
- لا يُحجب القرّاء بسبب التحديث. يتم الاستدعاء الشبكي خارج قفل القراءة/الكتابة.
- تحدّث goroutine واحدة فقط في كل مرة. يمنع قفل ثانٍ مع إعادة الفحص بعد الحصول عليه اندفاع الطلبات عند انتهاء مدة الصلاحية تحت الحمل.
- البيانات القديمة أفضل من لا شيء. إذا فشل التحديث ولدينا لقطة أقدم، نعيدها مع
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 استدعاءً شهريًا لنسخة تعمل باستمرار. وهذا يناسب الخطة المجانية البالغة 1000 طلب. أما مدة صلاحية عشر دقائق فتعني نحو 4300 استدعاء شهريًا، لذا تتطلب الفواصل الأقصر خطة مدفوعة؛ راجع خطط الأسعار.
الخطوة 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 صفر خانات وللدولار USD خانتان وللدينار الكويتي KWD ثلاث، لذا يجب على نظام الإنتاج التحجيم وفق أس كل عملة. يسرد دليل رموز العملات 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 (10000 سنت):
go run . &
curl "http://localhost:8080/convert?amount=10000&from=USD&to=EUR"تتضمن الاستجابة rates_timestamp وعلامة stale، ليتمكن النظام اللاحق من عرض "الأسعار بتاريخ..." أو رفض التسعير إذا كانت البيانات قديمة جدًا. غالبًا ما يكون هذا الحقل أول ما يطلبه المدققون والفرق المالية.
الخطوة 6: اختبر دون لمس الشبكة
بما أن العنوان الأساسي قابل للحقن، يمكنك اختبار منطق العميل والذاكرة المؤقتة كاملًا عبر httptest.NewServer. يثبت هذا الاختبار أهم سلوك: عندما تبدأ الواجهة بالفشل، تقدّم الذاكرة المؤقتة بيانات قديمة بدل الخطأ.
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 ويمكن إدراجها في التكامل المستمر. ويمكنك بالأسلوب نفسه إضافة حالات لاستجابات 429 وJSON تالف وخوادم بطيئة (للتحقق من المهلة).
قائمة التحقق قبل الإنتاج
قبل الإطلاق، راجع هذه النقاط:
- اختر مدة صلاحية معقولة. ساعة واحدة تناسب الفوترة وأسعار العرض في المتاجر وتُبقي نسخة واحدة ضمن الخطة المجانية. قد تحتاج ميزات التسعير أو التداول إلى دقائق أو ثوانٍ. حدّد المدة وفق مخاطر العمل ثم اختر الخطة.
- اعرض الطابع الزمني للسعر في كل مكان يرى فيه العميل مبلغًا محوّلًا، واحفظ السعر المستخدم مع كل معاملة لإمكان إعادة إنتاجها لاحقًا.
- أطلق تنبيهًا عند التقادم. أصدر مقياسًا عندما يستمر
stale=trueفوق حد معين. الذاكرة المؤقتة التي تقدّم بصمت بيانات عمرها يوم أسوأ من خطأ. - سخّن الذاكرة المؤقتة عند الإقلاع وأفشل فحوص الجاهزية إذا فشل الجلب الأول، لتكتشف المفتاح الخاطئ أثناء النشر.
- عالج
429بالتراجع التدريجي بدل إعادة المحاولة فورًا. إذا تكرر ظهوره فإما أن مدة الصلاحية قصيرة جدًا أو أن عدة نسخ تحدّث بشكل مستقل. وتحل ذاكرة مشتركة مثل Redis الحالة الثانية. - قارن بين المزوّدين من حين لآخر. إن كنت تقيّم مصادر البيانات، فصفحة مقارنة واجهات العملات ونظرة واجهة العملات المجانية توضحان ما ينبغي النظر فيه.
إذا كنت تعمل بأكثر من لغة، فستجد الأنماط نفسها في شرح Rust وشرح Python.
الأسئلة الشائعة
هل توجد واجهة مجانية لأسعار الصرف للغة Go؟
نعم. تقدّم Finexly خطة مجانية بـ1000 طلب شهريًا، ولا يحتاج العميل أعلاه إلى أي حزم خارجية. مع ذاكرة مؤقتة لمدة ساعة تستهلك نسخة واحدة تعمل باستمرار نحو 720 طلبًا شهريًا، مما يترك هامشًا. تذكّر أن كل نسخة إضافية تحدّث بمفردها. راجع خطط الأسعار للحدود الحالية.
هل أستخدم float64 لأسعار الصرف في Go؟
استخدام float64 لـالسعر مقبول عمومًا، لأن الأسعار تقريبات بعدد قليل من الأرقام المعنوية. أما استخدامه لـالمبالغ المالية فغير مقبول. احتفظ بالمبالغ وحدات صغرى صحيحة أو استخدم مكتبة عشرية، وقرّب مرة واحدة في النهاية.
كم مرة يجب تحديث أسعار الصرف؟
يعتمد ذلك على تحملك للمخاطر. تكفي عادة من 15 إلى 60 دقيقة لأسعار العرض والتقارير. غالبًا ما تثبّت الفوترة سعرًا لكل مستند. وأي عملية تحرّك الأموال في الوقت الفعلي يجب أن تحفظ السعر والطابع الزمني المستخدمين بدقة مهما كانت فترة التحديث.
كيف أمنع تسرب مفتاح API في سجلات Go؟
عندما يُرسل المفتاح معاملَ استعلام، تضمّن أخطاء http.Client العنوان الكامل. فكّ *url.Error بـerrors.As وسجّل حقل Err فقط، كما يفعل العميل في هذا الدليل. وتجنّب أيضًا تسجيل عناوين الطلبات الكاملة في الوسطاء.
هل يمكنني جلب عدة عملات أساسية بالتوازي؟
نعم. استخدم golang.org/x/sync/errgroup مع SetLimit لتحديد التوازي، وشارك Client نفسه فهو آمن للاستخدام المتزامن. لكن في معظم الحالات يكون استخدام عملة أساسية واحدة مع حساب الأسعار المتقاطعة من الخطوة 4 أبسط وأرخص.
الخطوات التالية
هل أنت مستعد لدمج أسعار الصرف الفورية في خدمة Go الخاصة بك؟ احصل على مفتاح Finexly API المجاني، دون الحاجة إلى بطاقة ائتمان. ابدأ بـ1000 طلب مجاني شهريًا وارتقِ بالخطة مع نموك.
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 →