Финальный проект
Финальный проект
Ты прошёл девять модулей. Ты знаешь типы и структуры, функции и методы, интерфейсы и указатели, горутины и каналы, обработку ошибок, пакеты и тестирование. Теперь соберём всё это в одно целое.
Финальный проект — 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. Пакеты из неё импортируемы только внутри того же модуля. Это архитектурный барьер: никакой внешний код не зависит от твоих внутренних деталей.
Слой хранилища
// 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")// 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 для записи.
Слой сервиса
// 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-специфика.
Слой обработчиков
// 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
}Точка входа
// 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, чистая бизнес-логика:
// 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:
// 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 — это язык, который вознаграждает простоту. Пиши понятный код, используй стандартную библиотеку когда можешь, добавляй зависимости осознанно. Эти принципы не устареют.