MFormations
Modern Go Engineering

Chapitre 4

04 - Web avec Go

04 - Web avec Go

Cours 04 : Web avec Go

1. net/http avancé

1.1 Handler et HandlerFunc

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

// HandlerFunc adapte une fonction en Handler
type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

// Utilisation
func helloHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:])
}

func main() {
    http.HandleFunc("/hello", helloHandler)
    http.ListenAndServe(":8080", nil)
}

1.2 ServeMux (Go 1.22+)

mux := http.NewServeMux()

// Patterns améliorés (Go 1.22+)
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("PUT /users/{id}", updateUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)

// Path values
func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "User ID: %s", id)
}

// Sous-patterns
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
    // Catch-all pour /api/*
})

1.3 ResponseWriter

func handler(w http.ResponseWriter, r *http.Request) {
    // Headers
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("X-Request-ID", r.Header.Get("X-Request-ID"))
    
    // Status code
    w.WriteHeader(http.StatusOK)
    
    // Body
    json.NewEncoder(w).Encode(map[string]any{
        "message": "success",
    })
}

// Écrire response JSON helper
func writeJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

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

2. Frameworks Web

2.1 Chi

import "github.com/go-chi/chi/v5"

func main() {
    r := chi.NewRouter()

    // Middleware
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.RequestID)
    r.Use(middleware.RealIP)

    // Routes
    r.Get("/users", listUsers)
    r.Post("/users", createUser)
    
    r.Route("/users/{id}", func(r chi.Router) {
        r.Use(userCtx)
        r.Get("/", getUser)
        r.Put("/", updateUser)
        r.Delete("/", deleteUser)
    })

    // Groups
    r.Group(func(r chi.Router) {
        r.Use(authMiddleware)
        r.Get("/admin", adminHandler)
    })

    http.ListenAndServe(":8080", r)
}

2.2 Gin

import "github.com/gin-gonic/gin"

func main() {
    r := gin.Default()

    r.GET("/users", listUsers)
    r.POST("/users", createUser)
    
    r.GET("/users/:id", getUser)
    r.PUT("/users/:id", updateUser)
    r.DELETE("/users/:id", deleteUser)

    // Groups
    api := r.Group("/api")
    {
        api.GET("/users", listUsers)
        api.POST("/users", createUser)
    }

    // Binding et validation
    r.POST("/users", func(c *gin.Context) {
        var user User
        if err := c.ShouldBindJSON(&user); err != nil {
            c.JSON(400, gin.H{"error": err.Error()})
            return
        }
        c.JSON(201, user)
    })

    r.Run(":8080")
}

2.3 Echo

import "github.com/labstack/echo/v4"

func main() {
    e := echo.New()

    // Middleware
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())

    e.GET("/users/:id", getUser)
    e.POST("/users", createUser)

    // Groups
    g := e.Group("/admin")
    g.Use(middleware.KeyAuth(func(key string, c echo.Context) (bool, error) {
        return key == "secret", nil
    }))

    e.Logger.Fatal(e.Start(":8080"))
}

2.4 Fiber

import "github.com/gofiber/fiber/v2"

func main() {
    app := fiber.New()

    app.Get("/users/:id", func(c *fiber.Ctx) error {
        id := c.Params("id")
        return c.JSON(fiber.Map{
            "id":   id,
            "name": "Alice",
        })
    })

    app.Listen(":8080")
}

2.5 Comparatif

FrameworkPerformancePopularitéMiddlewareRoutingLearning
ChiHauteHauteExcellenteExcellentFacile
GinTrès hauteTrès hauteBonneBonMoyen
EchoHauteHauteBonneBonFacile
FiberExcellenteMoyenneMoyenneBonTrès facile

3. Templates

3.1 html/template

import "html/template"

type PageData struct {
    Title   string
    User    *User
    Items   []string
    IsAdmin bool
}

func handler(w http.ResponseWriter, r *http.Request) {
    tmpl := template.Must(template.ParseFiles(
        "templates/base.html",
        "templates/nav.html",
        "templates/content.html",
    ))

    data := PageData{
        Title: "Dashboard",
        User:  &User{Name: "Alice"},
        Items: []string{"Item 1", "Item 2"},
    }

    tmpl.ExecuteTemplate(w, "base.html", data)
}

3.2 Templates avec partials

// templates/base.html
{{define "base"}}
<!DOCTYPE html>
<html>
<head>
    <title>{{block "title" .}}Default Title{{end}}</title>
</head>
<body>
    {{template "nav" .}}
    {{block "content" .}}{{end}}
    {{template "footer" .}}
</body>
</html>
{{end}}

// templates/nav.html
{{define "nav"}}
<nav>
    <a href="/">Home</a>
    {{if .IsAdmin}}
    <a href="/admin">Admin</a>
    {{end}}
</nav>
{{end}}

3.3 Fonctions personnalisées

funcMap := template.FuncMap{
    "upper": strings.ToUpper,
    "lower": strings.ToLower,
    "date": func(t time.Time) string {
        return t.Format("January 2, 2006")
    },
    "inc": func(i int) int {
        return i + 1
    },
}

tmpl := template.New("").Funcs(funcMap)
tmpl = template.Must(tmpl.ParseGlob("templates/*.html"))

4. Middleware

4.1 Logging

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // Wrap ResponseWriter pour capturer le status
        lw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
        
        next.ServeHTTP(lw, r)
        
        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", lw.statusCode,
            "duration", time.Since(start),
            "remote", r.RemoteAddr,
        )
    })
}

type loggingResponseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (lw *loggingResponseWriter) WriteHeader(code int) {
    lw.statusCode = code
    lw.ResponseWriter.WriteHeader(code)
}

4.2 Recovery

func recoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                slog.Error("panic recovered",
                    "error", err,
                    "path", r.URL.Path,
                )
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

4.3 Auth

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token == "" {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        
        user, err := validateToken(token)
        if err != nil {
            http.Error(w, "Invalid token", http.StatusUnauthorized)
            return
        }
        
        ctx := context.WithValue(r.Context(), "user", user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

4.4 CORS

func corsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        
        if r.Method == "OPTIONS" {
            w.WriteHeader(http.StatusNoContent)
            return
        }
        
        next.ServeHTTP(w, r)
    })
}

4.5 Rate Limiting

type RateLimiter struct {
    mu       sync.Mutex
    visitors map[string]*visitor
    rate     time.Duration
    burst    int
}

type visitor struct {
    limiter  *rate.Limiter
    lastSeen time.Time
}

func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ip := r.RemoteAddr
        
        rl.mu.Lock()
        v, exists := rl.visitors[ip]
        if !exists {
            v = &visitor{
                limiter:  rate.NewLimiter(rate.Every(rl.rate), rl.burst),
                lastSeen: time.Now(),
            }
            rl.visitors[ip] = v
        }
        v.lastSeen = time.Now()
        rl.mu.Unlock()
        
        if !v.limiter.Allow() {
            http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
            return
        }
        
        next.ServeHTTP(w, r)
    })
}

5. Configuration (Viper)

import "github.com/spf13/viper"

type Config struct {
    Server   ServerConfig   `mapstructure:"server"`
    Database DatabaseConfig `mapstructure:"database"`
    Redis    RedisConfig    `mapstructure:"redis"`
    Logging  LoggingConfig  `mapstructure:"logging"`
}

type ServerConfig struct {
    Port         int           `mapstructure:"port"`
    ReadTimeout  time.Duration `mapstructure:"read_timeout"`
    WriteTimeout time.Duration `mapstructure:"write_timeout"`
}

func LoadConfig(path string) (*Config, error) {
    viper.SetConfigFile(path)
    viper.AutomaticEnv()
    viper.SetEnvPrefix("APP")

    if err := viper.ReadInConfig(); err != nil {
        return nil, err
    }

    var cfg Config
    if err := viper.Unmarshal(&cfg); err != nil {
        return nil, err
    }

    return &cfg, nil
}

6. Graceful Shutdown

func main() {
    r := chi.NewRouter()
    // ... routes setup ...

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      r,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
        IdleTimeout:  60 * time.Second,
    }

    // Démarrer le serveur
    go func() {
        slog.Info("Server starting on :8080")
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            slog.Error("server error", "error", err)
            os.Exit(1)
        }
    }()

    // Attendre le signal d'arrêt
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    slog.Info("Shutting down server...")

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        slog.Error("server forced to shutdown", "error", err)
        os.Exit(1)
    }

    slog.Info("Server stopped")
}

7. Tests HTTP

7.1 httptest

func TestHandler(t *testing.T) {
    tests := []struct {
        name       string
        method     string
        path       string
        body       string
        wantStatus int
        wantBody   string
    }{
        {"get user", "GET", "/users/1", "", 200, `{"id":1}`},
        {"not found", "GET", "/users/999", "", 404, `{"error":"not found"}`},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest(tt.method, tt.path, 
                strings.NewReader(tt.body))
            rec := httptest.NewRecorder()

            handler(rec, req)

            assert.Equal(t, tt.wantStatus, rec.Code)
            assert.JSONEq(t, tt.wantBody, rec.Body.String())
        })
    }
}

7.2 Golden Files

func TestHandlerWithGolden(t *testing.T) {
    req := httptest.NewRequest("GET", "/users/1", nil)
    rec := httptest.NewRecorder()

    handler(rec, req)

    // Mettre à jour avec -update
    golden := filepath.Join("testdata", t.Name()+".golden")
    if *update {
        os.WriteFile(golden, rec.Body.Bytes(), 0644)
    }

    expected, _ := os.ReadFile(golden)
    assert.Equal(t, string(expected), rec.Body.String())
}

Résumé

  • net/http : Handler, ServeMux (Go 1.22+ patterns), ResponseWriter
  • Frameworks : Chi (router), Gin, Echo, Fiber
  • Templates : html/template, partials, FuncMap
  • Middleware : logging, auth, recovery, CORS, rate limiting
  • Configuration : Viper, envconfig
  • Graceful shutdown : signaux OS, context timeout
  • Tests : httptest, golden files