При розробці на Go часто виникає потреба швидко тестувати зміни без перезбірки Docker контейнера. У цій статті розглянемо як налаштувати гаряче перезавантаження (hot reload) за допомогою Air та додати систему міграцій для PostgreSQL.
Що ми будемо робити
- Налаштуємо окреме dev-середовище з Air для автоматичного перезапуску
- Додамо систему міграцій бази даних
- Налаштуємо збереження результатів парсингу в PostgreSQL
- Організуємо структуру проєкту для зручної розробки
Крок 1: Створення Dockerfile.dev
Спочатку створимо окремий Dockerfile для розробки:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
FROM golang:1.25-alpine RUN apk add --no-cache git ca-certificates tzdata bash WORKDIR /app # Встановлюємо Air для hot reload RUN go install github.com/air-verse/air@latest COPY go.mod go.sum* ./ RUN go mod download CMD ["air", "-c", ".air.toml"] |
Крок 2: Конфігурація Air
Створюємо файл .air.toml для налаштування hot reload:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
root = "." tmp_dir = "tmp" [build] bin = "./tmp/content-parser" cmd = "go build -buildvcs=false -o ./tmp/content-parser ./cmd/server" delay = 1000 exclude_dir = ["assets", "tmp", "vendor", "testdata", "storage", "logs", ".git"] exclude_regex = ["_test.go"] include_ext = ["go", "tpl", "tmpl", "html"] kill_delay = "500ms" stop_on_error = true [color] build = "yellow" main = "magenta" runner = "green" watcher = "cyan" [log] main_only = false time = false [screen] clear_on_rebuild = false keep_scroll = true |
Важливі параметри:
cmd– команда збірки з-buildvcs=falseдля уникнення помилок Git в Dockerexclude_dir– директорії які не треба відслідковуватиstop_on_error = true– зупиняти при помилках компіляції
Крок 3: Docker Compose з профілями
Додаємо dev-сервіс в docker-compose.yml:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
services: content-parser-dev: profiles: ["dev"] build: context: . dockerfile: Dockerfile.dev ports: - "8081:8080" volumes: - .:/app - ./storage/parsed-media:/root/media - ./logs:/root/logs working_dir: /app command: sh -c "go mod download && go mod tidy && air -c .air.toml" environment: - PORT=8080 - GO_ENV=development - DATABASE_URL=postgresql://ai_admin:ai_password_123@ai-database:5432/ai_gateway_db?sslmode=disable networks: - parser-network depends_on: ai-database: condition: service_healthy |
Використання профілів дозволяє запускати:
|
1 2 3 4 5 6 |
# Production docker compose up # Development docker compose --profile dev up |
Крок 4: Система міграцій
Створення пакету database
internal/database/database.go:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
package database import ( "database/sql" "fmt" "log" "os" "strings" _ "github.com/lib/pq" ) var DB *sql.DB func Connect() error { databaseURL := os.Getenv("DATABASE_URL") if databaseURL == "" { return fmt.Errorf("DATABASE_URL не встановлено") } // Додаємо sslmode=disable для локальної розробки if !strings.Contains(databaseURL, "sslmode=") { if strings.Contains(databaseURL, "?") { databaseURL += "&sslmode=disable" } else { databaseURL += "?sslmode=disable" } } var err error DB, err = sql.Open("postgres", databaseURL) if err != nil { return fmt.Errorf("помилка підключення до БД: %w", err) } DB.SetMaxOpenConns(25) DB.SetMaxIdleConns(5) if err = DB.Ping(); err != nil { return fmt.Errorf("помилка ping БД: %w", err) } log.Println("✅ Успішно підключено до бази даних") return nil } func Close() error { if DB != nil { return DB.Close() } return nil } |
Система міграцій
internal/database/migrate.go:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 |
package database import ( "database/sql" "fmt" "log" "os" "path/filepath" "sort" "strings" ) func RunMigrations(db *sql.DB, migrationsPath string) error { if err := createMigrationsTable(db); err != nil { return fmt.Errorf("помилка створення таблиці міграцій: %w", err) } files, err := getMigrationFiles(migrationsPath) if err != nil { return fmt.Errorf("помилка читання міграцій: %w", err) } if len(files) == 0 { log.Println("📦 Міграції не знайдено") return nil } for _, file := range files { if err := runMigration(db, file); err != nil { return fmt.Errorf("помилка виконання міграції %s: %w", filepath.Base(file), err) } } log.Printf("✅ Виконано %d міграцій успішно\n", len(files)) return nil } func createMigrationsTable(db *sql.DB) error { query := ` CREATE TABLE IF NOT EXISTS schema_migrations ( id SERIAL PRIMARY KEY, version VARCHAR(255) UNIQUE NOT NULL, applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ` _, err := db.Exec(query) return err } func getMigrationFiles(migrationsPath string) ([]string, error) { var files []string err := filepath.WalkDir(migrationsPath, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } // Читаємо тільки .up.sql файли if !d.IsDir() && strings.HasSuffix(d.Name(), ".up.sql") { files = append(files, path) } return nil }) if err != nil { return nil, err } sort.Strings(files) return files, nil } func runMigration(db *sql.DB, filePath string) error { version := filepath.Base(filePath) var exists bool err := db.QueryRow( "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version, ).Scan(&exists) if err != nil { return err } if exists { log.Printf("⏭️ Міграція %s вже виконана, пропускаємо\n", version) return nil } content, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("помилка читання файлу: %w", err) } tx, err := db.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(string(content)); err != nil { return fmt.Errorf("помилка виконання SQL: %w", err) } if _, err := tx.Exec( "INSERT INTO schema_migrations (version) VALUES ($1)", version, ); err != nil { return err } if err := tx.Commit(); err != nil { return err } log.Printf("✅ Міграція %s виконана успішно\n", version) return nil } |
Крок 5: Створення першої міграції
migrations/000001_create_parse_results.up.sql:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
CREATE TABLE IF NOT EXISTS parse_results ( id SERIAL PRIMARY KEY, job_id VARCHAR(255) UNIQUE NOT NULL, project_id BIGINT NOT NULL, user_id BIGINT NOT NULL, article_id BIGINT, url TEXT NOT NULL, title TEXT, author VARCHAR(500), extracted_text TEXT, published_date TIMESTAMP, key_points JSONB, images JSONB, metadata JSONB, status VARCHAR(50) DEFAULT 'completed', processing_time_ms INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_parse_results_job_id ON parse_results(job_id); CREATE INDEX idx_parse_results_project_id ON parse_results(project_id); CREATE INDEX idx_parse_results_url ON parse_results(url); CREATE INDEX idx_parse_results_status ON parse_results(status); |
Крок 6: Інтеграція в main.go
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
package main import ( "content-parser/config" "content-parser/internal/api" "content-parser/internal/api/handlers" "content-parser/internal/database" "fmt" "log" "github.com/gin-gonic/gin" "github.com/joho/godotenv" ) func main() { if err := godotenv.Load(); err != nil { log.Println("No .env file found, using environment variables") } cfg := config.Load() // Підключення до БД if err := database.Connect(); err != nil { log.Fatalf("❌ Помилка підключення до БД: %v", err) } defer database.Close() // Міграції migrationsPath := "./migrations" if err := database.RunMigrations(database.DB, migrationsPath); err != nil { log.Fatalf("❌ Помилка виконання міграцій: %v", err) } // Ініціалізація storage if err := handlers.InitStorage(cfg); err != nil { log.Fatalf("Failed to init storage: %v", err) } if cfg.Server.Environment == "production" { gin.SetMode(gin.ReleaseMode) } router := gin.Default() router.GET("/api/v1/health", func(c *gin.Context) { c.JSON(200, gin.H{ "status": "ok", "service": "content-parser", "version": "1.0.0", "environment": cfg.Server.Environment, }) }) api.SetupRoutes(router, cfg) addr := fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port) log.Printf("🚀 Content Parser starting on %s", addr) if err := router.Run(addr); err != nil { log.Fatalf("Failed to start server: %v", err) } } |
Крок 7: Storage для роботи з БД
internal/storage/postgres.go:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
package storage import ( "content-parser/pkg/models" "database/sql" "encoding/json" "fmt" ) type Storage struct { db *sql.DB } func NewStorageWithDB(db *sql.DB) (*Storage, error) { if db == nil { return nil, fmt.Errorf("database connection is nil") } return &Storage{db: db}, nil } func (s *Storage) SaveParseResult(req models.ParseRequest, result *models.ParseResult) error { keyPoints, _ := json.Marshal(result.KeyPoints) images, _ := json.Marshal(result.Images) metadata, _ := json.Marshal(result.Metadata) query := ` INSERT INTO parse_results (job_id, project_id, user_id, article_id, url, title, author, extracted_text, published_date, key_points, images, metadata, status, processing_time_ms) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ON CONFLICT (job_id) DO NOTHING RETURNING id ` var id int64 err := s.db.QueryRow(query, result.JobID, req.ProjectID, req.UserID, req.ArticleID, result.URL, result.Title, result.Author, result.ExtractedText, result.PublishedDate, keyPoints, images, metadata, result.Status, result.ProcessingTime, ).Scan(&id) if err == sql.ErrNoRows { return nil } return err } func (s *Storage) GetParseResultByJobID(jobID string) (*models.ParseResult, error) { query := ` SELECT job_id, url, title, author, extracted_text, published_date, key_points, images, metadata, status, processing_time_ms FROM parse_results WHERE job_id = $1 ` var result models.ParseResult var keyPoints, images, metadata []byte err := s.db.QueryRow(query, jobID).Scan( &result.JobID, &result.URL, &result.Title, &result.Author, &result.ExtractedText, &result.PublishedDate, &keyPoints, &images, &metadata, &result.Status, &result.ProcessingTime, ) if err != nil { return nil, err } json.Unmarshal(keyPoints, &result.KeyPoints) json.Unmarshal(images, &result.Images) json.Unmarshal(metadata, &result.Metadata) return &result, nil } |
Тестування
Запуск dev-версії
|
1 2 |
docker compose --profile dev up |
Парсинг URL
|
1 2 3 4 5 6 7 8 9 10 11 12 |
curl -X POST http://localhost:8081/api/v1/parse/url \ -H "Content-Type: application/json" \ -d '{ "url": "https://www.bbc.com/news", "project_id": 1, "user_id": 1, "options": { "extract_images": true, "timeout": 30 } }' |
Перевірка в БД
|
1 2 3 |
docker exec -it ai-database-parser psql -U ai_admin -d ai_gateway_db \ -c "SELECT job_id, url, title, status FROM parse_results ORDER BY created_at DESC LIMIT 3;" |
Очікуваний результат:
|
1 2 3 4 |
job_id | url | title | status --------------------------------------+--------------------------+----------+----------- e3cd4f67-9a1b-422c-972d-37d61434199e | https://www.bbc.com/news | NewsNews | completed |
Типові проблеми та рішення
SSL помилка PostgreSQL
Проблема: pq: SSL is not enabled on the server
Рішення: Додати ?sslmode=disable до DATABASE_URL або обробити це в коді:
|
1 2 3 4 5 6 7 8 |
if !strings.Contains(databaseURL, "sslmode=") { if strings.Contains(databaseURL, "?") { databaseURL += "&sslmode=disable" } else { databaseURL += "?sslmode=disable" } } |
VCS помилка при збірці
Проблема: error obtaining VCS status: exit status 128
Рішення: Додати -buildvcs=false до команди збірки в .air.toml:
|
1 2 |
cmd = "go build -buildvcs=false -o ./tmp/content-parser ./cmd/server" |
Дані не зберігаються
Проблема: Парсинг працює, але записів в БД немає
Рішення: Перевірити:
- Чи ініціалізовано storage: додати логи
- Чи викликається SaveParseResult
- Чи є помилки при збереженні
|
1 2 3 4 5 6 7 8 9 |
log.Printf("DEBUG: globalStorage == nil: %v", globalStorage == nil) if globalStorage != nil { if err := globalStorage.SaveParseResult(req, result); err != nil { log.Printf("❌ Failed to save: %v", err) } else { log.Printf("✅ Saved: %s", result.JobID) } } |
Висновки
Ми налаштували повноцінне dev-середовище з:
- Автоматичним перезапуском при зміні коду
- Системою міграцій бази даних
- Збереженням результатів у PostgreSQL
- Роздільними конфігураціями для dev/prod
Тепер розробка стала набагато швидшою – не потрібно перезбирати Docker контейнер після кожної зміни коду.
Корисні команди
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# Запуск dev docker compose --profile dev up # Логи з підсвіткою docker compose logs content-parser-dev --tail=50 --follow # Перевірка міграцій docker exec -it ai-database-parser psql -U ai_admin -d ai_gateway_db \ -c "SELECT * FROM schema_migrations;" # Структура таблиці docker exec -it ai-database-parser psql -U ai_admin -d ai_gateway_db \ -c "\d+ parse_results" |