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/tasksvalidira i stvara zadatak.GET /v1/tasks/{id}vraća jedan zadatak.GET /healthzizvještava može li proces posluživati HTTP.GET /readyzvraća neuspjeh čim započne gašenje.GET /metricsizlaž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
go vet ./...igo test -race ./...prolaze.- Valjano stvaranje vraća
201, zaglavljeLocationi normalizirani JSON. - Neispravna, prevelika tijela s nepoznatim poljima i semantički nevaljana tijela odbijaju se.
- Zdravlje, spremnost, zapisi, ID-jevi zahtjeva i metrike ponašaju se kako je dokumentirano.
SIGTERMuklanja spremnost, dovršava aktivne zahtjeve i završava unutar konfiguriranog proračuna.- Osluškivač je privatan ili zaštićen TLS-om, autentikacijom, ograničenjima stope i pravilima vatrozida.
- 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.