2. Архитектура простого HTTP сервиса
Когда мы говорим о «простом HTTP сервисе», «простой» не означает «плохо спроектированный». Это основа, на которой строятся все production-сервисы. Давайте разберем, из каких компонентов состоит минимальный, но правильно спроектированный HTTP-сервер на Go.
В мире бэкенд-разработки существует два опасных соблазна. Первый — броситься в омут «микрофреймворков», которые прячут сетевой ввод-вывод за магическими аннотациями и глобальными синглтонами. Второй — нагородить двадцать слоев абстракций ради вымышленной «чистой архитектуры», превратив тривиальное сохранение записи в базе в бесконечное перекладывание DTO из одного пакета в другой.
Идиоматичный подход Go, завещанный инженерами Bell Labs, заключается в прозрачности и явном контроле. Простой сервис на Go собирается из стандартных блоков как надежный швейцарский механизм: каждый винтик на виду, поток управления очевиден, а зависимости передаются явно через аргументы конструкторов.
flowchart TD
subgraph Transport ["1. Внешний транспортный слой (cmd / internal/server)"]
Listener["TCP Listener (:8080)"] --> Mux["HTTP Router (ServeMux / Chi)"]
Mux --> MidChain["Middleware Chain\n(Recovery -> Logging -> Auth)"]
end
subgraph Handlers ["2. Слой адаптеров / HTTP Handlers (internal/handlers)"]
MidChain --> HUser["UserHandler\n(Decode JSON, Validate, Status Codes)"]
MidChain --> HHealth["HealthCheckHandler"]
end
subgraph Domain ["3. Доменный слой и сервисы (internal/services)"]
HUser --> UService["UserService (Бизнес-правила)\nЗависит от интерфейса UserRepository"]
end
subgraph Infrastructure ["4. Инфраструктурный слой (internal/repository)"]
UService -.->|"Имплементирует"| IRepo["interface UserRepository"]
IRepo --- RepoImpl["PostgresUserRepository\n(SQL Queries, database/sql)"]
end
RepoImpl --> DB[("База данных PostgreSQL")]
classDef transport fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
classDef handler fill:#0f172a,stroke:#6366f1,stroke-width:2px,color:#f8fafc;
classDef domain fill:#1e1e2e,stroke:#10b981,stroke-width:2px,color:#f8fafc;
classDef infra fill:#2d1b4e,stroke:#f43f5e,stroke-width:2px,color:#f8fafc;
class Listener,Mux,MidChain transport;
class HUser,HHealth handler;
class UService,IRepo domain;
class RepoImpl,DB infra;
Основные компоненты сервиса
Простой HTTP-сервис — это не просто http.ListenAndServe. Это полноценная архитектура, включающая:
- HTTP-сервер: принимает и обрабатывает HTTP-запросы
- Роутинг: направляет запросы в нужные обработчики
- Middleware: логика, общая для всех запросов (логирование, аутентификация, CORS)
- Обработчики (handlers): конкретная бизнес-логика
- Конфигурация: настройки приложения (порт, база данных, внешние API)
- Зависимости: подключение к БД, кешу, внешним сервисам
- Graceful shutdown: корректное завершение при сигнале ОС
Каждый из этих компонентов решает строго очерченную задачу. Если сервер отвечает за управление TCP-сокетами и сетевыми таймаутами, то обработчик не должен знать ничего о SQL-запросах или формате хранения кэша в Redis.
Минимальная архитектура
Начнем с точки входа. Программа должна запускаться, настраивать зависимости, слушать входящие соединения в фоновой горутине и корректно перехватывать системные сигналы ядра Linux (SIGINT, SIGTERM), гарантируя, что ни один клиентский запрос не оборвется на полуслове при перезапуске процесса:
// main.go
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
// Создаем HTTP-сервер
server := &http.Server{
Addr: ":8080",
Handler: setupRoutes(), // настраиваем маршруты
}
// Запускаем сервер в отдельной горутине
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server failed to start: %v", err)
}
}()
// Ждем сигнал остановки
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down server...")
// Корректно завершаем сервер
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 exited")
}
func setupRoutes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
return mux
}
Архитектурные слои
Разделение сервиса на четкие слои предотвращает «спагетти-код», при котором логика валидации HTTP-заголовков переплетается с генерацией SQL-запросов.
1. Внешний слой: HTTP-сервер и маршрутизация
Транспортный слой отвечает за привязку к сетевому порту, управление жизненным циклом сервера и регистрацию маршрутов:
type Server struct {
httpServer *http.Server
router *http.ServeMux
}
func NewServer(port string) *Server {
router := http.NewServeMux()
s := &Server{
router: router,
httpServer: &http.Server{
Addr: ":" + port,
Handler: router,
},
}
s.setupRoutes()
return s
}
func (s *Server) setupRoutes() {
s.router.HandleFunc("/api/users", s.handleUsers)
s.router.HandleFunc("/health", s.healthCheck)
}
2. Слой middleware: общая логика для всех запросов
Middleware представляет собой функции-обертки над http.Handler или http.HandlerFunc. Они перехватывают поток управления до вызова целевого обработчика и после него:
func (s *Server) withLogging(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Вызываем оригинальный обработчик
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
}
}
func (s *Server) withRecovery(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic recovered: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
}
}
3. Слой обработчиков: бизнес-логика
Задача обработчика (Handler) — распаковать HTTP-запрос (распарсить URL query params, JSON body, извлечь заголовки), передать проверенные структуры в доменный сервис, принять результат и сериализовать HTTP-ответ с корректным статус-кодом:
type UserService struct {
db Database
}
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.getUsers(w, r)
case http.MethodPost:
s.createUser(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) getUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.userService.GetAll(r.Context())
if err != nil {
http.Error(w, "Failed to get users", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
4. Слой приложения: бизнес-логика и взаимодействие с инфраструктурой
Слой сервиса (Service / UseCase) полностью изолирован от деталей HTTP. Он не знает про http.ResponseWriter или *http.Request. Он оперирует исключительно типами предметной области и контекстом выполнения:
type UserService struct {
repo UserRepository
}
func (s *UserService) GetAll(ctx context.Context) ([]User, error) {
// Валидация
if err := validateContext(ctx); err != nil {
return nil, err
}
// Бизнес-логика
return s.repo.FindAll(ctx)
}
Правильная инициализация зависимостей
В Go принято избегать «магических» контейнеров инверсии управления и глобальных переменных. Инициализация зависимостей происходит явно в функции main — так называемом корне композиции (Composition Root):
func main() {
// Загружаем конфигурацию
cfg := loadConfig()
// Создаем зависимости
db, err := connectDB(cfg.DatabaseURL)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Создаем сервисы
userService := &UserService{
repo: NewUserRepository(db),
}
// Создаем сервер
server := NewServer(cfg.Port, userService)
// Запускаем сервер
if err := server.Start(); err != nil {
log.Fatal(err)
}
}
Иерархия папок (Standard Go Project Layout)
Для организации исходного кода промышленных сервисов сообщество выработало проверенную структуру каталогов:
my-service/
├── cmd/
│ └── my-service/
│ └── main.go
├── internal/
│ ├── handlers/
│ ├── services/
│ ├── repository/
│ └── middleware/
├── pkg/
├── configs/
├── deployments/
└── go.mod
cmd/my-service/main.go: точка входа, инициализация и запуск приложенияinternal/handlers: HTTP-обработчикиinternal/services: бизнес-логикаinternal/repository: работа с базой данныхinternal/middleware: общая логика для HTTP-запросов
Директория internal/ имеет специальный статус в компиляторе Go: пакеты, расположенные внутри internal/, не могут быть импортированы сторонними модулями. Это гарантирует жесткую инкапсуляцию внутренней архитектуры вашего сервиса.
Ключевые принципы архитектуры
При проектировании HTTP-сервисов на Go мы следуем четырем базовым принципам:
- Single Responsibility: каждый компонент отвечает за одну задачу
- Dependency Inversion: зависимости инжектятся, а не создаются внутри
- Interface Segregation: используем интерфейсы для слабой связанности
- Clean Architecture: слои не зависят от внешних слоев
Обратите внимание на то, где объявляется интерфейс: в идиоматичном Go интерфейс объявляется потребителем (в слое сервиса), а не реализацией (в слое репозитория):
// Интерфейс для репозитория
type UserRepository interface {
FindAll(ctx context.Context) ([]User, error)
FindByID(ctx context.Context, id int64) (*User, error)
Create(ctx context.Context, user *User) error
}
// Сервис зависит от интерфейса, а не от конкретной реализации
type UserService struct {
repo UserRepository // интерфейс, а не конкретная структура
}
Заключение
Эта архитектура может показаться избыточной для простого сервиса, но она масштабируется и поддерживается легко. Каждый компонент можно тестировать отдельно, заменять реализации и развивать независимо.
Следующая статья: 3. net/http сервер с нуля