Golang i Gin: jak zbudować szybkie API, testy i middleware od zera

1
13
Rate this post

Nawigacja:

Dlaczego Gin i Go nadają się do szybkiego API

Go vs inne języki przy budowie API

Go zyskało popularność w budowie API z jednego prostego powodu: łączy prostotę składni z bardzo dobrą współbieżnością i wydajnością. Kompiluje się do jednej binarki, nie wymaga osobnego runtime’u ani wirtualnej maszyny. W praktyce oznacza to szybkie uruchamianie i prostą dystrybucję – szczególnie w środowiskach kontenerowych.

W porównaniu z typowymi stosami do API:

  • Go vs Node.js – Node jest świetny do prototypów, ma ogromny ekosystem, ale wymaga runtime’u i często mocnego „pilnowania” jednowątkowego event loopa przy obciążeniach CPU. Go oferuje natywne gorutyny i scheduler, który bez kombinacji obsłuży tysiące połączeń równolegle.
  • Go vs Java / Kotlin – JVM daje dojrzałe frameworki i bogate biblioteki, ale wymaga bardziej złożonej konfiguracji, ma cięższy start i większy footprint pamięci. Go jest skromniejsze jeśli chodzi o magiczne abstrakcje, za to bardzo przewidywalne w produkcji.
  • Go vs Python / Ruby – oba języki są szybkie w tworzeniu, ale wolniejsze w wykonaniu i mocno zależne od dodatkowych serwerów aplikacyjnych (Gunicorn, Unicorn, Passenger). Go znów wygrywa prostotą wdrożenia i surową wydajnością.

Istotny aspekt to biblioteka standardowa: pakiet net/http umożliwia zbudowanie stabilnego serwera HTTP bez żadnych zewnętrznych zależności. Frameworki takie jak Gin nie zastępują tego fundamentu, tylko go rozszerzają o wygodniejsze API i dodatki.

Czym jest Gin i co go wyróżnia

Gin to lekki, wydajny framework HTTP dla Go, z API bardzo zbliżonym do net/http. Pod spodem używa szybkiego routera (inspirowanego m.in. httprouter), zapewnia wygodny system middleware, prostą obsługę JSON oraz mechanizmy bindowania i walidacji.

Najważniejsze cechy Gin, które robią różnicę przy budowie REST API w Golang:

  • Wydajny router – dynamiczne ścieżki, grupy ścieżek, parametry w URL i dobry performance pod dużym ruchem.
  • Kontekst requestu – obiekt gin.Context przekazywany do handlerów zawiera request, response writer, parametry, metody do JSON, statusów HTTP, helpery do bindowania.
  • Middleware w Gin – elastyczny łańcuch middleware, łatwe wpinanie logowania, recovery, CORS, autoryzacji, rate limiting itp.
  • Integracja z JSON – funkcje c.JSON, c.Bind, c.ShouldBindJSON skracają typowe operacje HTTP + JSON do kilku linii.

Gin nie próbuje być „mega frameworkiem” typu full-stack. Nie narzuca warstwy ORM, systemu template’ów HTML ani złożonej architektury. Dzięki temu dobrze komponuje się z własnym podziałem na warstwy (services, repositories) oraz narzędziami, które sam dobierasz.

Mit: „Gin automatycznie zrobi API szybkie”

Popularne przekonanie mówi: „wystarczy użyć Gin, a API będzie szybkie”. Rzeczywistość jest mniej magiczna. Gin sam z siebie jest lekki i wydajny, ale największe wąskie gardła w API rzadko leżą w samym routerze.

Rzeczywiste źródła problemów z performance API w Gin to najczęściej:

  • zbyt wolne I/O (baza danych, zewnętrzne API, dysk),
  • brak cache, nadmiarowe zapytania i brak batching’u,
  • ciężka logika biznesowa „upchana” w handlerach,
  • brak limitów współbieżności i timeouts.

Mit polega na obwinianiu frameworka za coś, co wymaga dobrej architektury i myślenia o przepływie danych. Gin zapewnia szybki „szkielet”, ale o to, co dzieje się w środku i jak efektywnie używasz gorutyn oraz zasobów, trzeba zadbać samodzielnie.

Kiedy Gin ma sens, a kiedy wystarczy net/http

Gin jest świetnym wyborem dla większości REST API w Golang, zwłaszcza gdy:

  • tworzysz więcej niż kilka endpointów i zależy ci na routerze i middleware,
  • chcesz wygodnego bindowania JSON, URL, form-data,
  • planujesz logowanie, recovery, autoryzację na poziomie middleware.

Są jednak przypadki, gdy czyste net/http jest wystarczające lub wręcz lepsze:

  • mały serwis z jednym–dwoma endpointami (np. health check, prosty proxy),
  • gdy potrzebna jest pełna kontrola nad tym, co dzieje się z requestem i responsem,
  • gdy build ma być wyjątkowo minimalny i zależności mają być bliskie zero.

Alternatywne frameworki (Echo, Chi, Fiber) też mają swoje zalety, ale pattern pracy jest podobny: router + middleware + kontekst requestu. Dla wielu zespołów wybór sprowadza się do tego, który framework znają lepiej i który ma bardziej odpowiadające im API.

Przygotowanie środowiska i struktura projektu od pierwszej linijki

Inicjalizacja modułu i minimalne wymagania

Start projektu w Go zaczyna się od ustawienia modułu. W katalogu projektu:

go mod init github.com/twoja-firma/todo-api
go env GOPATH
go version

Dobrze używać aktualnie wspieranej wersji Go (np. 1.21+), bo nowsze wersje wnoszą usprawnienia w zarządzaniu modułami, bezpieczeństwie i wydajności. Plik go.mod będzie wyglądał mniej więcej tak:

module github.com/twoja-firma/todo-api

go 1.21

Na tym etapie nie trzeba nic więcej. Resztą zależności zajmie się mechanizm modułów Go w momencie, gdy dodasz pierwsze importy i uruchomisz go mod tidy.

Struktura katalogów dla API w Go

Przy małej aplikacji da się wszystko upchnąć w kilku plikach w jednym katalogu. Problem pojawia się, gdy rośnie liczba endpointów, logiki biznesowej i zależności. Wtedy warto od razu postawić na prostą, ale skalowalną strukturę, np. z użyciem cmd/ i internal/:

todo-api/
  cmd/
    api/
      main.go
  internal/
    http/
      router.go
      middleware.go
    task/
      handler.go
      service.go
      repository.go
      models.go
    config/
      config.go
    platform/
      db/
        postgres.go

Kluczowe założenia:

  • cmd/api – wejście do aplikacji (funkcja main), tylko bootstrapping, bez logiki biznesowej.
  • internal/http – konfiguracja routera Gin, rejestracja middleware, łączenie handlerów.
  • internal/task – moduł domenowy: handler HTTP, serwis, repozytorium i modele (Task, TaskFilter itd.).
  • internal/config – konfiguracja z env, pliku lub flag.
  • internal/platform/db – techniczne detale do bazy (np. inicjalizacja połączenia PostgreSQL).

Taka struktura pozwala stopniowo rozbudowywać aplikację, dokładając nowe moduły domenowe (np. user/, auth/) bez zmieniania istniejących pakietów w „god file” i bez chaosu.

Konwencje nazewnicze i unikanie „god file”

„God file” to plik, który ma wszystko: router, konfigurację, logikę biznesową, dostęp do bazy, helpery i testy. Na początku bywa wygodny, ale po kilku sprintach staje się koszmarem w utrzymaniu. Dobrze przyjąć prostą konwencję:

  • w pakiecie domenowym (task) trzy główne pliki: handler.go, service.go, repository.go,
  • modele w models.go lub rozbite, jeśli robi się ich dużo,
  • middleware w dedykowanym pliku, np. middleware.go w pakiecie HTTP.

Nazwy funkcji i typów niech jasno mówią, czym się zajmują: TaskService, TaskRepository, NewTaskHandler. Zamiast „uniwersalnych” typów typu Manager, Helper, lepiej od razu nazwać rolę w domenie.

Konfiguracja: env, pliki i narzędzia

W prostych API spokojnie wystarczy pakiet os i ewentualnie wczytywanie zmiennych środowiskowych. Przykładowy pakiet config:

package config

import (
  "log"
  "os"
)

type Config struct {
  Addr   string
  DBURL  string
  Env    string
}

func Load() *Config {
  cfg := &Config{
    Addr:  getEnv("HTTP_ADDR", ":8080"),
    DBURL: mustEnv("DB_URL"),
    Env:   getEnv("APP_ENV", "local"),
  }
  return cfg
}

func getEnv(key, def string) string {
  if v := os.Getenv(key); v != "" {
    return v
  }
  return def
}

func mustEnv(key string) string {
  v := os.Getenv(key)
  if v == "" {
    log.Fatalf("missing required env: %s", key)
  }
  return v
}

Bardziej rozbudowane projekty czasem sięgają po biblioteki typu Viper, ale płaci się za to większą złożonością i trudniejszym testowaniem. W wielu projektach zwykłe odczyty z env plus mała struktura konfiguracyjna są wystarczające i prostsze w obsłudze.

Przykładowe drzewo katalogów małej, ale rosnącej aplikacji

Po dodaniu kilku elementów (router, pierwszy moduł domenowy, konfiguracja) projekt może wyglądać tak:

todo-api/
  cmd/
    api/
      main.go               // uruchomienie serwera
  internal/
    http/
      router.go             // tworzenie gin.Engine, rejestracja tras
      middleware.go         // globalne i grupowe middleware
    task/
      models.go             // struct Task, DTO, error'y domenowe
      handler.go            // HTTP handler'y for REST API w Gin
      service.go            // logika biznesowa
      repository.go         // interfejs i implementacja in-memory / DB
    config/
      config.go             // ładowanie konfiguracji
  go.mod
  go.sum

Taką strukturę można bezboleśnie rozszerzać, np. dodając internal/user, internal/auth, bez ryzyka, że wszystko trafi do jednego, nieczytelnego pliku.

Pierwsze API w Gin: router, endpointy i JSON

Instalacja Gin i minimalny serwer

Aby dodać Gin do projektu, wystarczy jeden import i uruchomienie go mod tidy:

go get github.com/gin-gonic/gin

Minimalny serwer w cmd/api/main.go może wyglądać tak:

package main

import (
  "github.com/gin-gonic/gin"
  "log"
)

func main() {
  r := gin.Default() // Logger + Recovery
  r.GET("/ping", func(c *gin.Context) {
    c.JSON(200, gin.H{"message": "pong"})
  })

  if err := r.Run(":8080"); err != nil {
    log.Fatal(err)
  }
}

gin.Default() tworzy silnik z dwoma globalnymi middleware: Logger i Recovery. Dla pełnej kontroli można użyć gin.New() i samemu dodać middleware.

Praca z gin.Engine, grupy ścieżek i metody HTTP

Dla REST API w Golang z Gin kluczowy jest router. *gin.Engine udostępnia metody odpowiadające metodom HTTP:

  • GET(path string, handlers ...gin.HandlerFunc)
  • POST(path string, handlers ...gin.HandlerFunc)
  • PUT, DELETE, PATCH, OPTIONS, HEAD

Grupy ścieżek są praktyczne do wersjonowania i logicznego grupowania endpointów. W pliku internal/http/router.go:

package http

import "github.com/gin-gonic/gin"

func NewRouter(taskHandler TaskHandler) *gin.Engine {
  r := gin.New()
  r.Use(gin.Logger(), gin.Recovery())

  api := r.Group("/api")
  v1 := api.Group("/v1")

  task := v1.Group("/tasks")
  {
    task.GET("", taskHandler.ListTasks)
    task.POST("", taskHandler.CreateTask)
    task.GET("/:id", taskHandler.GetTaskByID)
    task.DELETE("/:id", taskHandler.DeleteTask)
  }

  return r
}

Router groups (api, v1, task) pozwalają na łatwe dodawanie middleware per grupa (np. autoryzacja tylko dla /api/v1 albo tylko dla /tasks).

Struktury danych i JSON: bind, ShouldBindJSON, c.JSON

Gin znacząco upraszcza pracę z JSON. Przykładowy model Task:

package task

type Task struct {
  ID      int64  `json:"id"`
  Title   string `json:"title" binding:"required"`
  Done    bool   `json:"done"`
}

Handler POST, który przyjmuje JSON, waliduje i zwraca utworzony obiekt:

Handler POST: tworzenie zadania i odpowiedzi JSON

Przykładowy handler, który używa walidacji Gin i zwraca JSON, może wyglądać tak:

package task

import (
  "net/http"

  "github.com/gin-gonic/gin"
)

type Handler struct {
  service Service
}

func NewHandler(s Service) *Handler {
  return &Handler{service: s}
}

func (h *Handler) CreateTask(c *gin.Context) {
  var input CreateTaskInput
  if err := c.ShouldBindJSON(&input); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{
      "error": "invalid_payload",
      "details": err.Error(),
    })
    return
  }

  task, err := h.service.CreateTask(c, input)
  if err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{
      "error": "cannot_create_task",
    })
    return
  }

  c.JSON(http.StatusCreated, task)
}

type CreateTaskInput struct {
  Title string `json:"title" binding:"required,min=3,max=255"`
}

Kluczowe elementy:

  • ShouldBindJSON – wypełnia strukturę z JSON-a i używa tagów binding do walidacji.
  • http.StatusCreated – zwracamy 201 zamiast domyślnego 200 po utworzeniu zasobu.
  • obsługa błędów w jednym miejscu, zamiast rozlewania ich po kilku if-ach z logiką biznesową.

Do prostych endpointów ShouldBindJSON jest zwykle wystarczające. Przy bardziej złożonych payloadach lepiej rozdzielić modele domenowe (np. Task) od struktur transportowych (np. CreateTaskInput, TaskResponse).

Rozdzielenie modeli domenowych i DTO

Popularny skrót myślowy głosi, że „w małym API można używać tych samych struktur wszędzie”. Kusi, bo jest szybko. Zazwyczaj kończy się tym, że struktura domenowa ma tagi JSON, tagi do ORMa, walidacje, komentarze do swaggera i trzydzieści pól, z których klient widzi pięć.

Zamiast tego proste rozdzielenie wystarcza na długo:

type Task struct {
  ID      int64
  Title   string
  Done    bool
}

type TaskResponse struct {
  ID    int64  `json:"id"`
  Title string `json:"title"`
  Done  bool   `json:"done"`
}

func ToTaskResponse(t *Task) TaskResponse {
  return TaskResponse{
    ID:    t.ID,
    Title: t.Title,
    Done:  t.Done,
  }
}

Dzięki temu można bez bólu zmieniać model domenowy (np. dodać wewnętrzne flagi, metadane) bez łamania kontraktu API.

Kolorowy kod źródłowy wyświetlony na ekranie komputera
Źródło: Pexels | Autor: Markus Spiske

Projektowanie kontraktu API: routing, wersjonowanie i konwencje

Konsekwentne ścieżki i czasowniki HTTP

RESTowe API na Gin nie wymaga żadnej „magii”, ale korzysta z prostych, spójnych zasad. Dla zasobu tasks można przyjąć:

  • GET /api/v1/tasks – lista zadań,
  • POST /api/v1/tasks – stworzenie zadania,
  • GET /api/v1/tasks/:id – pobranie jednego zadania,
  • PUT /api/v1/tasks/:id – pełna aktualizacja,
  • PATCH /api/v1/tasks/:id – częściowa aktualizacja,
  • DELETE /api/v1/tasks/:id – usunięcie zadania.

Typowy błąd polega na mieszaniu czasowników w URL-ach (/createTask, /deleteTask) z czasownikami HTTP. Skoro HTTP dostarcza semantykę, nie ma sensu powielać jej w ścieżce.

Wersjonowanie w ścieżce vs w nagłówkach

Najprostszy i najczęściej spotykany model wersjonowania to wersja w ścieżce (/api/v1, /api/v2). Dobrze się skaluje, jest widoczny w logach, a w Gin układa się naturalnie dzięki grupom:

api := r.Group("/api")

v1 := api.Group("/v1")
{
  tasks := v1.Group("/tasks")
  tasks.GET("", taskHandler.ListTasks)
}

v2 := api.Group("/v2")
{
  tasks := v2.Group("/tasks")
  tasks.GET("", taskV2Handler.ListTasks)
}

Wersjonowanie nagłówkiem (np. Accept: application/vnd.myapi.v1+json) ma sens w kontrolowanych środowiskach (wewnętrzne API), ale jest trudniejsze w debugowaniu i monitoringu. W logach Nginxa od razu widać /api/v1, nagłówki nie zawsze.

Stabilność kontraktu a wewnętrzna ewolucja

Mit: „Jak raz wypuścimy v1, to jesteśmy zablokowani”. W praktyce większość zmian da się wprowadzić wstecznie kompatybilnie: dodać nowe pola w odpowiedzi, umożliwić nowe filtracje, zaakceptować opcjonalne pola w payloadzie. Twarde cięcie do v2 opłaca się dopiero przy faktycznie łamiących zmianach, np. przeniesienie całego modelu autoryzacji.

Kluczowe założenie: kontrakt API jest stabilny, ale implementacja w środku może się zmieniać. Nowe serwisy, refaktoring repozytoriów, caching – wszystko to można robić bez naruszania kontraktu, o ile kształt requestów i response’ów pozostaje spójny.

Parametry ścieżki, query i body – kiedy co stosować

Podział jest prosty, ale często bywa mieszany:

  • Parametry ścieżki – adresują konkretny zasób (/tasks/123).
  • Query – wpływają na listę lub widok (/tasks?done=true&limit=50).
  • Body – dane tworzone/aktualizowane (JSON przy POST/PUT/PATCH).

W Gin parametry ścieżki i zapytania czyta się bezpośrednio z kontekstu:

func (h *Handler) ListTasks(c *gin.Context) {
  done := c.Query("done") // string, "" jeśli brak
  limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))

  filter := TaskFilter{
    Done:  parseBoolPtr(done),
    Limit: limit,
  }

  tasks, err := h.service.ListTasks(c, filter)
  if err != nil {
    c.JSON(http.StatusInternalServerError, gin.H{
      "error": "cannot_list_tasks",
    })
    return
  }

  c.JSON(http.StatusOK, tasks)
}

Architektura handlerów: jak nie robić „grubej warstwy HTTP”

Rozdzielenie odpowiedzialności: handler – serwis – repozytorium

Handler Gin ma jedną odpowiedzialność: zamienić HTTP (JSON, nagłówki, statusy) na wywołania logiki biznesowej i z powrotem. Im mniej wie o szczegółach domeny, tym lepiej.

type Service interface {
  CreateTask(ctx context.Context, in CreateTaskInput) (*Task, error)
  ListTasks(ctx context.Context, f TaskFilter) ([]*Task, error)
  // ...
}

type Repository interface {
  Insert(ctx context.Context, t *Task) error
  FindByID(ctx context.Context, id int64) (*Task, error)
  FindAll(ctx context.Context, f TaskFilter) ([]*Task, error)
}

Handler odwołuje się tylko do Service. Serwis nie zna Gin – używa context.Context. Repozytorium nie musi wiedzieć nic o HTTP ani JSON-ie, operuje na modelu domenowym.

Cienkie handlery: przykład pełnego przepływu

Prosty handler pobierający zadanie po ID może wyglądać tak:

func (h *Handler) GetTaskByID(c *gin.Context) {
  idParam := c.Param("id")
  id, err := strconv.ParseInt(idParam, 10, 64)
  if err != nil {
    c.JSON(http.StatusBadRequest, gin.H{
      "error": "invalid_id",
    })
    return
  }

  task, err := h.service.GetTaskByID(c, id)
  if err != nil {
    if errors.Is(err, ErrTaskNotFound) {
      c.JSON(http.StatusNotFound, gin.H{
        "error": "task_not_found",
      })
      return
    }

    c.JSON(http.StatusInternalServerError, gin.H{
      "error": "cannot_get_task",
    })
    return
  }

  c.JSON(http.StatusOK, ToTaskResponse(task))
}

Tutaj HTTP jest cienką powłoką: parsowanie ID, przypisanie statusu, mapowanie błędów. Cała reszta dzieje się w serwisie.

Serwis jako centrum logiki biznesowej

Serwis spina reguły biznesowe, walidacje, interakcje z repozytoriami. Nie parsuje JSON-a i nie wypisuje statusów HTTP.

type service struct {
  repo Repository
}

func NewService(r Repository) Service {
  return &service{repo: r}
}

func (s *service) CreateTask(ctx context.Context, in CreateTaskInput) (*Task, error) {
  if strings.TrimSpace(in.Title) == "" {
    return nil, ErrInvalidTitle
  }

  task := &Task{
    Title: in.Title,
    Done:  false,
  }

  if err := s.repo.Insert(ctx, task); err != nil {
    return nil, err
  }

  return task, nil
}

Mit: „Do prostego CRUD-a wystarczy handler + repo, serwis to overkill”. Gdy pojawi się pierwsza reguła biznesowa (np. maksymalna liczba otwartych zadań na użytkownika), serwis staje się naturalnym miejscem na takie zasady. Bez niego logika ląduje w handlerach, które rosną w niekontrolowany sposób.

Repozytorium i mockowanie w testach

Repozytorium izoluje dostęp do danych. Implementacja in-memory do testów integracyjnych może wyglądać tak:

type InMemoryRepo struct {
  mu    sync.RWMutex
  seq   int64
  tasks map[int64]*Task
}

func NewInMemoryRepo() *InMemoryRepo {
  return &InMemoryRepo{
    tasks: make(map[int64]*Task),
  }
}

func (r *InMemoryRepo) Insert(ctx context.Context, t *Task) error {
  r.mu.Lock()
  defer r.mu.Unlock()

  r.seq++
  t.ID = r.seq
  r.tasks[t.ID] = t
  return nil
}

W testach serwisu można podmienić repo lekkim mockiem lub implementacją w pamięci, bez uruchamiania bazy danych.

Inicjalizacja zależności w main.go

Łączenie warstw powinno odbywać się na brzegu aplikacji – typowo w cmd/api/main.go:

package main

import (
  "log"

  "github.com/gin-gonic/gin"
  "github.com/twoja-firma/todo-api/internal/config"
  "github.com/twoja-firma/todo-api/internal/http"
  "github.com/twoja-firma/todo-api/internal/task"
)

func main() {
  cfg := config.Load()

  repo := task.NewInMemoryRepo() // albo postgres.NewTaskRepo(db)
  svc := task.NewService(repo)
  h   := task.NewHandler(svc)

  r := gin.New()
  r.Use(gin.Logger(), gin.Recovery())

  http.RegisterRoutes(r, h)

  if err := r.Run(cfg.Addr); err != nil {
    log.Fatal(err)
  }
}

Rezygnacja z „magii” DI i reflection w Go zwykle ułatwia życie. Zależności są jawne, łatwe do prześledzenia w kodzie i w testach.

Middleware w Gin: od logowania po autoryzację

Struktura middleware w Gin

Middleware w Gin to po prostu gin.HandlerFunc – funkcja z dostępem do kontekstu i łańcucha wywołań:

func MyMiddleware() gin.HandlerFunc {
  return func(c *gin.Context) {
    // kod przed handlerem
    c.Next()
    // kod po handlerze
  }
}

c.Next() przekazuje sterowanie do kolejnego middleware/handlera. Można też przerwać łańcuch, nie wywołując Next() i od razu zwrócić odpowiedź (np. przy błędzie autoryzacji).

Globalne middleware vs grupowe

Gin pozwala na trzy główne poziomy podpinania middleware:

  • globalne – dla całego routera: r.Use(...),
  • grupowe – dla danej grupy: v1.Use(...),
  • lokalne – dla konkretnego endpointu: r.GET("/ping", MyMiddleware(), handler).

Przykład rozdzielenia: globalne logowanie i recovery, autoryzacja tylko na ścieżkach /api/v1.

r := gin.New()
r.Use(gin.Logger(), gin.Recovery())

api := r.Group("/api")
v1 := api.Group("/v1")
v1.Use(AuthMiddleware())

tasks := v1.Group("/tasks")
tasks.GET("", taskHandler.ListTasks)

Własne logowanie requestów

Wbudowany gin.Logger() jest w porządku na start, ale często trzeba dopasować logi do własnego formatu lub systemu (ELK, Loki, Datadog). Prosty middleware logujący czas wykonania i status:

func RequestLogger(logger *log.Logger) gin.HandlerFunc {
  return func(c *gin.Context) {
    start := time.Now()
    path := c.Request.URL.Path
    method := c.Request.Method

    c.Next()

    latency := time.Since(start)
    status := c.Writer.Status()

    logger.Printf("%s %s %d %s", method, path, status, latency)
  }
}

Można go połączyć z loggerem strukturalnym (zap, zerolog), dopisując pole request_id z innego middleware.

Trace ID / Request ID

Śledzenie pojedynczego requestu przez wiele usług jest niemal obowiązkowe w rozproszonych systemach. Prosty middleware generujący lub odczytujący X-Request-Id:

const requestIDKey = "request_id"

func RequestID() gin.HandlerFunc {
  return func(c *gin.Context) {
    rid := c.Request.Header.Get("X-Request-Id")
    if rid == "" {
      rid = uuid.NewString()
    }

    c.Set(requestIDKey, rid)
    c.Writer.Header().Set("X-Request-Id", rid)

    c.Next()
  }
}

func GetRequestID(c *gin.Context) string {
  if v, ok := c.Get(requestIDKey); ok {
    if s, ok := v.(string); ok {
      return s
    }
  }
  return ""
}

Następnie w loggerze można dorzucić request_id do każdego wpisu logu.

Autoryzacja z tokenem Bearer

Najczęstszy scenariusz w API HTTP to nagłówek Authorization: Bearer <token>. Gin nie ma wbudowanej obsługi JWT czy OAuth2 – i dobrze. Logika autoryzacji trafia do osobnego middleware, którego łatwo wymienić lub rozwinąć.

func AuthMiddleware(tokenVerifier TokenVerifier) gin.HandlerFunc {
  return func(c *gin.Context) {
    authHeader := c.GetHeader("Authorization")
    if authHeader == "" {
      c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
        "error": "missing_authorization_header",
      })
      return
    }

    parts := strings.SplitN(authHeader, " ", 2)
    if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
      c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
        "error": "invalid_authorization_header",
      })
      return
    }

    token := parts[1]
    claims, err := tokenVerifier.Verify(c.Request.Context(), token)
    if err != nil {
      c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
        "error": "invalid_token",
      })
      return
    }

    c.Set("user_id", claims.UserID)
    c.Set("scopes", claims.Scopes)

    c.Next()
  }
}

type TokenVerifier interface {
  Verify(ctx context.Context, token string) (*Claims, error)
}

type Claims struct {
  UserID string
  Scopes []string
}

Middleware sprawdza nagłówek, weryfikuje token, a potem wstrzykuje do kontekstu informacje o użytkowniku, które handlery mogą odczytać:

func GetCurrentUserID(c *gin.Context) (string, bool) {
  v, ok := c.Get("user_id")
  if !ok {
    return "", false
  }
  s, ok := v.(string)
  return s, ok
}

Mit: „autoryzacja w handlerze jest prostsza, bo mam wszystko w jednym miejscu”. Potem dochodzi drugi endpoint, trzeci, czwarty i wszędzie ląduje kopiuj-wklej tego samego kodu sprawdzającego token. Middleware usuwa ten duplikat, a logika uprawnień może trafić do osobnej funkcji lub serwisu.

Uprawnienia oparte na rolach / scope’ach

Samo sprawdzenie, czy token jest poprawny, to za mało. Często trzeba wymusić konkretne role lub zakresy (scopes). Dobrym wzorcem jest cienki middleware autoryzacyjny „nadbudowany” nad middleware uwierzytelniającym:

func RequireScopes(required ...string) gin.HandlerFunc {
  requiredSet := make(map[string]struct{}, len(required))
  for _, s := range required {
    requiredSet[s] = struct{}{}
  }

  return func(c *gin.Context) {
    v, ok := c.Get("scopes")
    if !ok {
      c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
        "error": "missing_scopes",
      })
      return
    }

    scopes, ok := v.([]string)
    if !ok {
      c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
        "error": "invalid_scopes_type",
      })
      return
    }

    if !hasAllScopes(scopes, requiredSet) {
      c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
        "error": "insufficient_permissions",
      })
      return
    }

    c.Next()
  }
}

func hasAllScopes(userScopes []string, required map[string]struct{}) bool {
  userSet := make(map[string]struct{}, len(userScopes))
  for _, s := range userScopes {
    userSet[s] = struct{}{}
  }

  for r := range required {
    if _, ok := userSet[r]; !ok {
      return false
    }
  }
  return true
}

Takie middleware podpinamy już na poziomie konkretnych ścieżek:

tasks := v1.Group("/tasks")
tasks.Use(AuthMiddleware(verifier))

tasks.POST("", RequireScopes("tasks:write"), taskHandler.CreateTask)
tasks.GET("", RequireScopes("tasks:read"), taskHandler.ListTasks)

Przy większych projektach scopes można zmapować na role domenowe w serwisie, zamiast przemycać logikę autoryzacji do warstwy HTTP. Middleware wtedy tylko tłumaczy token na „kim jest użytkownik”, a serwis decyduje, co wolno.

Rate limiting per użytkownik / IP

Gin sam z siebie nie ogranicza liczby żądań. Przy publicznych API sensowne jest wstawienie prostego rate limitera. Popularny wzorzec: token bucket oparty na pamięci lub Redisie.

type Limiter interface {
  Allow(key string) bool
}

func RateLimit(l Limiter, keyFunc func(*gin.Context) string) gin.HandlerFunc {
  return func(c *gin.Context) {
    key := keyFunc(c)
    if key == "" {
      key = c.ClientIP()
    }

    if !l.Allow(key) {
      c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
        "error": "rate_limit_exceeded",
      })
      return
    }

    c.Next()
  }
}

Prosta implementacja w pamięci używająca golang.org/x/time/rate:

type MemoryLimiter struct {
  mu    sync.Mutex
  limit rate.Limit
  burst int
  m     map[string]*rate.Limiter
}

func NewMemoryLimiter(limit rate.Limit, burst int) *MemoryLimiter {
  return &MemoryLimiter{
    limit: limit,
    burst: burst,
    m:     make(map[string]*rate.Limiter),
  }
}

func (ml *MemoryLimiter) getLimiter(key string) *rate.Limiter {
  ml.mu.Lock()
  defer ml.mu.Unlock()

  if l, ok := ml.m[key]; ok {
    return l
  }
  l := rate.NewLimiter(ml.limit, ml.burst)
  ml.m[key] = l
  return l
}

func (ml *MemoryLimiter) Allow(key string) bool {
  return ml.getLimiter(key).Allow()
}

Podpięcie pod router:

limiter := NewMemoryLimiter(5, 10) // średnio 5 rps, burst 10
r.Use(RateLimit(limiter, func(c *gin.Context) string {
  if uid, ok := GetCurrentUserID(c); ok {
    return "user:" + uid
  }
  return "ip:" + c.ClientIP()
}))

Mit: „rate limiting musi być w API gatewayu, backend nie powinien się tym zajmować”. Gateway często jest, ale nie zawsze pilnuje wszystkich scenariuszy (np. limit per user ID). Dodatkowy prosty limiter w API potrafi uratować bazę przy źle skonfigurowanym kliencie.

Obsługa błędów, walidacja i spójne komunikaty

Spójny format odpowiedzi błędów

Jeśli każdy handler zwraca błędy po swojemu, klient szybko tonie w chaosie. O wiele czytelniej jest zdefiniować jeden format i się go trzymać. Przykładowo:

type ErrorResponse struct {
  Error   string            `json:"error"`
  Message string            `json:"message,omitempty"`
  Fields  map[string]string `json:"fields,omitempty"`
  TraceID string            `json:"trace_id,omitempty"`
}

Wiele zespołów tworzy helper do wysyłania takich odpowiedzi i używa go wyłącznie z jednego miejsca – np. z globalnego middleware.

Globalny handler błędów

Gin ma mechanizm panic recovery, ale warto dołożyć własny middleware, który zamienia błędy biznesowe na odpowiedzi HTTP. Schemat: serwisy zwracają error z typami/kodami, a middleware na końcu łańcucha sprawdza, co się wydarzyło.

type AppError struct {
  Code    string
  Message string
  Status  int
}

func (e *AppError) Error() string { return e.Message }

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

Serwis może wtedy robić:

var (
  ErrTaskNotFound = NewAppError("task_not_found", "task not found", http.StatusNotFound)
  ErrInvalidInput = NewAppError("invalid_input", "invalid input", http.StatusBadRequest)
)

Middleware obsługi błędów bazujący na c.Errors:

func ErrorHandler() gin.HandlerFunc {
  return func(c *gin.Context) {
    c.Next()

    if len(c.Errors) == 0 {
      return
    }

    // Bierzemy ostatni błąd, często wystarczy
    err := c.Errors.Last().Err

    traceID := GetRequestID(c)

    var appErr *AppError
    if errors.As(err, &appErr) {
      c.JSON(appErr.Status, ErrorResponse{
        Error:   appErr.Code,
        Message: appErr.Message,
        TraceID: traceID,
      })
      return
    }

    // fallback na 500
    c.JSON(http.StatusInternalServerError, ErrorResponse{
      Error:   "internal_error",
      Message: "internal server error",
      TraceID: traceID,
    })
  }
}

Żeby to działało, handler nie powinien sam bezpośrednio pisać odpowiedzi przy każdym błędzie, tylko delegować je przez c.Error:

func (h *Handler) GetTaskByID(c *gin.Context) {
  idParam := c.Param("id")
  id, err := strconv.ParseInt(idParam, 10, 64)
  if err != nil {
    _ = c.Error(NewAppError("invalid_id", "id must be integer", http.StatusBadRequest))
    return
  }

  task, err := h.service.GetTaskByID(c, id)
  if err != nil {
    _ = c.Error(err)
    return
  }

  c.JSON(http.StatusOK, ToTaskResponse(task))
}

Mit: „globalny handler błędów ukrywa szczegóły i utrudnia debugowanie”. W praktyce centralizacja ułatwia dodanie trace ID, powiązanie z loggerem i uniknięcie wysyłania do klienta wewnętrznych komunikatów z bazy.

Walidacja wejścia z użyciem struktur

Gin świetnie współpracuje z biblioteką go-playground/validator, którą ma pod spodem. Do prostych API wystarczą tagi w strukturach, dzięki którym nie pisze się manualnego „if title == "" { … }” w każdym handlerze.

type CreateTaskRequest struct {
  Title string `json:"title" binding:"required,min=3,max=200"`
  Notes string `json:"notes" binding:"max=1000"`
}

Handler może wtedy wyglądać tak:

func (h *Handler) CreateTask(c *gin.Context) {
  var req CreateTaskRequest
  if err := c.ShouldBindJSON(&req); err != nil {
    // błąd walidacji z Gin/validatora
    _ = c.Error(FromBindingError(err))
    return
  }

  input := CreateTaskInput{
    Title: req.Title,
    Notes: req.Notes,
  }

  task, err := h.service.CreateTask(c, input)
  if err != nil {
    _ = c.Error(err)
    return
  }

  c.JSON(http.StatusCreated, ToTaskResponse(task))
}

Potrzebny jest jeszcze adapter, który zamieni błędy bindingu na uporządkowany format pól.

Mapowanie błędów walidacji na pola

Błąd z ShouldBind może być zwykłym błędem JSON (źle sformatowane dane) albo błędem walidacji pól. Oba przypadki można ładnie opakować w ErrorResponse.Fields.

func FromBindingError(err error) error {
  var verr validator.ValidationErrors
  if errors.As(err, &verr) {
    fields := make(map[string]string, len(verr))
    for _, fe := range verr {
      field := fe.Field() // nazwa pola w strukturze
      fields[strings.ToLower(field[:1])+field[1:]] = validationMessage(fe)
    }

    return &AppError{
      Code:   "validation_error",
      Status: http.StatusBadRequest,
      Message: "validation failed",
      // Fields wstrzykniemy niżej przez typ rozszerzony
    }
  }

  // np. niepoprawny JSON
  return NewAppError("invalid_body", "invalid request body", http.StatusBadRequest)
}

Dołączanie pól do AppError można rozwiązać przez rozszerzony typ:

type ValidationError struct {
  *AppError
  Fields map[string]string
}

func (e *ValidationError) Error() string { return e.AppError.Error() }

func NewValidationError(fields map[string]string) *ValidationError {
  return &ValidationError{
    AppError: NewAppError("validation_error", "validation failed", http.StatusBadRequest),
    Fields:   fields,
  }
}

Wersja FromBindingError z polami:

func FromBindingError(err error) error {
  var verr validator.ValidationErrors
  if errors.As(err, &verr) {
    fields := make(map[string]string, len(verr))
    for _, fe := range verr {
      jsonName := fe.Field()
      fields[toSnakeCase(jsonName)] = validationMessage(fe)
    }
    return NewValidationError(fields)
  }
  return NewAppError("invalid_body", "invalid request body", http.StatusBadRequest)
}

Middleware błędów trzeba wtedy rozszerzyć o obsługę ValidationError:

func ErrorHandler() gin.HandlerFunc {
  return func(c *gin.Context) {
    c.Next()
    if len(c.Errors) == 0 {
      return
    }

    err := c.Errors.Last().Err
    traceID := GetRequestID(c)

    var vErr *ValidationError
    if errors.As(err, &vErr) {
      c.JSON(vErr.Status, ErrorResponse{
        Error:   vErr.Code,
        Message: vErr.Message,
        Fields:  vErr.Fields,
        TraceID: traceID,
      })
      return
    }

    var appErr *AppError
    if errors.As(err, &appErr) {
      c.JSON(appErr.Status, ErrorResponse{
        Error:   appErr.Code,
        Message: appErr.Message,
        TraceID: traceID,
      })
      return
    }

    c.JSON(http.StatusInternalServerError, ErrorResponse{
      Error:   "internal_error",
      Message: "internal server error",
      TraceID: traceID,
    })
  }
}

Komunikaty walidacji – techniczne czy biznesowe?

Istnieje pokusa, by klientowi zwracać dokładnie ten sam komunikat, który generuje validator: „Title is a required field” itp. To wygodne, ale mało elastyczne i trudne do lokalizacji. Zazwyczaj lepiej jest mapować kody reguł na własne komunikaty.

func validationMessage(fe validator.FieldError) string {
  switch fe.Tag() {
  case "required":
    return "is required"
  case "min":
    return fmt.Sprintf("must be at least %s characters", fe.Param())
  case "max":
    return fmt.Sprintf("must be at most %s characters", fe.Param())
  default:
    return "is invalid"
  }
}

Odpowiedź dla błędnego tytułu mogłaby wyglądać tak:

{
  "error": "validation_error",
  "message": "validation failed",
  "fields": {
    "title": "must be at least 3 characters"
  },
  "trace_id": "a12b3c..."
}

Mit: „klientom wystarczy HTTP 400, nie potrzebują szczegółów”. Przy frontendzie rozwijanym przez inny zespół brak precyzyjnych pól błędów generuje lawinę pytań typu „dlaczego nie działa?”. Zwięzłe, ale konkretne komunikaty po polu rozwiązują większość takich sporów.

Błędy domenowe a HTTP – kto decyduje o statusie?

Najczęściej zadawane pytania (FAQ)

Czy Gin naprawdę przyspieszy moje API w Go?

Gin sam w sobie jest szybki i lekki, ale nie jest magicznym przyspieszaczem. Router i middleware to zwykle ułamek całkowitego czasu obsługi requestu. Najczęściej prawdziwe problemy z wydajnością siedzą w bazie danych, zewnętrznych API albo w ciężkiej logice biznesowej upchanej w handlerach.

Różnica między „mit: framework jest wolny” a „rzeczywistość: źle zaprojektowane I/O” wychodzi na produkcji. Jeśli zadbasz o cache, rozsądne zapytania, timeouts i limity współbieżności, Gin zapewni solidny szkielet, który wąskim gardłem nie będzie.

Kiedy wybrać Gin, a kiedy wystarczy net/http w Go?

Gin ma sens, gdy tworzysz więcej niż kilka endpointów, chcesz wygodnego routera, prostego bindowania JSON i planujesz korzystać z middleware (logowanie, recovery, autoryzacja, CORS itd.). Wtedy oszczędzasz dziesiątki linii powtarzalnego kodu i łatwiej rozwijasz API.

Czyste net/http jest wystarczające dla mikroserwisów typu health check, prosty proxy czy wewnętrzny webhook, gdzie masz 1–2 endpointy i zależy ci na absolutnym minimum zależności. Przy rozbudowie takich serwisów często i tak kończy się na dołożeniu routera w stylu Gin/Chi.

Jaką strukturę katalogów stosować w projekcie API z Gin?

Praktyczny wzorzec to podział na katalog cmd/ (wejście do aplikacji) i internal/ (logika biznesowa, HTTP, konfiguracja, baza). Typowy układ może wyglądać tak: cmd/api/main.go oraz w internal/ osobne pakiety http/, task/, config/, platform/db/.

Mit brzmi: „na starcie wystarczy jeden plik main.go, reszta później”. Rzeczywistość jest taka, że ten „tymczasowy” god file bardzo szybko staje się śmietnikiem. Wczesny podział na handler, service, repository i wydzielony router oszczędza później przepisywania połowy projektu.

Jak poprawnie zorganizować handler, service i repository w Gin?

Dobrym punktem wyjścia jest osobny pakiet dla każdego modułu domenowego, np. internal/task z plikami: handler.go, service.go, repository.go, models.go. Handler przyjmuje gin.Context, wyciąga dane z requestu i wywołuje serwis. Serwis zawiera logikę biznesową, a repozytorium komunikuje się z bazą.

Taki podział ogranicza pokusę pakowania wszystkiego w handler. Testy też są prostsze: serwis możesz przetestować z mockowanym repo, bez HTTP, a handler osobno, z użyciem testowego routera Gin.

Jak skonfigurować Gin i API w Go pod środowiska (dev, staging, prod)?

Najprostsze i najpewniejsze rozwiązanie to trzymanie konfiguracji w zmiennych środowiskowych i wczytywanie ich w małym pakiecie config. Typowy Config zawiera adres HTTP, URL bazy, nazwę środowiska. Do odczytu wystarczy standardowa biblioteka os plus kilka helperów typu getEnv i mustEnv.

Bogatsze biblioteki konfiguracyjne (np. Viper) kuszą funkcjami, ale wprowadzają dodatkową złożoność i utrudniają testy. W większości API proste env + struktura konfiguracyjna w Go w zupełności wystarczają, a zmiana środowiska to tylko inny zestaw zmiennych w Dockerze lub w CI/CD.

Jak pisać testy dla API zbudowanego w Gin?

W testach HTTP najpierw tworzysz router Gin skonfigurowany tak jak w produkcji (bez serwera), a potem wysyłasz do niego sztuczne requesty z pakietem net/http/httptest. To pozwala testować zachowanie endpointów (statusy, body, nagłówki) bez odpalania prawdziwego serwera.

Dla logiki biznesowej lepiej pisać testy jednostkowe na poziomie serwisu, bez Gin. Wstrzykujesz tam mockowane repozytorium i sprawdzasz same reguły domenowe. Ten podział (testy handlera osobno, testy serwisu osobno) pomaga szybko namierzyć, czy błąd leży w HTTP, czy w logice.

Jak poprawnie używać middleware w Gin (logowanie, recovery, auth)?

Middleware w Gin to funkcje, które „owijają” handler. Rejestrujesz je globalnie (np. logowanie, recovery) albo na poziomie grupy ścieżek (np. autoryzacja dla /api). Typowy setup to: globalnie logger i recovery, a na grupach dodatkowo CORS, auth, rate limiting.

Częsty mit: „wrzucę całą logikę autoryzacji do handlera, będzie prościej”. W praktyce kończy się to kopiowaniem kodu po wszystkich endpointach. Uporządkowane middleware trzymają cross‑cutting concerns w jednym miejscu i ułatwiają włączanie/wyłączanie funkcji per grupa endpointów.

Najważniejsze wnioski

  • Go nadaje się do szybkich API, bo łączy prostą składnię, wbudowaną współbieżność (gorutyny, scheduler) i pojedynczą binarkę bez runtime’u, co ułatwia start, deployment i pracę w kontenerach.
  • Na tle Node.js, Javy/Kotlina i Pythona/Ruby Go wygrywa przewidywalnością w produkcji, mniejszym zużyciem zasobów i prostym modelem wdrożenia, nawet jeśli oferuje mniej „magicznych” abstrakcji i gotowych frameworków.
  • Gin rozszerza standardowy pakiet net/http o szybki router, wygodny kontekst requestu, elastyczne middleware i skróty do pracy z JSON-em, ale nie próbuje być full‑stackiem – nie narzuca ORM, template’ów ani sztywnej architektury.
  • Mit, że „Gin sam zrobi API szybkie”, bierze się z mylenia frameworka z architekturą; realne problemy z wydajnością wynikają głównie z wolnego I/O, braku cache, ciężkiej logiki w handlerach oraz braku limitów współbieżności i timeoutów.
  • Gin ma największy sens, gdy rośnie liczba endpointów, potrzebny jest rozbudowany router, middleware (logowanie, recovery, autoryzacja, CORS) i wygodne bindowanie JSON/parametrów; w bardzo małych, prostych serwisach często wystarczy czyste net/http.
  • Sam wybór frameworka (Gin vs Echo, Chi, Fiber) jest drugorzędny wobec wzorca pracy „router + middleware + kontekst requestu”; w praktyce liczy się znajomość narzędzia przez zespół i to, jak poukładasz logikę biznesową, a nie logo na stronie projektu.
Poprzedni artykułNajlepsze darmowe IDE do Pythona: porównanie funkcji i wygody pracy
Dorota Wiśniewski
Dorota Wiśniewski zajmuje się tematyką AI w praktyce oraz odpowiedzialnym korzystaniem z narzędzi generatywnych. Pokazuje, jak dobierać modele i usługi do zadania, jak pisać skuteczne prompty i jak oceniać jakość wyników, uwzględniając halucynacje i stronniczość. W artykułach opiera się na eksperymentach, porównaniach i powtarzalnych scenariuszach testowych, a także na analizie polityk prywatności i licencji. Zwraca uwagę na ochronę danych, zgodność z regulacjami i dobre praktyki wdrożeń. Jej celem jest użyteczna wiedza bez hype’u.

1 KOMENTARZ

  1. Bardzo ciekawy artykuł! Doceniam szczegółowe omówienie, jak zbudować szybkie API za pomocą Golang i Gin od samego początku. Bardzo pomocne było również rozpisanie krok po kroku procesu tworzenia testów i middleware. Dzięki temu artykułowi mogłem lepiej zrozumieć, jak wykorzystać te narzędzia w praktyce.

    Jednakże brakowało mi trochę głębszego omówienia potencjalnych pułapek czy problemów, na jakie można natrafić podczas pracy z Golang i Gin. Byłoby fajnie, gdyby autor poświęcił więcej uwagi na przykładowe scenariusze, w których można popełnić błąd lub na trudności, jakie mogą wystąpić podczas implementacji. Mimo tego, artykuł zdecydowanie rozjaśnił mi pewne zagadnienia i skłonił do eksperymentowania z Golang i Gin przy tworzeniu API.

Komentarze są dostępne tylko po zalogowaniu.