О проекте

Цель: закрепить все знания курса в реальном проекте.

Проект: Мини-сервис для управления задачами (Task Manager)

Длительность: 4 недели


Описание проекта

Вы создадите консольное приложение и HTTP API для управления задачами. Проект включает:

  • Консольный интерфейс для работы с задачами
  • Параллельный обработчик файлов
  • HTTP API с эндпоинтами
  • Хранение данных в PostgreSQL

Требования к проекту

Обязательные компоненты

  1. Консольное приложение

    • Добавление задач
    • Просмотр списка задач
    • Отметка задач как выполненных
    • Удаление задач
  2. Параллельный обработчик

    • Импорт задач из CSV файла
    • Параллельная обработка строк
    • Контроль количества воркеров
  3. HTTP API

    • GET /tasks — список задач
    • POST /tasks — создание задачи
    • PUT /tasks/{id}/complete — отметка как выполненной
    • DELETE /tasks/{id} — удаление задачи
  4. База данных

    • PostgreSQL для хранения задач
    • Миграции для создания таблицы
    • Транзакции для операций
  5. Тестирование

    • Юнит-тесты для бизнес-логики
    • Покрытие кода > 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")
    }
}

Запустите тесты:

bash
go 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
);

Примените миграцию:

text
psql -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
  1. Примените миграции:
    bash
    psql -U user -d taskdb -f migrations/001_create_tasks.sql

Использование

Консольное приложение

Добавить задачу:

bash
go run ./cmd/cli add "Купить молоко" "2 литра"

Список задач:

bash
go run ./cmd/cli list

HTTP API

Запустить сервер:

bash
go run ./cmd/api

Создать задачу:

bash
curl -X POST http://localhost:8080/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Купить хлеб","description":"Бородинский"}'

Список задач:

bash
curl http://localhost:8080/tasks

Импорт из CSV

bash
go run ./cmd/importer tasks.csv 5

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

bash
go 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, где вы построите полноценный сервис
с архитектурой, наблюдаемостью и безопасностью.

Удачи! 🚀