Jak pisać szybkie API w Go bez bólu głowy: praktyczny przewodnik dla web developerów

0
49
3.3/5 - (3 votes)

Dlaczego Go nadaje się do szybkich API i kiedy ma sens

Charakterystyka Go pod kątem wydajnych API

Go powstało z myślą o szybkim, prostym i przewidywalnym backendzie. Dla web developera oznacza to kilka bardzo konkretnych korzyści przy budowie API. Kompilowany kod startuje błyskawicznie i zużywa relatywnie mało pamięci, a wbudowany serwer HTTP jest wystarczająco szybki dla większości zastosowań bez dodatkowych serwerów aplikacyjnych. To eliminuje całą klasę problemów z masą zależności i skomplikowanymi runtime’ami.

Język jest minimalny – ma mało konstrukcji, brak dziedziczenia klas, ograniczoną ilość „magii” na poziomie języka. W praktyce projekty w Go zwykle wyglądają podobnie, łatwo się je czyta i stosunkowo łatwo przejmuje po kimś. To obniża koszt utrzymania API i skraca czas wdrażania nowych osób.

Współbieżność jest w Go wbudowana w sam język za pomocą goroutines i kanałów. Nie trzeba dodawać zewnętrznych bibliotek do obsługi współbieżności ani walczyć z event loopem. Każde żądanie HTTP jest obsługiwane w osobnej goroutine, co w naturalny sposób skaluje się na wiele rdzeni procesora. Daje to wysoką przepustowość API przy zachowaniu prostego modelu programowania.

Gdzie Go daje największy zwrot z inwestycji

Sens przejścia na Go pojawia się, gdy:

  • API obsługuje lub ma obsługiwać duży ruch (dziesiątki tysięcy requestów na minutę) i obecny stack zaczyna się dusić.
  • Backend jest zbiorem mikroserwisów, gdzie liczy się mały footprint pamięci, prostota deploymentu (jeden binarny plik) i przewidywalna wydajność.
  • Budujesz szybki proxy / gateway (np. do innych usług, systemów legacy, zewnętrznych API) i każda milisekunda opóźnienia i każdy MB RAM ma znaczenie.
  • Masz sporo zadań I/O (bazy danych, inne serwisy, kolejki), które idealnie pasują do modelu współbieżnego Go.

W takich scenariuszach Go pozwala uzyskać więcej requestów na sekundę na tym samym sprzęcie, zmniejszyć koszty chmury i uprościć utrzymanie (mniej warstw pośrednich). W wielu firmach migracja pojedynczych krytycznych usług do Go pozwoliła zredukować liczbę maszyn lub planowane skalowanie.

Kiedy Go nie jest konieczne i co z kosztami migracji

Nie ma sensu przepisywać działającego systemu na Go tylko dlatego, że jest „szybkie”. Jeśli API:

  • obsługuje umiarkowany ruch i działa stabilnie w Node, Pythonie czy PHP,
  • większość kosztu to integracje z zewnętrznymi systemami, a nie sama logika API,
  • zespół nie zna Go, a terminy są napięte,

to migracja może być droższa niż zysk z wydajności. Często wystarczy dopracować cache, dodać prosty rate limiting, poprawić zapytania do bazy, aby obecny stack wystarczył jeszcze długo.

W Go warto zaczynać od nowych usług lub od wydzielenia wąskich gardeł jako osobnych serwisów. Pozwala to przetestować stack bez ryzyka przepisywania wszystkiego. Koszt wejścia to nauczenie się podstaw języka i toolingów (moduły, testy, pprof), ale w zamian dostaje się przewidywalny, „nudny” backend, który robi swoje.

Czas wdrożenia vs wydajność – porównanie do Node/Pythona

W zespołach webowych często główne porównanie to Go vs Node/Python. Node/Python pozwalają zwykle szybciej „postawić coś działającego”, bo wiele osób zna te języki, a ekosystem ma mnóstwo gotowych paczek. Problem zaczyna się przy wzroście ruchu – często trzeba dokładać kolejne instancje, kombinować z workerami, scale-outem, tuningiem GC.

Go z kolei wymaga nieco więcej dyscypliny na starcie (jasny podział na pakiety, świadome obchodzenie się z concurrency), ale za to mniej zaskoczeń przy obciążeniu. Model request-per-goroutine jest bliższy intuicji większości programistów niż ręczne manewrowanie event loopem. W zamian za trochę dłuższy onboarding zespół dostaje silnik, który lepiej skaluje się przy dużym ruchu.

Fundamenty szybkiego API w Go – jak poprawnie zacząć projekt

Minimalna struktura projektu API w Go

Układ projektu ma ogromny wpływ na to, jak szybko można rozwijać i optymalizować API. Nie trzeba jednak od razu korzystać z rozbudowanych boilerplate’ów i generatorów. W wielu przypadkach wystarczy prosty, ale konsekwentny podział na pakiety:

  • cmd/api/ – punkt wejścia (main.go) z konfiguracją serwera HTTP, DI i wiringiem.
  • internal/transport/http/ – router, middleware, handlery HTTP.
  • internal/services/ – logika biznesowa (use case’y aplikacji).
  • internal/repository/ – dostęp do bazy danych, cache i innych źródeł.
  • internal/config/ – ładowanie konfiguracji z env / plików.
  • pkg/ – ewentualne paczki wielokrotnego użycia, niezależne od konkretnej domeny.

Taki podział wymusza separację odpowiedzialności. Handler HTTP nie wie, jak dokładnie jest zrealizowana baza, tylko woła serwis. Serwis nie zna szczegółów HTTP, tylko dostaje dane wejściowe i zwraca wynik lub błąd. Przy optymalizacji można więc wymieniać warstwy (np. dodać cache w repository), bez ruszania całej reszty.

Konfiguracja serwera HTTP z timeoutami

Domyślny serwer HTTP w Go jest szybki, ale bez konfiguracji timeoutów łatwo zabić go wolnymi klientami lub wiszącymi połączeniami. Podstawowa konfiguracja powinna zawierać co najmniej:

  • ReadTimeout – maksymalny czas od nawiązania połączenia do odczytania całego request body.
  • WriteTimeout – maksymalny czas na wysłanie odpowiedzi.
  • IdleTimeout – jak długo utrzymywać nieaktywne połączenie keep-alive.

Przykładowa konfiguracja bez zbędnych dodatków:

srv := &http.Server{
    Addr:         ":8080",
    Handler:      router, // np. http.ServeMux lub Gin
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
    IdleTimeout:  60 * time.Second,
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
    log.Fatalf("server error: %v", err)
}

Takie timeouty chronią serwer przed klientami, którzy trzymają połączenie i nic nie wysyłają. Są też kluczowe przy ograniczaniu kosztów – zamiast skalować maszynę, często wystarczy odciąć zbyt wolne lub nieuczciwe połączenia.

Wybór routera: standardowa biblioteka vs frameworki

Go ma wbudowany router http.ServeMux, który dla wielu API jest w pełni wystarczający. Pozwala rejestrować ścieżki i handler funkcje bez dodatkowego narzutu. Dla projektu, który ma być tani w utrzymaniu i prosty, to często najlepszy start:

mux := http.NewServeMux()
mux.HandleFunc("/v1/users", getUsersHandler)
mux.HandleFunc("/v1/users/", userDetailHandler) // pattern prefix

Jeśli potrzeba bardziej wygodnego routera (parametry ścieżek, grupowanie, middleware), rozsądnymi wyborami są chi, Gin lub Echo. Wszystkie są lekkie i dojrzałe. Dla większości API wystarczy jeden z nich – bez inwestowania czasu w egzotyczne frameworki.

OpcjaZaletyWadyKiedy użyć
http.ServeMuxwbudowany, zero zależności, bardzo prostybrak parametrów ścieżek, mniej wygodny routingmałe API, proste ścieżki, minimalizm
chilekki, idiomatyczny, elastyczny middlewaremniej „magii”, ale ciut więcej pisania niż w Ginwiększe API, mikroserwisy, granularny routing
Ginszybki, wygodny, dużo przykładów w siecibardziej „frameworkowy” charakterAPI JSON-first, zespoły z doświadczeniem w frameworkach

Prosty kontrakt API: JSON, wersjonowanie i kody HTTP

Kontrakt API warto ustalić wcześnie, ale bez przesadnej formalizacji. W praktyce opłaca się przyjąć kilka prostych zasad:

  • JSON jako format – jest standardem w webie, prosty do obsługi w Go (encoding/json), dobrze wspierany na froncie.
  • Wersjonowanie w URL – np. /v1/..., /v2/..., bez wymyślnych negocjacji wersji nagłówkami.
  • Spójne kody HTTP – 200/201/204 dla sukcesu, 400 dla błędów walidacji, 401/403 dla autoryzacji, 404 gdy brak zasobu, 500 gdy błąd serwera.
  • Spójny format błędu – ten sam kształt odpowiedzi przy wszystkich błędach aplikacyjnych.

Taki minimalny standard wystarcza, by frontend i inne serwisy mogły korzystać z API bez zbędnych dyskusji, a jednocześnie nie wymaga dużych inwestycji w pełne kontrakty OpenAPI na pierwszym etapie.

Projektowanie handlerów HTTP bez bólu – czystość, prostota, mało magii

Szablon prostego i czytelnego handlera

Dobry handler HTTP powinien być: krótki, przewidywalny i łatwy do testowania. Najprostszy szablon obejmuje:

  • pobranie context.Context z requestu,
  • wczytanie i walidację danych wejściowych,
  • wywołanie serwisu (logika biznesowa),
  • mapowanie błędów na kody HTTP,
  • zwrócenie odpowiedzi JSON.
type createUserRequest struct {
    Email string `json:"email"`
    Name  string `json:"name"`
}

type createUserResponse struct {
    ID string `json:"id"`
}

func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    var req createUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        h.writeError(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
        return
    }

    if req.Email == "" {
        h.writeError(w, http.StatusBadRequest, "email_required", "Email is required")
        return
    }

    id, err := h.service.CreateUser(ctx, req.Email, req.Name)
    if err != nil {
        // mapowanie błędów domenowych
        if errors.Is(err, services.ErrEmailExists) {
            h.writeError(w, http.StatusConflict, "email_exists", "Email already in use")
            return
        }
        h.writeError(w, http.StatusInternalServerError, "internal_error", "Something went wrong")
        return
    }

    h.writeJSON(w, http.StatusCreated, createUserResponse{ID: id})
}

Takie podejście usuwa magię: widać dokładnie, co się dzieje z requestem. Każdą część da się osobno przetestować i zoptymalizować.

Unikanie „god objects” – mniejsze komponenty i wstrzykiwanie zależności

Częstą pułapką w Go jest tworzenie jednego „mega serwisu” lub „mega handlera”, który robi wszystko. Utrudnia to optymalizację i testy. Dużo lepiej rozbić odpowiedzialności na mniejsze interfejsy i struktury, a później złożyć je w main.go.

Przykład minimalnego podziału:

  • UserService – odpowiada za logikę związaną z użytkownikami (np. rejestracja, reset hasła, profil).
  • UserRepository – odpowiada za interakcję z bazą (CRUD użytkowników).
  • UserHandler – odpowiada za mapowanie HTTP <-> serwis.
type UserService interface {
    CreateUser(ctx context.Context, email, name string) (string, error)
}

type UserRepository interface {
    Insert(ctx context.Context, u User) (string, error)
}

type userService struct {
    repo UserRepository
}

type UserHandler struct {
    service UserService
}

func NewUserHandler(service UserService) *UserHandler {
    return &UserHandler{service: service}
}

W cmd/api/main.go wszystko jest łączone:

repo := repository.NewUserRepository(db)
svc := services.NewUserService(repo)
handler := httptransport.NewUserHandler(svc)

// router.HandleFunc("/v1/users", handler.CreateUser)

Takie rozbicie nie wymaga frameworka DI, a jednocześnie pozwala testować każdy element osobno i łatwo wymienić implementację (np. dodać cache w repository).

JSON: Decoder vs Marshal i oszczędzanie alokacji

W Go standardowy pakiet encoding/json jest wystarczająco szybki dla ogromnej liczby API. Największe zyski wynikają zwykle nie z podmiany biblioteki, ale z poprawnego użycia podstawowych funkcji.

  • Czytanie danych: json.NewDecoder(r.Body).Decode(&dst) – streamuje dane, nie wymaga trzymania całego body w pamięci jako []byte.
  • Zapisywanie odpowiedzi: json.NewEncoder(w).Encode(resp) – pozwala od razu pisać na wyjście.

Reużywanie encoderów, buforów i unikanie zbędnych kopii

Spora część kosztu obsługi requestu to alokacje pamięci. Da się je ograniczyć bez heroicznych optymalizacji, wykorzystując proste wzorce reużycia.

Po pierwsze – jeśli masz wspólną funkcję do pisania JSON, nie twórz za każdym razem nowego encodera bez potrzeby. Drobny, ale powtarzany setki tysięcy razy koszt:

type JSONWriter struct {
    buf *bytes.Buffer
}

func NewJSONWriter() *JSONWriter {
    return &JSONWriter{buf: &bytes.Buffer{}}
}

func (w *JSONWriter) WriteJSON(rw http.ResponseWriter, status int, v any) {
    w.buf.Reset()

    enc := json.NewEncoder(w.buf)
    enc.SetEscapeHTML(false)

    if err := enc.Encode(v); err != nil {
        http.Error(rw, "internal error", http.StatusInternalServerError)
        return
    }

    rw.Header().Set("Content-Type", "application/json")
    rw.WriteHeader(status)
    _, _ = rw.Write(w.buf.Bytes())
}

Można pójść krok dalej i korzystać z sync.Pool, jeśli handlerów jest dużo, a odpowiedzi spore:

var bufPool = sync.Pool{
    New: func() any { return &bytes.Buffer{} },
}

func writeJSON(w http.ResponseWriter, status int, v any) {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    defer bufPool.Put(buf)

    enc := json.NewEncoder(buf)
    enc.SetEscapeHTML(false)

    if err := enc.Encode(v); err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    _, _ = w.Write(buf.Bytes())
}

Takie podejście szczególnie pomaga przy odpowiedziach, które składają się z wielu małych struktur lub dużych list – unikamy powtarzających się alokacji buforów per-request.

Obsługa błędów bez rozrastania handlerów

Im większe API, tym więcej przypadków błędów. Rozsądnie jest ujednolicić mapowanie błędów domenowych na HTTP, żeby każdy handler nie miał swojej lokalnej „matematyki” kodów i komunikatów.

Niedrogi schemat: zdefiniuj typ błędu aplikacyjnego z kodem, statusem i komunikatem dla klienta.

type AppError struct {
    Code       string `json:"code"`
    Message    string `json:"message"`
    HTTPStatus int    `json:"-"`
}

func (e *AppError) Error() string {
    return e.Code + ": " + e.Message
}

func NewAppError(status int, code, msg string) *AppError {
    return &AppError{
        HTTPStatus: status,
        Code:       code,
        Message:    msg,
    }
}

Serwis zwraca wtedy error, ale w uzasadnionych przypadkach tworzy *AppError:

var ErrEmailExists = NewAppError(
    http.StatusConflict,
    "email_exists",
    "Email already in use",
)

func (s *userService) CreateUser(ctx context.Context, email, name string) (string, error) {
    // ...
    if exists {
        return "", ErrEmailExists
    }
    // ...
}

Handler ma prostą funkcję pomocniczą:

func (h *UserHandler) handleError(w http.ResponseWriter, err error) {
    var appErr *AppError
    if errors.As(err, &appErr) {
        writeJSON(w, appErr.HTTPStatus, appErr)
        return
    }

    log.Printf("unexpected error: %v", err)
    writeJSON(w, http.StatusInternalServerError, &AppError{
        Code:    "internal_error",
        Message: "Something went wrong",
    })
}

W efekcie handler robi się dużo chudszy:

id, err := h.service.CreateUser(ctx, req.Email, req.Name)
if err != nil {
    h.handleError(w, err)
    return
}

Mniej kodu w handlerach to nie tylko lepsza czytelność, ale też mniejsza podatność na błędy typu „w tym handlerze zapomniałem o 409, zwracam 500”.

Walidacja danych wejściowych z głową

Walidację da się przegiąć. Rozbudowane biblioteki walidacyjne z refleksją spowalniają requesty i utrudniają debugowanie. Najtańszy wariant: prosta walidacja ręczna w handlerze lub blisko serwisu.

Przy prostym API w zupełności wystarczy:

func (r *createUserRequest) Validate() *AppError {
    if r.Email == "" {
        return NewAppError(http.StatusBadRequest, "email_required", "Email is required")
    }
    if !strings.Contains(r.Email, "@") {
        return NewAppError(http.StatusBadRequest, "email_invalid", "Invalid email")
    }
    if len(r.Name) > 200 {
        return NewAppError(http.StatusBadRequest, "name_too_long", "Name too long")
    }
    return nil
}
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    h.handleError(w, NewAppError(http.StatusBadRequest, "invalid_json", "Invalid request body"))
    return
}
if verr := req.Validate(); verr != nil {
    h.handleError(w, verr)
    return
}

Gdy walidacja zaczyna być skomplikowana (wiele struktur, wielokrotne użycie), można wtedy sięgnąć po bibliotekę pokroju go-playground/validator. Z perspektywy kosztów lepiej to zrobić, gdy rzeczywiście jest co automatyzować, niż od razu dorzucać kolejne zależności.

Zbliżenie ekranu z ciemnym motywem i kodem źródłowym w języku Go
Źródło: Pexels | Autor: luis gomes

Wydajność na poziomie HTTP – router, middleware, reużycie zasobów

Cienkie middleware zamiast cienkiego API

Łatwo przesadzić z warstwą middleware: logowanie, metryki, tracing, auth, limity, CORS, panic recovery, i nagle każdy request przechodzi przez kilkanaście funkcji, zanim dotrze do handlera. To kosztuje CPU i pamięć.

Prościej traktować middleware jak filtr, który musi uzasadnić swój koszt. Dobre praktyki:

  • autoryzację i limity nakładać tylko na ścieżki, które ich potrzebują (router z grupami ścieżek),
  • ciężkie operacje (np. parsowanie skomplikowanych nagłówków) robić leniwie, gdy handler faktycznie korzysta z danych,
  • łączenie kilku funkcji w jedno middleware, gdy i tak działają razem (np. log + metryka + trace ID).

Przykład prostego middleware logującego czas obsługi requestu bez nadmiarowych bajerów:

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        dur := time.Since(start)
        log.Printf("%s %s %s", r.Method, r.URL.Path, dur)
    })
}

Warto mieć też „panic recovery”, ale bez rozbudowanych stosów i refleksji:

func Recover(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("panic: %v", rec)
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Takie dwa middleware’y dają przyzwoite bezpieczeństwo i observability bez wielkich kosztów runtime’owych.

Statyczna konfiguracja zamiast globalnych zmiennych

Router i middleware dobrze jest składać raz przy starcie i trzymać jako gotowy łańcuch handlerów, zamiast wprowadzać globalne przełączniki, które są sprawdzane przy każdym requestcie.

Przykładowy „stack” przy użyciu standardowego http.ServeMux:

func buildHandler(deps *Dependencies) http.Handler {
    mux := http.NewServeMux()

    userHandler := httptransport.NewUserHandler(deps.UserService)

    mux.HandleFunc("/v1/users", userHandler.CreateUser)
    mux.HandleFunc("/v1/health", deps.HealthHandler)

    // kolejność ma znaczenie
    h := http.Handler(mux)
    h = Logging(h)
    h = Recover(h)

    return h
}

func main() {
    deps := NewDependencies()
    handler := buildHandler(deps)

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      handler,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatalf("server error: %v", err)
    }
}

Brak globali typu var enableDebug bool zmniejsza ryzyko wyścigów i blokad. Zmiana konfiguracji odbywa się przez nowy deploy, nie przez runtime’owe przełączanie flag.

Keep-alive, gzip i cache – szybkie API to nie tylko kod

Go domyślnie wspiera HTTP keep-alive, więc kolejne requesty klienta nie wymagają ponownego nawiązywania połączenia. Z punktu widzenia API wystarczy nie wyłączać tego mechanizmu (nie manipulować ręcznie nagłówkiem Connection), a w proxy przed API (np. nginx, Cloudflare) zadbać o sensowne limity.

Gzip (a dokładniej kompresja Content-Encoding) często jest realizowany przed API, w reverse proxy. To zwykle tańsze rozwiązanie niż dorzucanie middleware’ów kompresujących w Go. Gdy jednak API musi samodzielnie kompresować odpowiedzi (np. brak zewnętrznego proxy), opłaca się:

  • kompresować tylko większe odpowiedzi (np. > 1 KB),
  • unikać wysokich poziomów kompresji, które zwiększają CPU,
  • re-używać gzip.Writer przez sync.Pool.
var gzipPool = sync.Pool{
    New: func() any {
        // poziom 1-3 zwykle jest wystarczający dla API
        w, _ := gzip.NewWriterLevel(io.Discard, gzip.BestSpeed)
        return w
    },
}

func Gzip(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
            next.ServeHTTP(w, r)
            return
        }

        gz := gzipPool.Get().(*gzip.Writer)
        defer gzipPool.Put(gz)

        gz.Reset(w)
        defer gz.Close()

        w.Header().Set("Content-Encoding", "gzip")
        gw := &gzipResponseWriter{ResponseWriter: w, Writer: gz}

        next.ServeHTTP(gw, r)
    })
}

type gzipResponseWriter struct {
    http.ResponseWriter
    io.Writer
}

func (w *gzipResponseWriter) Write(b []byte) (int, error) {
    return w.Writer.Write(b)
}

Cache po stronie HTTP (nagłówki ETag, Cache-Control) to kolejny tani sposób na odciążenie API. Zamiast dokładać Redis, można przyczyścić kontrakt HTTP tak, by klienci nie pytali o te same dane co chwilę. Flagowy przykład: listy referencyjne, konfiguracje, słowniki.

Uporządkowane limity requestu (body size, nagłówki)

Ataki i przypadkowe „zalanie” API często polegają na wysłaniu bardzo dużego body lub dziwnych nagłówków. Zamiast później walczyć z GC, lepiej przyciąć request na wejściu.

Ograniczenie rozmiaru body jest banalne i tanie:

const maxRequestBody = 2 * 1024 * 1024 // 2 MB

func LimitBody(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
        next.ServeHTTP(w, r)
    })
}

Po stronie nagłówków większość ograniczeń osiąga się konfiguracją reverse proxy (nginx, Envoy). W Go spokojnie można zostać przy domyślnych limitach, chyba że API jest wystawione bezpośrednio do Internetu – wtedy opłaca się jawnie obniżyć MaxHeaderBytes w http.Server.

Praca z bazą danych i I/O – największe wąskie gardła API

Wybór klienta DB: prostota kontra „magia ORM”

Trzon większości API to baza danych. Dobrze dobrany sposób dostępu do DB decyduje bardziej o wydajności niż wybór routera. Na budżetowych projektach prosta kombinacja:

  • database/sql z niskopoziomowym driverem (np. pgx dla Postgresa),
  • lekka warstwa repozytoriów pisanych ręcznie,

daje lepszy stosunek efektu do wysiłku niż pełny ORM.

type UserRepository interface {
    Insert(ctx context.Context, u *User) (string, error)
    GetByEmail(ctx context.Context, email string) (*User, error)
}

type pgUserRepository struct {
    db *sql.DB
}

func (r *pgUserRepository) Insert(ctx context.Context, u *User) (string, error) {
    var id string
    err := r.db.QueryRowContext(ctx,
        `INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id`,
        u.Email, u.Name,
    ).Scan(&id)
    return id, err
}

Pełne ORM-y (GORM, ent i inne) mają sens, gdy projekt ma bardzo rozbudowany model domenowy, a zespół nie chce utrzymywać SQL-a ręcznie. Trzeba jednak zaakceptować dodatkowy koszt abstrakcji i czasem mniej przewidywalne zapytania.

Konfiguracja puli połączeń – szybki zysk bez zmian w kodzie handlerów

Domyślne ustawienia *sql.DB są konserwatywne. Przy API obciążonym równolegle zysk z ręcznej konfiguracji puli jest duży w stosunku do wysiłku:

db, err := sql.Open("pgx", dsn)
if err != nil {
    log.Fatalf("db open: %v", err)
}

// dopasować do liczby CPU i przepustowości bazy
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(20)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)

Parametry zależą od konkretnej bazy i środowiska. Zaniżona liczba połączeń = kolejki i timeouty, zbyt wysoka = przeciążona baza. W praktyce sensowny jest prosty eksperyment: zacząć od wartości zbliżonych do liczby CPU, a potem obserwować metryki bazy i API.

Krótko żyjące transakcje – mniejszy lock, większy throughput

Transakcje trzymają blokady w bazie. Im dłużej trwają, tym mocniej ograniczają równoległość i psują latency API. Dlatego transakcje powinny obejmować tylko to, co konieczne – od momentu pierwszego SQL-a do ostatniego.

Co warto zapamiętać

  • Go sprawdza się przy budowie szybkich API dzięki kompilowanemu kodowi, małemu zużyciu pamięci i wbudowanemu, wydajnemu serwerowi HTTP – bez dodatkowych runtime’ów i serwerów aplikacyjnych.
  • Minimalistyczny język i przewidywalny styl projektów obniżają koszt utrzymania: kod jest prosty do czytania, łatwo przejąć go po kimś i szybciej wdrożyć nowych developerów.
  • Model współbieżności oparty na goroutines i kanałach umożliwia naturalne skalowanie na wiele rdzeni: każde żądanie HTTP może działać w osobnej goroutine bez walki z event loopem.
  • Go daje największy zwrot z inwestycji przy dużym ruchu, mikroserwisach i proxy/gatewayach, gdzie liczy się liczba requestów na sekundę, footprint pamięci i prosty deployment jednego binarnego pliku.
  • Przepisywanie stabilnych API w Node/Python/PHP tylko dla „wydajności Go” zwykle się nie opłaca; taniej jest najpierw poprawić cache, zapytania do bazy czy dodać rate limiting, a Go wprowadzać w nowych usługach lub wydzielonych wąskich gardłach.
  • W porównaniu z Node/Python Go wymaga nieco więcej dyscypliny na starcie, ale odwdzięcza się stabilnym zachowaniem pod obciążeniem – zamiast dokładania kolejnych instancji łatwiej wycisnąć więcej z tego samego sprzętu.
  • Prosta, konsekwentna struktura projektu (podział na cmd, transport HTTP, serwisy, repozytoria, konfigurację) i poprawnie ustawione timeouty serwera HTTP to tani sposób na uniknięcie wielu problemów z wydajnością i „wiszącymi” połączeniami.
Poprzedni artykułJak zainstalować i skonfigurować OpenMediaVault
Następny artykułAI w rekrutacji: jak używać jej legalnie i bez dyskryminacji
Oliwia Michalski
Oliwia Michalski pisze o chmurze, aplikacjach webowych i narzędziach produktywności, wybierając tematy, które realnie usprawniają pracę użytkowników i zespołów. W poradnikach pokazuje konfiguracje krok po kroku, zwracając uwagę na bezpieczeństwo kont, uprawnienia, kopie danych i kontrolę kosztów. Materiały opiera na dokumentacji dostawców, testach funkcji oraz porównaniach planów i ograniczeń usług. Dba o precyzyjny język i transparentne założenia, dzięki czemu czytelnik wie, kiedy dane rozwiązanie ma sens, a kiedy lepiej szukać alternatywy.