Kısım II · İnşaat · Bölüm 37

Go ile API Yazmak

Protokol ile dilin birleşmesi (Building an HTTP API in Go)

Neden burada · Neye dayanıyor

Neden burada: HTTP ve JSON sözleşmesi ile Go dilini ayrı ayrı gördün. Şimdi ikisini gerçekten çalışan bir yerel sunucuda birleştiriyor, sınırlarını da görünür tutuyoruz.

Neye dayanıyor: Bölüm 35: yöntem, kod, gövde ve tekrar teslim; Bölüm 36: yapı, arayüz, hata ve eşzamanlılık. Bölüm 34.2 hangi beyana güvenilmeyeceğini söyler.

  1. Kat 10Bizim uygulamamız: Go arka uç, Postgres ve istemciBu bölüm: Go ile API Yazmak. Bölüm 1'in kat planı üzerindeyiz.
  2. Kat 9 ve 8Web ve ağKullanıcı arayüzünden gelen istekler HTTP ile taşınır.
  3. Kat 7 ve 5İşletim sistemi, diller ve derleyiciProgram süreç olur; derleyici Go metnini çalıştırılabilir dosyaya çevirir.
  4. Kat 0–4 ve 6Fiziksel temel, makine dili ve açılışBellek, depolama ve işlemci alttaki işi sürdürür; burada yeni donanım yok.

Bölüm sonunda bir isteğin yönlendiriciden işleyiciye, servisten depoya nasıl geçtiğini izleyebileceksin. Tam kaynak kodu ve sınanmış örnekler var. Gerçek kullanıcı güvenliği ve kalıcı saklama henüz yok; gösterim ile tamamlanmış ürünü özellikle ayıracağız.

37.1 net/http, işleyici ve yönlendirici

Bölüm 35 HTTP mesajını, Bölüm 36 Go fonksiyonunu kurdu. Şimdi net/http ağ mesajını o fonksiyona ulaştıracak. Paket HTTP istemcisi ve sunucusu için araçlar sunar; ürünün puan kuralını bilmez.1

İşleyici (handler), HTTP isteğini karşılayıp yanıt üretir. http.Handler, ServeHTTP(ResponseWriter, *Request) metodunu ister. *http.Request yöntem, URL, başlık, gövde ve bağlamı taşır. http.ResponseWriter giden yanıtı yazma arayüzüdür; yanıtı return etmek yerine ona yazarsın. İşleyici döndükten sonra bu yazıcıyı arka plandan kullanma.1

func leaderboard(w http.ResponseWriter, r *http.Request) {
    // r: gelen isteği oku
    // w: gidecek yanıtı yaz
}

Bu yalnız imzadır, tam program değil. w yazma arayüzü, r isteğe işaretçidir; adlar tercihimizdir. http.HandlerFunc bu imzalı fonksiyonu Handler’a uyarlar. Bölüm 36’nın arayüz fikri burada kütüphane kapısına dönüşür.1

Yönlendirici (router), yöntem ve yol gibi bilgilere göre hangi işleyicinin çağrılacağını seçer. Standart pakette http.NewServeMux() bunun için bir çoklayıcı kurar. mux.HandleFunc("GET /leaderboard", fn), istek örüntüsü ile fonksiyonu eşler. Buradaki yönlendirici, Bölüm 19'daki paketlerin ağdaki yolunu seçen cihaz değildir; HTTP mesajını program içindeki kapıya yönlendirir. Aynı İngilizce ad farklı ölçekte iş yapıyor.1

Bu örneğin yöntemli ServeMux örüntüleri kullanılan Go araç zincirinde desteklenir; aşağıdaki tam program Go 1.27.2 ile sınandı. GET örüntüsü HEAD isteğiyle de eşleşebilir; genel olarak yöntemli örüntünün anlamını yalnız string karşılaştırması sanma. Tanımsız yol için 404, tanınan yolun desteklenmeyen yöntemi için 405 yanıtını çoklayıcı verebilir. Kendi işleyicimizin JSON hata biçimi, çoklayıcının bütün varsayılan hatalarını otomatik değiştirmez; bu küçük programdaki yönlendirme hataları düz metindir.1

İşleyici isteğe ne yapılacağını, http.Server ise dinleme adresini ve bağlantı ayarlarını belirler. Aynı işleyiciyi yerel HTTP test sunucusuna bağlayıp kodu ve gövdeyi kontrol edebiliriz.

37.2 JSON okumak ve yazmak; doğrulama

HTTP gövdesi Go belleğine kendiliğinden “güvenilir cevap” olarak düşmez. Doğrulama (validation), girdinin beklenen biçime ve izin verilen kurallara uyup uymadığını denetlemektir. Bu bölümde üç sınır kuracağız: taşıma sınırı (boyut ve içerik tipi), biçim sınırı (JSON ve alanlar), anlam sınırı (kimlikler, şık aralığı ve soru). Bunları ayırmak, her başarısızlığı “sunucu çöktü” diye adlandırmayı önler.

type Answer struct {
    QuestionID *int `json:"question_id"`
    Choice     *int `json:"choice"`
}

Yapı alanları büyük harfle başladığı için JSON paketi onları kullanabilir. Ters tırnaktaki json:"question_id" bir alan etiketi (struct tag)dir; Go adıyla mesaj adını eşler. *int, eksik/null alanın nil olmasını ve gelen 0’dan ayrılmasını sağlar. Ayrıştırma sonrasında nil ve aralık denetimini ayrı yaparız.2

Tam programdaki readAnswer önce mime.ParseMediaType ile Content-Type'ı okur. JSON olmayan veya geçersiz başlığa 415 der. Ardından http.MaxBytesReader ile gövdeyi 4096 byte ile sınırlar; bu örneğin seçilmiş eğitim sınırıdır, HTTP standardının dayattığı evrensel sayı değildir. Sınır aşıldığında *http.MaxBytesError yakalanır ve 413 döner. Türkçe karakterlerin byte hesabı Bölüm 2.5'teydi; içerik uzunluğunu karakter diye düşünmüyoruz.1

json.NewDecoder(r.Body) gövdeden JSON ayrıştıran araç kurar. DisallowUnknownFields(), yapıda tanımlanmamış alanı reddeder. Bu nedenle {"points":999} mesajı puan kuralına ulaşamaz. dec.Decode(&a) alanları a içine yazar; adres vermemizin nedeni, decoder'ın o değişkeni doldurmasıdır. İlk değer çözüldükten sonra bir kez daha Decode çağırıp io.EOF bekleriz: böylece tek JSON değerinin arkasına ikinci bir belge eklenmesini reddederiz.2

encoding/json geriye uyumlu davranışlar taşır; bilinmeyen alanları reddetmek bütün ayrıntıları katılaştırmaz. Ek testte yinelenen choice alanının son değeri seçildi; büyük harfli QUESTION_ID ve CHOICE da kabul edildi. Dolayısıyla bu program tam katı JSON doğrulayıcısı değildir.2

Sonra anlamı denetleriz: alan var mı, soru kimliği pozitif mi, şık 1–3 arasında mı? Biçim hatasına 400, aralık dışı şıkka 422; pozitif ama bulunmayan soruya servis 404 verir. Bu eşleme bizim API sözleşmemizdir. Şıkların 1’den başlaması da tercihtir; JavaScript’in sıfır tabanlı dizi indeksini doğrudan göndermeyiz.

Yanıt yönünde sendJSON önce json.Marshal ile byte dizisini hazırlar, sonra Content-Type'ı ayarlar, WriteHeader ile durum kodunu, Write ile gövdeyi gönderir. Sıra önemlidir: önce gövdeye yazmak, henüz kod belirlenmediyse 200'ün örtük gönderilmesine yol açar. Bir kez başarı başlıklarını gönderip sonra “aslında 500'dü” demek mümkün değildir. Bu yüzden bu küçük yanıtlarda serileştirme hatasını başlığı göndermeden önce ele alıyoruz.12

Write bağlantı hatası döndürebilir; örnek bunu kaydeder. Başlık gönderildikten sonra yeni durum kodu yazmayız. Yanıtın ulaşmaması, daha önce kaydedilen veriyi geri almaz; Bölüm 35.4 bu yüzden tekrar teslim politikasını istemişti.

37.3 Ara katman: kayıt ve kimlik kontrolü

Ara katman (middleware), işleyiciyi çevreleyen, isteğin öncesinde veya sonrasında ortak iş yapan parçadır. Go'da bunu bir işleyici alıp başka işleyici döndüren fonksiyonla kurabiliriz: func(next http.Handler) http.Handler. İçteki işin puan mı, liste mi olduğunu bilmeden kayıt, kimlik veya başka ortak kontrol ekleriz. Bu, net/http'nin Handler arayüzü üzerine kurduğumuz bir düzenleme kalıbıdır; ayrı bir dil özelliği değildir.1

start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))

İlk satır başlangıç anını alır. İkinci satır içteki işleyiciyi çağırır; asıl işi atlamaz. Son satır yöntem, yol ve geçen süreyi kayda yazar. Bu parça tam programdaki logging fonksiyonunun içidir. Gövdeleri, çerezleri, parolaları veya kimlik başlıklarını kaydetmiyoruz. Bu kayıt sürümü yanıt kodunu da yakalamaz; ona ihtiyaç varsa yazıcıyı uygun biçimde sarmalamak gerekir. Bölüm 34'teki daha ayrıntılı kayıt gösterimini birebir aynı kod sanma.

Kimlik kontrolü için önce bir güvenlik sınırı koymalıyız: gerçek oturum, parola veya belirteç doğrulama bu bölümün konusu değil, Bölüm 41'in konusu. Aşağıdaki program yalnız yerelde çalışan bir gösterimdir. X-Demo-User: kerem başlığını gören ara katman, kullanıcı kimliğini 1 kabul eder; başlık yoksa 401 döndürür. Bu başlığı herkes yazabilir. Dolayısıyla bu, kimliği kanıtlayan bir güvenlik mekanizması değil, kimlik doğrulanmış olsaydı veri hangi yoldan taşınırdı sorusunun sahnesidir.

Bu başlık bir sır değil, bilerek sahte kimliktir

X-Demo-User ile gerçek hesap korunmaz. Sunucuyu internete açma, başlığın adını değiştirip güvenli olduğunu sanma. Bölüm 33'te görünen token-kerem de öğretici yer tutucudur; burada gerçek token gibi kullanılmaz. Ön yüzdeki örneği bu yerel deneye bağlayacaksan, yalnız deneme ortamında bu gösterim başlığını gönderen bir uyarlama gerekir. Üretim sürümünde onun yerini gerçek doğrulama almalıdır. CORS da bu açığı onaramaz.

Ara katman kimliği context.WithValue ile bağlama koyar; özel userKey tipi anahtar çakışmasını önler. r.WithContext(ctx) bu bilgiyi iç işleyiciye taşır. Bağlamda kimlik taşımak onun gerçekliğini kanıtlamaz.3

logging(mux) dışta olduğu için reddedilen istek de kayda girer. demoIdentity(post) yalnız cevap yazmayı sarar; liste herkese açıktır. Reddeden ara katman return eder, next çağırmaz: hata yazıp devam etmek, reddedilmiş isteğin veri değiştirmesine yol açabilir.

37.4 context ve zaman aşımı

Bağlam (context), bir işin iptal sinyalini, varsa son tarihini ve istek kapsamlı değerlerini katmanlar boyunca taşıyan ortak arayüzdür. Zaman aşımı (timeout), belirlediğimiz süre bittiğinde işi sürdürmeme kararıdır. r.Context() gelen HTTP isteğiyle ilişkilidir; sunucu tarafında bağlantının kapanması veya işleyicinin dönmesi gibi durumlarda iptal edilir. Alt işlemler de aynı işin parçasıysa bu bağlamı almalıdır.13

ctx, cancel := context.WithTimeout(r.Context(), time.Second)
defer cancel()
rows, err := repo.Top(ctx, limit)

İlk satır üst isteğin bağlamından en çok bir saniyelik bütçe türetir; daha erken üst son tarihi aşmaz. defer cancel(), fonksiyondan çıkarken iptal kaynaklarını bırakır; defer çağrıyı çıkışa erteler. Son satır bütçeyi depoya taşır.34

Context goroutine’i zorla öldürmez: alt iş ctx.Done() veya ctx.Err() ile iptale uymalıdır. Bellek deposu kilidi aldıktan sonra ctx.Err() kontrol eder; iptalde yazmadan döner. Kilit bekleyişinin kendisi kesilmediği için kesin duvar saati garantisi yoktur. Postgres çağrısına da bağlam taşınmalıdır.3

ReadHeaderTimeout başlık okumayı, ReadTimeout istek okumayı, WriteTimeout yanıt yazmayı, IdleTimeout boş bağlantı bekleyişini sınırlar. Bunlar servis bütçesinden farklıdır. POST bağlamı gövde çözüldükten sonra kurulduğu için bütün istek bir saniyeyle sınırlı değildir. Kodun 2, 5, 30 saniyeleri eğitim tercihidir, evrensel üretim önerisi değil.1

İptal, kaydedilmiş veriyi geri almaz. Bu API servis iptalini 503’e eşler; bu bizim sözleşmemizdir. Her zaman aşımı 504 değildir: 504, ağ geçidinin üst sunucudan zamanında yanıt alamamasını anlatır.5

37.5 Katmanlı yapı: işleyici → servis → depo

Bir işleyicide JSON, puan hesabı, kilit, sıralama ve veri saklama satırlarını art arda yazabilirdik. Çalışabilir, ama bir kural değiştiğinde HTTP ayrıntıları arasına dalmak gerekirdi. Bu bölüm için servis (service), uygulama kuralını koordine eden parçadır. Depo (repository), veriyi okuma/yazma davranışını saklama ayrıntılarından ayıran arayüzdür. Buradaki depo, Bölüm 28'deki Git deposu değildir; aynı Türkçe sözcük farklı bağlamda kullanılıyor.

ParçaBildiğiBilmemesi gereken
İşleyiciYöntem, limit, JSON, durum koduVeri bellekte mi Postgres'te mi?
ServisSoru var mı, cevap doğru mu, aday ödül ne?HTTP başlığı nasıl yazılır?
DepoPuan ile tekrar ödül kaydını birlikte nasıl saklarım?Ekrandaki düğmenin rengi ne?

Bu katmanlar kütüphanenin zorunlu şablonu değil, Bölüm 27’nin sorumluluk ayrımını gerçekleme tercihimizdir. Okunabilirlik için aynı dosyadalar; hepsi aynı Go sürecinde çalışır, ayrı sunuculara taşınmış mikroservisler değiller.

Repository.Top sıralı kayıtları, Save cevap sonucunu verir. Service.Submit yalnız soru 1’i tanır; doğru şık 2, aday ödül 10’dur. Tek soruluk eğitim verisi gerçek soru bankası değildir. Depo, kullanıcı–soru çiftinin ilk ödülünü kayıt ve puanla birlikte korur. Soru bankası büyüyünce servis soru bilgisini de depodan istemelidir.

Neden “daha önce ödül aldı mı?” kontrolü ile yazıyı depoda birleştiriyoruz? İki eşzamanlı isteğin ikisi de önce “almadı” okuyup sonra ayrı ayrı ödül vermesini istemiyoruz. Bellek gerçeklemesinde sync.Mutex kilidi hem kontrolü hem puan ve cevap kaydını kapsar. defer m.mu.Unlock() çıkarken kilidi bırakır. Bölüm 14.4'ün yarış durumunu yerel süreçte böyle sınırlarız. Postgres'e taşınınca aynı güvencenin veritabanı işlemi ve kısıtlar üzerinden kurulması gerekir; Go kilidi başka sunucu kopyasını korumaz.

Ödül politikası özellikle açıktır: her geçerli deneme bir cevap kaydı oluşturur; bir kullanıcının bu sorudaki ilk doğru cevabı ödül alır; aynı doğru cevabın sonraki gönderimleri yeni deneme kaydı oluşturabilir ama ek puan vermez. Bu nedenle ikinci POST yine 201 döner ve correct:true olabilir; points_awarded:0 döner. Tam HTTP isteğini idempotent yaptığımızı iddia etmiyoruz, çünkü kayıt sayısı artar; yalnız “aynı sorudan tekrar ödül” kuralını sağlıyoruz. Yanlış cevap verip sonra doğruyu denemek de burada izinlidir.

Bellek deposu ortak haritayı açmak yerine yapıları kopyalar. sort.Slice puanı azalan, eşitlikte kimliği artan sıralar. Bu ikinci ölçüt görünür sonucu belirli kılar; ürün başka eşitlik kuralı seçebilir. Bölüm 38.3 aynı fikri SQL’in ORDER BY ifadesiyle kuracak.

37.6 Tam örnek: GET /leaderboard ve POST /answers

Tam kodu boş deneme klasöründe main.go olarak kaydet; Go kuruluysa go mod init example.com/kerem-api, sonra go run . ile başlat. Yalnız 127.0.0.1:8080 üzerinde dinler; port doluysa hata verir. Telefonda localhost telefonun kendisidir; ağa açma ve yayına alma Bölüm 43’tedir.

package main

import (
    "context"
    "encoding/json"
    "errors"
    "io"
    "log"
    "mime"
    "net/http"
    "sort"
    "strconv"
    "sync"
    "time"
)

type Entry struct {
    UserID int `json:"user_id"`
    Name string `json:"name"`
    Points int `json:"points"`
}
type Answer struct {
    QuestionID *int `json:"question_id"`
    Choice *int `json:"choice"`
}
type Result struct {
    Correct bool `json:"correct"`
    Awarded int `json:"points_awarded"`
    Total int `json:"total_points"`
}
var errQuestion = errors.New("soru bulunamadı")

type Repository interface {
    Top(context.Context, int) ([]Entry, error)
    Save(context.Context, int, int, int) (Result, error)
}
type storedAnswer struct {
    userID, questionID int
    result Result
}
type Memory struct {
    mu sync.Mutex
    users map[int]Entry
    rewarded map[[2]int]bool
    answers []storedAnswer
}
func newMemory() *Memory {
    return &Memory{
        users: map[int]Entry{
            1: {1, "Kerem", 40},
            2: {2, "Emre", 70},
            3: {3, "Ayşe", 55},
        },
        rewarded: make(map[[2]int]bool),
    }
}
func (m *Memory) Top(ctx context.Context, limit int) ([]Entry, error) {
    m.mu.Lock()
    defer m.mu.Unlock()
    if err := ctx.Err(); err != nil { return nil, err }
    rows := make([]Entry, 0, len(m.users))
    for _, user := range m.users { rows = append(rows, user) }
    sort.Slice(rows, func(i, j int) bool {
        if rows[i].Points == rows[j].Points { return rows[i].UserID < rows[j].UserID }
        return rows[i].Points > rows[j].Points
    })
    if limit < len(rows) { rows = rows[:limit] }
    return rows, nil
}
func (m *Memory) Save(ctx context.Context, userID, questionID, candidate int) (Result, error) {
    m.mu.Lock()
    defer m.mu.Unlock()
    if err := ctx.Err(); err != nil { return Result{}, err }
    user := m.users[userID]
    correct := candidate > 0
    award := 0
    key := [2]int{userID, questionID}
    if correct && !m.rewarded[key] {
        award = candidate
        m.rewarded[key] = true
    }
    user.Points += award
    m.users[userID] = user
    result := Result{correct, award, user.Points}
    m.answers = append(m.answers, storedAnswer{userID, questionID, result})
    return result, nil
}

type Service struct { repo Repository }
func (s Service) Submit(ctx context.Context, userID int, a Answer) (Result, error) {
    if *a.QuestionID != 1 { return Result{}, errQuestion }
    candidate := 0
    if *a.Choice == 2 { candidate = 10 }
    return s.repo.Save(ctx, userID, *a.QuestionID, candidate)
}
func sendJSON(w http.ResponseWriter, status int, value any) {
    data, err := json.Marshal(value)
    if err != nil { http.Error(w, "yanıt üretilemedi", 500); return }
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    if _, err = w.Write(append(data, '\n')); err != nil { log.Print("yanıt yazılamadı: ", err) }
}
func fail(w http.ResponseWriter, status int, message string) {
    sendJSON(w, status, map[string]string{"error": message})
}
func serviceError(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, errQuestion): fail(w, 404, "soru bulunamadı")
    case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
        fail(w, 503, "iş tamamlanamadı")
    default:
        log.Print("iç hata: ", err)
        fail(w, 500, "iç hata")
    }
}
func readAnswer(w http.ResponseWriter, r *http.Request) (Answer, int) {
    media, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
    if err != nil || media != "application/json" { return Answer{}, 415 }
    r.Body = http.MaxBytesReader(w, r.Body, 4096)
    defer r.Body.Close()
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()
    var a Answer
    if err := dec.Decode(&a); err != nil {
        var big *http.MaxBytesError
        if errors.As(err, &big) { return Answer{}, 413 }
        return Answer{}, 400
    }
    var extra any
    if err := dec.Decode(&extra); err != io.EOF {
        var big *http.MaxBytesError
        if errors.As(err, &big) { return Answer{}, 413 }
        return Answer{}, 400
    }
    if a.QuestionID == nil || a.Choice == nil || *a.QuestionID < 1 { return Answer{}, 400 }
    if *a.Choice < 1 || *a.Choice > 3 { return Answer{}, 422 }
    return a, 0
}

type userKey struct{}
func demoIdentity(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Herkesin yazabildiği gösterim başlığı; gerçek kimlik doğrulama DEĞİL.
        if r.Header.Get("X-Demo-User") != "kerem" {
            w.Header().Set("WWW-Authenticate", `Demo realm="local-example"`)
            fail(w, 401, "yerel gösterim kimliği gerekli")
            return
        }
        ctx := context.WithValue(r.Context(), userKey{}, 1)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
    })
}
func newAPI(repo Repository) http.Handler {
    svc := Service{repo: repo}
    mux := http.NewServeMux()
    mux.HandleFunc("GET /leaderboard", func(w http.ResponseWriter, r *http.Request) {
        limit := 10
        if raw, exists := r.URL.Query()["limit"]; exists {
            if len(raw) != 1 { fail(w, 400, "geçersiz limit"); return }
            n, err := strconv.Atoi(raw[0])
            if err != nil || n < 1 || n > 100 { fail(w, 400, "geçersiz limit"); return }
            limit = n
        }
        ctx, cancel := context.WithTimeout(r.Context(), time.Second)
        defer cancel()
        rows, err := repo.Top(ctx, limit)
        if err != nil { serviceError(w, err); return }
        sendJSON(w, 200, rows)
    })
    post := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        a, status := readAnswer(w, r)
        if status != 0 { fail(w, status, "geçersiz cevap isteği"); return }
        ctx, cancel := context.WithTimeout(r.Context(), time.Second)
        defer cancel()
        userID := r.Context().Value(userKey{}).(int)
        result, err := svc.Submit(ctx, userID, a)
        if err != nil { serviceError(w, err); return }
        sendJSON(w, 201, result)
    })
    mux.Handle("POST /answers", demoIdentity(post))
    return logging(mux)
}
func main() {
    server := &http.Server{
        Addr: "127.0.0.1:8080",
        Handler: newAPI(newMemory()),
        ReadHeaderTimeout: 2 * time.Second,
        ReadTimeout: 5 * time.Second,
        WriteTimeout: 5 * time.Second,
        IdleTimeout: 30 * time.Second,
    }
    log.Print("yalnız yerelde dinliyorum: ", server.Addr)
    log.Fatal(server.ListenAndServe())
}

Programı blok blok, satırların görevleriyle okumak

  1. package ve import satırları ana paketi ve kullanılan standart araçları tanımlar. context süre/iptal; encoding/json serileştirme; errors hata eşleme; io dosya sonu; mime içerik tipi; net/http protokol; sort sıralama; strconv metinden sayı; sync kilit; time süre; log kayıt içindir.
  2. Entry, istemcinin tanıdığı user_id, name, points alanlarını taşır. Answer yalnız istemcinin beyanını taşır. Result sunucunun ürettiği sonucu taşır. Üçünü ayırmak, istemcinin sonuç puanını girişe sokuşturmasını zorlaştırır. errQuestion soru yokluğunu ayırt edilen hata değerine dönüştürür.
  3. Repository yöntemlerinin parametreleri bağlam, limit veya kullanıcı/soru/aday ödüldür. storedAnswer her kabulün sonucunu saklar. Memory ortak harita, ödül kaydı, cevap dizisi ve bunların kilidini toplar. newMemory başlangıç puanlarını kurar; make boş ödül haritasını yazılabilir hâle getirir.
  4. Top kilidi alır, iptali kontrol eder, range ile kullanıcıları yeni dilime kopyalar. Sıralama fonksiyonu iki kaydı karşılaştırır. Dilim uzunluğu limitten büyükse rows[:limit] ilk parçayı seçer; sonra listeyi ve nil hatayı döndürür. Limitin pozitifliği dış işleyicide denetlenmiştir.
  5. Save aynı kilit altında iptali kontrol eder, oyuncuyu bulur, kullanıcı–soru çiftini iki tam sayılı anahtara koyar. İlk doğru başarıda aday ödülü verir ve çifti işaretler. Puanı günceller, sonucu kurar, cevap dizisine ekler ve sonucu döndürür. Bu küçük depo, çağıranın geçerli kullanıcı ve aday ödül verdiğini varsayar; herkese açık ayrı bir API değildir.
  6. Service.Submit bulunmayan soruyu reddeder; doğru şık için aday ödül hesaplar. *a.Choice gibi dereference işlemleri öncesinde alanların nil olmadığı işleyicide denetlenmiştir. Bu servis başka yerden çağrılacaksa aynı giriş önkoşulları orada da korunmalı veya denetim servise alınmalıdır.
  7. sendJSON önce gövdeyi üretir, hata varsa 500 yazar; başarılıysa başlık, kod ve gövde sırasını uygular. fail küçük hata nesnesi kurar. serviceError hata değerini uygun HTTP koduna eşler; beklenmeyen iç ayrıntıyı kullanıcıya açmak yerine kayda geçirir.
  8. readAnswer'ın her erken dönüşü daha aşağıdaki işlemleri durdurur. Başlık, byte sınırı, JSON, ikinci değer, eksik alan ve şık aralığı sırayla denetlenir. Başarıdaki sıfır durum değeri bu yardımcı fonksiyonun kendi anlaşmasıdır; ağda 0 kodu gönderilmez.
  9. demoIdentity yalnız sahte yerel kimliği bağlama ekler. logging iç işin çevresinde süre tutar. newAPI işleyicileri kurar; strconv.Atoi limit metnini çevirir, tek değer ve 1–100 sınırını denetler. defer cancel() her alt işin bağlamını çıkışta bırakır.
  10. main Server'ı adres, işleyici ve süre ayarlarıyla kurar; dinlemeyi başlatır. ListenAndServe dönünce gelen hata kayda yazılır ve program biter. Bu örnekte kontrollü kapanış, TLS, gerçek kullanıcı girişi, kalıcı depo veya CORS yapılandırması yoktur; bunlar yapılmış gibi davranmayacağız.

İstek gönderince ne görürsün?

curl 'http://127.0.0.1:8080/leaderboard?limit=3'
curl -X POST 'http://127.0.0.1:8080/answers' \
  -H 'Content-Type: application/json' \
  -H 'X-Demo-User: kerem' \
  -d '{"question_id":1,"choice":2}'

İlk komut Emre 70, Ayşe 55, Kerem 40 listesini getirir. İkincide -X yöntem, -H başlık, -d gövdedir; ters eğik çizgi Unix kabuğunda satırı sürdürür. İlk doğru cevap şu sonucu üretir:

{"correct":true,"points_awarded":10,"total_points":50}

Aynı doğru cevap tekrar gönderilirse correct:true, ödül 0, toplam 50 olur. Sonra yanlış şık gönderilirse correct:false, ödül 0, toplam 50 olur. Süreci yeniden başlatırsan bellekteki puan ve ödül işaretleri başlangıca döner. Bu, uygulamanın kalıcı tamamlanmış hâli değil; bir sonraki bölümde neden veritabanına ihtiyaç duyduğumuzu görünür yapan sınırdır.

Uygulamamızda: ön yüz neyi yapmayı bırakıyor?

TanStack Query leaderboard verisinin kopyasını tutabilir; cevaptan sonra sorguyu geçersiz kılabilir. Ama ödül kuralını o belirlemez. Expo istemcisi soru ve şık beyanını gönderir; Go doğrular, servis karar verir, depo sonucu saklar. Postgres bu deponun kalıcı gerçeklemesi olacak. Redis daha sonra gerekirse önbellek olacak; bu programda gizlice mevcut değil. Seri ve can için de önce kural ve veri modeli tanımlanmalıdır; tek soruluk örnek bunları tamamladığını iddia etmez.

Gerçek doğrulamanın kapsamı

Kitap yazılırken aynı kaynak kod Go 1.27.2 ile derlendi ve yerel HTTP test sunucusunda 23 istek durumu sınandı: liste, limit sınırları, yöntem/yol hatası, gösterim kimliği, içerik tipi, bozuk ve fazla JSON, eksik/null/yanlış tip alan, şık aralığı, bulunmayan soru, büyük gövde ve ardışık cevaplar. Ayrıca 30 eşzamanlı doğru cevap toplam yalnız 10 ödül verdi; puan 50 kaldı, 30 deneme kaydı oluştu. İptal edilmiş yazıda değişiklik olmadığı ve iptale uyan bekleyen deponun 503'e dönüştüğü sınandı. Yarış denetleyicili test çalıştırması geçti. Bu, gerçek oturum güvenliği veya Postgres testi değildir.

Bölüm 34 ile bağlantı

Sunucu artık portu dinleyen süreç, yönlendirici ve Go fonksiyonlarıdır. Bölüm 34’ün güven sınırı doğrulamada, puan kararında ve sahte kimlik uyarısında görünür. Eksik parça kalıcı saklamadır; sıradaki bölüm onu kuracak.

Bu bölümde neler kuruldu

net/http mesajı işleyiciye ulaştırır. Doğrulama taşıma, biçim ve anlam sınırlarını ayrı kurar. Ara katman ortak davranışı sarar; kimliği taşımak kimliği doğrulamak değildir. Context süre ve iptali işbirliğiyle iletir. İşleyici, servis ve depo aynı süreçte ayrı sorumluluklar taşır. Çalışan bellek örneği tekrar ödülü sınırlar ama kalıcı değildir; Postgres'e neden ihtiyaç duyduğumuz artık somuttur.

Terimler sözlükte, Bölüm 37 başlığı altında.

Birincil okuma

İlk okuma: kaynak 1, net/http belgesinde Servers, Handler, ResponseWriter ve ServeMux — Patterns. Derinleşme: kaynak 2, Decoder.Decode ve Decoder.DisallowUnknownFields; kaynak 4, Context ve Derived contexts.

Takıldığın yeri yapay zekâ öğretmenine sor: “POST /answers için bir geçerli, bir eksik alanlı, bir tekrar teslim isteği ver. Her birinin hangi katmanda ne olacağını önce ben anlatayım.”

Bölüm sonu soruları

Kitaba bakmadan cevapla. Geri bildirim yalnız sonucu değil, gerekçeyi de gösterir.

1. İşleyicinin temel görevi hangisidir?
İşleyici gelen Request üzerinden okur ve ResponseWriter üzerinden yanıt kurar. Bkz. 37.1.
2. Bilinmeyen JSON alanını hangi çağrı reddeder?
Decoder bu seçenekle yapıda tanımlanmamış alanları reddeder; bütün JSON ayrıntılarını katılaştırmaz. Bkz. 37.2.
3. Neden ikinci Decode sonucunda EOF aranır?
İlk Decode yalnız ilk değeri çözer; ikinci belge bulunmamalıdır. Bkz. 37.2.
4. X-Demo-User başlığı neyi sağlar?
Başlığı herkes yazabilir; bu yalnız yerel sahneleme, gerçek kimlik doğrulama değildir. Bkz. 37.3.
5. WithTimeout çağrısı neyi tek başına sağlamaz?
İptal işbirliği gerektirir; alt iş sinyale uymalıdır. Bkz. 37.4.
6. Depo arayüzü hangi ayrımı kolaylaştırır?
İşleyici verinin bellekte mi Postgres içinde mi saklandığını bilmek zorunda kalmaz. Bkz. 37.5.
7. Örnekte tekrarlanan doğru cevabın sonucu nedir?
Politika kullanıcı–soru çifti için yalnız ilk doğru başarıya ödül verir. Bkz. 37.6.
8. Sunucu yeniden başlatılınca örnek veri ne olur?
Bu programda kalıcı depo yoktur; süreç belleği yeniden kurulur. Bkz. 37.6.
9. Bir cevap kaydedildi ama yanıt gönderilemedi. Ne oldu, istemci ne bilmiyor ve yeniden denemede neyi korumalısın?
Örnek cevabı göster

İşlem sunucuda tamamlanmış olabilir ama istemci sonucu görmemiştir. Aynı başarının tekrar ödül üretmemesi gibi kurallar kalıcı kayıtta korunmalı; ağ hatası yazmanın geri alındığını kanıtlamaz. Bkz. 37.5.

10. Programın üç katmanını kendi cümlenle anlat ve Postgres eklenince hangi sınırın değişeceğini söyle.
Örnek cevabı göster

İşleyici HTTP/JSON dilini, servis soru ve ödül kararını, depo kayıt ayrıntısını yönetir. Postgres eklenince depo gerçeklemesi ve gerekli atomik kayıt güvenceleri değişir; ön yüzün alan adlarının değişmesi gerekmez. Bkz. 37.5.

Kaynaklar

  1. Go · net/http package documentation: Handler, HandlerFunc, ServeMux, ResponseWriter, MaxBytesReader, Server ve Request.Context.
  2. Go · encoding/json package documentation: alan etiketleri, Decoder, Marshal ve geriye uyumlu ayrıştırıcının davranış sınırları.
  3. Go · context package documentation: WithTimeout, CancelFunc, Err, Done ve isteğe özgü değerler.
  4. Sameer Ajmani · Go Concurrency Patterns: Context: bağlamın API sınırları boyunca taşınması ve işbirlikçi iptal.
  5. MDN · HTTP response status codes: 503 ve 504 anlamlarının ayrımı.