О проекте
Цель: закрепить все знания курса в реальном проекте.
Проект: Мини-сервис для управления задачами (Task Manager)
Длительность: 4 недели
Описание проекта
Вы создадите консольное приложение и HTTP API для управления задачами. Проект включает:
- Консольный интерфейс для работы с задачами
- Параллельный обработчик файлов
- HTTP API с эндпоинтами
- Хранение данных в PostgreSQL
Требования к проекту
Обязательные компоненты
-
Консольное приложение
- Добавление задач
- Просмотр списка задач
- Отметка задач как выполненных
- Удаление задач
-
Параллельный обработчик
- Импорт задач из CSV файла
- Параллельная обработка строк
- Контроль количества воркеров
-
HTTP API
- GET /tasks — список задач
- POST /tasks — создание задачи
- PUT /tasks/{id}/complete — отметка как выполненной
- DELETE /tasks/{id} — удаление задачи
-
База данных
- PostgreSQL для хранения задач
- Миграции для создания таблицы
- Транзакции для операций
-
Тестирование
- Юнит-тесты для бизнес-логики
- Покрытие кода > 60%
Структура проекта
task-manager/
├── cmd/
│ ├── cli/
│ │ └── main.go # Консольное приложение
│ ├── api/
│ │ └── main.go # HTTP сервер
│ └── importer/
│ └── main.go # Импорт из CSV
├── internal/
│ ├── models/
│ │ └── task.go # Модель задачи
│ ├── repository/
│ │ ├── interface.go # Интерфейс репозитория
│ │ └── postgres.go # Реализация для PostgreSQL
│ ├── service/
│ │ └── task_service.go # Бизнес-логика
│ ├── handlers/
│ │ └── http.go # HTTP хэндлеры
│ └── importer/
│ └── csv_importer.go # Импорт из CSV
├── migrations/
│ └── 001_create_tasks.sql # Миграция
├── go.mod
├── go.sum
└── README.md
Пошаговый план реализации
Неделя 1: Консольное приложение и модель
День 1-2: Модель задачи
Создайте модель Task:
go// internal/models/task.go package models import "time" type Task struct { ID int64 Title string Description string Completed bool CreatedAt time.Time CompletedAt *time.Time } func NewTask(title, description string) *Task { return &Task{ Title: title, Description: description, CreatedAt: time.Now(), } } func (t *Task) Complete() { t.Completed = true now := time.Now() t.CompletedAt = &now }
День 3-4: Репозиторий
Создайте интерфейс и реализацию:
go// internal/repository/interface.go package repository import ( "context" "task-manager/internal/models" ) type TaskRepository interface { Create(ctx context.Context, task *models.Task) error GetByID(ctx context.Context, id int64) (*models.Task, error) List(ctx context.Context) ([]*models.Task, error) Update(ctx context.Context, task *models.Task) error Delete(ctx context.Context, id int64) error } // internal/repository/postgres.go package repository import ( "context" "database/sql" "fmt" "task-manager/internal/models" ) type PostgresTaskRepository struct { db *sql.DB } func NewPostgresTaskRepository(db *sql.DB) *PostgresTaskRepository { return &PostgresTaskRepository{db: db} } func (r *PostgresTaskRepository) Create(ctx context.Context, task *models.Task) error { query := `INSERT INTO tasks (title, description, completed, created_at) VALUES ($1, $2, $3, $4) RETURNING id` return r.db.QueryRowContext(ctx, query, task.Title, task.Description, task.Completed, task.CreatedAt, ).Scan(&task.ID) } // Реализуйте остальные методы
День 5-7: Сервис и консольное приложение
Создайте сервис:
go// internal/service/task_service.go package service import ( "context" "errors" "task-manager/internal/models" "task-manager/internal/repository" ) type TaskService struct { repo repository.TaskRepository } func NewTaskService(repo repository.TaskRepository) *TaskService { return &TaskService{repo: repo} } func (s *TaskService) CreateTask(ctx context.Context, title, description string) (*models.Task, error) { if title == "" { return nil, errors.New("title is required") } task := models.NewTask(title, description) if err := s.repo.Create(ctx, task); err != nil { return nil, err } return task, nil } func (s *TaskService) CompleteTask(ctx context.Context, id int64) error { task, err := s.repo.GetByID(ctx, id) if err != nil { return err } if task.Completed { return errors.New("task already completed") } task.Complete() return s.repo.Update(ctx, task) } // Реализуйте остальные методы
Создайте консольное приложение:
go// cmd/cli/main.go package main import ( "context" "database/sql" "fmt" "os" "task-manager/internal/repository" "task-manager/internal/service" _ "github.com/lib/pq" ) func main() { if len(os.Args) < 2 { fmt.Println("Usage: task-manager <command> [args]") os.Exit(1) } // Подключение к БД db, err := sql.Open("postgres", "postgres://user:pass@localhost/taskdb?sslmode=disable") if err != nil { panic(err) } defer db.Close() repo := repository.NewPostgresTaskRepository(db) svc := service.NewTaskService(repo) ctx := context.Background() command := os.Args[1] switch command { case "add": if len(os.Args) < 4 { fmt.Println("Usage: task-manager add <title> <description>") os.Exit(1) } task, err := svc.CreateTask(ctx, os.Args[2], os.Args[3]) if err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } fmt.Printf("Created task %d: %s\n", task.ID, task.Title) case "list": tasks, err := svc.ListTasks(ctx) if err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } for _, task := range tasks { status := "[ ]" if task.Completed { status = "[x]" } fmt.Printf("%s %d: %s\n", status, task.ID, task.Title) } case "complete": // Реализуйте case "delete": // Реализуйте default: fmt.Printf("Unknown command: %s\n", command) os.Exit(1) } }
Неделя 2: Параллельный обработчик и тесты
День 1-3: Импорт из CSV
Создайте импортер:
go// internal/importer/csv_importer.go package importer import ( "context" "encoding/csv" "io" "os" "sync" "task-manager/internal/models" "task-manager/internal/service" ) type CSVImporter struct { service *service.TaskService workers int } func NewCSVImporter(service *service.TaskService, workers int) *CSVImporter { return &CSVImporter{ service: service, workers: workers, } } type taskData struct { title string description string } func (i *CSVImporter) Import(ctx context.Context, filename string) error { file, err := os.Open(filename) if err != nil { return err } defer file.Close() reader := csv.NewReader(file) // Пропускаем заголовок if _, err := reader.Read(); err != nil { return err } // Канал для задач tasksCh := make(chan taskData, 100) var wg sync.WaitGroup // Запуск воркеров for w := 0; w < i.workers; w++ { wg.Add(1) go func() { defer wg.Done() for task := range tasksCh { if _, err := i.service.CreateTask(ctx, task.title, task.description); err != nil { // Логируем ошибку, но продолжаем fmt.Printf("Error creating task: %v\n", err) } } }() } // Чтение CSV и отправка в канал for { record, err := reader.Read() if err == io.EOF { break } if err != nil { return err } tasksCh <- taskData{ title: record[0], description: record[1], } } close(tasksCh) wg.Wait() return nil }
Создайте консольное приложение для импорта:
go// cmd/importer/main.go package main import ( "context" "database/sql" "fmt" "os" "task-manager/internal/importer" "task-manager/internal/repository" "task-manager/internal/service" _ "github.com/lib/pq" ) func main() { if len(os.Args) < 2 { fmt.Println("Usage: importer <csv-file> [workers]") os.Exit(1) } filename := os.Args[1] workers := 5 if len(os.Args) > 2 { fmt.Sscanf(os.Args[2], "%d", &workers) } // Подключение к БД db, err := sql.Open("postgres", "postgres://user:pass@localhost/taskdb?sslmode=disable") if err != nil { panic(err) } defer db.Close() repo := repository.NewPostgresTaskRepository(db) svc := service.NewTaskService(repo) imp := importer.NewCSVImporter(svc, workers) if err := imp.Import(context.Background(), filename); err != nil { fmt.Printf("Error: %v\n", err) os.Exit(1) } fmt.Println("Import completed") }
День 4-7: Тестирование
Напишите тесты для сервиса:
go// internal/service/task_service_test.go package service import ( "context" "errors" "testing" "task-manager/internal/models" ) type mockRepository struct { tasks map[int64]*models.Task nextID int64 } func newMockRepository() *mockRepository { return &mockRepository{ tasks: make(map[int64]*models.Task), nextID: 1, } } func (m *mockRepository) Create(ctx context.Context, task *models.Task) error { task.ID = m.nextID m.tasks[task.ID] = task m.nextID++ return nil } func (m *mockRepository) GetByID(ctx context.Context, id int64) (*models.Task, error) { task, ok := m.tasks[id] if !ok { return nil, errors.New("task not found") } return task, nil } func (m *mockRepository) List(ctx context.Context) ([]*models.Task, error) { tasks := make([]*models.Task, 0, len(m.tasks)) for _, task := range m.tasks { tasks = append(tasks, task) } return tasks, nil } func (m *mockRepository) Update(ctx context.Context, task *models.Task) error { if _, ok := m.tasks[task.ID]; !ok { return errors.New("task not found") } m.tasks[task.ID] = task return nil } func (m *mockRepository) Delete(ctx context.Context, id int64) error { if _, ok := m.tasks[id]; !ok { return errors.New("task not found") } delete(m.tasks, id) return nil } func TestTaskService_CreateTask(t *testing.T) { repo := newMockRepository() svc := NewTaskService(repo) tests := []struct { name string title string description string wantErr bool }{ {"valid task", "Test task", "Description", false}, {"empty title", "", "Description", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { task, err := svc.CreateTask(context.Background(), tt.title, tt.description) if (err != nil) != tt.wantErr { t.Errorf("CreateTask() error = %v, wantErr %v", err, tt.wantErr) return } if !tt.wantErr && task == nil { t.Error("CreateTask() returned nil task") } }) } } func TestTaskService_CompleteTask(t *testing.T) { repo := newMockRepository() svc := NewTaskService(repo) // Создаём задачу task, _ := svc.CreateTask(context.Background(), "Test", "Desc") // Отмечаем как выполненную err := svc.CompleteTask(context.Background(), task.ID) if err != nil { t.Errorf("CompleteTask() error = %v", err) } // Проверяем что задача выполнена updated, _ := svc.GetTask(context.Background(), task.ID) if !updated.Completed { t.Error("Task should be completed") } // Пытаемся отметить снова err = svc.CompleteTask(context.Background(), task.ID) if err == nil { t.Error("CompleteTask() should return error for already completed task") } }
Запустите тесты:
bashgo test -v -cover ./internal/service/
Неделя 3: HTTP API
День 1-3: HTTP хэндлеры
Создайте хэндлеры:
go// internal/handlers/http.go package handlers import ( "encoding/json" "net/http" "strconv" "task-manager/internal/service" ) type TaskHandler struct { service *service.TaskService } func NewTaskHandler(service *service.TaskService) *TaskHandler { return &TaskHandler{service: service} } type CreateTaskRequest struct { Title string `json:"title"` Description string `json:"description"` } type TaskResponse struct { ID int64 `json:"id"` Title string `json:"title"` Description string `json:"description"` Completed bool `json:"completed"` } func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) { var req CreateTaskRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } task, err := h.service.CreateTask(r.Context(), req.Title, req.Description) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(TaskResponse{ ID: task.ID, Title: task.Title, Description: task.Description, Completed: task.Completed, }) } func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) { tasks, err := h.service.ListTasks(r.Context()) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } response := make([]TaskResponse, len(tasks)) for i, task := range tasks { response[i] = TaskResponse{ ID: task.ID, Title: task.Title, Description: task.Description, Completed: task.Completed, } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } func (h *TaskHandler) CompleteTask(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid ID", http.StatusBadRequest) return } if err := h.service.CompleteTask(r.Context(), id); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.WriteHeader(http.StatusOK) } func (h *TaskHandler) DeleteTask(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { http.Error(w, "Invalid ID", http.StatusBadRequest) return } if err := h.service.DeleteTask(r.Context(), id); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) }
День 4-5: HTTP сервер
Создайте сервер:
go// cmd/api/main.go package main import ( "database/sql" "fmt" "log" "net/http" "task-manager/internal/handlers" "task-manager/internal/repository" "task-manager/internal/service" _ "github.com/lib/pq" ) func main() { // Подключение к БД db, err := sql.Open("postgres", "postgres://user:pass@localhost/taskdb?sslmode=disable") if err != nil { log.Fatal(err) } defer db.Close() repo := repository.NewPostgresTaskRepository(db) svc := service.NewTaskService(repo) handler := handlers.NewTaskHandler(svc) mux := http.NewServeMux() mux.HandleFunc("POST /tasks", handler.CreateTask) mux.HandleFunc("GET /tasks", handler.ListTasks) mux.HandleFunc("PUT /tasks/{id}/complete", handler.CompleteTask) mux.HandleFunc("DELETE /tasks/{id}", handler.DeleteTask) fmt.Println("Server starting on :8080") log.Fatal(http.ListenAndServe(":8080", mux)) }
День 6-7: Миграции
Создайте миграцию:
sql// migrations/001_create_tasks.sql CREATE TABLE IF NOT EXISTS tasks ( id SERIAL PRIMARY KEY, title VARCHAR(255) NOT NULL, description TEXT, completed BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP NOT NULL DEFAULT NOW(), completed_at TIMESTAMP );
Примените миграцию:
textpsql -U user -d taskdb -f migrations/001_create_tasks.sql
Неделя 4: Финализация
День 1-3: Доработка и тестирование
- Добавьте недостающие методы
- Напишите больше тестов
- Проверьте покрытие кода
- Исправьте баги
День 4-5: Документация
Создайте README.md:
text# Task Manager Простой сервис для управления задачами. ## Установка 1. Установите Go 1.22+ 2. Установите PostgreSQL 3. Создайте базу данных: ```bash createdb taskdb
- Примените миграции:
bash
psql -U user -d taskdb -f migrations/001_create_tasks.sql
Использование
Консольное приложение
Добавить задачу:
bashgo run ./cmd/cli add "Купить молоко" "2 литра"
Список задач:
bashgo run ./cmd/cli list
HTTP API
Запустить сервер:
bashgo run ./cmd/api
Создать задачу:
bashcurl -X POST http://localhost:8080/tasks \ -H "Content-Type: application/json" \ -d '{"title":"Купить хлеб","description":"Бородинский"}'
Список задач:
bashcurl http://localhost:8080/tasks
Импорт из CSV
bashgo run ./cmd/importer tasks.csv 5
Тестирование
bashgo test -v -cover ./...
#### День 6-7: Рефакторинг и проверка
- Проведите рефакторинг кода
- Убедитесь что код отформатирован
- Проверьте что проект запускается
- Подготовьте проект к сдаче
---
## Quiz "Проверь себя"
````quiz
Q: Что входит в обязательные компоненты практического проекта?
- консольное приложение*
- параллельный импорт из CSV*
- HTTP API*
- GraphQL-шлюз
Q: Какие бинарники собираются из структуры проекта task-manager?
- cmd/cli — консольное приложение*
- cmd/api — HTTP-сервер*
- cmd/importer — импорт из CSV*
- cmd/migrator — веб-интерфейс задач
Q: Что выведет этот код после запуска команды `task-manager list`?
```go
task := models.NewTask("Buy milk", "2 liters")
fmt.Printf("%s %d: %s\n", "[ ]", task.ID, task.Title)
- 0: Buy milk (ID присвоит БД после сохранения)*
- 1: Buy milk
- panic: ID не заполнен
- пустую строку Q: Где находится интерфейс репозитория и где его реализация для PostgreSQL?
- интерфейс в internal/repository/interface.go, реализация в internal/repository/postgres.go*
- оба в internal/service/
- интерфейс в cmd/api/, реализация в migrations/
- интерфейс в internal/models/, реализация в internal/handlers/ Q: Зачем сервис TaskService принимает интерфейс TaskRepository, а не конкретную структуру?
- бизнес-логика не зависит от БД: можно подставить мок в тестах*
- так быстрее работают SQL-запросы
- интерфейс кэширует результаты запросов
- иначе нельзя вызвать методы репозитория Q: Как CSVImporter ограничивает количество параллельных воркеров?
- число воркеров задаётся при создании и ограничивает горутины, читающие канал задач*
- все строки обрабатываются в одной горутине
- по одному воркеру на каждый файл CSV
- ограничение задаётся переменной окружения ОС Q: Каким методом HTTP API создаётся новая задача и какой статус возвращается?
- POST /tasks — 201 Created*
- PUT /tasks/{id} — 200 OK
- POST /tasks — 204 No Content
- GET /tasks/create — 200 OK Q: Что делает CompleteTask при попытке выполнить уже выполненную задачу?
- возвращает ошибку "task already completed"*
- молча обновляет дату выполнения
- удаляет задачу
- создаёт копию задачи Q: Какой минимальный порог покрытия тестами требует проект?
- больше 60%*
- больше 20%
- ровно 100%
- покрытие не проверяется
---
## Критерии приёмки
### Обязательные
- [ ] Реализовано консольное приложение с 4 командами
- [ ] Реализован параллельный импортер из CSV
- [ ] Реализован HTTP API с 4 эндпоинтами
- [ ] Подключена PostgreSQL с миграциями
- [ ] Написаны тесты с покрытием > 60%
- [ ] Код отформатирован через `gofmt`
- [ ] Код проходит `go vet`
- [ ] Проект запускается и работает
- [ ] Написана документация в README.md
### Желательные
- [ ] Покрытие тестов > 80%
- [ ] Добавлено логирование
- [ ] Добавлена валидация входных данных
- [ ] Обработка ошибок улучшена
- [ ] Добавлены комментарии к коду
---
## Проверка проекта
### 1. Консольное приложение
```bash
# Добавить задачу
go run ./cmd/cli add "Test task" "Test description"
# Список задач
go run ./cmd/cli list
# Отметить как выполненную
go run ./cmd/cli complete 1
# Удалить задачу
go run ./cmd/cli delete 1
```
### 2. HTTP API
```bash
# Запустить сервер
go run ./cmd/api
# В другом терминале:
# Создать задачу
curl -X POST http://localhost:8080/tasks \
-H "Content-Type: application/json" \
-d '{"title":"API task","description":"Created via API"}'
# Список задач
curl http://localhost:8080/tasks
# Отметить как выполненную
curl -X PUT http://localhost:8080/tasks/1/complete
# Удалить задачу
curl -X DELETE http://localhost:8080/tasks/1
```
### 3. Импорт из CSV
Создайте файл `tasks.csv`:
```text
title,description
Task 1,Description 1
Task 2,Description 2
Task 3,Description 3
```
Импортируйте:
```bash
go run ./cmd/importer tasks.csv 3
```
### 4. Тесты
```bash
go test -v -cover ./...
```
---
## 🔗 Мостик к Курсу 2
После успешного завершения проекта вы готовы к Курсу 2:
Go Backend Developer, где вы построите полноценный сервис
с архитектурой, наблюдаемостью и безопасностью.
Удачи! 🚀