Модуль
1HTTP-сервер на Go2JSON API3Graceful Shutdown4Финальный проект← вы здесь
Урок 4~12 минут

Финальный проект

Финальный проект

Ты прошёл девять модулей. Ты знаешь типы и структуры, функции и методы, интерфейсы и указатели, горутины и каналы, обработку ошибок, пакеты и тестирование. Теперь соберём всё это в одно целое.

Финальный проект — REST API сервис управления задачами (todo-list). Это классика, потому что в ней есть всё: CRUD, хранение данных, конкурентность, ошибки, тестирование.

Что мы строим

POST   /api/tasks          - создать задачу
GET    /api/tasks          - список задач (с фильтрами)
GET    /api/tasks/{id}     - получить задачу
PUT    /api/tasks/{id}     - обновить задачу
DELETE /api/tasks/{id}     - удалить задачу
GET    /health             - health check

In-memory хранилище с mutex — никаких баз данных, чтобы сосредоточиться на архитектуре.

Структура проекта

todo-api/
├── cmd/
│   └── server/
│       └── main.go          # точка входа
├── internal/
│   ├── handler/
│   │   ├── handler.go       # HTTP обработчики
│   │   └── handler_test.go  # тесты
│   ├── service/
│   │   ├── service.go       # бизнес-логика
│   │   └── service_test.go
│   └── store/
│       ├── store.go         # интерфейс хранилища
│       └── memory.go        # in-memory реализация
├── go.mod
└── go.sum

internal/ — это специальная директория в Go. Пакеты из неё импортируемы только внутри того же модуля. Это архитектурный барьер: никакой внешний код не зависит от твоих внутренних деталей.

Слой хранилища

go
// internal/store/store.go
package store
 
import "time"
 
type Task struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
}
 
// Интерфейс — сервис работает с ним, не с конкретной реализацией
type Store interface {
    Create(title string) (*Task, error)
    GetAll() ([]*Task, error)
    GetByID(id int) (*Task, error)
    Update(id int, title string, done bool) (*Task, error)
    Delete(id int) error
}
 
var ErrNotFound = errors.New("task not found")
go
// internal/store/memory.go
package store
 
import (
    "errors"
    "sync"
)
 
type MemoryStore struct {
    mu      sync.RWMutex
    tasks   map[int]*Task
    counter int
}
 
func NewMemoryStore() *MemoryStore {
    return &MemoryStore{tasks: make(map[int]*Task)}
}
 
func (s *MemoryStore) Create(title string) (*Task, error) {
    s.mu.Lock()
    defer s.mu.Unlock()
 
    s.counter++
    task := &Task{
        ID:        s.counter,
        Title:     title,
        CreatedAt: time.Now(),
    }
    s.tasks[task.ID] = task
    return task, nil
}
 
func (s *MemoryStore) GetByID(id int) (*Task, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()
 
    task, ok := s.tasks[id]
    if !ok {
        return nil, ErrNotFound
    }
    return task, nil
}

sync.RWMutex — правильный выбор для хранилища: много читателей, редкие записи. RLock для чтения, Lock для записи.

Слой сервиса

go
// internal/service/service.go
package service
 
import (
    "errors"
    "fmt"
    "todo-api/internal/store"
)
 
type Service struct {
    store store.Store // зависим от интерфейса, не от конкретной реализации
}
 
func New(s store.Store) *Service {
    return &Service{store: s}
}
 
type CreateTaskRequest struct {
    Title string `json:"title"`
}
 
func (s *Service) CreateTask(req CreateTaskRequest) (*store.Task, error) {
    if req.Title == "" {
        return nil, fmt.Errorf("title is required")
    }
    if len(req.Title) > 200 {
        return nil, fmt.Errorf("title too long (max 200 chars)")
    }
    return s.store.Create(req.Title)
}
 
func (s *Service) DeleteTask(id int) error {
    err := s.store.Delete(id)
    if errors.Is(err, store.ErrNotFound) {
        return err // пробрасываем sentinel error — handler знает что делать
    }
    return err
}

Сервис — тонкий слой между handler и store. Здесь бизнес-логика и валидация, но не HTTP-специфика.

Слой обработчиков

go
// internal/handler/handler.go
package handler
 
import (
    "encoding/json"
    "errors"
    "net/http"
    "strconv"
    "strings"
    "todo-api/internal/service"
    "todo-api/internal/store"
)
 
type Handler struct {
    svc *service.Service
}
 
func New(svc *service.Service) *Handler {
    return &Handler{svc: svc}
}
 
func (h *Handler) Routes() http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("/health", h.health)
    mux.HandleFunc("/api/tasks", h.tasks)
    mux.HandleFunc("/api/tasks/", h.taskByID)
    return Logger(mux) // middleware
}
 
func (h *Handler) tasks(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodPost:
        h.createTask(w, r)
    case http.MethodGet:
        h.listTasks(w, r)
    default:
        writeJSON(w, http.StatusMethodNotAllowed, errResponse("method not allowed"))
    }
}
 
func (h *Handler) createTask(w http.ResponseWriter, r *http.Request) {
    var req service.CreateTaskRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeJSON(w, http.StatusBadRequest, errResponse("invalid JSON"))
        return
    }
 
    task, err := h.svc.CreateTask(req)
    if err != nil {
        writeJSON(w, http.StatusUnprocessableEntity, errResponse(err.Error()))
        return
    }
 
    writeJSON(w, http.StatusCreated, task)
}
 
func (h *Handler) taskByID(w http.ResponseWriter, r *http.Request) {
    // Вручную парсим /api/tasks/42
    idStr := strings.TrimPrefix(r.URL.Path, "/api/tasks/")
    id, err := strconv.Atoi(idStr)
    if err != nil || id <= 0 {
        writeJSON(w, http.StatusBadRequest, errResponse("invalid task id"))
        return
    }
 
    switch r.Method {
    case http.MethodGet:
        h.getTask(w, r, id)
    case http.MethodDelete:
        h.deleteTask(w, r, id)
    default:
        writeJSON(w, http.StatusMethodNotAllowed, errResponse("method not allowed"))
    }
}
 
func (h *Handler) deleteTask(w http.ResponseWriter, r *http.Request, id int) {
    err := h.svc.DeleteTask(id)
    if errors.Is(err, store.ErrNotFound) {
        writeJSON(w, http.StatusNotFound, errResponse("task not found"))
        return
    }
    if err != nil {
        writeJSON(w, http.StatusInternalServerError, errResponse("internal error"))
        return
    }
    w.WriteHeader(http.StatusNoContent) // 204 — success, no body
}

Точка входа

go
// cmd/server/main.go
package main
 
import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
 
    "todo-api/internal/handler"
    "todo-api/internal/service"
    "todo-api/internal/store"
)
 
func main() {
    // Зависимости собираем вручную (dependency injection без фреймворка)
    st := store.NewMemoryStore()
    svc := service.New(st)
    h := handler.New(svc)
 
    server := &http.Server{
        Addr:         ":8080",
        Handler:      h.Routes(),
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
        IdleTimeout:  60 * time.Second,
    }
 
    go func() {
        log.Printf("server listening on :8080")
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("server error: %v", err)
        }
    }()
 
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
 
    log.Println("shutting down...")
 
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
 
    if err := server.Shutdown(ctx); err != nil {
        log.Printf("shutdown error: %v", err)
    }
 
    log.Println("done")
}

Тестирование

Тесты сервиса — без HTTP, чистая бизнес-логика:

go
// internal/service/service_test.go
func TestCreateTask(t *testing.T) {
    svc := service.New(store.NewMemoryStore())
 
    t.Run("valid task", func(t *testing.T) {
        task, err := svc.CreateTask(service.CreateTaskRequest{Title: "Buy milk"})
        if err != nil {
            t.Fatalf("unexpected error: %v", err)
        }
        if task.Title != "Buy milk" {
            t.Errorf("got title %q, want %q", task.Title, "Buy milk")
        }
    })
 
    t.Run("empty title", func(t *testing.T) {
        _, err := svc.CreateTask(service.CreateTaskRequest{Title: ""})
        if err == nil {
            t.Fatal("expected error for empty title")
        }
    })
}

Тесты handler через httptest:

go
// internal/handler/handler_test.go
func TestCreateTask(t *testing.T) {
    h := handler.New(service.New(store.NewMemoryStore()))
 
    body := `{"title": "Write tests"}`
    req := httptest.NewRequest(http.MethodPost, "/api/tasks", strings.NewReader(body))
    req.Header.Set("Content-Type", "application/json")
    rec := httptest.NewRecorder()
 
    h.Routes().ServeHTTP(rec, req)
 
    if rec.Code != http.StatusCreated {
        t.Errorf("got status %d, want 201", rec.Code)
    }
}

httptest.NewRecorder — это http.ResponseWriter который записывает ответ в память. Позволяет тестировать HTTP-обработчики без реального сервера.

Что дальше

Этот сервис — основа. Чтобы сделать его production-ready, нужно добавить:

  • База данных — PostgreSQL через pgx или database/sql
  • Конфигурация — через переменные среды (os.Getenv) или viper
  • Логирование — структурированные логи через slog (Go 1.21) или zap
  • Метрики — Prometheus через prometheus/client_golang
  • Трейсинг — OpenTelemetry для распределённой трассировки
  • Аутентификация — JWT токены, middleware
  • Документация — OpenAPI/Swagger через swaggo
  • Деплой — Docker, Kubernetes, CI/CD

Это уже не курс по Go — это курс по построению микросервисов. Но теперь у тебя есть фундамент.

Итог курса

За 10 модулей ты прошёл путь от Hello, World! до полноценного REST API:

  • Модуль 1: Установка, go run, go build — первые шаги
  • Модуль 2: Типы, переменные, условия, циклы — фундамент
  • Модуль 3: Функции, замыкания, defer, panic/recover
  • Модуль 4: Слайсы, map, struct — составные типы
  • Модуль 5: Интерфейсы — полиморфизм без наследования
  • Модуль 6: Указатели, стек и куча — как работает память
  • Модуль 7: Горутины и каналы — конкурентность без головной боли
  • Модуль 8: Обработка ошибок — явно лучше, чем исключения
  • Модуль 9: Пакеты, модули, тестирование, инструменты
  • Модуль 10: HTTP-сервер, JSON API, graceful shutdown

Go — это язык, который вознаграждает простоту. Пиши понятный код, используй стандартную библиотеку когда можешь, добавляй зависимости осознанно. Эти принципы не устареют.

Ты прошёл весь курс. Теперь у тебя есть инструменты чтобы написать production-ready Go сервис. Это не конец — это точка отсчёта.
🎯
Миссия 1 из 4
Что нужно передать в context.WithTimeout при graceful shutdown, чтобы дать 30 секунд на завершение?