Лекція 10. Шарувата архітектура та конвеєри обробки запитів
🎯 Мета лекції
- Опанувати концепцію Middleware як конвеєрної обробки HTTP-запитів у мові Go.
- Зрозуміти принципи шаруватої архітектури та Clean Architecture для серверних застосунків.
- Навчитися застосовувати ручне впровадження залежностей (Dependency Injection) для слабкої зв'язності компонентів.
- Набути практичних навичок модульного тестування HTTP-шару за допомогою пакета
net/http/httptest.
🔑 Ключові терміни
- Middleware: проміжний обробник запиту, що виконується перед основним handler та може модифікувати запит, відповідь або припинити подальшу обробку.
- Clean Architecture: архітектурний підхід, що ізолює бізнес-логіку від зовнішніх деталей доставки даних (HTTP, БД, UI).
- Dependency Injection (DI): патерн передачі залежностей ззовні замість створення їх усередині компонента.
- Repository Pattern: абстракція доступу до даних через інтерфейси, що дозволяє змінювати джерело даних без зміни бізнес-логіки.
Налаштування проєкту з нуля
Перш ніж почати писати код, створимо правильну структуру Go-проєкту.
Ініціалізація Go-модуля
# Створюємо теку проєкту
mkdir myapp
cd myapp
# Ініціалізуємо Go-модуль
go mod init myapp
# Встановлюємо необхідні залежності
go get github.com/go-chi/chi/v5
go get golang.org/x/time/rate
go get golang.org/x/crypto/bcrypt
go get github.com/lib/pq # PostgreSQL драйвер
go get github.com/google/uuid # UUID generation
Після виконання цих команд з'явиться файл go.mod:
// go.mod
module myapp
go 1.21
require (
github.com/go-chi/chi/v5 v5.0.11
github.com/google/uuid v1.5.0
github.com/lib/pq v1.10.9
golang.org/x/crypto v0.17.0
golang.org/x/time v0.5.0
)
Створення базової структури каталогів
# Створюємо структуру проєкту
mkdir -p cmd/server
mkdir -p internal/{domain,usecase,repository/{memory,postgres},delivery/http}
mkdir -p middleware
mkdir -p migrations
# Створюємо основні файли
touch cmd/server/main.go
touch middleware/{logger,recovery,cors,ratelimit,auth}.go
touch internal/domain/{user,user_repository,errors}.go
touch internal/usecase/user_usecase.go
touch internal/repository/memory/user_repository.go
touch internal/repository/postgres/user_repository.go
touch internal/delivery/http/user_handler.go
go.mod (myapp) має відповідати імпортам у коді. У реальних проєктах використовуйте повний шлях, наприклад: github.com/username/myapp.Проблема дублювання коду в HTTP-обробниках
Початок: прості обробники без структури
Припустимо, ми розробляємо API для управління користувачами. Почнемо з найпростішого підходу — напишемо кілька HTTP-обробників без будь-якої архітектури:
// cmd/simple-server/main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
// Примітка: для запуску цього прикладу достатньо стандартної бібліотеки Go
// Глобальна змінна для зберігання користувачів (in-memory)
var users = make(map[string]string)
func main() {
http.HandleFunc("/api/users", handleUsers)
http.HandleFunc("/api/health", handleHealth)
fmt.Println("Server starting on :8080...")
log.Fatal(http.ListenAndServe(":8080", nil))
}
func handleUsers(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Printf("[%s] %s - started", r.Method, r.URL.Path)
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
case http.MethodPost:
var user struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
log.Printf("[%s] %s - completed in %v (error: invalid JSON)",
r.Method, r.URL.Path, time.Since(start))
return
}
users[user.Email] = user.Name
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, "User %s created", user.Name)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
log.Printf("[%s] %s - completed in %v", r.Method, r.URL.Path, time.Since(start))
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Printf("[%s] %s - started", r.Method, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"time": time.Now().Format(time.RFC3339),
})
log.Printf("[%s] %s - completed in %v", r.Method, r.URL.Path, time.Since(start))
}
Що ми бачимо в цьому коді?
Кожен обробник містить однаковий повторюваний код:
- Логування початку запиту з часом старту
- Встановлення заголовка
Content-Type - Логування завершення запиту з тривалістю
Це тільки два ендпоінти! Уявіть, що буде при 20-30 ендпоінтах. А що, якщо нам потрібно додати:
- Перехоплення паніки (panic recovery) щоб сервер не падав при помилках?
- CORS-заголовки для роботи з браузерними клієнтами?
- Автентифікацію через JWT токени?
- Обмеження частоти запитів (rate limiting)?
Код стане абсолютно нечитабельним. Саме цю проблему вирішує патерн Middleware.
Еволюція: виділення спільної логіки
Спроба 1: винесення логування у функцію
Спробуємо зменшити дублювання, створивши допоміжну функцію:
func logRequest(method, path string, start time.Time, stage string) {
if stage == "start" {
log.Printf("[%s] %s - started", method, path)
} else {
log.Printf("[%s] %s - completed in %v", method, path, time.Since(start))
}
}
func handleUsers(w http.ResponseWriter, r *http.Request) {
start := time.Now()
logRequest(r.Method, r.URL.Path, start, "start")
defer logRequest(r.Method, r.URL.Path, start, "end")
// ... решта логіки
}
Це трохи краще, але:
- Треба пам'ятати викликати
logRequestу кожному обробнику - Неможливо легко вимкнути логування для окремих ендпоінтів
- Код все одно дублюється
Спроба 2: обгортаюча функція
Створимо функцію, що приймає обробник і повертає новий обробник з логуванням:
func withLogging(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Printf("[%s] %s - started", r.Method, r.URL.Path)
// Викликаємо оригінальний обробник
next(w, r)
log.Printf("[%s] %s - completed in %v", r.Method, r.URL.Path, time.Since(start))
}
}
Тепер можна обгорнути будь-який обробник:
func main() {
http.HandleFunc("/api/users", withLogging(handleUsers))
http.HandleFunc("/api/health", withLogging(handleHealth))
log.Fatal(http.ListenAndServe(":8080", nil))
}
Це вже набагато краще! Але є проблема: withLogging приймає http.HandlerFunc, а не http.Handler. Це обмежує можливості композиції.
Middleware: стандартний патерн у Go
Що таке Middleware?
Middleware (проміжне програмне забезпечення, проміжний обробник) — це функція, яка:
- Приймає
http.Handler(наступний обробник у ланцюжку) - Повертає новий
http.Handler, що обгортає переданий - Може виконувати код до та/або після виклику наступного обробника
- Може припинити подальшу обробку (не викликати
next.ServeHTTP)
Стандартна сигнатура Middleware
У Go прийнято визначати Middleware як тип:
type Middleware func(http.Handler) http.Handler
Це функція, що:
- Вхід:
http.Handler(наступний обробник) - Вихід:
http.Handler(новий обробник з додатковою логікою)
http.Handler має лише один метод: ServeHTTP(ResponseWriter, *Request). Будь-яка структура чи функція, що реалізує цей метод, є валідним HTTP-обробником.Створюємо перший Middleware з нуля
Крок 1: Розуміємо http.Handler
Щоб створити Middleware, спочатку потрібно зрозуміти інтерфейс http.Handler:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
Будь-який тип, що має метод ServeHTTP, може бути обробником. Наприклад:
// Простий обробник як структура
type MyHandler struct{}
func (h *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello from MyHandler")
}
// Використання
http.Handle("/test", &MyHandler{})
Але є зручніший спосіб — тип http.HandlerFunc:
// HandlerFunc — це адаптер, що перетворює функцію на Handler
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
Завдяки цьому можна писати так:
func myHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello")
}
http.Handle("/test", http.HandlerFunc(myHandler))
http.HandleFunc() робить це автоматично — перетворює функцію на http.Handler через http.HandlerFunc. Тому http.HandleFunc("/path", myFunc) еквівалентно http.Handle("/path", http.HandlerFunc(myFunc)).Крок 2: Створюємо найпростіший Middleware
Створимо Middleware, що просто виводить повідомлення до та після виконання наступного обробника:
// middleware/simple.go
package middleware
import (
"fmt"
"net/http"
)
// Simple — найпростіший Middleware для демонстрації концепції
func Simple(next http.Handler) http.Handler {
// Повертаємо нову функцію, що обгортає next
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("BEFORE: Middleware executed before handler")
// Викликаємо наступний обробник у ланцюжку
next.ServeHTTP(w, r)
fmt.Println("AFTER: Middleware executed after handler")
})
}
Покрокове пояснення:
Simple— це функція, що приймаєhttp.Handlerта повертаєhttp.Handler- Всередині ми створюємо анонімну функцію
func(w http.ResponseWriter, r *http.Request) - Обгортаємо цю функцію в
http.HandlerFunc(), щоб перетворити наhttp.Handler - ДО виклику
next.ServeHTTP()виконується препроцесинг - ПІСЛЯ виклику
next.ServeHTTP()виконується постпроцесинг
Крок 3: Використовуємо Middleware
Створимо повний робочий приклад:
// cmd/middleware-demo/main.go
package main
import (
"fmt"
"log"
"net/http"
"myapp/middleware" // Наш власний пакет
)
func mainHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello from main handler!")
fmt.Println("HANDLER: Main handler executing")
}
func main() {
// Обгортаємо handler у Middleware
wrappedHandler := middleware.Simple(http.HandlerFunc(mainHandler))
http.Handle("/", wrappedHandler)
fmt.Println("Server starting on :8080...")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Запускаємо сервер:
Виконуємо запит:
У логах сервера побачимо:
next завдяки замиканню (closure). Це дозволяє їй викликати наступний обробник пізніше, коли прийде HTTP-запит.Реальний приклад: Middleware логування
Тепер створимо корисний Middleware, що логує кожен HTTP-запит з деталями:
// middleware/logger.go
package middleware
import (
"log"
"net/http"
"time"
)
// Logger — Middleware для логування HTTP-запитів
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Записуємо час початку обробки
start := time.Now()
// Логуємо деталі запиту
log.Printf(
"[START] Method=%s Path=%s RemoteAddr=%s",
r.Method,
r.URL.Path,
r.RemoteAddr,
)
// Викликаємо наступний обробник
next.ServeHTTP(w, r)
// Обчислюємо тривалість обробки
duration := time.Since(start)
// Логуємо завершення
log.Printf(
"[END] Method=%s Path=%s Duration=%v",
r.Method,
r.URL.Path,
duration,
)
})
}
Проблема: як дізнатися статус-код відповіді?
Поточна реалізація не знає, який статус-код повернув обробник. Для цього потрібно обгорнути http.ResponseWriter у власний тип, що перехоплює виклик WriteHeader():
// middleware/logger.go (покращена версія)
package middleware
import (
"log"
"net/http"
"time"
)
// responseWriter — обгортка для http.ResponseWriter, що зберігає статус-код
type responseWriter struct {
http.ResponseWriter
statusCode int
written bool
}
// WriteHeader перехоплює виклик та зберігає статус-код
func (rw *responseWriter) WriteHeader(code int) {
if !rw.written {
rw.statusCode = code
rw.written = true
rw.ResponseWriter.WriteHeader(code)
}
}
// Write перехоплює запис даних (якщо WriteHeader не викликано, статус 200)
func (rw *responseWriter) Write(b []byte) (int, error) {
if !rw.written {
rw.statusCode = http.StatusOK
rw.written = true
}
return rw.ResponseWriter.Write(b)
}
// Logger — Middleware з логуванням статус-коду
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Обгортаємо ResponseWriter
wrapped := &responseWriter{
ResponseWriter: w,
statusCode: http.StatusOK, // за замовчуванням 200
written: false,
}
log.Printf(
"[→] %s %s from %s",
r.Method,
r.URL.Path,
r.RemoteAddr,
)
// Викликаємо наступний обробник з обгорнутим writer
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
log.Printf(
"[←] %s %s → %d (%v)",
r.Method,
r.URL.Path,
wrapped.statusCode,
duration,
)
})
}
http.ResponseWriter у нашу структуру дозволяє автоматично делегувати всі інші методи інтерфейсу оригінальному writer. Ми перевизначаємо лише WriteHeader та Write.Повний робочий приклад з логуванням
// cmd/logger-server/main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"myapp/middleware"
)
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "healthy",
})
}
func errorHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Something went wrong", http.StatusInternalServerError)
}
func main() {
// Обгортаємо кожен handler у Logger Middleware
http.Handle("/health", middleware.Logger(http.HandlerFunc(healthHandler)))
http.Handle("/error", middleware.Logger(http.HandlerFunc(errorHandler)))
fmt.Println("Server with logging starting on :8080...")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Виконуємо запити:
Логи сервера:
Чудово! Тепер у нас є робочий Middleware, що логує всі запити з статус-кодами та тривалістю обробки.
Middleware для перехоплення паніки
Якщо в обробнику виникає паніка (panic), стандартна поведінка Go — завершити горутину і розірвати з'єднання. Це неприйнятно для продакшн-систем. Створимо Middleware для перехоплення паніки:
// middleware/recovery.go
package middleware
import (
"log"
"net/http"
"runtime/debug"
)
// Recovery — Middleware для перехоплення паніки
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
// Логуємо паніку та стек викликів
log.Printf("PANIC: %v\n%s", err, debug.Stack())
// Повертаємо клієнту статус 500
http.Error(w,
"Internal Server Error",
http.StatusInternalServerError)
}
}()
// Викликаємо наступний обробник
next.ServeHTTP(w, r)
})
}
Пояснення:
deferгарантує, що анонімна функція виконається після завершенняnext.ServeHTTP()(навіть якщо сталася паніка)recover()перехоплює паніку і повертає значення, передане уpanic()debug.Stack()повертає повний стек викликів для діагностики
Тестуємо Recovery Middleware
// cmd/recovery-demo/main.go
package main
import (
"fmt"
"log"
"net/http"
"myapp/middleware"
)
func panicHandler(w http.ResponseWriter, r *http.Request) {
// Навмисна паніка для демонстрації
panic("Something terrible happened!")
}
func normalHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Everything is fine here")
}
func main() {
// Обгортаємо handler у Recovery та Logger
http.Handle("/panic",
middleware.Logger(
middleware.Recovery(
http.HandlerFunc(panicHandler))))
http.Handle("/normal",
middleware.Logger(
middleware.Recovery(
http.HandlerFunc(normalHandler))))
fmt.Println("Server with panic recovery on :8080...")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Запит до /panic:
Лог сервера (сервер не падає!):
error. Recovery Middleware — це останній рубіж захисту від непередбачених ситуацій, а не основний механізм обробки помилок.Композиція Middleware: побудова ланцюжків
Проблема вкладеності
Коли у нас є кілька Middleware, вкладення стає громіздким:
handler := middleware.Logger(
middleware.Recovery(
middleware.CORS(
http.HandlerFunc(myHandler))))
Це важко читати і підтримувати. Створимо допоміжну функцію для композиції:
// middleware/chain.go
package middleware
import "net/http"
// Chain послідовно застосовує список Middleware до handler
func Chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
// Застосовуємо Middleware у зворотному порядку,
// щоб перший у списку виконувався першим
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}
Чому зворотний порядок? Middleware застосовуються як обгортки:
Тепер можна писати так:
handler := middleware.Chain(
http.HandlerFunc(myHandler),
middleware.Logger,
middleware.Recovery,
middleware.CORS,
)
r.Use() для групування Middleware, що ще зручніше. Але розуміння того, як працює Chain, допомагає зрозуміти внутрішню механіку.CORS Middleware: дозвіл крос-доменних запитів
Що таке CORS?
CORS (Cross-Origin Resource Sharing, спільне використання ресурсів між різними джерелами) — це механізм безпеки браузерів, що контролює, які домени можуть виконувати запити до вашого API.
Проблема: браузер блокує AJAX-запити до API на іншому домені з міркувань безпеки:
Клієнт: https://myapp.com
API: https://api.myserver.com ← Різні домени!
Рішення: API має додавати спеціальні CORS-заголовки, що дозволяють крос-доменні запити.
Preflight-запити
Перед основним запитом браузер надсилає preflight-запит з методом OPTIONS:
OPTIONS /api/users HTTP/1.1
Origin: https://myapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
Сервер має відповісти, які методи та заголовки дозволені:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Реалізація CORS Middleware
// middleware/cors.go
package middleware
import (
"net/http"
"strings"
)
// CORSConfig — налаштування CORS
type CORSConfig struct {
AllowOrigins []string // Дозволені домени (["*"] = всі)
AllowMethods []string // Дозволені HTTP-методи
AllowHeaders []string // Дозволені заголовки
ExposeHeaders []string // Заголовки, доступні клієнту
AllowCredentials bool // Дозволити cookies
MaxAge int // Час кешування preflight (секунди)
}
// DefaultCORSConfig — базова конфігурація
func DefaultCORSConfig() CORSConfig {
return CORSConfig{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
ExposeHeaders: []string{},
AllowCredentials: false,
MaxAge: 86400, // 24 години
}
}
// CORS — Middleware для обробки крос-доменних запитів
func CORS(config CORSConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Перевіряємо, чи дозволений домен
allowedOrigin := ""
for _, allowed := range config.AllowOrigins {
if allowed == "*" || allowed == origin {
allowedOrigin = allowed
break
}
}
// Якщо домен дозволений, встановлюємо заголовки
if allowedOrigin != "" {
if allowedOrigin == "*" {
w.Header().Set("Access-Control-Allow-Origin", "*")
} else {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
w.Header().Set("Access-Control-Allow-Methods",
strings.Join(config.AllowMethods, ", "))
w.Header().Set("Access-Control-Allow-Headers",
strings.Join(config.AllowHeaders, ", "))
if len(config.ExposeHeaders) > 0 {
w.Header().Set("Access-Control-Expose-Headers",
strings.Join(config.ExposeHeaders, ", "))
}
if config.AllowCredentials {
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
if config.MaxAge > 0 {
w.Header().Set("Access-Control-Max-Age",
fmt.Sprintf("%d", config.MaxAge))
}
}
// Preflight-запит (OPTIONS) — відповідаємо одразу
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
// Інші запити — передаємо далі
next.ServeHTTP(w, r)
})
}
}
Використання CORS
// cmd/cors-server/main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"myapp/middleware"
)
func apiHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "API доступне з будь-якого домену",
})
}
func main() {
corsConfig := middleware.DefaultCORSConfig()
handler := middleware.Chain(
http.HandlerFunc(apiHandler),
middleware.Logger,
middleware.Recovery,
middleware.CORS(corsConfig),
)
http.Handle("/api/data", handler)
fmt.Println("CORS-enabled server on :8080...")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Тестування з браузера:
<!-- test.html -->
<!DOCTYPE html>
<html>
<head>
<title>CORS Test</title>
</head>
<body>
<button onclick="fetchData()">Fetch Data</button>
<pre id="result"></pre>
<script>
async function fetchData() {
try {
const response = await fetch('http://localhost:8080/api/data');
const data = await response.json();
document.getElementById('result').textContent =
JSON.stringify(data, null, 2);
} catch (error) {
document.getElementById('result').textContent =
'Error: ' + error.message;
}
}
</script>
</body>
</html>
Без CORS Middleware браузер заблокував би запит. З CORS — запит пройде успішно.
Rate Limiting Middleware
Навіщо потрібен Rate Limiting?
Rate Limiting (обмеження частоти запитів) захищає API від:
- DDoS-атак
- Ненавмисного перевантаження (помилки у клієнтському коді)
- Зловживань безплатними API
Типові стратегії:
- Fixed Window: X запитів за хвилину
- Sliding Window: X запитів за останню хвилину
- Token Bucket: поповнюваний запас токенів
Реалізація через Token Bucket
Використаємо пакет golang.org/x/time/rate:
go get golang.org/x/time/rate
// middleware/ratelimit.go
package middleware
import (
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
)
// RateLimiter зберігає окремий лімітер для кожного клієнта
type RateLimiter struct {
mu sync.Mutex
limiters map[string]*rate.Limiter
rate rate.Limit
burst int
}
// NewRateLimiter створює новий rate limiter
// rps - запитів на секунду, burst - максимальний сплеск
func NewRateLimiter(rps int, burst int) *RateLimiter {
return &RateLimiter{
limiters: make(map[string]*rate.Limiter),
rate: rate.Limit(rps),
burst: burst,
}
}
// getLimiter отримує або створює лімітер для IP
func (rl *RateLimiter) getLimiter(ip string) *rate.Limiter {
rl.mu.Lock()
defer rl.mu.Unlock()
limiter, exists := rl.limiters[ip]
if !exists {
limiter = rate.NewLimiter(rl.rate, rl.burst)
rl.limiters[ip] = limiter
}
return limiter
}
// Middleware повертає HTTP Middleware
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Отримуємо IP клієнта
ip := r.RemoteAddr
// Перевіряємо ліміт
limiter := rl.getLimiter(ip)
if !limiter.Allow() {
// Ліміт перевищено
w.Header().Set("X-RateLimit-Retry-After",
time.Second.String())
http.Error(w,
"Too Many Requests - Rate limit exceeded",
http.StatusTooManyRequests)
return
}
// Ліміт не перевищено — передаємо далі
next.ServeHTTP(w, r)
})
}
Використання Rate Limiter
// cmd/ratelimit-server/main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"myapp/middleware"
)
func dataHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"data": "This endpoint is rate-limited to 2 req/sec",
})
}
func main() {
// Дозволяємо 2 запити на секунду, сплеск до 5
rateLimiter := middleware.NewRateLimiter(2, 5)
handler := middleware.Chain(
http.HandlerFunc(dataHandler),
middleware.Logger,
middleware.Recovery,
rateLimiter.Middleware,
)
http.Handle("/api/data", handler)
fmt.Println("Rate-limited server (2 req/s) on :8080...")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Тестування:
rate токенів на секунду, але не може вмістити більше burst токенів. Це дозволяє короткочасні сплески запитів при збереженні середньої швидкості.Authentication Middleware: передача даних через контекст
Проблема передачі даних між Middleware
Припустимо, Middleware автентифікації перевіряє JWT токен та отримує user_id. Як передати цю інформацію до обробника?
Погані варіанти:
- ❌ Глобальні змінні (не потокобезпечні, плутанина при конкурентних запитах)
- ❌ Заголовки відповіді (це для відповіді клієнту, не для внутрішньої передачі даних)
- ❌ Модифікація URL (неестетично і порушує REST-принципи)
Правильне рішення: context.Context.
Що таке context.Context?
context.Context — це стандартний механізм Go для передачі:
- Метаданих між функціями (user ID, request ID, trace ID)
- Сигналів скасування операцій
- Таймаутів виконання
Кожен HTTP-запит має власний контекст: r.Context().
context.WithValue(), context.WithTimeout() тощо повертають новий контекст, не змінюючи оригінал. Це забезпечує потокобезпечність.Створення власних ключів контексту
Ключі контексту мають бути унікальними типами, щоб уникнути конфліктів:
// middleware/auth.go
package middleware
import (
"context"
"net/http"
"strings"
)
// contextKey — приватний тип для ключів контексту
type contextKey string
const (
// UserIDKey — ключ для збереження user_id у контексті
UserIDKey contextKey = "user_id"
)
contextKey замість звичайного string запобігає випадковим колізіям з ключами з інших пакетів. Це рекомендована практика у Go.Authentication Middleware
Створимо Middleware, що перевіряє JWT токен (спрощена версія):
// middleware/auth.go (продовження)
package middleware
import (
"context"
"fmt"
"net/http"
"strings"
)
// Auth — Middleware для перевірки Bearer токена
func Auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Читаємо заголовок Authorization
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w,
"Missing Authorization header",
http.StatusUnauthorized)
return
}
// Очікуємо формат: "Bearer <token>"
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w,
"Invalid Authorization header format. Expected: Bearer <token>",
http.StatusUnauthorized)
return
}
token := parts[1]
// Валідація токена (тут спрощено)
userID, err := validateJWT(token)
if err != nil {
http.Error(w,
"Invalid or expired token: " + err.Error(),
http.StatusUnauthorized)
return
}
// Додаємо user_id у контекст запиту
ctx := context.WithValue(r.Context(), UserIDKey, userID)
// Передаємо оновлений запит з новим контекстом
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// validateJWT — спрощена валідація токена
// У реальному проєкті тут має бути перевірка підпису JWT
func validateJWT(token string) (string, error) {
// Для демонстрації: просто повертаємо токен як user_id
if token == "" {
return "", fmt.Errorf("empty token")
}
// У реальності тут буде:
// 1. Парсинг JWT
// 2. Перевірка підпису
// 3. Перевірка термінів дії (exp)
// 4. Витягування claims (user_id, roles, etc.)
return "user-123", nil // Заглушка
}
Отримання даних з контексту в обробнику
// cmd/auth-server/main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"myapp/middleware"
)
// Публічний ендпоінт (без автентифікації)
func publicHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "This is a public endpoint",
})
}
// Захищений ендпоінт (вимагає автентифікації)
func protectedHandler(w http.ResponseWriter, r *http.Request) {
// Витягуємо user_id з контексту
userID, ok := r.Context().Value(middleware.UserIDKey).(string)
if !ok {
http.Error(w, "User not authenticated", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "This is a protected endpoint",
"user_id": userID,
})
}
func main() {
// Публічний маршрут (без Auth Middleware)
http.Handle("/api/public",
middleware.Chain(
http.HandlerFunc(publicHandler),
middleware.Logger,
middleware.Recovery,
))
// Захищений маршрут (з Auth Middleware)
http.Handle("/api/protected",
middleware.Chain(
http.HandlerFunc(protectedHandler),
middleware.Logger,
middleware.Recovery,
middleware.Auth, // Додаємо автентифікацію
))
fmt.Println("Server with authentication on :8080...")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Тестування:
Якщо два різні пакети використовують однаковий string-ключ (наприклад, "user_id"), виникне конфлікт — значення будуть перезаписувати одне одного. Використання приватного типу contextKey гарантує, що ключ є унікальним, навіть якщо інший пакет також має ключ зі значенням "user_id".
// Пакет A
type contextKey string
const UserIDKey contextKey = "user_id"
// Пакет B
type contextKey string
const UserIDKey contextKey = "user_id"
// Ці ключі РІЗНІ, бо мають різні типи!
Інтеграція Middleware з Chi Router
Chi Router: маршрутизатор з вбудованою підтримкою Middleware
Chi — популярний легковаговий маршрутизатор для Go з вбудованою підтримкою Middleware:
go get -u github.com/go-chi/chi/v5
Групування маршрутів з різними Middleware
Chi дозволяє застосовувати Middleware до груп маршрутів:
// cmd/chi-server/main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"myapp/middleware"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
// Глобальні Middleware (застосовуються до всіх маршрутів)
r.Use(middleware.Logger)
r.Use(middleware.Recovery)
// Публічні маршрути
r.Get("/api/public", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{
"message": "Public endpoint",
})
})
// Група захищених маршрутів
r.Group(func(r chi.Router) {
r.Use(middleware.Auth) // Auth тільки для цієї групи
r.Get("/api/profile", func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value(middleware.UserIDKey).(string)
json.NewEncoder(w).Encode(map[string]string{
"user_id": userID,
"message": "Your profile",
})
})
r.Get("/api/settings", func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value(middleware.UserIDKey).(string)
json.NewEncoder(w).Encode(map[string]string{
"user_id": userID,
"message": "Your settings",
})
})
})
// Адміністративна група з додатковим rate limiting
r.Group(func(r chi.Router) {
r.Use(middleware.Auth)
rateLimiter := middleware.NewRateLimiter(1, 3)
r.Use(rateLimiter.Middleware)
r.Get("/api/admin/users", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{
"message": "Admin only - user list",
})
})
})
fmt.Println("Chi server with grouped middleware on :8080...")
log.Fatal(http.ListenAndServe(":8080", r))
}
Повний приклад: реальний сервер з усіма Middleware
Об'єднаємо все, що ми вивчили, в один повноцінний сервер:
// cmd/full-server/main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"myapp/middleware"
"github.com/go-chi/chi/v5"
)
// Структури даних
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
// In-memory зберігання (у реальності — БД)
var users = map[string]*User{
"user-1": {
ID: "user-1",
Name: "John Doe",
Email: "john@example.com",
CreatedAt: time.Now().Add(-24 * time.Hour),
},
}
func main() {
r := chi.NewRouter()
// Глобальні Middleware
r.Use(middleware.Logger)
r.Use(middleware.Recovery)
corsConfig := middleware.DefaultCORSConfig()
r.Use(middleware.CORS(corsConfig))
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "ok",
"timestamp": time.Now().Unix(),
})
})
// Публічні маршрути
r.Post("/api/register", registerHandler)
r.Post("/api/login", loginHandler)
// Захищені маршрути
r.Group(func(r chi.Router) {
r.Use(middleware.Auth)
r.Get("/api/users/me", getCurrentUserHandler)
r.Put("/api/users/me", updateCurrentUserHandler)
})
// Адмінські маршрути з rate limiting
r.Group(func(r chi.Router) {
r.Use(middleware.Auth)
rateLimiter := middleware.NewRateLimiter(5, 10)
r.Use(rateLimiter.Middleware)
r.Get("/api/admin/users", listUsersHandler)
r.Get("/api/admin/stats", getStatsHandler)
})
fmt.Println("╔════════════════════════════════════════╗")
fmt.Println("║ Full-featured server on :8080 🚀 ║")
fmt.Println("╠════════════════════════════════════════╣")
fmt.Println("║ Endpoints: ║")
fmt.Println("║ GET /health ║")
fmt.Println("║ POST /api/register ║")
fmt.Println("║ POST /api/login ║")
fmt.Println("║ GET /api/users/me (auth) ║")
fmt.Println("║ PUT /api/users/me (auth) ║")
fmt.Println("║ GET /api/admin/users (auth+limit) ║")
fmt.Println("║ GET /api/admin/stats (auth+limit) ║")
fmt.Println("╚════════════════════════════════════════╝")
log.Fatal(http.ListenAndServe(":8080", r))
}
func registerHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Тут має бути хешування пароля і збереження в БД
newUser := &User{
ID: fmt.Sprintf("user-%d", len(users)+1),
Name: req.Name,
Email: req.Email,
CreatedAt: time.Now(),
}
users[newUser.ID] = newUser
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(newUser)
}
func loginHandler(w http.ResponseWriter, r *http.Request) {
// Спрощена реалізація (у реальності — перевірка паролю, генерація JWT)
json.NewEncoder(w).Encode(map[string]string{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", // Заглушка
})
}
func getCurrentUserHandler(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value(middleware.UserIDKey).(string)
user, exists := users[userID]
if !exists {
http.Error(w, "User not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(user)
}
func updateCurrentUserHandler(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value(middleware.UserIDKey).(string)
var updates struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
user, exists := users[userID]
if !exists {
http.Error(w, "User not found", http.StatusNotFound)
return
}
user.Name = updates.Name
json.NewEncoder(w).Encode(user)
}
func listUsersHandler(w http.ResponseWriter, r *http.Request) {
allUsers := make([]*User, 0, len(users))
for _, u := range users {
allUsers = append(allUsers, u)
}
json.NewEncoder(w).Encode(allUsers)
}
func getStatsHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"total_users": len(users),
"server_uptime": "24h", // Заглушка
"requests_today": 12543, // Заглушка
})
}
Цей сервер демонструє всі аспекти Middleware, які ми вивчили: логування, recovery, CORS, автентифікацію, rate limiting та групування маршрутів.
Від монолітного коду до Clean Architecture
Проблема: бізнес-логіка у HTTP-обробниках
Повернемось до нашого прикладу сервера. Зараз вся логіка знаходиться безпосередньо в обробниках:
func registerHandler(w http.ResponseWriter, r *http.Request) {
// 1. Парсинг JSON
var req struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
json.NewDecoder(r.Body).Decode(&req)
// 2. Валідація
if req.Email == "" || req.Password == "" {
http.Error(w, "Invalid input", http.StatusBadRequest)
return
}
// 3. Перевірка існування користувача
for _, u := range users {
if u.Email == req.Email {
http.Error(w, "Email already exists", http.StatusConflict)
return
}
}
// 4. Хешування пароля
hashedPassword := hashPassword(req.Password)
// 5. Створення користувача
newUser := &User{
ID: generateID(),
Name: req.Name,
Email: req.Email,
Password: hashedPassword,
}
users[newUser.ID] = newUser
// 6. Формування відповіді
json.NewEncoder(w).Encode(newUser)
}
Що не так з цим кодом?
- Неможливо протестувати бізнес-логіку без запуску HTTP-сервера
- Порушення Single Responsibility Principle: обробник відповідає і за HTTP, і за валідацію, і за роботу з даними
- Жорстка прив'язка до способу зберігання даних (in-memory map)
- Неможливо повторно використати логіку реєстрації в інших місцях (наприклад, в CLI-утиліті або gRPC-сервісі)
- Складність розширення: додавання функціональності (відправка email, логування аудиту) робить код ще більш заплутаним
Крок 1: Виділяємо бізнес-логіку в окрему функцію
Спробуємо винести логіку реєстрації за межі HTTP-обробника:
// business/user.go
package business
import (
"errors"
"fmt"
)
type User struct {
ID string
Name string
Email string
Password string
}
// RegisterUser — бізнес-логіка реєстрації
func RegisterUser(name, email, password string, existingUsers map[string]*User) (*User, error) {
// Валідація
if email == "" || password == "" {
return nil, errors.New("email and password are required")
}
if len(password) < 8 {
return nil, errors.New("password must be at least 8 characters")
}
// Перевірка дублікатів
for _, u := range existingUsers {
if u.Email == email {
return nil, errors.New("email already exists")
}
}
// Хешування пароля
hashedPassword := hashPassword(password)
// Створення користувача
user := &User{
ID: fmt.Sprintf("user-%d", len(existingUsers)+1),
Name: name,
Email: email,
Password: hashedPassword,
}
return user, nil
}
func hashPassword(password string) string {
// Спрощена реалізація
return "hashed_" + password
}
Тепер HTTP-обробник стає простішим:
func registerHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Викликаємо бізнес-логіку
user, err := business.RegisterUser(req.Name, req.Email, req.Password, users)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Зберігаємо користувача
users[user.ID] = user
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
Це вже краще, але:
- Бізнес-логіка все ще залежить від
map[string]*User(конкретного способу зберігання) - Складно замінити map на базу даних
- Немає чіткого розділення відповідальності
Clean Architecture: філософія та принципи
Історична довідка
Clean Architecture (чиста архітектура) — це архітектурний підхід, запропонований Робертом Мартіном (Uncle Bob) у 2012 році. Він узагальнює ідеї з кількох інших архітектурних патернів:
- Hexagonal Architecture (Alistair Cockburn, 2005)
- Onion Architecture (Jeffrey Palermo, 2008)
- DCI (Data, Context, Interactions) (Trygve Reenskaug, 2000-і)
Всі ці підходи об'єднує спільна ідея: бізнес-логіка не повинна залежати від технічних деталей.
Ключовий принцип: Dependency Rule
Правило залежностей (Dependency Rule): Залежності вихідного коду можуть вказувати тільки всередину (до внутрішніх шарів). Внутрішні шари не повинні знати про існування зовнішніх.
Чотири основні шари
1. Entities (Domain Layer) — шар доменних сутностей
Найвнутрішній шар. Містить:
- Доменні об'єкти — структури даних, що моделюють предметну область
- Бізнес-правила рівня сутності — валідація, обчислення, інваріанти
// internal/domain/user.go
package domain
import (
"errors"
"regexp"
"time"
)
// User — доменна сутність користувача
type User struct {
ID string
Name string
Email string
Password string // Хеш пароля
CreatedAt time.Time
UpdatedAt time.Time
}
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
// Validate перевіряє коректність даних користувача
func (u *User) Validate() error {
if u.Name == "" {
return errors.New("name is required")
}
if u.Email == "" {
return errors.New("email is required")
}
if !emailRegex.MatchString(u.Email) {
return errors.New("invalid email format")
}
if len(u.Password) < 8 {
return errors.New("password must be at least 8 characters")
}
return nil
}
// IsPasswordValid перевіряє відповідність пароля хешу
func (u *User) IsPasswordValid(plainPassword string) bool {
// У реальності тут bcrypt.CompareHashAndPassword
return "hashed_"+plainPassword == u.Password
}
2. Use Cases (Application/Business Logic Layer) — шар сценаріїв
Містить конкретні бізнес-сценарії:
- Реєстрація користувача
- Вхід в систему
- Оформлення замовлення
- Відправка повідомлення
Use Case залежить від інтерфейсів, а не від реалізацій:
// internal/domain/user_repository.go
package domain
import "context"
// UserRepository — інтерфейс для доступу до даних користувачів
type UserRepository interface {
Create(ctx context.Context, user *User) error
FindByID(ctx context.Context, id string) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
}
// Стандартні помилки домену
var (
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidCredentials = errors.New("invalid credentials")
)
Тепер створюємо Use Case:
// internal/usecase/user_usecase.go
package usecase
import (
"context"
"myapp/internal/domain"
"time"
"golang.org/x/crypto/bcrypt"
)
// UserUseCase — бізнес-логіка роботи з користувачами
type UserUseCase struct {
repo domain.UserRepository
}
// NewUserUseCase створює новий UseCase
func NewUserUseCase(repo domain.UserRepository) *UserUseCase {
return &UserUseCase{repo: repo}
}
// RegisterUser реєструє нового користувача
func (uc *UserUseCase) RegisterUser(ctx context.Context, name, email, plainPassword string) (*domain.User, error) {
// 1. Перевіряємо, чи не існує користувач з таким email
existing, _ := uc.repo.FindByEmail(ctx, email)
if existing != nil {
return nil, domain.ErrUserAlreadyExists
}
// 2. Хешуємо пароль
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
// 3. Створюємо доменну сутність
user := &domain.User{
ID: generateUserID(),
Name: name,
Email: email,
Password: string(hashedPassword),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 4. Валідуємо доменні правила
if err := user.Validate(); err != nil {
return nil, err
}
// 5. Зберігаємо через репозиторій
if err := uc.repo.Create(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// Login виконує вхід користувача
func (uc *UserUseCase) Login(ctx context.Context, email, plainPassword string) (*domain.User, error) {
// 1. Знаходимо користувача
user, err := uc.repo.FindByEmail(ctx, email)
if err != nil {
return nil, domain.ErrInvalidCredentials
}
// 2. Перевіряємо пароль
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(plainPassword)); err != nil {
return nil, domain.ErrInvalidCredentials
}
return user, nil
}
func generateUserID() string {
// У реальності: UUID або ULID
return fmt.Sprintf("user-%d", time.Now().UnixNano())
}
Ключові моменти:
UserUseCaseзалежить від інтерфейсуdomain.UserRepository, а не від конкретної реалізації- Бізнес-логіка не знає, де зберігаються дані (PostgreSQL? MongoDB? in-memory?)
- Немає залежності від HTTP, JSON, або будь-яких інших технічних деталей
3. Repository Pattern — абстракція доступу до даних
Repository — це міст між бізнес-логікою та базою даних. Інтерфейс визначається в Domain, реалізація — у зовнішньому шарі.
In-memory реалізація (для тестування та прототипування):
// internal/repository/memory/user_repository.go
package memory
import (
"context"
"myapp/internal/domain"
"sync"
)
// UserRepository — in-memory реалізація
type UserRepository struct {
mu sync.RWMutex
users map[string]*domain.User
}
// NewUserRepository створює новий in-memory репозиторій
func NewUserRepository() *UserRepository {
return &UserRepository{
users: make(map[string]*domain.User),
}
}
func (r *UserRepository) Create(ctx context.Context, user *domain.User) error {
r.mu.Lock()
defer r.mu.Unlock()
r.users[user.ID] = user
return nil
}
func (r *UserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
user, exists := r.users[id]
if !exists {
return nil, domain.ErrUserNotFound
}
return user, nil
}
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, user := range r.users {
if user.Email == email {
return user, nil
}
}
return nil, domain.ErrUserNotFound
}
func (r *UserRepository) Update(ctx context.Context, user *domain.User) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.users[user.ID]; !exists {
return domain.ErrUserNotFound
}
r.users[user.ID] = user
return nil
}
func (r *UserRepository) Delete(ctx context.Context, id string) error {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.users, id)
return nil
}
PostgreSQL реалізація (для продакшну):
// internal/repository/postgres/user_repository.go
package postgres
import (
"context"
"database/sql"
"myapp/internal/domain"
)
// UserRepository — PostgreSQL реалізація
type UserRepository struct {
db *sql.DB
}
// NewUserRepository створює PostgreSQL репозиторій
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(ctx context.Context, user *domain.User) error {
query := `
INSERT INTO users (id, name, email, password, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
`
_, err := r.db.ExecContext(ctx, query,
user.ID,
user.Name,
user.Email,
user.Password,
user.CreatedAt,
user.UpdatedAt,
)
return err
}
func (r *UserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
query := `
SELECT id, name, email, password, created_at, updated_at
FROM users
WHERE id = $1
`
user := &domain.User{}
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Name,
&user.Email,
&user.Password,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, domain.ErrUserNotFound
}
return user, err
}
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
query := `
SELECT id, name, email, password, created_at, updated_at
FROM users
WHERE email = $1
`
user := &domain.User{}
err := r.db.QueryRowContext(ctx, query, email).Scan(
&user.ID,
&user.Name,
&user.Email,
&user.Password,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, domain.ErrUserNotFound
}
return user, err
}
func (r *UserRepository) Update(ctx context.Context, user *domain.User) error {
query := `
UPDATE users
SET name = $2, email = $3, password = $4, updated_at = $5
WHERE id = $1
`
result, err := r.db.ExecContext(ctx, query,
user.ID,
user.Name,
user.Email,
user.Password,
user.UpdatedAt,
)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return domain.ErrUserNotFound
}
return nil
}
func (r *UserRepository) Delete(ctx context.Context, id string) error {
query := `DELETE FROM users WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, id)
return err
}
4. Delivery Layer (HTTP Handlers) — шар доставки
Найзовнішній шар. Відповідає за:
- Парсинг HTTP-запитів (JSON, form data, query parameters)
- Виклик відповідного UseCase
- Формування HTTP-відповіді
// internal/delivery/http/user_handler.go
package http
import (
"encoding/json"
"myapp/internal/domain"
"myapp/internal/usecase"
"net/http"
)
// UserHandler — HTTP-обробники для користувачів
type UserHandler struct {
useCase *usecase.UserUseCase
}
// NewUserHandler створює новий handler
func NewUserHandler(useCase *usecase.UserUseCase) *UserHandler {
return &UserHandler{useCase: useCase}
}
// RegisterRequest — DTO для реєстрації
type RegisterRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
// UserResponse — DTO для відповіді (без пароля!)
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt string `json:"created_at"`
}
// Register — POST /api/users/register
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Викликаємо UseCase
user, err := h.useCase.RegisterUser(r.Context(), req.Name, req.Email, req.Password)
if err != nil {
// Мапування доменних помилок на HTTP-статуси
if err == domain.ErrUserAlreadyExists {
http.Error(w, err.Error(), http.StatusConflict)
return
}
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Формуємо відповідь (без пароля!)
response := UserResponse{
ID: user.ID,
Name: user.Name,
Email: user.Email,
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(response)
}
// LoginRequest — DTO для входу
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// LoginResponse — DTO з токеном
type LoginResponse struct {
Token string `json:"token"`
User UserResponse `json:"user"`
}
// Login — POST /api/users/login
func (h *UserHandler) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Викликаємо UseCase
user, err := h.useCase.Login(r.Context(), req.Email, req.Password)
if err != nil {
if err == domain.ErrInvalidCredentials {
http.Error(w, "Invalid email or password", http.StatusUnauthorized)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Генеруємо JWT токен (тут спрощено)
token := generateJWT(user.ID)
response := LoginResponse{
Token: token,
User: UserResponse{
ID: user.ID,
Name: user.Name,
Email: user.Email,
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z"),
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func generateJWT(userID string) string {
// Спрощена реалізація
// У реальності: jwt-go або golang-jwt
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + userID
}
- DTO містить тільки поля, необхідні для клієнта (без пароля!)
- DTO може мати іншу структуру (згруповані поля, змінені імена)
- DTO спрощує версіонування API (зміна DTO не впливає на Domain)
Dependency Injection: збираємо все разом
Тепер потрібно з'єднати всі шари. Це робиться у функції main() через ручне впровадження залежностей (manual dependency injection):
// cmd/server/main.go
package main
import (
"database/sql"
"fmt"
"log"
"net/http"
"myapp/internal/delivery/http"
httphandler "myapp/internal/delivery/http"
"myapp/internal/repository/memory"
"myapp/internal/repository/postgres"
"myapp/internal/usecase"
"myapp/middleware"
"github.com/go-chi/chi/v5"
_ "github.com/lib/pq"
)
func main() {
// Вибираємо тип репозиторію через змінну оточення
usePostgres := os.Getenv("USE_POSTGRES") == "true"
var userRepo domain.UserRepository
if usePostgres {
// PostgreSQL реалізація
db, err := sql.Open("postgres",
"host=localhost port=5432 user=myuser password=mypass dbname=mydb sslmode=disable")
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatal("Database is unreachable:", err)
}
userRepo = postgres.NewUserRepository(db)
log.Println("Using PostgreSQL repository")
} else {
// In-memory реалізація
userRepo = memory.NewUserRepository()
log.Println("Using in-memory repository")
}
// Створюємо UseCase (впроваджуємо репозиторій)
userUseCase := usecase.NewUserUseCase(userRepo)
// Створюємо HTTP handler (впроваджуємо UseCase)
userHandler := httphandler.NewUserHandler(userUseCase)
// Налаштовуємо роутер
r := chi.NewRouter()
// Глобальні Middleware
r.Use(middleware.Logger)
r.Use(middleware.Recovery)
// Маршрути
r.Post("/api/users/register", userHandler.Register)
r.Post("/api/users/login", userHandler.Login)
// Захищені маршрути
r.Group(func(r chi.Router) {
r.Use(middleware.Auth)
// тут можуть бути інші маршрути
})
fmt.Println("🚀 Server with Clean Architecture on :8080")
log.Fatal(http.ListenAndServe(":8080", r))
}
Що ми отримали?
- Тестованість: Кожен шар можна тестувати незалежно з моками
- Гнучкість: Легко змінити СУБД, HTTP-фреймворк, формат відповіді
- Розширюваність: Додати gRPC-сервіс паралельно з HTTP без зміни бізнес-логіки
- Підтримуваність: Чітке розділення відповідальності
Модульне тестування з Clean Architecture
Перевага архітектури: ізольоване тестування кожного шару
Завдяки чіткому розділенню шарів, ми можемо тестувати:
- Domain: валідацію та бізнес-правила
- UseCase: бізнес-логіку з мок-репозиторіями
- Repository: запити до БД (інтеграційні тести)
- Handlers: HTTP-шар з мок-UseCases
Пакет net/http/httptest
Go надає вбудований пакет для тестування HTTP без запуску сервера:
Основні компоненти:
httptest.ResponseRecorder— записує відповідь без надсилання по мережіhttptest.NewRequest— створює фіктивний HTTP-запит
Крок 1: Тестування Domain
// internal/domain/user_test.go
package domain
import (
"testing"
)
func TestUser_Validate(t *testing.T) {
tests := []struct {
name string
user User
wantErr bool
errMsg string
}{
{
name: "Valid user",
user: User{
Name: "John Doe",
Email: "john@example.com",
Password: "password123",
},
wantErr: false,
},
{
name: "Missing name",
user: User{
Name: "",
Email: "john@example.com",
Password: "password123",
},
wantErr: true,
errMsg: "name is required",
},
{
name: "Invalid email format",
user: User{
Name: "John",
Email: "invalid-email",
Password: "password123",
},
wantErr: true,
errMsg: "invalid email format",
},
{
name: "Short password",
user: User{
Name: "John",
Email: "john@example.com",
Password: "short",
},
wantErr: true,
errMsg: "password must be at least 8 characters",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.user.Validate()
if tt.wantErr {
if err == nil {
t.Errorf("Expected error, got nil")
} else if err.Error() != tt.errMsg {
t.Errorf("Expected error %q, got %q", tt.errMsg, err.Error())
}
} else {
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
}
})
}
}
Запуск тестів:
Крок 2: Тестування UseCase з мок-репозиторієм
Створимо мок-реалізацію UserRepository:
// internal/usecase/user_usecase_test.go
package usecase
import (
"context"
"myapp/internal/domain"
"testing"
)
// MockUserRepository — мок для тестування
type MockUserRepository struct {
users map[string]*domain.User
createErr error
findByEmailFn func(ctx context.Context, email string) (*domain.User, error)
}
func NewMockUserRepository() *MockUserRepository {
return &MockUserRepository{
users: make(map[string]*domain.User),
}
}
func (m *MockUserRepository) Create(ctx context.Context, user *domain.User) error {
if m.createErr != nil {
return m.createErr
}
m.users[user.ID] = user
return nil
}
func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
if m.findByEmailFn != nil {
return m.findByEmailFn(ctx, email)
}
for _, u := range m.users {
if u.Email == email {
return u, nil
}
}
return nil, domain.ErrUserNotFound
}
func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
user, exists := m.users[id]
if !exists {
return nil, domain.ErrUserNotFound
}
return user, nil
}
func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) error {
m.users[user.ID] = user
return nil
}
func (m *MockUserRepository) Delete(ctx context.Context, id string) error {
delete(m.users, id)
return nil
}
Тепер тестуємо UseCase:
// internal/usecase/user_usecase_test.go (продовження)
func TestUserUseCase_RegisterUser_Success(t *testing.T) {
// Arrange: створюємо мок-репозиторій
mockRepo := NewMockUserRepository()
uc := NewUserUseCase(mockRepo)
// Act: викликаємо метод UseCase
user, err := uc.RegisterUser(
context.Background(),
"John Doe",
"john@example.com",
"password123",
)
// Assert: перевіряємо результат
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if user == nil {
t.Fatal("Expected user, got nil")
}
if user.Name != "John Doe" {
t.Errorf("Expected name 'John Doe', got '%s'", user.Name)
}
if user.Email != "john@example.com" {
t.Errorf("Expected email 'john@example.com', got '%s'", user.Email)
}
// Перевіряємо, що користувач збережений у репозиторії
savedUser, err := mockRepo.FindByEmail(context.Background(), "john@example.com")
if err != nil {
t.Fatalf("User not saved in repository: %v", err)
}
if savedUser.ID != user.ID {
t.Errorf("Saved user ID mismatch")
}
}
func TestUserUseCase_RegisterUser_DuplicateEmail(t *testing.T) {
// Arrange: створюємо репозиторій з існуючим користувачем
mockRepo := NewMockUserRepository()
mockRepo.findByEmailFn = func(ctx context.Context, email string) (*domain.User, error) {
// Імітуємо, що користувач вже існує
return &domain.User{
ID: "existing-user",
Email: email,
}, nil
}
uc := NewUserUseCase(mockRepo)
// Act
user, err := uc.RegisterUser(
context.Background(),
"Jane Doe",
"jane@example.com",
"password123",
)
// Assert: очікуємо помилку
if err != domain.ErrUserAlreadyExists {
t.Errorf("Expected ErrUserAlreadyExists, got %v", err)
}
if user != nil {
t.Errorf("Expected nil user, got %v", user)
}
}
func TestUserUseCase_Login_Success(t *testing.T) {
// Arrange: створюємо користувача з хешованим паролем
mockRepo := NewMockUserRepository()
// Хешуємо пароль (bcrypt)
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
existingUser := &domain.User{
ID: "user-1",
Name: "John",
Email: "john@example.com",
Password: string(hashedPassword),
}
mockRepo.users[existingUser.ID] = existingUser
uc := NewUserUseCase(mockRepo)
// Act
user, err := uc.Login(context.Background(), "john@example.com", "password123")
// Assert
if err != nil {
t.Fatalf("Expected successful login, got error: %v", err)
}
if user.ID != "user-1" {
t.Errorf("Expected user ID 'user-1', got '%s'", user.ID)
}
}
func TestUserUseCase_Login_InvalidPassword(t *testing.T) {
// Arrange
mockRepo := NewMockUserRepository()
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.DefaultCost)
mockRepo.users["user-1"] = &domain.User{
ID: "user-1",
Email: "john@example.com",
Password: string(hashedPassword),
}
uc := NewUserUseCase(mockRepo)
// Act: неправильний пароль
user, err := uc.Login(context.Background(), "john@example.com", "wrong-password")
// Assert
if err != domain.ErrInvalidCredentials {
t.Errorf("Expected ErrInvalidCredentials, got %v", err)
}
if user != nil {
t.Errorf("Expected nil user, got %v", user)
}
}
Крок 3: Тестування HTTP-обробників
Тестуємо HTTP-шар з мок-UseCase:
// internal/delivery/http/user_handler_test.go
package http
import (
"bytes"
"context"
"encoding/json"
"myapp/internal/domain"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// MockUserUseCase — мок UseCase для тестування handlers
type MockUserUseCase struct {
registerFn func(ctx context.Context, name, email, password string) (*domain.User, error)
loginFn func(ctx context.Context, email, password string) (*domain.User, error)
}
func (m *MockUserUseCase) RegisterUser(ctx context.Context, name, email, password string) (*domain.User, error) {
if m.registerFn != nil {
return m.registerFn(ctx, name, email, password)
}
return nil, nil
}
func (m *MockUserUseCase) Login(ctx context.Context, email, password string) (*domain.User, error) {
if m.loginFn != nil {
return m.loginFn(ctx, email, password)
}
return nil, nil
}
func TestUserHandler_Register_Success(t *testing.T) {
// Arrange: створюємо мок UseCase
mockUC := &MockUserUseCase{
registerFn: func(ctx context.Context, name, email, password string) (*domain.User, error) {
return &domain.User{
ID: "user-123",
Name: name,
Email: email,
CreatedAt: time.Now(),
}, nil
},
}
handler := NewUserHandler(mockUC)
// Створюємо HTTP-запит
reqBody := RegisterRequest{
Name: "John Doe",
Email: "john@example.com",
Password: "password123",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/users/register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// Створюємо ResponseRecorder
rr := httptest.NewRecorder()
// Act: викликаємо handler
handler.Register(rr, req)
// Assert: перевіряємо статус-код
if status := rr.Code; status != http.StatusCreated {
t.Errorf("Expected status %d, got %d", http.StatusCreated, status)
}
// Перевіряємо Content-Type
expectedCT := "application/json"
if ct := rr.Header().Get("Content-Type"); ct != expectedCT {
t.Errorf("Expected Content-Type %s, got %s", expectedCT, ct)
}
// Перевіряємо тіло відповіді
var response UserResponse
if err := json.NewDecoder(rr.Body).Decode(&response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Email != "john@example.com" {
t.Errorf("Expected email 'john@example.com', got '%s'", response.Email)
}
if response.Name != "John Doe" {
t.Errorf("Expected name 'John Doe', got '%s'", response.Name)
}
}
func TestUserHandler_Register_InvalidJSON(t *testing.T) {
mockUC := &MockUserUseCase{}
handler := NewUserHandler(mockUC)
// Невалідний JSON
req := httptest.NewRequest(http.MethodPost, "/api/users/register",
bytes.NewReader([]byte("invalid json")))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
handler.Register(rr, req)
if status := rr.Code; status != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, status)
}
}
func TestUserHandler_Register_DuplicateEmail(t *testing.T) {
mockUC := &MockUserUseCase{
registerFn: func(ctx context.Context, name, email, password string) (*domain.User, error) {
return nil, domain.ErrUserAlreadyExists
},
}
handler := NewUserHandler(mockUC)
reqBody := RegisterRequest{
Name: "John",
Email: "existing@example.com",
Password: "password123",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/users/register", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
handler.Register(rr, req)
if status := rr.Code; status != http.StatusConflict {
t.Errorf("Expected status %d (Conflict), got %d", http.StatusConflict, status)
}
}
Структура проєкту з Clean Architecture
Рекомендована організація каталогів
Пояснення структури:
cmd/server/main.go— точка входу. Тут відбувається створення та з'єднання всіх компонентів (DI).internal/— приватний код, що не може імпортуватися іншими проєктами (механізм Go).internal/domain/— найвнутрішній шар, без зовнішніх залежностей.internal/usecase/— бізнес-логіка, залежить тільки від Domain.internal/repository/— реалізації доступу до даних.internal/delivery/http/— HTTP-шар, залежить від UseCase.migrations/— SQL-файли для версіонування схеми БД.pkg/— код, що може бути використаний іншими проєктами (опціонально).
internal/ є вбудованою в Go. Компілятор забороняє імпортування пакетів з internal/ іншими модулями. Це гарантує інкапсуляцію та запобігає випадковому використанню приватного коду.Порівняння підходів: монолітний vs Clean Architecture
| Критерій | Монолітний обробник | Clean Architecture |
|---|---|---|
| Складність входу | ⭐ Низька (почати легко) | ⭐⭐⭐ Висока (багато файлів і шарів) |
| Тестованість | ❌ Складно: потрібен HTTP-сервер та БД | ✅ Легко: кожен шар тестується окремо |
| Повторне використання | ❌ Бізнес-логіка прив'язана до HTTP | ✅ UseCase можна викликати з HTTP, gRPC, CLI |
| Зміна технологій | ❌ Вимагає переписування всього коду | ✅ Заміна лише реалізації Repository або Delivery |
| Підтримуваність | ❌ Код швидко стає заплутаним | ✅ Чітке розділення відповідальності |
| Швидкість розробки | ⚡ Швидше на старті (прототипи, MVP) | 🐢 Повільніше на старті, але швидше при масштабуванні |
| Розмір проєкту | Підходить для 1-5 ендпоінтів | Виправданий для 10+ ендпоінтів |
Коли використовувати Clean Architecture?
✅ Варто використовувати, якщо:
- Проєкт планується розвивати довгостроково
- Команда > 2-3 розробників
- Очікується часта зміна вимог
- Потрібна підтримка різних способів доставки (HTTP + gRPC + CLI)
- Критична надійність та покриття тестами
❌ Можна обійтись без неї, якщо:
- Простий прототип або MVP
- Проєкт на 1-3 ендпоінти
- Одноразова утиліта
- Дедлайн через кілька днів
Додаткові поради та best practices
1. Завжди передавайте context.Context першим аргументом
// ✅ Правильно
func (uc *UserUseCase) RegisterUser(ctx context.Context, name, email, password string) (*domain.User, error)
// ❌ Неправильно
func (uc *UserUseCase) RegisterUser(name, email, password string) (*domain.User, error)
Контекст дозволяє скасовувати операції, встановлювати таймаути та передавати метадані.
2. Використовуйте DTO замість доменних сутностей у відповідях
// ✅ Правильно: окремий DTO без пароля
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// ❌ Неправильно: віддаємо доменну сутність з паролем
json.NewEncoder(w).Encode(user) // user містить Password!
3. Доменні помилки vs HTTP-статуси
Мапування доменних помилок на HTTP-статуси має відбуватися тільки в Delivery Layer:
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
user, err := h.useCase.RegisterUser(...)
if err != nil {
// Мапування доменних помилок на HTTP-статуси
switch err {
case domain.ErrUserAlreadyExists:
http.Error(w, err.Error(), http.StatusConflict)
case domain.ErrInvalidEmail:
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
return
}
// ...
}
4. Не витікайте абстракції
UseCase не повинен знати про HTTP, JSON, або статус-коди:
// ❌ Погано: UseCase знає про HTTP
func (uc *UserUseCase) RegisterUser(...) (int, error) {
if exists {
return http.StatusConflict, errors.New("user exists")
}
// ...
return http.StatusCreated, nil
}
// ✅ Добре: UseCase повертає доменну помилку
func (uc *UserUseCase) RegisterUser(...) (*domain.User, error) {
if exists {
return nil, domain.ErrUserAlreadyExists
}
// ...
return user, nil
}
5. Інтеграційні тести vs модульні тести
Модульні тести (unit tests):
- Тестують окремий компонент з моками
- Швидкі (мілісекунди)
- Легко налагоджувати
- Використовуйте для Domain та UseCase
Інтеграційні тести (integration tests):
- Тестують взаємодію з реальною БД/API
- Повільніші (секунди)
- Виявляють проблеми інтеграції
- Використовуйте для Repository та E2E-тестів
Запитання для самоконтролю
Middleware створює новий обробник, що обгортає попередній. Це дозволяє:
- Виконати код до виклику
next.ServeHTTP()(препроцесинг) - Вирішити, чи викликати
next.ServeHTTP()взагалі (наприклад, припинити обробку при невалідній автентифікації) - Виконати код після виклику
next.ServeHTTP()(постпроцесинг, логування результату)
Без цього підходу неможливо було б контролювати потік виконання.
Domain (Entities):
- Доменні сутності та бізнес-правила рівня окремої сутності
- Наприклад: валідація email, перевірка мінімальної довжини пароля
- Не залежить ні від чого (навіть від інших доменних сутностей)
UseCase (Business Logic):
- Сценарії використання, що оркеструють роботу кількох сутностей
- Наприклад: "зареєструвати користувача" = перевірити існування + хешувати пароль + зберегти
- Залежить від Domain (використовує доменні сутності та репозиторії)
Аналогія: Domain — це "словник мови", UseCase — "речення та абзаци".
Інтерфейс UserRepository дозволяє:
- Підставити різні реалізації без зміни UseCase:
- PostgreSQL для продакшну
- In-memory для локальної розробки
- Мок для тестування
- Дотриматися Dependency Rule: UseCase (внутрішній шар) залежить від інтерфейсу Domain, а не від зовнішньої реалізації Repository.
- Полегшити тестування: можна створити мок-реалізацію за 10 рядків коду замість налаштування реальної БД.
Так, але це може бути надлишковим (over-engineering).
Для проєкту на 3-5 ендпоінтів розділення на 4 шари додає більше складності, ніж переваг.
Компромісні підходи:
- Почніть з простих обробників без шарування
- Коли з'являється дублювання бізнес-логіки, винесіть її в окремі функції
- Коли змінюється спосіб зберігання (in-memory → PostgreSQL), введіть Repository Pattern
- Повна Clean Architecture — коли проєкт переростає 10-15 ендпоінтів або працює команда > 3 осіб
Правило: архітектура має полегшувати розробку, а не ускладнювати.
Middleware тестується через httptest аналогічно до звичайних обробників:
func TestLogger_Middleware(t *testing.T) {
nextCalled := false
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
})
loggedHandler := middleware.Logger(nextHandler)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rr := httptest.NewRecorder()
loggedHandler.ServeHTTP(rr, req)
if !nextCalled {
t.Error("Next handler was not called")
}
if rr.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", rr.Code)
}
}
Для Middleware, що модифікує контекст, перевірте r.Context().Value(...) у наступному обробнику.
SQL-міграції для PostgreSQL
Створення таблиці користувачів
Міграції дозволяють версіонувати схему бази даних. Створимо міграції для таблиці users:
-- migrations/001_create_users_table.up.sql
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(255) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_email ON users(email);
-- Тригер для автоматичного оновлення updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- migrations/001_create_users_table.down.sql
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
DROP FUNCTION IF EXISTS update_updated_at_column();
DROP INDEX IF EXISTS idx_users_email;
DROP TABLE IF EXISTS users;
Застосування міграцій
Встановимо інструмент для міграцій:
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
Запуск міграцій:
migrate -path migrations -database "postgres://user:password@localhost:5432/mydb?sslmode=disable" up
migrate -path migrations -database "postgres://user:password@localhost:5432/mydb?sslmode=disable" down
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: myuser
POSTGRES_PASSWORD: mypass
POSTGRES_DB: mydb
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Запуск:
docker compose up -d
End-to-End: повний приклад від створення до запуску
Тепер об'єднаємо все, що ми вивчили, в один повний робочий проєкт.
Крок 1: Структура проєкту
Фінальна структура має виглядати так:
Крок 2: Створення Makefile
Створимо Makefile для автоматизації:
# Makefile
.PHONY: help run test migrate-up migrate-down docker-up docker-down clean
help: ## Показати довідку
@echo "Доступні команди:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
run: ## Запустити сервер (in-memory)
@echo "🚀 Запуск сервера..."
go run cmd/server/main.go
run-postgres: ## Запустити сервер (PostgreSQL)
@echo "🚀 Запуск сервера з PostgreSQL..."
USE_POSTGRES=true go run cmd/server/main.go
test: ## Запустити всі тести
@echo "🧪 Запуск тестів..."
go test -v -cover ./...
test-coverage: ## Запустити тести з покриттям
@echo "📊 Звіт покриття тестами..."
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
docker-up: ## Запустити PostgreSQL через Docker
@echo "🐳 Запуск PostgreSQL..."
docker compose up -d
@sleep 2
@echo "✅ PostgreSQL готовий на localhost:5432"
docker-down: ## Зупинити Docker контейнери
@echo "🛑 Зупинка контейнерів..."
docker compose down
migrate-up: ## Застосувати міграції
@echo "⬆️ Застосування міграцій..."
migrate -path migrations -database "postgres://myuser:mypass@localhost:5432/mydb?sslmode=disable" up
migrate-down: ## Відкотити міграції
@echo "⬇️ Відкат міграцій..."
migrate -path migrations -database "postgres://myuser:mypass@localhost:5432/mydb?sslmode=disable" down
clean: ## Очистити згенеровані файли
@echo "🧹 Очищення..."
rm -f coverage.out
go clean -testcache
deps: ## Встановити залежності
@echo "📦 Встановлення залежностей..."
go mod download
go mod tidy
build: ## Зібрати бінарний файл
@echo "🔨 Збірка..."
go build -o bin/server cmd/server/main.go
@echo "✅ Бінарний файл: bin/server"
Крок 3: Запуск всієї системи
Варіант 1: In-memory (без бази даних)
# Встановити залежності
make deps
# Запустити сервер
make run
Варіант 2: З PostgreSQL
# 1. Запустити PostgreSQL
make docker-up
# 2. Застосувати міграції
make migrate-up
# 3. Запустити сервер
make run-postgres
Крок 4: Тестування API
# Реєстрація користувача
curl -X POST http://localhost:8080/api/users/register \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "john@example.com",
"password": "password123"
}'
# Вхід
curl -X POST http://localhost:8080/api/users/login \
-H "Content-Type: application/json" \
-d '{
"email": "john@example.com",
"password": "password123"
}'
# Отримати профіль (потрібен токен з попереднього запиту)
curl http://localhost:8080/api/users/me \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
# Реєстрація
http POST :8080/api/users/register \
name="John Doe" \
email="john@example.com" \
password="password123"
# Вхід
http POST :8080/api/users/login \
email="john@example.com" \
password="password123"
# Профіль
http :8080/api/users/me \
"Authorization: Bearer YOUR_TOKEN"
Крок 5: Запуск тестів
# Всі тести
make test
# Тести з покриттям
make test-coverage
Обробка помилок через шари архітектури
Філософія обробки помилок у Go
У Go немає винятків (exceptions). Замість цього помилки повертаються як значення типу error. Це явний підхід, що змушує розробника обробляти помилки на кожному кроці.
Доменні помилки
Створимо власні типи помилок для домену:
// internal/domain/errors.go
package domain
import "errors"
// Базові помилки домену
var (
// Помилки користувача
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExists = errors.New("user already exists")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrInvalidEmail = errors.New("invalid email format")
ErrPasswordTooShort = errors.New("password too short")
// Помилки автентифікації
ErrUnauthorized = errors.New("unauthorized")
ErrInvalidToken = errors.New("invalid token")
ErrTokenExpired = errors.New("token expired")
// Системні помилки
ErrInternal = errors.New("internal server error")
ErrDatabaseConnection = errors.New("database connection failed")
)
Власні типи помилок з контекстом
Для складніших випадків створюємо структури помилок:
// internal/domain/errors.go (продовження)
package domain
import "fmt"
// ValidationError — помилка валідації з деталями
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error: field '%s' - %s", e.Field, e.Message)
}
// NewValidationError створює нову помилку валідації
func NewValidationError(field, message string) *ValidationError {
return &ValidationError{
Field: field,
Message: message,
}
}
// NotFoundError — помилка "не знайдено" з деталями
type NotFoundError struct {
Resource string
ID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s with id '%s' not found", e.Resource, e.ID)
}
Обгортання помилок для трейсингу
Go 1.13+ дозволяє обгортати помилки через %w:
// internal/repository/postgres/user_repository.go
func (r *UserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
user := &domain.User{}
err := r.db.QueryRowContext(ctx, query, id).Scan(...)
if err == sql.ErrNoRows {
// Повертаємо доменну помилку
return nil, domain.ErrUserNotFound
}
if err != nil {
// Обгортаємо помилку для збереження контексту
return nil, fmt.Errorf("failed to find user by id %s: %w", id, err)
}
return user, nil
}
UseCase: обробка та трансформація помилок
// internal/usecase/user_usecase.go
func (uc *UserUseCase) RegisterUser(ctx context.Context, name, email, password string) (*domain.User, error) {
// 1. Валідація вхідних даних
if email == "" {
return nil, domain.NewValidationError("email", "is required")
}
if len(password) < 8 {
return nil, domain.NewValidationError("password", "must be at least 8 characters")
}
// 2. Перевірка існування
existing, err := uc.repo.FindByEmail(ctx, email)
if err != nil && err != domain.ErrUserNotFound {
// Несподівана помилка БД
return nil, fmt.Errorf("failed to check user existence: %w", err)
}
if existing != nil {
return nil, domain.ErrUserAlreadyExists
}
// 3. Хешування пароля
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// 4. Створення користувача
user := &domain.User{
ID: generateUserID(),
Name: name,
Email: email,
Password: string(hashedPassword),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 5. Валідація доменних правил
if err := user.Validate(); err != nil {
return nil, err
}
// 6. Збереження
if err := uc.repo.Create(ctx, user); err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
return user, nil
}
HTTP Handler: мапування на статус-коди
// internal/delivery/http/user_handler.go
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondWithError(w, http.StatusBadRequest, "Invalid JSON format")
return
}
user, err := h.useCase.RegisterUser(r.Context(), req.Name, req.Email, req.Password)
if err != nil {
handleUseCaseError(w, err)
return
}
respondWithJSON(w, http.StatusCreated, toUserResponse(user))
}
// handleUseCaseError мапує доменні помилки на HTTP-статуси
func handleUseCaseError(w http.ResponseWriter, err error) {
// Перевіряємо тип помилки
var validationErr *domain.ValidationError
if errors.As(err, &validationErr) {
respondWithError(w, http.StatusBadRequest, validationErr.Error())
return
}
var notFoundErr *domain.NotFoundError
if errors.As(err, ¬FoundErr) {
respondWithError(w, http.StatusNotFound, notFoundErr.Error())
return
}
// Перевіряємо стандартні доменні помилки
switch {
case errors.Is(err, domain.ErrUserAlreadyExists):
respondWithError(w, http.StatusConflict, err.Error())
case errors.Is(err, domain.ErrInvalidCredentials):
respondWithError(w, http.StatusUnauthorized, err.Error())
case errors.Is(err, domain.ErrUserNotFound):
respondWithError(w, http.StatusNotFound, err.Error())
case errors.Is(err, domain.ErrUnauthorized):
respondWithError(w, http.StatusUnauthorized, err.Error())
default:
// Логуємо несподівану помилку
log.Printf("Unexpected error: %v", err)
respondWithError(w, http.StatusInternalServerError, "Internal server error")
}
}
// Допоміжні функції для відповідей
func respondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(data)
}
func respondWithError(w http.ResponseWriter, statusCode int, message string) {
respondWithJSON(w, statusCode, map[string]string{
"error": message,
})
}
Стандартизований формат помилок (RFC 7807 Problem Details)
Для складніших API можна використовувати стандарт RFC 7807:
// internal/delivery/http/problem.go
package http
// ProblemDetails — стандартний формат опису помилки
type ProblemDetails struct {
Type string `json:"type"` // URI типу помилки
Title string `json:"title"` // Короткий опис
Status int `json:"status"` // HTTP статус-код
Detail string `json:"detail"` // Детальний опис
Instance string `json:"instance,omitempty"` // URI запиту, що викликав помилку
}
func respondWithProblem(w http.ResponseWriter, problem ProblemDetails) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(problem.Status)
json.NewEncoder(w).Encode(problem)
}
// Приклад використання
func handleUseCaseErrorRFC7807(w http.ResponseWriter, r *http.Request, err error) {
var problem ProblemDetails
switch {
case errors.Is(err, domain.ErrUserAlreadyExists):
problem = ProblemDetails{
Type: "https://api.example.com/errors/user-already-exists",
Title: "User Already Exists",
Status: http.StatusConflict,
Detail: "A user with this email address already exists in the system",
Instance: r.URL.Path,
}
case errors.Is(err, domain.ErrInvalidCredentials):
problem = ProblemDetails{
Type: "https://api.example.com/errors/invalid-credentials",
Title: "Invalid Credentials",
Status: http.StatusUnauthorized,
Detail: "The provided email or password is incorrect",
Instance: r.URL.Path,
}
default:
problem = ProblemDetails{
Type: "https://api.example.com/errors/internal",
Title: "Internal Server Error",
Status: http.StatusInternalServerError,
Detail: "An unexpected error occurred",
Instance: r.URL.Path,
}
}
respondWithProblem(w, problem)
}
- Стандартизований формат, зрозумілий багатьом клієнтам
- Машиночитаємі
typeдля автоматичної обробки - Детальна інформація для діагностики
- Підтримка у багатьох бібліотеках та фреймворках
Context Cancellation: скасування операцій на практиці
Навіщо потрібно скасування?
Уявіть ситуацію: користувач відкрив сторінку у браузері, браузер почав завантажувати дані з API, але користувач передумав і закрив вкладку. Без механізму скасування сервер продовжить виконувати запит, витрачаючи ресурси на операцію, результат якої вже нікому не потрібен.
Базовий приклад скасування
// internal/usecase/user_usecase.go
func (uc *UserUseCase) SearchUsers(ctx context.Context, query string) ([]*domain.User, error) {
// Симуляція довгої операції (наприклад, пошук у великій БД)
results := make([]*domain.User, 0)
// Припустимо, ми шукаємо серед 1 мільйона користувачів
for i := 0; i < 1000000; i++ {
// Перевіряємо, чи не скасовано контекст
select {
case <-ctx.Done():
// Клієнт відключився або вийшов таймаут
return nil, ctx.Err()
default:
// Продовжуємо роботу
}
// Перевірка кожної тисячної ітерації (оптимізація)
if i%1000 == 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
}
// Логіка пошуку...
if matchesQuery(i, query) {
user := fetchUser(i)
results = append(results, user)
}
}
return results, nil
}
Встановлення таймаутів у Handler
// internal/delivery/http/user_handler.go
func (h *UserHandler) SearchUsers(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
// Встановлюємо таймаут 5 секунд для операції
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() // Завжди викликаємо cancel для очищення ресурсів
users, err := h.useCase.SearchUsers(ctx, query)
if err != nil {
// Перевіряємо, чи це таймаут
if errors.Is(err, context.DeadlineExceeded) {
respondWithError(w, http.StatusRequestTimeout, "Search timeout exceeded")
return
}
// Перевіряємо, чи це скасування
if errors.Is(err, context.Canceled) {
// Клієнт відключився - просто логуємо
log.Printf("Client disconnected during search")
return
}
handleUseCaseError(w, err)
return
}
respondWithJSON(w, http.StatusOK, users)
}
Передача контексту у запити до БД
// internal/repository/postgres/user_repository.go
func (r *UserRepository) SearchByName(ctx context.Context, name string) ([]*domain.User, error) {
// PostgreSQL автоматично скасує запит, коли ctx буде скасовано
query := `
SELECT id, name, email, created_at, updated_at
FROM users
WHERE name ILIKE $1
LIMIT 100
`
rows, err := r.db.QueryContext(ctx, query, "%"+name+"%")
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
return nil, fmt.Errorf("search query failed: %w", err)
}
defer rows.Close()
users := make([]*domain.User, 0)
for rows.Next() {
// Перевіряємо контекст навіть під час ітерації
if ctx.Err() != nil {
return nil, ctx.Err()
}
var user domain.User
if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt); err != nil {
return nil, fmt.Errorf("failed to scan user: %w", err)
}
users = append(users, &user)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return users, nil
}
Контекст у горутинах
r.Context() напряму в горутину! HTTP-контекст може бути скасовано після завершення handler, а горутина ще працюватиме.❌ Неправильно:
func (h *Handler) ProcessAsync(w http.ResponseWriter, r *http.Request) {
go func() {
// ❌ r.Context() може бути скасовано!
result := h.service.LongOperation(r.Context())
// Зберегти result...
}()
w.WriteHeader(http.StatusAccepted)
fmt.Fprintln(w, "Processing started")
}
✅ Правильно:
func (h *Handler) ProcessAsync(w http.ResponseWriter, r *http.Request) {
// Створюємо новий контекст, незалежний від HTTP-запиту
ctx := context.Background()
// Або з таймаутом для фонової задачі
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
go func() {
defer cancel()
result := h.service.LongOperation(ctx)
// Зберегти result...
}()
w.WriteHeader(http.StatusAccepted)
fmt.Fprintln(w, "Processing started")
}
Graceful Shutdown: коректне завершення сервера
// cmd/server/main.go
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
// ... ініціалізація залежностей
server := &http.Server{
Addr: ":8080",
Handler: router,
}
// Канал для сигналів завершення
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// Запускаємо сервер у горутині
go func() {
log.Println("Server starting on :8080...")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()
// Чекаємо сигнал завершення
<-quit
log.Println("Shutting down server...")
// Даємо серверу 30 секунд для завершення активних запитів
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server gracefully stopped")
}
Тестування Graceful Shutdown:
Моніторинг скасувань
Додамо метрики для відстеження скасованих операцій:
// middleware/context_monitor.go
package middleware
import (
"context"
"errors"
"log"
"net/http"
"sync/atomic"
"time"
)
var (
canceledRequests int64
timeoutRequests int64
completedRequests int64
)
// ContextMonitor — Middleware для моніторингу контекстів
func ContextMonitor(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
done := make(chan struct{})
go func() {
next.ServeHTTP(w, r)
close(done)
}()
select {
case <-done:
// Запит завершився нормально
atomic.AddInt64(&completedRequests, 1)
case <-r.Context().Done():
// Контекст скасовано
err := r.Context().Err()
if errors.Is(err, context.Canceled) {
atomic.AddInt64(&canceledRequests, 1)
log.Printf("Request canceled: %s %s", r.Method, r.URL.Path)
} else if errors.Is(err, context.DeadlineExceeded) {
atomic.AddInt64(&timeoutRequests, 1)
log.Printf("Request timeout: %s %s", r.Method, r.URL.Path)
}
// Чекаємо завершення handler (але не більше 5 секунд)
select {
case <-done:
case <-time.After(5 * time.Second):
log.Printf("Handler didn't stop after context cancellation")
}
}
})
}
// GetStats повертає статистику
func GetContextStats() map[string]int64 {
return map[string]int64{
"completed": atomic.LoadInt64(&completedRequests),
"canceled": atomic.LoadInt64(&canceledRequests),
"timeout": atomic.LoadInt64(&timeoutRequests),
}
}
Типові помилки початківців та як їх уникати
Помилка 1: Забування про помилки в Middleware
❌ Неправильно:
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println("Request received")
next.ServeHTTP(w, r)
// Що якщо next.ServeHTTP() викликав panic?
})
}
✅ Правильно:
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("PANIC: %v", err)
}
}()
log.Println("Request received")
next.ServeHTTP(w, r)
log.Println("Request completed")
})
}
Або краще — використовуйте окремий Recovery Middleware.
Помилка 2: Модифікація ResponseWriter після WriteHeader
❌ Неправильно:
func handler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("X-Custom", "value") // ❌ Заголовки вже надіслані!
json.NewEncoder(w).Encode(data)
}
✅ Правильно:
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Custom", "value") // ✅ Спочатку заголовки
w.WriteHeader(http.StatusOK) // Потім статус
json.NewEncoder(w).Encode(data) // Потім тіло
}
Помилка 3: Витік горутин через неперевірку ctx.Done()
❌ Неправильно:
func (uc *UserUseCase) ProcessLongOperation(ctx context.Context) error {
// Довга операція без перевірки контексту
time.Sleep(10 * time.Second)
// Що якщо клієнт відключився?
return uc.repo.Save(ctx, data)
}
✅ Правильно:
func (uc *UserUseCase) ProcessLongOperation(ctx context.Context) error {
select {
case <-time.After(10 * time.Second):
return uc.repo.Save(ctx, data)
case <-ctx.Done():
return ctx.Err() // Операція скасована
}
}
Помилка 4: UseCase залежить від HTTP-деталей
❌ Неправильно:
func (uc *UserUseCase) RegisterUser(w http.ResponseWriter, r *http.Request) {
// UseCase не повинен знати про HTTP!
var req RegisterRequest
json.NewDecoder(r.Body).Decode(&req)
// ...
json.NewEncoder(w).Encode(user)
}
✅ Правильно:
func (uc *UserUseCase) RegisterUser(ctx context.Context, name, email, password string) (*domain.User, error) {
// UseCase працює з чистими даними
// ...
return user, nil
}
Помилка 5: Повернення доменних сутностей з паролями
❌ Неправильно:
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
user, _ := h.useCase.GetUser(...)
json.NewEncoder(w).Encode(user) // ❌ Віддаємо пароль клієнту!
}
✅ Правильно:
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
// Без password!
}
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
user, _ := h.useCase.GetUser(...)
response := UserResponse{
ID: user.ID,
Name: user.Name,
Email: user.Email,
}
json.NewEncoder(w).Encode(response) // ✅ Безпечно
}
Помилка 6: Ігнорування помилок Close()
❌ Неправильно:
defer r.Body.Close() // Ігноруємо можливу помилку
✅ Правильно:
defer func() {
if err := r.Body.Close(); err != nil {
log.Printf("Failed to close body: %v", err)
}
}()
Помилка 7: Неправильне використання контекстних ключів
❌ Неправильно:
ctx := context.WithValue(r.Context(), "user_id", userID) // ❌ String як ключ
✅ Правильно:
type contextKey string
const UserIDKey contextKey = "user_id"
ctx := context.WithValue(r.Context(), UserIDKey, userID) // ✅ Власний тип
Помилка 8: Мутація вхідних параметрів
❌ Неправильно:
func (uc *UserUseCase) UpdateUser(user *domain.User) error {
user.UpdatedAt = time.Now() // ❌ Мутуємо вхідний параметр
return uc.repo.Update(user)
}
✅ Правильно:
func (uc *UserUseCase) UpdateUser(user *domain.User) error {
// Створюємо копію
updated := *user
updated.UpdatedAt = time.Now()
return uc.repo.Update(&updated)
}
Debugging та Troubleshooting
Логування у Middleware: правильний підхід
Структуроване логування з log/slog (Go 1.21+):
// middleware/logger.go
package middleware
import (
"log/slog"
"net/http"
"time"
)
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Обгортаємо ResponseWriter
wrapped := &responseWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
}
// Логуємо початок запиту
slog.Info("request started",
"method", r.Method,
"path", r.URL.Path,
"remote_addr", r.RemoteAddr,
"user_agent", r.UserAgent(),
)
// Виконуємо запит
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
// Логуємо завершення з контекстом
logger := slog.With(
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.statusCode,
"duration_ms", duration.Milliseconds(),
"remote_addr", r.RemoteAddr,
)
// Різний рівень залежно від статусу
switch {
case wrapped.statusCode >= 500:
logger.Error("request completed with server error")
case wrapped.statusCode >= 400:
logger.Warn("request completed with client error")
default:
logger.Info("request completed successfully")
}
})
}
Request ID для трейсингу
Додамо унікальний ID до кожного запиту для відстеження через всі шари:
// middleware/request_id.go
package middleware
import (
"context"
"net/http"
"github.com/google/uuid"
)
// Встановлення: go get github.com/google/uuid
type contextKey string
const RequestIDKey contextKey = "request_id"
// RequestID додає унікальний ID до кожного запиту
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Перевіряємо, чи є вже ID у заголовку
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
// Генеруємо новий UUID
requestID = uuid.New().String()
}
// Додаємо у контекст
ctx := context.WithValue(r.Context(), RequestIDKey, requestID)
// Додаємо у відповідь для клієнта
w.Header().Set("X-Request-ID", requestID)
// Передаємо далі
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// GetRequestID витягує ID з контексту
func GetRequestID(ctx context.Context) string {
if id, ok := ctx.Value(RequestIDKey).(string); ok {
return id
}
return "unknown"
}
Використання у логуванні:
// internal/usecase/user_usecase.go
func (uc *UserUseCase) RegisterUser(ctx context.Context, name, email, password string) (*domain.User, error) {
requestID := middleware.GetRequestID(ctx)
slog.Info("registering user",
"request_id", requestID,
"email", email,
)
// ... логіка реєстрації
if err != nil {
slog.Error("failed to register user",
"request_id", requestID,
"email", email,
"error", err,
)
return nil, err
}
slog.Info("user registered successfully",
"request_id", requestID,
"user_id", user.ID,
)
return user, nil
}
Debugging через pprof
Додамо ендпоінти для профілювання:
// cmd/server/main.go
import (
"net/http"
"net/http/pprof"
"github.com/go-chi/chi/v5"
)
func setupDebugRoutes(r *chi.Mux) {
// Увага: не додавайте ці маршрути у продакшн без автентифікації!
r.Mount("/debug/pprof", http.HandlerFunc(pprof.Index))
r.Handle("/debug/pprof/cmdline", http.HandlerFunc(pprof.Cmdline))
r.Handle("/debug/pprof/profile", http.HandlerFunc(pprof.Profile))
r.Handle("/debug/pprof/symbol", http.HandlerFunc(pprof.Symbol))
r.Handle("/debug/pprof/trace", http.HandlerFunc(pprof.Trace))
}
Використання pprof:
# Збираємо CPU профіль протягом 30 секунд
go tool pprof http://localhost:8080/debug/pprof/profile?seconds=30
# Аналізуємо у інтерактивному режимі
(pprof) top10
(pprof) list functionName
(pprof) web
# Heap профіль
go tool pprof http://localhost:8080/debug/pprof/heap
# Allocs профіль
go tool pprof http://localhost:8080/debug/pprof/allocs
# Перевірка горутин
curl http://localhost:8080/debug/pprof/goroutine?debug=1
# Або через pprof
go tool pprof http://localhost:8080/debug/pprof/goroutine
Health Check ендпоінт
// internal/delivery/http/health_handler.go
package http
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"time"
)
type HealthHandler struct {
db *sql.DB
}
type HealthResponse struct {
Status string `json:"status"`
Timestamp int64 `json:"timestamp"`
Services map[string]string `json:"services"`
}
func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
response := HealthResponse{
Status: "ok",
Timestamp: time.Now().Unix(),
Services: make(map[string]string),
}
// Перевірка підключення до БД
if h.db != nil {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := h.db.PingContext(ctx); err != nil {
response.Status = "degraded"
response.Services["database"] = "unhealthy: " + err.Error()
} else {
response.Services["database"] = "healthy"
}
}
// Можна додати перевірки інших сервісів
// Redis, RabbitMQ, external APIs...
statusCode := http.StatusOK
if response.Status != "ok" {
statusCode = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(response)
}
// Liveness probe (для Kubernetes)
func (h *HealthHandler) Liveness(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("alive"))
}
// Readiness probe (для Kubernetes)
func (h *HealthHandler) Readiness(w http.ResponseWriter, r *http.Request) {
// Перевіряємо критичні залежності
if h.db != nil {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := h.db.PingContext(ctx); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("not ready"))
return
}
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ready"))
}
Metrics ендпоінт (Prometheus-сумісний)
// middleware/metrics.go
package middleware
import (
"fmt"
"net/http"
"strconv"
"sync/atomic"
"time"
)
var (
requestsTotal int64
requestsInFlight int64
requestDurations = make(map[string][]time.Duration)
)
// Metrics — Middleware для збору метрик
func Metrics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
atomic.AddInt64(&requestsTotal, 1)
atomic.AddInt64(&requestsInFlight, 1)
defer atomic.AddInt64(&requestsInFlight, -1)
wrapped := &responseWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
// Зберігаємо тривалість для гістограми
key := r.Method + " " + r.URL.Path
// Тут має бути потокобезпечна структура даних
// Для простоти пропускаємо
})
}
// MetricsHandler віддає метрики у Prometheus форматі
func MetricsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
fmt.Fprintf(w, "# HELP http_requests_total Total number of HTTP requests\n")
fmt.Fprintf(w, "# TYPE http_requests_total counter\n")
fmt.Fprintf(w, "http_requests_total %d\n", atomic.LoadInt64(&requestsTotal))
fmt.Fprintf(w, "# HELP http_requests_in_flight Current number of HTTP requests being served\n")
fmt.Fprintf(w, "# TYPE http_requests_in_flight gauge\n")
fmt.Fprintf(w, "http_requests_in_flight %d\n", atomic.LoadInt64(&requestsInFlight))
}
Checklist для review коду
Використовуйте цей чеклист при перевірці коду:
Middleware
- Middleware повертає
http.Handler - Викликається
next.ServeHTTP(w, r)(якщо не припиняємо обробку) - Panic Recovery на місці
- Логування включає важливі поля (method, path, status, duration)
Clean Architecture
- Domain не імпортує нічого, крім стандартної бібліотеки
- UseCase залежить від інтерфейсів, а не реалізацій
- Repository визначений як інтерфейс у Domain
- Handler не містить бізнес-логіки
Context
-
context.Contextпередається першим параметром - Використовуються власні типи для ключів контексту
- Перевіряється
ctx.Done()у довгих операціях - Контекст не зберігається у структурах
HTTP
- Заголовки встановлюються перед
WriteHeader() - Статус-код відповідає RFC (200, 201, 400, 404, 500...)
- Використовуються DTO замість доменних сутностей
- Паролі не потрапляють у відповіді
Тестування
- Кожен шар має тести
- Використовуються моки для ізоляції
- Table-driven tests для множинних сценаріїв
- Покриття тестами > 80%
Безпека
- Паролі хешуються (bcrypt, argon2)
- JWT токени перевіряються
- CORS налаштований правильно
- Rate limiting на місці
Підсумки лекції
У цій лекції ми детально розглянули два фундаментальні архітектурні підходи для побудови професійних серверних застосунків на Go:
🔗 Middleware Pattern
Що вивчили:
- Middleware — це функція, що обгортає
http.Handlerі повертає новийhttp.Handler - Створили власні Middleware з нуля: Logger, Recovery, CORS, RateLimit, Auth
- Навчилися композиції Middleware через функцію
Chain - Освоїли передачу даних через
context.Context - Інтегрували Middleware з Chi Router для групування маршрутів
Ключова ідея: Middleware дозволяє модульно додавати наскрізну функціональність (логування, автентифікація, захист) без зміни основної бізнес-логіки.
🏛️ Clean Architecture
Що вивчили:
- Еволюція від монолітного коду до шаруватої архітектури
- Чотири шари: Domain → UseCase → Repository → Delivery
- Dependency Rule: залежності завжди спрямовані всередину
- Repository Pattern для абстракції доступу до даних
- Ручне Dependency Injection для зв'язування компонентів
- Модульне тестування кожного шару з моками
Ключова ідея: Бізнес-логіка не повинна залежати від технічних деталей (HTTP, БД, UI). Це забезпечує тестованість, гнучкість та можливість незалежної зміни технологій.
🧪 Тестування
net/http/httptestдля тестування HTTP-обробників без сервера- Створення мок-реалізацій репозиторіїв та UseCases
- Table-driven tests для покриття різних сценаріїв
- Ізольоване тестування кожного шару
📖 Наступна лекція
Лекція 11. Архітектура та маршрутизація фреймворку Gin
Ми розглянемо високопродуктивний веб-фреймворк Gin, що надає зручні абстракції поверх стандартного net/http: маршрутизацію через HttpRouter, центральну сутність gin.Context, Model Binding, валідацію через теги та автоматичну генерацію Swagger-документації.
🔗 Додаткові матеріали
Документація та статті:
- Writing Middleware in Go
- The Clean Architecture (Robert C. Martin)
- Go net/http/httptest package
- Repository Pattern in Go
Бібліотеки:
- go-chi/chi — легковаговий роутер з Middleware
- golang/mock — генерація моків
- stretchr/testify — зручні assertion для тестів
Проєктування REST API та маршрутизація засобами Chi
Еволюція від RPC до HTTP API, архітектурний стиль REST, мотивація та обмеження, складові HTTP-запитів (URL, заголовки, методи, статус-коди), проєктування номенклатури URL, CRUD-операції, маршрутизатор go-chi/chi з підтримкою Radix Tree, параметри шляху, суб-роутери, Middleware та побудова REST API у Go з дотриманням принципів Clean Architecture.
Лекція 11. Архітектура та маршрутизація фреймворку Gin
Знайомство з вебфреймворком Gin: архітектура, двигун Engine, контекст запиту, маршрутизація та middleware