Урок 11: HTTP-сервер на стандартной библиотеке

Урок 11. HTTP-сервер на стандартной библиотеке

🔄 Node.js → Go (ключевые аналоги):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-http-server && cd go-http-server
go mod init go-http-server

# Устанавливаем UUID для генерации ID
go get github.com/google/uuid
go mod tidy

💻 Код программы

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "log"
    "net/http"
    "os"
    "os/signal"
    "strings"
    "sync"
    "syscall"
    "time"

    "github.com/google/uuid"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  1. МОДЕЛЬ ДАННЫХ                                      ║
// ╚══════════════════════════════════════════════════════════╝

// Note — заметка. Теги json управляют сериализацией.
// `json:"id"` — имя поля в JSON.
// `json:"-"` — поле не сериализуется.
type Note struct {
    ID        string    `json:"id"`
    Title     string    `json:"title"`
    Content   string    `json:"content"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

// CreateNoteRequest — DTO для создания заметки
type CreateNoteRequest struct {
    Title   string `json:"title"`
    Content string `json:"content"`
}

// UpdateNoteRequest — DTO для обновления
type UpdateNoteRequest struct {
    Title   *string `json:"title"`   // Указатель — чтобы отличить отсутствие от пустой строки
    Content *string `json:"content"`
}

// ErrorResponse — структура ошибки для клиента
type ErrorResponse struct {
    Error   string `json:"error"`
    Message string `json:"message,omitempty"`
    Code    int    `json:"code"`
}

// ╔══════════════════════════════════════════════════════════╗
// ║  2. ХРАНИЛИЩЕ ЗАМЕТОК (потокобезопасное)              ║
// ╚══════════════════════════════════════════════════════════╝

// NoteStore — потокобезопасное in-memory хранилище.
// sync.RWMutex позволяет множественное чтение.
type NoteStore struct {
    mu    sync.RWMutex
    notes map[string]Note
}

// NewNoteStore — конструктор
func NewNoteStore() *NoteStore {
    return &NoteStore{
        notes: make(map[string]Note),
    }
}

// Create — создаёт новую заметку
func (s *NoteStore) Create(title, content string) Note {
    s.mu.Lock()
    defer s.mu.Unlock()

    now := time.Now()
    note := Note{
        ID:        uuid.New().String(),
        Title:     title,
        Content:   content,
        CreatedAt: now,
        UpdatedAt: now,
    }
    s.notes[note.ID] = note
    return note
}

// GetAll — возвращает все заметки (слайс, отсортированный по CreatedAt)
func (s *NoteStore) GetAll() []Note {
    s.mu.RLock()
    defer s.mu.RUnlock()

    result := make([]Note, 0, len(s.notes))
    for _, n := range s.notes {
        result = append(result, n)
    }
    // TODO: сортировка по CreatedAt
    return result
}

// GetByID — возвращает заметку по ID
func (s *NoteStore) GetByID(id string) (Note, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    note, ok := s.notes[id]
    if !ok {
        return Note{}, ErrNotFound
    }
    return note, nil
}

// Update — обновляет заметку
func (s *NoteStore) Update(id string, req UpdateNoteRequest) (Note, error) {
    s.mu.Lock()
    defer s.mu.Unlock()

    note, ok := s.notes[id]
    if !ok {
        return Note{}, ErrNotFound
    }

    if req.Title != nil {
        note.Title = *req.Title
    }
    if req.Content != nil {
        note.Content = *req.Content
    }
    note.UpdatedAt = time.Now()
    s.notes[id] = note
    return note, nil
}

// Delete — удаляет заметку
func (s *NoteStore) Delete(id string) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    if _, ok := s.notes[id]; !ok {
        return ErrNotFound
    }
    delete(s.notes, id)
    return nil
}

// Search — поиск по заголовку (простая реализация)
func (s *NoteStore) Search(query string) []Note {
    s.mu.RLock()
    defer s.mu.RUnlock()

    var result []Note
    lowerQuery := strings.ToLower(query)
    for _, n := range s.notes {
        if strings.Contains(strings.ToLower(n.Title), lowerQuery) {
            result = append(result, n)
        }
    }
    return result
}

// ╔══════════════════════════════════════════════════════════╗
// ║  3. ОШИБКИ ХРАНИЛИЩА                                   ║
// ╚══════════════════════════════════════════════════════════╝

var (
    ErrNotFound   = errors.New("note not found")
    ErrEmptyTitle = errors.New("title must not be empty")
)

// ╔══════════════════════════════════════════════════════════╗
// ║  4. HTTP-ХЕНДЛЕРЫ                                       ║
// ╚══════════════════════════════════════════════════════════╝

// Handler — структура с зависимостями (store)
type Handler struct {
    store *NoteStore
}

// NewHandler — конструктор
func NewHandler(store *NoteStore) *Handler {
    return &Handler{store: store}
}

// ==========================================
// Хелперы для JSON-ответов
// ==========================================

// writeJSON — отправляет JSON-ответ
func writeJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    // json.NewEncoder пишет прямо в ResponseWriter (io.Writer)
    if err := json.NewEncoder(w).Encode(data); err != nil {
        log.Printf("Ошибка кодирования JSON: %v", err)
    }
}

// writeError — отправляет ошибку в JSON
func writeError(w http.ResponseWriter, status int, message string) {
    writeJSON(w, status, ErrorResponse{
        Error:   http.StatusText(status),
        Message: message,
        Code:    status,
    })
}

// decodeJSON — читает JSON из тела запроса
func decodeJSON(r *http.Request, v any) error {
    // Ограничиваем размер тела (1MB)
    r.Body = http.MaxBytesReader(nil, r.Body, 1<<20) // 1MB

    dec := json.NewDecoder(r.Body)
    // Запрещаем неизвестные поля (строгий режим)
    dec.DisallowUnknownFields()

    if err := dec.Decode(v); err != nil {
        return fmt.Errorf("decode JSON: %w", err)
    }
    return nil
}

// ==========================================
// CRUD ХЕНДЛЕРЫ
// ==========================================

// CreateNote — POST /api/notes
func (h *Handler) CreateNote(w http.ResponseWriter, r *http.Request) {
    var req CreateNoteRequest
    if err := decodeJSON(r, &req); err != nil {
        writeError(w, http.StatusBadRequest, "Invalid JSON: "+err.Error())
        return
    }

    // Валидация
    if strings.TrimSpace(req.Title) == "" {
        writeError(w, http.StatusBadRequest, ErrEmptyTitle.Error())
        return
    }

    note := h.store.Create(req.Title, req.Content)
    writeJSON(w, http.StatusCreated, note)
}

// GetAllNotes — GET /api/notes
func (h *Handler) GetAllNotes(w http.ResponseWriter, r *http.Request) {
    // Проверяем query-параметр ?q=search
    query := r.URL.Query().Get("q")
    var notes []Note
    if query != "" {
        notes = h.store.Search(query)
    } else {
        notes = h.store.GetAll()
    }

    // Всегда возвращаем массив (не nil) — для красивого JSON
    if notes == nil {
        notes = []Note{}
    }
    writeJSON(w, http.StatusOK, notes)
}

// GetNote — GET /api/notes/{id}
func (h *Handler) GetNote(w http.ResponseWriter, r *http.Request) {
    // r.PathValue — новое в Go 1.22 (аналог req.params в Express)
    id := r.PathValue("id")
    if id == "" {
        writeError(w, http.StatusBadRequest, "ID is required")
        return
    }

    note, err := h.store.GetByID(id)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            writeError(w, http.StatusNotFound, "Note not found")
            return
        }
        writeError(w, http.StatusInternalServerError, err.Error())
        return
    }

    writeJSON(w, http.StatusOK, note)
}

// UpdateNote — PUT /api/notes/{id}
func (h *Handler) UpdateNote(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    if id == "" {
        writeError(w, http.StatusBadRequest, "ID is required")
        return
    }

    var req UpdateNoteRequest
    if err := decodeJSON(r, &req); err != nil {
        writeError(w, http.StatusBadRequest, "Invalid JSON: "+err.Error())
        return
    }

    // Проверяем, что хотя бы одно поле передано
    if req.Title == nil && req.Content == nil {
        writeError(w, http.StatusBadRequest, "At least one field required")
        return
    }

    note, err := h.store.Update(id, req)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            writeError(w, http.StatusNotFound, "Note not found")
            return
        }
        writeError(w, http.StatusInternalServerError, err.Error())
        return
    }

    writeJSON(w, http.StatusOK, note)
}

// DeleteNote — DELETE /api/notes/{id}
func (h *Handler) DeleteNote(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    if id == "" {
        writeError(w, http.StatusBadRequest, "ID is required")
        return
    }

    if err := h.store.Delete(id); err != nil {
        if errors.Is(err, ErrNotFound) {
            writeError(w, http.StatusNotFound, "Note not found")
            return
        }
        writeError(w, http.StatusInternalServerError, err.Error())
        return
    }

    // 204 No Content — успех без тела ответа
    w.WriteHeader(http.StatusNoContent)
}

// HealthCheck — GET /health (для проверки живости сервера)
func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) {
    writeJSON(w, http.StatusOK, map[string]any{
        "status": "ok",
        "time":   time.Now().Format(time.RFC3339),
    })
}

// ╔══════════════════════════════════════════════════════════╗
// ║  5. НАСТРОЙКА РОУТЕРА                                  ║
// ╚══════════════════════════════════════════════════════════╝

// setupRouter — создаёт и настраивает ServeMux
func setupRouter(h *Handler) *http.ServeMux {
    // http.NewServeMux — мультиплексор (роутер)
    mux := http.NewServeMux()

    // В Go 1.22+ можно указывать HTTP-метод прямо в паттерне:
    // "METHOD /path" — роутинг по методу
    // "/path/{name}" — path-параметр
    // "/path/{name}/..." — захват всего остатка пути

    // Заметки
    mux.HandleFunc("GET /api/notes", h.GetAllNotes)
    mux.HandleFunc("POST /api/notes", h.CreateNote)
    mux.HandleFunc("GET /api/notes/{id}", h.GetNote)
    mux.HandleFunc("PUT /api/notes/{id}", h.UpdateNote)
    mux.HandleFunc("DELETE /api/notes/{id}", h.DeleteNote)

    // Health check
    mux.HandleFunc("GET /health", h.HealthCheck)

    // Если Go < 1.22, используем старый способ:
    // mux.HandleFunc("/api/notes", func(w http.ResponseWriter, r *http.Request) {
    //     switch r.Method {
    //     case http.MethodGet: ...
    //     case http.MethodPost: ...
    //     }
    // })

    return mux
}

// ╔══════════════════════════════════════════════════════════╗
// ║  6. MIDDLEWARE (логирование)                            ║
// ╚══════════════════════════════════════════════════════════╝

// loggingMiddleware — логирует каждый запрос
func loggingMiddleware(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}

        next.ServeHTTP(wrapped, r)

        log.Printf("%s %s%d (%v)",
            r.Method,
            r.URL.Path,
            wrapped.statusCode,
            time.Since(start),
        )
    })
}

// responseWriter — обёртка для захвата статус-кода
type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

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

// ╔══════════════════════════════════════════════════════════╗
// ║  MAIN                                                    ║
// ╚══════════════════════════════════════════════════════════╝

func main() {
    log.SetFlags(log.LstdFlags | log.Lmicroseconds)
    log.Println("🚀 Запуск HTTP-сервера...")

    // Инициализация зависимостей
    store := NewNoteStore()
    handler := NewHandler(store)
    router := setupRouter(handler)

    // Оборачиваем роутер в middleware
    app := loggingMiddleware(router)

    // Настройка HTTP-сервера с таймаутами
    server := &http.Server{
        Addr:         ":8080",
        Handler:      app,
        ReadTimeout:  5 * time.Second,  // Максимальное время чтения запроса
        WriteTimeout: 10 * time.Second, // Максимальное время записи ответа
        IdleTimeout:  120 * time.Second, // Таймаут keep-alive соединений
        // MaxHeaderBytes: 1 << 20,      // Максимальный размер заголовков (1MB)
    }

    // Запуск сервера в горутине
    go func() {
        log.Printf("Сервер слушает на http://localhost%s", server.Addr)
        log.Println("Доступные эндпоинты:")
        log.Println("  GET    /health")
        log.Println("  GET    /api/notes")
        log.Println("  GET    /api/notes?q=search")
        log.Println("  POST   /api/notes")
        log.Println("  GET    /api/notes/{id}")
        log.Println("  PUT    /api/notes/{id}")
        log.Println("  DELETE /api/notes/{id}")

        // ListenAndServe блокируется, пока сервер работает
        if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            log.Fatalf("Ошибка сервера: %v", err)
        }
    }()

    // ==========================================
    // Graceful shutdown
    // ==========================================
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit // Блокируемся до получения сигнала

    log.Println("🛑 Выключение сервера...")

    // Даём 10 секунд на завершение активных запросов
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        log.Fatalf("Ошибка при остановке сервера: %v", err)
    }

    log.Println("✅ Сервер остановлен")
}

🧪 Тестирование API (curl-команды)

# Запуск сервера
go run main.go

# В другом терминале:

# Проверка здоровья
curl http://localhost:8080/health

# Создание заметки
curl -X POST http://localhost:8080/api/notes \
  -H "Content-Type: application/json" \
  -d '{"title":"Первая заметка","content":"Содержание заметки"}'

# Получение всех заметок
curl http://localhost:8080/api/notes

# Получение всех с поиском
curl "http://localhost:8080/api/notes?q=первая"

# Получение одной заметки (замените ID на реальный)
curl http://localhost:8080/api/notes/ВАШ_ID

# Обновление заметки
curl -X PUT http://localhost:8080/api/notes/ВАШ_ID \
  -H "Content-Type: application/json" \
  -d '{"title":"Обновлённый заголовок"}'

# Удаление заметки
curl -X DELETE http://localhost:8080/api/notes/ВАШ_ID

# Проверка ошибки: пустой заголовок
curl -X POST http://localhost:8080/api/notes \
  -H "Content-Type: application/json" \
  -d '{"title":"","content":"test"}'

# Проверка ошибки: несуществующий ID
curl http://localhost:8080/api/notes/nonexistent

📊 Структура HTTP-запроса в Go

КомпонентGoNode.js (Express)
Методr.Methodreq.method
Путьr.URL.Pathreq.path
Query-параметрыr.URL.Query().Get("q")req.query.q
Path-параметрыr.PathValue("id") (1.22+)req.params.id
Заголовкиr.Header.Get("Content-Type")req.headers['content-type']
Тело запросаio.ReadAll(r.Body)req.body
Ответ JSONjson.NewEncoder(w).Encode(v)res.json(v)
Статус ответаw.WriteHeader(200)res.status(200)

🚀 Запуск программы

# Запуск
go run main.go

# Сборка
go build -o http-server main.go
./http-server

# Запуск с race-детектором
go run -race main.go
💡 Практический совет:

💡 Best practices от сеньоров:

🔑 Ключевые концепции

Что нужно запомнить из этого урока:

💡 Для Node.js разработчика:

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