Туториали

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

Go REST API подготвено за продукција: Елегантно исклучување, валидација и интеграциско тестирање

REST API не е подготвен за продукција само затоа што враќа JSON. Тешкото однесување се појавува на рабовите: невалидни тела, бавни клиенти, конкурентни барања, сигнали при распоредување, преоптоварени зависности и тестови што мора да го вежбаат вистинскиот HTTP стек наместо директно да повикуваат обработувачи.

Овој туторијал гради компактен API за задачи со Go 1.22 и стандардната библиотека. Обезбедува строга валидација, ограничени HTTP истекувања, структурирани логови, проверки за здравје и подготвеност, лесни метрики, благодатно исклучување, опоравување од паника и интеграциски тестови преку вистински TCP врски.

Предуслови и граници на дизајнот

Ви треба Go 1.22 или понов, школка слична на Unix и дозвола за врзување на локална TCP порта. Апликацијата ги чува задачите во меморија за примерот да остане фокусиран на HTTP сигурноста. Нејзиното процесно однесување е ориентирано кон продукција, но податоците намерно се ефемерни: рестартирањето на услугата ги губи задачите, а повеќе реплики не споделуваат состојба. Трајното распоредување треба да ја замени мапата со складиште поддржано од база на податоци.

Серверот ги изложува следниве крајни точки:

  • POST /v1/tasks валидира и создава задача.
  • GET /v1/tasks/{id} враќа една задача.
  • GET /healthz известува дали процесот може да опслужува HTTP.
  • GET /readyz враќа неуспех веднаш штом започне исклучувањето.
  • GET /metrics изложува бројачи на барања локални за процесот.

Подготвеноста и живоста намерно се одвоени. Процесот што завршува може да остане жив додека ги испразнува барањата, но треба да го напушти балансерот на оптоварување пред да започне тоа празнење.

Создајте го проектот

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

Добиената структура останува доволно мала за ревизија:

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

Имплементирајте ја HTTP услугата

Ставете го следниов код во cmd/api/main.go. Рутирањето на Go 1.22 што е свесно за методите ни дава автоматско одбивање на методи без надворешен рутер.

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)
	}
}

Зошто овие граници се важни

ReadHeaderTimeout ги ограничува нападите со бавни заглавија, додека ReadTimeout одделно го ограничува читањето на целосното барање. Истекувањето на врска не би ги ограничило подоцнежните читања или пребарувањата во базата на податоци. Ограничувањето на телото од еден мегабајт ја штити меморијата пред JSON декодирањето, а DisallowUnknownFields ги фаќа правописните грешки на клиентот наместо тивко да отфрли податоци.

Буџетите за запишување и исклучување исто така се различни. Со оваа конфигурација, обработувачот има најмногу десет секунди за запишување, а исклучувањето му дава на серверот десет секунди да ги испразни активните обработувачи. Ако празнењето истече, Close ги прекинува преостанатите врски и процесот излегува со грешка, правејќи го деградираното исклучување видливо за оркестраторот.

Заклучувањето на мапата ги штити само изменувањето и пребарувањето. JSON кодирањето се случува откако заклучувањето ќе се ослободи, спречувајќи бавен клиент да претвори краток критичен дел во глобален спор.

Рачно испробајте го 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

Пробајте непознато поле или празен наслов. Првото враќа 400 Bad Request бидејќи претставувањето е невалидно за ова API; второто враќа 422 Unprocessable Entity бидејќи JSON е валиден, но прекршува деловно ограничување.

Додајте интеграциски тестови

Тестовите само за обработувачите може да пропуштат однесување при рутирање, поврзување и исклучување. Зачувајте го ова како cmd/api/main_test.go. Првиот тест користи HTTP тест-сервер; вториот отвора вистински слушач и докажува дека исклучувањето чека активно барање.

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)
	}
}

Извршете форматирање, статичка анализа, тестови и детекторот за трки:

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

Распоредете со systemd

Изградете на целната архитектура, проверете која било постојна одредишна локација пред да ја замените и инсталирајте со root привилегии:

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

Создајте /etc/systemd/system/task-api.service како 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

Услугата се врзува само за loopback. Поставете reverse proxy што прекинува TLS на истиот домаќин или приватен влез пред неа. Не го изложувајте слушачот за отворен текст или неавтентицираната крајна точка /metrics на јавниот интернет. Конфигурирајте го заштитниот ѕид на домаќинот да дозволува само наменети патеки за proxy или управување.

Набљудливост, безбедност и перформанси

Структурираните логови содржат ID на барање, латентност, големина на одговор, статус и адреса на врсникот. Серверот генерира сопствен монотоно растечки идентификатор на барање наместо да верува на произволен влез од клиентот. Преку повеќе реплики, дозволете влезниот слој да прикачи глобално единствен идентификатор за трага и валидирајте го пред пренесување.

Метриките намерно немаат зависности. Поголема услуга треба да изложи стандарден формат на систем за мониторинг и да додаде ограничени ознаки, како метод и рута. Никогаш не означувајте метрики со сурови патеки, ID на задачи, ID на барања или кориснички влез; тие вредности создаваат неограничена кардиналност.

Автентикацијата и авторизацијата се надвор од овој мал доменски модел, но припаѓаат пред деловните обработувачи. Прекинете TLS на контролиран proxy, ограничете ги стапките на барања таму, редигирајте тајни од логовите и пренесувајте автентициран идентитет преку валидиран механизам. Ако мапата ја замени база на податоци, применете одделни рокови за поврзување и пребарување; воспоставувањето врска не го ограничува извршувањето на пребарувањето.

Мапата во меморија исто така расте без ограничување. Тоа е прифатливо за овој оперативен скелет, но не и за неограничено продукциско оптоварување. Трајното складиште треба да додаде пагинација, квоти за складирање, индекси во базата на податоци и експлицитни истекувања за пребарувања. Тестирањето на оптоварување треба да ја мери латентноста на опашката и меморијата при репрезентативни големини на тела наместо да слави една бројка на барања во секунда.

Вообичаени режими на неуспех

  • Подготвеноста останува успешна при завршување: балансерот на оптоварување продолжува да испраќа нова работа додека старите барања се празнат. Поставете го знамето за исклучување пред да повикате Shutdown.
  • Исклучувањето никогаш не завршува: обработувачот игнорира откажување или зависноста нема краен рок. Дајте му на секој појдовен повик истекување пократко од вкупниот буџет за исклучување.
  • Валидацијата прифаќа дополнителен JSON: декодирањето само еднаш дозволува објекти што следат. Извршете го второто декодирање и побарајте io.EOF.
  • Истекувањата се копираат слепо: големите отпраќања или стриминг-одговорите може легитимно да ги надминат овие ограничувања. Изберете буџети според однесувањето на крајната точка, а не според конвенција.
  • Метриките исчезнуваат при рестартирање: овие бројачи се локални за процесот. Системот за мониторинг мора да ги собира и задржува надворешно.
  • Распоредувањата губат задачи: складиштето во примерот е поддржано од меморија. Користете трајно складиште пред податоците за задачите да ги третирате како постојани.

Конечна контролна листа за верификација

  1. go vet ./... и go test -race ./... поминуваат.
  2. Валидното создавање враќа 201, заглавие Location и нормализиран JSON.
  3. Невалидни, преголеми, со непознати полиња и семантички невалидни тела се одбиваат.
  4. Здравјето, подготвеноста, логовите, ID-ата на барањата и метриките се однесуваат како што е документирано.
  5. SIGTERM ја отстранува подготвеноста, ги испразнува активните барања и излегува во рамки на конфигурираниот буџет.
  6. Слушачот е приватен или заштитен со TLS, автентикација, ограничувања на стапката и политика на заштитен ѕид.
  7. Буџетите за запирање на распоредувањето и proxy-то го надминуваат десетсекундниот буџет на апликацијата за благодатно исклучување.

Продукциската сигурност ретко е една драматична функција. Таа е акумулација од мали, експлицитни граници: еден JSON објект, едно ограничување на тело, едно истекување за секоја фаза, една искрена состојба на подготвеност и еден тестиран договор за исклучување. Кога тие граници се видливи во кодот и извршливи во тестовите, API-то престанува да биде само функционално и почнува да станува доверливо.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.