Golang web serverių kūrimas

Kodėl Golang tapo populiarus web serverių kūrimui

Prieš kelerius metus, kai pirmą kartą susidūriau su Go programavimo kalba, ji man atrodė kaip kažkas tarp C ir Python – greita kaip pirmoji, bet paprasta kaip antroji. Ir žinot ką? Būtent dėl šio balanso Go tapo vienu iš populiariausių pasirinkimų kuriant web serverius.

Go kalba buvo sukurta Google inžinierių 2009 metais, ir nuo pat pradžių ji buvo orientuota į tinklo programavimą. Viena didžiausių jos privalumų – tai įmontuotas konkurencijos modelis su goroutines. Tai leidžia lengvai kurti serverius, kurie gali apdoroti tūkstančius užklausų vienu metu, nenaudojant sudėtingų thread’ų ar callback’ų.

Dar vienas dalykas, kuris mane tikrai sužavėjo – tai kompiliavimas į vieną executable failą. Jokių dependency hell problemų, jokių virtualių aplinkų. Sukompiliuoji programą ir tiesiog paleidi. Tai ypač patogu diegiant aplikacijas į produkciją ar kurdami Docker konteinerius.

Pirmieji žingsniai: paprasčiausias HTTP serveris

Pradėkime nuo pačių pagrindų. Go standartinė biblioteka turi puikų net/http paketą, kuris leidžia sukurti veikiantį web serverį vos keliais kodo eilutėmis:

package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Labas, pasauli!")
}

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServe(":8080", nil)
}

Ir viskas. Rimtai. Šis kodas sukuria veikiantį HTTP serverį, kuris klauso 8080 porto ir atsako „Labas, pasauli!” į bet kokią užklausą. Kai pirmą kartą tai pamačiau, negalėjau patikėti, kad tai taip paprasta.

Bet kas čia iš tikrųjų vyksta? http.HandleFunc registruoja handler funkciją konkrečiam URL keliui. http.ListenAndServe paleidžia serverį nurodytame porte. Antras parametras yra multiplexer (maršrutizatorius), bet jei perduodate nil, naudojamas numatytasis.

Kiekviena užklausa automatiškai apdorojama atskiroje goroutine, todėl jūsų serveris iš karto yra concurrent. Nereikia jokių papildomų bibliotekų ar konfigūracijų.

Routing ir middleware: kaip organizuoti kodą

Žinoma, realūs projektai nėra tokie paprasti. Jums reikės skirtingų endpoint’ų, skirtingų HTTP metodų, middleware funkcionalumo. Nors standartinė biblioteka tai palaiko, dažnai patogiau naudoti kažką galingesnio.

Aš asmeniškai dažniausiai naudoju Gorilla Mux arba Chi maršrutizatorius. Štai kaip atrodo kodas su Gorilla Mux:

package main

import (
    "encoding/json"
    "net/http"
    "github.com/gorilla/mux"
)

type User struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

func getUser(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    user := User{ID: vars["id"], Name: "Jonas"}
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

func main() {
    r := mux.NewRouter()
    r.HandleFunc("/users/{id}", getUser).Methods("GET")
    http.ListenAndServe(":8080", r)
}

Gorilla Mux leidžia lengvai apibrėžti URL parametrus, nurodyti HTTP metodus, grupuoti maršrutus. Tai daug lankstesnė sistema nei standartinis http.ServeMux.

Middleware Go pasaulyje yra tiesiog funkcijos, kurios apgaubia kitas funkcijas. Štai paprastas logging middleware pavyzdys:

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Printf("%s %s", r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
    })
}

// Naudojimas
r.Use(loggingMiddleware)

Tokiu būdu galite pridėti autentifikaciją, CORS header’ius, rate limiting – bet ką, kas turi būti vykdoma prieš arba po pagrindinės handler funkcijos.

Darbas su duomenų bazėmis

Web serveris be duomenų bazės yra kaip automobilis be ratų – teoriškai įdomu, bet praktiškai nenaudinga. Go turi puikią database/sql biblioteką, kuri palaiko daugelį duomenų bazių per driver’ius.

Štai kaip atrodo darbas su PostgreSQL:

import (
    "database/sql"
    _ "github.com/lib/pq"
)

func connectDB() (*sql.DB, error) {
    connStr := "user=username dbname=mydb sslmode=disable"
    db, err := sql.Open("postgres", connStr)
    if err != nil {
        return nil, err
    }
    
    if err = db.Ping(); err != nil {
        return nil, err
    }
    
    return db, nil
}

Svarbu suprasti, kad sql.Open iš tikrųjų nesukuria ryšio – ji tik paruošia connection pool. Realus ryšys užmezgamas tik kai atliekate užklausą arba iškviečiate Ping().

Daugelis programuotojų renkasi ORM bibliotekas kaip GORM arba sqlx, kurios supaprastina darbą su duomenų bazėmis. Asmeniškai aš esu kažkur per vidurį – paprastoms užklausoms naudoju database/sql, sudėtingesnėms – sqlx, kuri suteikia patogesnį API, bet neverčia atsisakyti SQL kontrolės.

type User struct {
    ID    int    `db:"id"`
    Email string `db:"email"`
    Name  string `db:"name"`
}

func getUsers(db *sqlx.DB) ([]User, error) {
    users := []User{}
    err := db.Select(&users, "SELECT id, email, name FROM users")
    return users, err
}

Vienas patarimas – visada naudokite prepared statements arba parametrizuotas užklausas. Tai apsaugo nuo SQL injection atakų ir pagerina našumą.

Error handling ir kontekstai

Go požiūris į error handling yra vienas iš labiausiai diskutuojamų kalbos aspektų. Jokių exceptions, tik explicit error grąžinimas. Iš pradžių tai gali atrodyti varginantis, bet ilgainiui supranti, kad tai skatina geriau pagalvoti apie klaidas.

Štai tipinis pattern’as:

func processRequest(w http.ResponseWriter, r *http.Request) {
    data, err := fetchData()
    if err != nil {
        http.Error(w, "Failed to fetch data", http.StatusInternalServerError)
        log.Printf("Error fetching data: %v", err)
        return
    }
    
    result, err := transformData(data)
    if err != nil {
        http.Error(w, "Failed to process data", http.StatusInternalServerError)
        log.Printf("Error transforming data: %v", err)
        return
    }
    
    json.NewEncoder(w).Encode(result)
}

Taip, čia daug if err != nil blokų. Bet žinote ką? Jūs visada žinote, kas gali nepavykti ir kaip su tuo elgiatės. Nėra paslėptų exception’ų, kurie gali iššokti iš bet kur.

Kitas svarbus dalykas – kontekstai. context.Context yra būdas perduoti deadline’us, cancel signalus ir request-scoped reikšmes per API ribas:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    // Nustatome timeout
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel()
    
    result, err := longRunningOperation(ctx)
    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            http.Error(w, "Request timeout", http.StatusRequestTimeout)
            return
        }
        http.Error(w, "Internal error", http.StatusInternalServerError)
        return
    }
    
    json.NewEncoder(w).Encode(result)
}

Kontekstai ypač svarbūs, kai jūsų serveris daro išorines užklausas ar dirba su duomenų bazėmis. Jie leidžia gracefully atšaukti operacijas, jei klientas nutraukia ryšį arba operacija trunka per ilgai.

Testavimas ir debugging

Go turi puikią testing infrastruktūrą įmontuotą į kalbą. Testai rašomi tiesiog sukuriant failus su _test.go galūne:

func TestGetUser(t *testing.T) {
    req, err := http.NewRequest("GET", "/users/123", nil)
    if err != nil {
        t.Fatal(err)
    }
    
    rr := httptest.NewRecorder()
    handler := http.HandlerFunc(getUser)
    handler.ServeHTTP(rr, req)
    
    if status := rr.Code; status != http.StatusOK {
        t.Errorf("handler returned wrong status code: got %v want %v",
            status, http.StatusOK)
    }
    
    expected := `{"id":"123","name":"Jonas"}`
    if rr.Body.String() != expected {
        t.Errorf("handler returned unexpected body: got %v want %v",
            rr.Body.String(), expected)
    }
}

httptest paketas leidžia testuoti HTTP handler’ius be realaus serverio paleidimo. Tai greitai ir patogiai.

Debugging’ui aš dažniausiai naudoju Delve debugger’į arba tiesiog senamadišką fmt.Printf. Taip, žinau, skamba primityviai, bet kartais paprasčiausi sprendimai yra geriausi. Žinoma, production aplinkoje naudokite tvarkingą logging biblioteką kaip zap arba logrus.

Dar vienas patarimas – naudokite pprof profiling’ui. Go turi įmontuotus profiling įrankius, kurie leidžia analizuoti CPU naudojimą, memory allocations, goroutine’ų skaičių:

import _ "net/http/pprof"

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    
    // Jūsų serverio kodas
}

Dabar galite eiti į http://localhost:6060/debug/pprof/ ir pamatyti detaliausią informaciją apie savo programos veikimą.

Production-ready serverio konfigūracija

Kai jūsų serveris yra paruoštas produkcijai, yra keletas dalykų, kuriuos būtina padaryti. Pirma – graceful shutdown. Jūs nenorite, kad serveris staiga nutrauktų ryšius su klientais, kai jį reikia restartinti:

func main() {
    srv := &http.Server{
        Addr:         ":8080",
        Handler:      router,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
        IdleTimeout:  60 * time.Second,
    }
    
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Server failed: %v", err)
        }
    }()
    
    // Laukiame interrupt signalo
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    
    log.Println("Shutting down server...")
    
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown: %v", err)
    }
    
    log.Println("Server exited")
}

Šis kodas užtikrina, kad serveris baigs apdoroti esamas užklausas prieš išsijungdamas.

Antra – timeout'ai. Visada nustatykite timeout'us. Viena blogai parašyta užklausa gali užkabinti jūsų serverį, jei nėra timeout'ų. ReadTimeout, WriteTimeout ir IdleTimeout yra jūsų draugai.

Trečia – rate limiting. Jei jūsų API yra viešas, jums reikia apsaugos nuo abuse. Galite naudoti bibliotekas kaip golang.org/x/time/rate:

var limiter = rate.NewLimiter(10, 20) // 10 requests per second, burst of 20

func rateLimitMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !limiter.Allow() {
            http.Error(w, "Too many requests", http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Kai viskas sueina į vieną vietą

Go web serverių kūrimas yra vienas iš malonesnių dalykų, kuriuos esu daręs programavimo karjeroje. Kalba duoda pakankamai abstrakcijų, kad būtų produktyvu, bet ne tiek daug, kad prarastumėte kontrolę. Standartinė biblioteka yra tokia gera, kad daugeliu atvejų jums nereikia išorinių framework'ų.

Žinoma, yra dalykų, kuriuos reikia išmokti – goroutine'ų valdymas, channel'ai, kontekstai. Bet kai juos įvaldote, galite kurti serverius, kurie lengvai aptarnauja tūkstančius concurrent connections, naudodami minimalius resursus.

Jei tik pradedate su Go, mano patarimas būtų toks: pradėkite nuo standartinės bibliotekos. Sukurkite paprastą serverį, pažaiskite su juo, supraskite kaip viskas veikia. Tik tada pridėkite išorines bibliotekas, jei tikrai jų reikia. Go filosofija yra paprastumas, ir dažnai paprasčiausi sprendimai yra geriausi.

Ir dar vienas dalykas – neskubėkite optimizuoti. "Premature optimization is the root of all evil", kaip sakė Donald Knuth. Pirmiausia padarykite, kad veiktų, tada padarykite, kad veiktų teisingai, ir tik tada – kad veiktų greitai. Go kompiliatorius ir runtime yra labai geri, todėl dažnai jūsų kodas bus pakankamai greitas be jokių optimizacijų.

Sėkmės kuriant savo Go serverius. Tikrai verta išbandyti, jei dar nesate.

Daugiau

Elasticsearch pilno teksto paieška: indeksavimas ir užklausos