Vodiči

Production-Ready Go REST API: Graceful Shutdown, Validation, and Integration Testing

Go REST API spreman za produkciju: elegantno gašenje, validacija i integracijsko testiranje

REST API nije spreman za produkciju samo zato što vraća JSON. Zahtjevno ponašanje pojavljuje se na rubovima: neispravna tijela, spori klijenti, konkurentni zahtjevi, signali implementacije, preopterećene ovisnosti i testovi koji moraju vježbati stvarni HTTP stog umjesto da izravno pozivaju obrađivače.

Ovaj vodič izrađuje kompaktnu API uslugu za zadatke koristeći Go 1.22 i standardnu biblioteku. Pruža strogu validaciju, ograničena HTTP vremenska ograničenja, strukturirane zapise, provjere zdravlja i spremnosti, lagane metrike, uredno gašenje, oporavak od panike i integracijske testove preko stvarnih TCP veza.

Preduvjeti i granice dizajna

Potreban vam je Go 1.22 ili noviji, ljuska nalik Unixu i dopuštenje za vezanje lokalnog TCP porta. Aplikacija pohranjuje zadatke u memoriji kako bi primjer ostao usmjeren na HTTP pouzdanost. Ponašanje procesa usmjereno je na produkciju, ali podaci su namjerno privremeni: ponovno pokretanje usluge gubi zadatke, a više replika ne dijeli stanje. Trajna implementacija trebala bi zamijeniti mapu spremištem temeljenim na bazi podataka.

Poslužitelj izlaže sljedeće krajnje točke:

  • POST /v1/tasks validira i stvara zadatak.
  • GET /v1/tasks/{id} vraća jedan zadatak.
  • GET /healthz izvještava može li proces posluživati HTTP.
  • GET /readyz vraća neuspjeh čim započne gašenje.
  • GET /metrics izlaže brojače zahtjeva lokalne za proces.

Spremnost i živost namjerno su odvojene. Proces koji se završava može ostati živ dok obrađuje preostale zahtjeve, ali bi trebao napustiti raspodjelnik opterećenja prije početka tog pražnjenja.

Izradite projekt

mkdir -p production-api/cmd/api
cd production-api
go mod init example.com/production-api

Dobivena struktura ostaje dovoljno mala za pregled:

production-api/
├── go.mod
└── cmd/
    └── api/
        ├── main.go
        └── main_test.go

Implementirajte HTTP uslugu

Sljedeći kôd smjestite u cmd/api/main.go. Usmjeravanje u Gou 1.22 koje poznaje metode daje nam automatsko odbijanje metoda bez vanjskog usmjerivača.

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"syscall"
	"time"
	"unicode/utf8"
)

type config struct {
	addr            string
	shutdownTimeout time.Duration
}

type task struct {
	ID        int64     `json:"id"`
	Title     string    `json:"title"`
	CreatedAt time.Time `json:"created_at"`
}

type metrics struct {
	requests atomic.Uint64
	failures atomic.Uint64
	inFlight atomic.Int64
	sequence atomic.Uint64
}

type app struct {
	logger       *slog.Logger
	mu           sync.RWMutex
	tasks        map[int64]task
	nextID       int64
	shuttingDown atomic.Bool
	metrics      metrics
}

type responseRecorder struct {
	http.ResponseWriter
	status int
	bytes  int
}

func (r *responseRecorder) WriteHeader(status int) {
	if r.status != 0 {
		return
	}
	r.status = status
	r.ResponseWriter.WriteHeader(status)
}

func (r *responseRecorder) Write(body []byte) (int, error) {
	if r.status == 0 {
		r.WriteHeader(http.StatusOK)
	}
	n, err := r.ResponseWriter.Write(body)
	r.bytes += n
	return n, err
}

func newApp(logger *slog.Logger) *app {
	return &app{
		logger: logger,
		tasks:  make(map[int64]task),
	}
}

func (a *app) routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", a.health)
	mux.HandleFunc("GET /readyz", a.ready)
	mux.HandleFunc("GET /metrics", a.serveMetrics)
	mux.HandleFunc("POST /v1/tasks", a.createTask)
	mux.HandleFunc("GET /v1/tasks/{id}", a.getTask)
	return a.observe(mux)
}

func (a *app) observe(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		started := time.Now()
		requestID := strconv.FormatUint(a.metrics.sequence.Add(1), 10)
		w.Header().Set("X-Request-ID", requestID)

		rec := &responseRecorder{ResponseWriter: w}
		a.metrics.inFlight.Add(1)

		defer func() {
			if value := recover(); value != nil {
				a.logger.Error("request panic",
					"request_id", requestID,
					"panic", fmt.Sprint(value))
				if rec.status == 0 {
					writeError(rec, http.StatusInternalServerError, "internal server error")
				}
			}

			if rec.status == 0 {
				rec.status = http.StatusOK
			}
			a.metrics.inFlight.Add(-1)
			a.metrics.requests.Add(1)
			if rec.status >= 500 {
				a.metrics.failures.Add(1)
			}

			a.logger.Info("request completed",
				"request_id", requestID,
				"method", r.Method,
				"path", r.URL.Path,
				"status", rec.status,
				"bytes", rec.bytes,
				"duration_ms", time.Since(started).Milliseconds(),
				"remote_addr", r.RemoteAddr)
		}()

		next.ServeHTTP(rec, r)
	})
}

func (a *app) health(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"status": "up"})
}

func (a *app) ready(w http.ResponseWriter, _ *http.Request) {
	if a.shuttingDown.Load() {
		writeError(w, http.StatusServiceUnavailable, "server is shutting down")
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}

func (a *app) serveMetrics(w http.ResponseWriter, _ *http.Request) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	fmt.Fprintf(w, "api_http_requests_total %d\n", a.metrics.requests.Load())
	fmt.Fprintf(w, "api_http_failures_total %d\n", a.metrics.failures.Load())
	fmt.Fprintf(w, "api_http_requests_in_flight %d\n", a.metrics.inFlight.Load())
}

func (a *app) createTask(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
	defer r.Body.Close()

	var input struct {
		Title string `json:"title"`
	}

	decoder := json.NewDecoder(r.Body)
	decoder.DisallowUnknownFields()

	if err := decoder.Decode(&input); err != nil {
		writeError(w, http.StatusBadRequest, "body must contain one valid JSON object")
		return
	}
	if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
		writeError(w, http.StatusBadRequest, "body must contain exactly one JSON object")
		return
	}

	input.Title = strings.TrimSpace(input.Title)
	length := utf8.RuneCountInString(input.Title)
	if length == 0 || length > 120 {
		writeError(w, http.StatusUnprocessableEntity,
			"title must contain between 1 and 120 characters")
		return
	}

	a.mu.Lock()
	a.nextID++
	created := task{
		ID:        a.nextID,
		Title:     input.Title,
		CreatedAt: time.Now().UTC(),
	}
	a.tasks[created.ID] = created
	a.mu.Unlock()

	w.Header().Set("Location", fmt.Sprintf("/v1/tasks/%d", created.ID))
	writeJSON(w, http.StatusCreated, created)
}

func (a *app) getTask(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil || id < 1 {
		writeError(w, http.StatusBadRequest, "task id must be a positive integer")
		return
	}

	a.mu.RLock()
	found, ok := a.tasks[id]
	a.mu.RUnlock()

	if !ok {
		writeError(w, http.StatusNotFound, "task not found")
		return
	}
	writeJSON(w, http.StatusOK, found)
}

func writeError(w http.ResponseWriter, status int, message string) {
	writeJSON(w, status, map[string]string{"error": message})
}

func writeJSON(w http.ResponseWriter, status int, value any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(value)
}

func durationFromEnv(name string, fallback time.Duration) (time.Duration, error) {
	value := os.Getenv(name)
	if value == "" {
		return fallback, nil
	}
	parsed, err := time.ParseDuration(value)
	if err != nil || parsed <= 0 {
		return 0, fmt.Errorf("%s must be a positive duration", name)
	}
	return parsed, nil
}

func loadConfig() (config, error) {
	cfg := config{addr: os.Getenv("ADDR")}
	if cfg.addr == "" {
		cfg.addr = ":8080"
	}

	var err error
	cfg.shutdownTimeout, err = durationFromEnv("SHUTDOWN_TIMEOUT", 10*time.Second)
	return cfg, err
}

func run() error {
	cfg, err := loadConfig()
	if err != nil {
		return err
	}

	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	application := newApp(logger)

	server := &http.Server{
		Addr:              cfg.addr,
		Handler:           application.routes(),
		ReadHeaderTimeout: 2 * time.Second,
		ReadTimeout:       5 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
		MaxHeaderBytes:    1 << 20,
		ErrorLog:          slog.NewLogLogger(logger.Handler(), slog.LevelError),
	}

	signalContext, stop := signal.NotifyContext(
		context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	serverErrors := make(chan error, 1)
	go func() {
		logger.Info("server starting", "addr", cfg.addr)
		serverErrors <- server.ListenAndServe()
	}()

	select {
	case err := <-serverErrors:
		if errors.Is(err, http.ErrServerClosed) {
			return nil
		}
		return fmt.Errorf("serve HTTP: %w", err)

	case <-signalContext.Done():
		stop()
		application.shuttingDown.Store(true)
		logger.Info("shutdown started",
			"timeout", cfg.shutdownTimeout.String())

		shutdownContext, cancel := context.WithTimeout(
			context.Background(), cfg.shutdownTimeout)
		defer cancel()

		if err := server.Shutdown(shutdownContext); err != nil {
			closeErr := server.Close()
			if closeErr != nil {
				return fmt.Errorf("shutdown: %v; force close: %w", err, closeErr)
			}
			return fmt.Errorf("graceful shutdown: %w", err)
		}

		err := <-serverErrors
		if !errors.Is(err, http.ErrServerClosed) {
			return fmt.Errorf("server stopped unexpectedly: %w", err)
		}
		logger.Info("shutdown completed")
		return nil
	}
}

func main() {
	if err := run(); err != nil {
		slog.Error("application stopped", "error", err)
		os.Exit(1)
	}
}

Zašto su ove granice važne

ReadHeaderTimeout ograničava napade sporim zaglavljima, dok ReadTimeout zasebno ograničava čitanje potpunog zahtjeva. Vremensko ograničenje veze ne bi ograničilo kasnija čitanja ni upite baze podataka. Ograničenje tijela na jedan megabajt štiti memoriju prije JSON dekodiranja, a DisallowUnknownFields hvata pravopisne pogreške klijenta umjesto da nečujno odbacuje podatke.

Proračuni za pisanje i gašenje također su odvojeni. Uz ovu konfiguraciju obrađivač ima najviše deset sekundi za pisanje, a gašenje poslužitelju daje deset sekundi za dovršavanje aktivnih obrađivača. Ako pražnjenje istekne, Close prekida preostale veze i proces završava s pogreškom, čime se degradirano gašenje čini vidljivim orkestratoru.

Zaključavanje mape štiti samo izmjenu i pretraživanje. JSON kodiranje odvija se nakon otpuštanja zaključavanja, čime se izbjegava da spor klijent pretvori kratki kritični odjeljak u globalno nadmetanje.

Ručno isprobajte API

go run ./cmd/api

curl --fail-with-body http://127.0.0.1:8080/readyz

curl --fail-with-body \
  -H 'Content-Type: application/json' \
  -d '{"title":"Review shutdown alerts"}' \
  http://127.0.0.1:8080/v1/tasks

curl --fail-with-body http://127.0.0.1:8080/v1/tasks/1
curl --fail-with-body http://127.0.0.1:8080/metrics

Isprobajte nepoznato polje ili prazan naslov. Prvo vraća 400 Bad Request jer je prikaz neispravan za ovaj API; drugo vraća 422 Unprocessable Entity jer je JSON valjan, ali krši poslovno ograničenje.

Dodajte integracijske testove

Testovi samo za obrađivače mogu propustiti ponašanje usmjeravanja, veze i gašenja. Spremite ovo kao cmd/api/main_test.go. Prvi test koristi HTTP testni poslužitelj; drugi otvara stvarni osluškivač i dokazuje da gašenje čeka aktivni zahtjev.

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

func testApp() *app {
	return newApp(slog.New(slog.NewTextHandler(io.Discard, nil)))
}

func TestCreateAndFetchTask(t *testing.T) {
	server := httptest.NewServer(testApp().routes())
	defer server.Close()

	client := &http.Client{Timeout: 2 * time.Second}
	response, err := client.Post(
		server.URL+"/v1/tasks",
		"application/json",
		bytes.NewBufferString(`{"title":"  Ship safely  "}`),
	)
	if err != nil {
		t.Fatal(err)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusCreated {
		t.Fatalf("create status: got %d", response.StatusCode)
	}

	var created task
	if err := json.NewDecoder(response.Body).Decode(&created); err != nil {
		t.Fatal(err)
	}
	if created.Title != "Ship safely" || created.ID != 1 {
		t.Fatalf("unexpected task: %+v", created)
	}

	response, err = client.Get(server.URL + response.Header.Get("Location"))
	if err != nil {
		t.Fatal(err)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		t.Fatalf("fetch status: got %d", response.StatusCode)
	}
}

func TestRejectsUnknownFields(t *testing.T) {
	server := httptest.NewServer(testApp().routes())
	defer server.Close()

	response, err := server.Client().Post(
		server.URL+"/v1/tasks",
		"application/json",
		strings.NewReader(`{"title":"valid","priority":1}`),
	)
	if err != nil {
		t.Fatal(err)
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusBadRequest {
		t.Fatalf("got %d, want 400", response.StatusCode)
	}
}

func TestShutdownDrainsActiveRequest(t *testing.T) {
	application := testApp()
	started := make(chan struct{})
	release := make(chan struct{})

	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		close(started)
		<-release
		application.routes().ServeHTTP(w, r)
	})

	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}

	server := &http.Server{Handler: handler}
	go func() { _ = server.Serve(listener) }()

	requestDone := make(chan error, 1)
	go func() {
		response, err := http.Get("http://" + listener.Addr().String() + "/healthz")
		if err == nil {
			defer response.Body.Close()
			if response.StatusCode != http.StatusOK {
				err = fmt.Errorf("unexpected status %d", response.StatusCode)
			}
		}
		requestDone <- err
	}()

	<-started
	shutdownDone := make(chan error, 1)
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), time.Second)
		defer cancel()
		shutdownDone <- server.Shutdown(ctx)
	}()

	select {
	case err := <-shutdownDone:
		t.Fatalf("shutdown returned before request drained: %v", err)
	case <-time.After(50 * time.Millisecond):
	}

	close(release)

	if err := <-requestDone; err != nil {
		t.Fatal(err)
	}
	if err := <-shutdownDone; err != nil {
		t.Fatal(err)
	}
}

Pokrenite formatiranje, statičku analizu, testove i detektor utrke:

gofmt -w cmd/api/main.go cmd/api/main_test.go
go vet ./...
go test -race ./...

Implementirajte pomoću systemd-a

Izgradite za ciljanu arhitekturu, pregledajte svako postojeće odredište prije zamjene i instalirajte s korijenskim ovlastima:

mkdir -p dist
go build -trimpath -ldflags='-s -w' -o ./dist/task-api ./cmd/api
sudo install -o root -g root -m 0755 ./dist/task-api /usr/local/bin/task-api

Izradite /etc/systemd/system/task-api.service kao root:

[Unit]
Description=Production task API
After=network.target

[Service]
Type=simple
DynamicUser=yes
ExecStart=/usr/local/bin/task-api
Environment=ADDR=127.0.0.1:8080
Environment=SHUTDOWN_TIMEOUT=10s
Restart=on-failure
RestartSec=2s
KillSignal=SIGTERM
TimeoutStopSec=15s
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
RestrictAddressFamilies=AF_INET AF_INET6
MemoryDenyWriteExecute=yes

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now task-api.service
sudo systemctl status task-api.service
sudo journalctl -u task-api.service --since today

Usluga se veže samo na povratnu petlju. Postavite obrnuti proxy koji završava TLS na istom računalu ili privatni ulaz ispred nje. Ne izlažite javnom internetu nešifrirani osluškivač ni neautentificiranu krajnju točku /metrics. Konfigurirajte vatrozid računala tako da dopušta samo predviđene proxy ili upravljačke putove.

Promatranje, sigurnost i performanse

Strukturirani zapisi sadrže ID-jeve zahtjeva, latenciju, veličinu odgovora, status i adresu ravnopravnog računala. Poslužitelj generira vlastiti monotonno rastući identifikator zahtjeva umjesto da vjeruje proizvoljnom unosu klijenta. Preko više replika dopustite ulazu da priloži globalno jedinstveni identifikator praćenja i validirajte ga prije prosljeđivanja.

Metrike su namjerno bez ovisnosti. Veća usluga trebala bi izložiti standardni format sustava za nadzor i dodati ograničene oznake poput metode i rute. Nikada ne označavajte metrike sirovim putanjama, ID-jevima zadataka, ID-jevima zahtjeva ili korisničkim unosom; te vrijednosti stvaraju neograničenu kardinalnost.

Autentikacija i autorizacija izvan su ovog malog modela domene, ali pripadaju prije poslovnih obrađivača. Završite TLS na kontroliranom proxyju, ondje ograničite stope zahtjeva, uklonite tajne iz zapisa i proslijedite autentificirani identitet kroz validirani mehanizam. Ako baza podataka zamijeni mapu, primijenite zasebne rokove za veze i upite; uspostavljanje veze ne ograničava izvršavanje upita.

Mapa u memoriji također raste bez ograničenja. To je prihvatljivo za ovaj operativni kostur, ali ne za neograničeno produkcijsko opterećenje. Trajno spremište trebalo bi dodati straničenje, kvote pohrane, indekse baze podataka i izričita vremenska ograničenja upita. Testiranje opterećenja trebalo bi mjeriti latenciju repa i memoriju pri reprezentativnim veličinama tijela, umjesto da slavi jedan broj zahtjeva u sekundi.

Uobičajeni načini neuspjeha

  • Spremnost ostaje uspješna tijekom završavanja: raspodjelnik opterećenja nastavlja slati novi posao dok se stari zahtjevi dovršavaju. Postavite zastavicu gašenja prije pozivanja Shutdown.
  • Gašenje nikad ne završava: obrađivač zanemaruje otkazivanje ili ovisnost nema rok. Svakom odlaznom pozivu dodijelite vremensko ograničenje kraće od ukupnog proračuna za gašenje.
  • Validacija prihvaća dodatni JSON: dekodiranje samo jednom dopušta završne objekte. Izvršite drugo dekodiranje i zahtijevajte io.EOF.
  • Vremenska ograničenja slijepo se kopiraju: velika slanja ili odgovori za strujanje mogu legitimno premašiti ta ograničenja. Birajte proračune prema ponašanju krajnje točke, a ne prema konvenciji.
  • Metrike nestaju pri ponovnom pokretanju: ti su brojači lokalni za proces. Sustav za nadzor mora ih prikupljati i čuvati izvana.
  • Implementacije gube zadatke: spremište primjera temelji se na memoriji. Upotrijebite trajnu pohranu prije nego što podatke zadataka počnete smatrati postojanima.

Završni kontrolni popis za provjeru

  1. go vet ./... i go test -race ./... prolaze.
  2. Valjano stvaranje vraća 201, zaglavlje Location i normalizirani JSON.
  3. Neispravna, prevelika tijela s nepoznatim poljima i semantički nevaljana tijela odbijaju se.
  4. Zdravlje, spremnost, zapisi, ID-jevi zahtjeva i metrike ponašaju se kako je dokumentirano.
  5. SIGTERM uklanja spremnost, dovršava aktivne zahtjeve i završava unutar konfiguriranog proračuna.
  6. Osluškivač je privatan ili zaštićen TLS-om, autentikacijom, ograničenjima stope i pravilima vatrozida.
  7. Proračuni za zaustavljanje implementacije i proxyja premašuju aplikacijski proračun od deset sekundi za uredno gašenje.

Pouzdanost u produkciji rijetko je jedna dramatična značajka. Ona je nakupljanje malih, izričitih granica: jedan JSON objekt, jedno ograničenje tijela, jedno vremensko ograničenje za svaku fazu, jedno iskreno stanje spremnosti i jedan testirani ugovor o gašenju. Kada su te granice vidljive u kôdu i izvršive u testovima, API prestaje biti samo funkcionalan i počinje biti pouzdan.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.