REST API на Go
С Go 1.22 стандартный net/http получил мощный маршрутизатор с поддержкой HTTP-методов и path-параметров. Для большинства проектов внешние роутеры больше не нужны.
Маршрутизация в Go 1.22+
Новый синтаксис
mux := http.NewServeMux()
// Method + path pattern
mux.HandleFunc("GET /api/users", listUsers)
mux.HandleFunc("POST /api/users", createUser)
mux.HandleFunc("GET /api/users/{id}", getUser)
mux.HandleFunc("PUT /api/users/{id}", updateUser)
mux.HandleFunc("DELETE /api/users/{id}", deleteUser)
// Wildcard: matches remaining path
mux.HandleFunc("GET /files/{path...}", serveFiles)
// Exact match with trailing slash
mux.HandleFunc("GET /api/", apiIndex) // matches only /api/
Path-параметры
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // extract {id} from URL
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
// Use id...
}
Полный CRUD-пример: User Resource
package main
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"sync"
"time"
)
// User represents a user in the system.
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateUserRequest is the request body for creating a user.
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
// Validate checks if the request is valid.
func (r CreateUserRequest) Validate() error {
if r.Name == "" {
return fmt.Errorf("name is required")
}
if r.Email == "" {
return fmt.Errorf("email is required")
}
return nil
}
// UpdateUserRequest is the request body for updating a user.
type UpdateUserRequest struct {
Name *string `json:"name,omitempty"`
Email *string `json:"email,omitempty"`
}
// ErrorResponse is the standard error response format.
type ErrorResponse struct {
Error string `json:"error"`
Code int `json:"code"`
Details string `json:"details,omitempty"`
}
// UserStore provides in-memory user storage.
type UserStore struct {
mu sync.RWMutex
users map[string]*User
seq int
}
// NewUserStore creates an empty UserStore.
func NewUserStore() *UserStore {
return &UserStore{users: make(map[string]*User)}
}
// UserHandler handles HTTP requests for users.
type UserHandler struct {
store *UserStore
logger *slog.Logger
}
// NewUserHandler creates a UserHandler.
func NewUserHandler(store *UserStore, logger *slog.Logger) *UserHandler {
return &UserHandler{store: store, logger: logger}
}
// RegisterRoutes registers all user routes.
func (h *UserHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/users", h.List)
mux.HandleFunc("POST /api/users", h.Create)
mux.HandleFunc("GET /api/users/{id}", h.Get)
mux.HandleFunc("PUT /api/users/{id}", h.Update)
mux.HandleFunc("DELETE /api/users/{id}", h.Delete)
}
// List returns all users.
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
h.store.mu.RLock()
users := make([]*User, 0, len(h.store.users))
for _, u := range h.store.users {
users = append(users, u)
}
h.store.mu.RUnlock()
writeJSON(w, http.StatusOK, users)
}
// Create creates a new user.
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON", err.Error())
return
}
if err := req.Validate(); err != nil {
writeError(w, http.StatusUnprocessableEntity, "validation failed", err.Error())
return
}
h.store.mu.Lock()
h.store.seq++
id := fmt.Sprintf("user-%d", h.store.seq)
now := time.Now()
user := &User{
ID: id,
Name: req.Name,
Email: req.Email,
CreatedAt: now,
UpdatedAt: now,
}
h.store.users[id] = user
h.store.mu.Unlock()
h.logger.Info("user created", "id", id, "name", req.Name)
writeJSON(w, http.StatusCreated, user)
}
// Get returns a single user by ID.
func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
h.store.mu.RLock()
user, ok := h.store.users[id]
h.store.mu.RUnlock()
if !ok {
writeError(w, http.StatusNotFound, "user not found", "")
return
}
writeJSON(w, http.StatusOK, user)
}
// Update partially updates a user.
func (h *UserHandler) Update(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req UpdateUserRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON", err.Error())
return
}
h.store.mu.Lock()
user, ok := h.store.users[id]
if !ok {
h.store.mu.Unlock()
writeError(w, http.StatusNotFound, "user not found", "")
return
}
if req.Name != nil {
user.Name = *req.Name
}
if req.Email != nil {
user.Email = *req.Email
}
user.UpdatedAt = time.Now()
h.store.mu.Unlock()
writeJSON(w, http.StatusOK, user)
}
// Delete removes a user by ID.
func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
h.store.mu.Lock()
_, ok := h.store.users[id]
if ok {
delete(h.store.users, id)
}
h.store.mu.Unlock()
if !ok {
writeError(w, http.StatusNotFound, "user not found", "")
return
}
w.WriteHeader(http.StatusNoContent)
}
Вспомогательные функции
// decodeJSON decodes JSON request body with size limit.
func decodeJSON(r *http.Request, v any) error {
// Limit request body to 1MB
r.Body = http.MaxBytesReader(nil, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields() // reject unknown fields
if err := dec.Decode(v); err != nil {
return fmt.Errorf("decoding JSON: %w", err)
}
// Ensure no extra data after JSON
if dec.More() {
return fmt.Errorf("request body must contain a single JSON object")
}
return nil
}
// writeJSON writes a JSON response.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Error("encoding JSON response", "err", err)
}
}
// writeError writes a JSON error response.
func writeError(w http.ResponseWriter, status int, msg, details string) {
writeJSON(w, status, ErrorResponse{
Error: msg,
Code: status,
Details: details,
})
}
Cursor-based пагинация
Offset-based пагинация (OFFSET 100 LIMIT 10) неэффективна при больших объёмах данных. Cursor-based пагинация стабильна и масштабируется.
// PageRequest represents cursor-based pagination parameters.
type PageRequest struct {
Cursor string // opaque cursor from previous response
Limit int // items per page (default 20, max 100)
}
// PageResponse wraps paginated results.
type PageResponse[T any] struct {
Items []T `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
// parsePagination extracts pagination params from query string.
func parsePagination(r *http.Request) PageRequest {
cursor := r.URL.Query().Get("cursor")
limit := 20
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
limit = parsed
}
}
return PageRequest{Cursor: cursor, Limit: limit}
}
// Example: paginated list of users
func (h *UserHandler) ListPaginated(w http.ResponseWriter, r *http.Request) {
page := parsePagination(r)
// In real code: cursor is base64-encoded last ID
users, nextCursor, err := h.store.ListAfterCursor(r.Context(), page.Cursor, page.Limit+1)
if err != nil {
writeError(w, http.StatusInternalServerError, "listing users", err.Error())
return
}
hasMore := len(users) > page.Limit
if hasMore {
users = users[:page.Limit] // trim extra item
nextCursor = users[len(users)-1].ID
}
writeJSON(w, http.StatusOK, PageResponse[*User]{
Items: users,
NextCursor: nextCursor,
HasMore: hasMore,
})
}
SQL для cursor-based пагинации:
-- First page
SELECT * FROM users ORDER BY id LIMIT 21;
-- Next page (cursor = last ID from previous page)
SELECT * FROM users WHERE id > $1 ORDER BY id LIMIT 21;
Middleware и запуск сервера
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
store := NewUserStore()
handler := NewUserHandler(store, logger)
mux := http.NewServeMux()
handler.RegisterRoutes(mux)
// Add middleware
var h http.Handler = mux
h = loggingMiddleware(logger)(h)
h = recoveryMiddleware()(h)
srv := &http.Server{
Addr: ":8080",
Handler: h,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Graceful shutdown
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
logger.Info("server starting", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server error", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)
}
chi router как альтернатива
Для более продвинутых возможностей (middleware groups, URL params, sub-routers) можно использовать go-chi/chi:
import "github.com/go-chi/chi/v5"
r := chi.NewRouter()
// Built-in middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
// Route groups
r.Route("/api/v1", func(r chi.Router) {
r.Use(authMiddleware) // applied to all /api/v1/* routes
r.Route("/users", func(r chi.Router) {
r.Get("/", listUsers) // GET /api/v1/users
r.Post("/", createUser) // POST /api/v1/users
r.Route("/{userID}", func(r chi.Router) {
r.Get("/", getUser) // GET /api/v1/users/{userID}
r.Put("/", updateUser) // PUT /api/v1/users/{userID}
r.Delete("/", deleteUser)
})
})
})
// Extract path param
func getUser(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID")
// ...
}
Когда использовать chi:
- Нужны middleware groups
- Нужен sub-routing
- Нужна совместимость с
net/http(chi реализуетhttp.Handler)
Когда достаточно net/http:
- Простой API (< 20 эндпоинтов)
- Нет сложных middleware-цепочек
- Go 1.22+ (есть method-based routing)