Урок 42 (бонус): go-chi — роутер и middleware для production

Урок 42 (бонус). go-chi: роутер и middleware

🔄 Node.js → Go (аналогии с Express):

📋 Что изучаем


1. Установка и настройка

1.1 Инициализация модуля

go mod init myapp

1.2 Установка chi

go get github.com/go-chi/chi/v5
go get github.com/go-chi/cors          # CORS middleware
go get github.com/go-chi/httprate      # rate limiter
go get github.com/go-chi/chi/middleware # основные middleware (встроены в chi)

Аналог в Node.js: npm install express cors express-rate-limit

1.3 Простейший сервер

package main

import (
    "net/http"
    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)

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

    // Глобальные middleware
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.RequestID)

    r.Get("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello, chi!"))
    })

    http.ListenAndServe(":3000", r)
}
💡 Практический совет:

Сравнение с Express: chi.NewRouter() — это как express(), а r.Use(...) — как app.use(...). Middleware в chi применяются в порядке объявления.


2. Маршрутизация

2.1 Базовые методы

r.Get("/users", listUsers)
r.Post("/users", createUser)
r.Put("/users/{id}", updateUser)   // {id} — параметр пути
r.Delete("/users/{id}", deleteUser)
r.Patch("/users/{id}", patchUser)
r.Options("/users/{id}", optionsUser)

2.2 Параметры пути

r.Get("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    w.Write([]byte("User ID: " + id))
})

// С регулярным выражением:
r.Get("/users/{id:[0-9]+}", func(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id") // гарантированно число
})

// Несколько параметров:
r.Get("/posts/{year}/{month}/{slug}", func(w http.ResponseWriter, r *http.Request) {
    year  := chi.URLParam(r, "year")
    month := chi.URLParam(r, "month")
    slug  := chi.URLParam(r, "slug")
})

Аналог в Express: req.params.idchi.URLParam(r, "id")

2.3 Query-параметры

r.Get("/search", func(w http.ResponseWriter, r *http.Request) {
    q    := r.URL.Query().Get("q")      // ?q=golang
    page := r.URL.Query().Get("page")   // ?page=2
    w.Write([]byte(fmt.Sprintf("Search: %s, Page: %s", q, page)))
})

3. Группировка маршрутов (Route)

chi поддерживает вложенные роутеры через r.Route() — аналог express.Router().

3.1 Простая группа

r.Route("/api", func(r chi.Router) {
    r.Get("/status", statusHandler)         // GET /api/status
    r.Get("/version", versionHandler)       // GET /api/version
})

3.2 Вложенные группы

r.Route("/api/v1", func(r chi.Router) {
    r.Route("/users", func(r chi.Router) {
        r.Get("/", listUsers)               // GET /api/v1/users
        r.Post("/", createUser)             // POST /api/v1/users
        r.Route("/{id}", func(r chi.Router) {
            r.Get("/", getUser)             // GET /api/v1/users/123
            r.Put("/", updateUser)          // PUT /api/v1/users/123
            r.Delete("/", deleteUser)       // DELETE /api/v1/users/123
        })
    })
})

3.3 Группы с общей middleware

r.Route("/admin", func(r chi.Router) {
    // Вся группа /admin требует авторизации
    r.Use(authMiddleware)
    r.Use(adminOnlyMiddleware)

    r.Get("/dashboard", dashboardHandler)   // GET /admin/dashboard
    r.Get("/stats", statsHandler)           // GET /admin/stats
})

Аналог в Express:

const adminRouter = express.Router();
adminRouter.use(authMiddleware);
adminRouter.use(adminOnlyMiddleware);
adminRouter.get('/dashboard', dashboardHandler);
app.use('/admin', adminRouter);

4. Middleware в chi

4.1 Встроенные middleware (из chi/middleware)

MiddlewareНазначениеАналог в Node.js
LoggerЛогирование запросов (метод, путь, статус, длительность)morgan
RecovererПерехват паник, возврат 500express-async-errors
RequestIDДобавляет X-Request-ID к каждому запросуexpress-request-id
RealIPОпределение реального IP через заголовки (X-Forwarded-For)express-X-Forwarded-For
TimeoutТаймаут запроса (контекст отмены)connect-timeout
CompressGzip-сжатие ответовcompression
HeartbeatEndpoint для health-check
CleanPathНормализация пути (удаление слешей)
RedirectSlashesРедирект при конечном слешеexpress-slash

4.2 Использование встроенных middleware

r := chi.NewRouter()

// Порядок важен! Первый = внешний
r.Use(middleware.RequestID)       // 1. ID запроса
r.Use(middleware.RealIP)          // 2. Реальный IP
r.Use(middleware.Logger)          // 3. Логирование
r.Use(middleware.Recoverer)       // 4. Восстановление после паник
r.Use(middleware.Timeout(30 * time.Second)) // 5. Таймаут 30с

// Middleware для конкретного маршрута
r.Get("/health", middleware.Heartbeat("/health"))

4.3 Порядок middleware

r.Use(A)    // снаружи — выполняется первой на входе, последней на выходе
r.Use(B)    // |
r.Use(C)    // внутри — выполняется последней на входе, первой на выходе

// Схема для запроса:
// Request → A → B → C → Handler → C → B → A → Response

Аналог в Express: точно так же — app.use(A)app.use(B)app.use(C), и они выполняются как onion-модель.


5. Кастомная middleware

5.1 Базовая middleware (логгер)

func customLogger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        // Обёртка для ResponseWriter, чтобы поймать статус
        wr := &responseWriter{ResponseWriter: w, status: 200}
        next.ServeHTTP(wr, r)

        log.Printf("[%s] %s %s %d %v",
            r.Method, r.URL.Path, r.RemoteAddr, wr.status, time.Since(start))
    })
}

type responseWriter struct {
    http.ResponseWriter
    status int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.status = code
    rw.ResponseWriter.WriteHeader(code)
}

5.2 Middleware авторизации (JWT)

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
        }

        // Валидация токена...
        userID := "user_123"

        // Передаём данные в контекст
        ctx := context.WithValue(r.Context(), "user_id", userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Использование:
r.With(authMiddleware).Get("/profile", profileHandler)

// Или глобально:
r.Use(authMiddleware)

5.3 Middleware CORS

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

r.Use(cors.Handler(cors.Options{
    AllowedOrigins:   []string{"https://example.com", "http://localhost:3000"},
    AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
    AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type"},
    ExposedHeaders:   []string{"Link"},
    AllowCredentials: false,
    MaxAge:           300, // seconds
}))

Аналог в Express: npm install corsapp.use(cors({ origin: '...' }))

5.4 Middleware Rate Limiter

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

r.Use(httprate.LimitByIP(100, 1*time.Minute))
// 100 запросов в минуту на IP

// Или по ключу:
r.Use(httprate.LimitBy(10, 1*time.Minute, httprate.KeyByIP))

5.5 Middleware с конфигурацией (замыкание)

func rateLimiter(maxRequests int, per time.Duration) func(http.Handler) http.Handler {
    visitors := make(map[string]int)
    var mu sync.Mutex

    // Очистка каждые per
    go func() {
        for {
            time.Sleep(per)
            mu.Lock()
            visitors = make(map[string]int)
            mu.Unlock()
        }
    }()

    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ip := r.RemoteAddr
            mu.Lock()
            visitors[ip]++
            count := visitors[ip]
            mu.Unlock()

            if count > maxRequests {
                http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

6. Контекст запроса (chi context)

chi расширяет стандартный context.Context, позволяя передавать данные между middleware и обработчиками.

6.1 Запись в контекст

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx := context.WithValue(r.Context(), "user", &User{ID: 1, Name: "Алиса"})
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

6.2 Чтение из контекста

func handler(w http.ResponseWriter, r *http.Request) {
    user := r.Context().Value("user").(*User)
    // или
    if user, ok := r.Context().Value("user").(*User); ok {
        json.NewEncoder(w).Encode(user)
    }
}

6.3 Route context от chi

chi также хранит параметры маршрута в контексте:

// Внутреннее устройство chi.URLParam:
func URLParam(r *http.Request, key string) string {
    return chi.RouteContext(r.Context()).URLParam(key)
}

7. Mount — вложенные роутеры

chi позволяет монтировать один роутер в другой — аналог app.use('/prefix', subRouter) в Express.

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

    // Монтируем под-роутер
    r.Mount("/api/v1", apiRouter())
    r.Mount("/admin", adminRouter())

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

func apiRouter() http.Handler {
    r := chi.NewRouter()
    r.Use(middleware.Logger)

    r.Get("/users", listUsers)
    r.Post("/users", createUser)

    return r
}

func adminRouter() http.Handler {
    r := chi.NewRouter()
    r.Use(authMiddleware)

    r.Get("/dashboard", dashboardHandler)
    r.Get("/stats", statsHandler)

    return r
}

8. Middleware цепочки (With)

r.With() создаёт временную группу с дополнительными middleware.

r.With(authMiddleware, adminMiddleware).Route("/admin", func(r chi.Router) {
    r.Get("/dashboard", dashboardHandler)
    r.Get("/stats", statsHandler)
})

// Эквивалентно:
r.Group(func(r chi.Router) {
    r.Use(authMiddleware)
    r.Use(adminMiddleware)
    r.Get("/admin/dashboard", dashboardHandler)
    r.Get("/admin/stats", statsHandler)
})

9. Production-паттерны

9.1 Структура API с версионированием

r := chi.NewRouter()

// Глобальные middleware
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Use(cors.Handler(cors.Options{/*...*/}))

// Health check
r.Get("/health", healthHandler)
r.Get("/ready", readinessHandler)

// API v1
r.Route("/api/v1", func(r chi.Router) {
    r.Use(rateLimiter)

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

    r.Route("/posts", func(r chi.Router) {
        r.Use(authMiddleware)
        r.Get("/", listPosts)
        r.Post("/", createPost)
        r.Route("/{id}", func(r chi.Router) {
            r.Get("/", getPost)
            r.Put("/", updatePost)
            r.Delete("/", deletePost)
        })
    })
})

// API v2 (новая версия)
r.Mount("/api/v2", v2Router())

9.2 Обработка ошибок (Error handler middleware)

func errorHandler(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Обёртка для перехвата ошибок из хендлеров
        wr := &errorResponseWriter{ResponseWriter: w, status: http.StatusOK}
        next.ServeHTTP(wr, r)

        if wr.err != nil {
            log.Printf("Handler error: %v", wr.err)
            // Можно отправить ошибку в Sentry/DataDog
        }
    })
}

type errorResponseWriter struct {
    http.ResponseWriter
    status int
    err    error
}

9.3 Chained middleware (модульная композиция)

func RequireAuth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Извлекаем токен
        token := extractToken(r)
        if token == "" {
            http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
            return
        }
        // Валидируем
        user, err := validateJWT(token)
        if err != nil {
            http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized)
            return
        }
        // Кладём в контекст
        ctx := context.WithValue(r.Context(), "user", user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func RequireRole(role string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            user := r.Context().Value("user").(*User)
            if !user.HasRole(role) {
                http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

// Использование:
r.With(RequireAuth, RequireRole("admin")).Get("/admin", adminHandler)

🔑 Ключевые выводы

← Предыдущий урок Следующий урок →