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. - Истекувањата се копираат слепо: големите отпраќања или стриминг-одговорите може легитимно да ги надминат овие ограничувања. Изберете буџети според однесувањето на крајната точка, а не според конвенција.
- Метриките исчезнуваат при рестартирање: овие бројачи се локални за процесот. Системот за мониторинг мора да ги собира и задржува надворешно.
- Распоредувањата губат задачи: складиштето во примерот е поддржано од меморија. Користете трајно складиште пред податоците за задачите да ги третирате како постојани.
Конечна контролна листа за верификација
go vet ./...иgo test -race ./...поминуваат.- Валидното создавање враќа
201, заглавиеLocationи нормализиран JSON. - Невалидни, преголеми, со непознати полиња и семантички невалидни тела се одбиваат.
- Здравјето, подготвеноста, логовите, ID-ата на барањата и метриките се однесуваат како што е документирано.
SIGTERMја отстранува подготвеноста, ги испразнува активните барања и излегува во рамки на конфигурираниот буџет.- Слушачот е приватен или заштитен со TLS, автентикација, ограничувања на стапката и политика на заштитен ѕид.
- Буџетите за запирање на распоредувањето и proxy-то го надминуваат десетсекундниот буџет на апликацијата за благодатно исклучување.
Продукциската сигурност ретко е една драматична функција. Таа е акумулација од мали, експлицитни граници: еден JSON објект, едно ограничување на тело, едно истекување за секоја фаза, една искрена состојба на подготвеност и еден тестиран договор за исклучување. Кога тие граници се видливи во кодот и извршливи во тестовите, API-то престанува да биде само функционално и почнува да станува доверливо.