Урок 15: MongoDB — CRUD, индексы, агрегации

Урок 15. MongoDB — CRUD, индексы, агрегации

🔄 Node.js → Go (ключевые аналоги):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-mongodb && cd go-mongodb
go mod init go-mongodb

# Устанавливаем драйвер MongoDB
go get go.mongodb.org/mongo-driver/mongo
go get go.mongodb.org/mongo-driver/bson
go get github.com/google/uuid
go mod tidy

# Запускаем MongoDB
docker run -d --name mongo-lesson15 \
  -e MONGO_INITDB_ROOT_USERNAME=admin \
  -e MONGO_INITDB_ROOT_PASSWORD=secret \
  -p 27017:27017 \
  mongo:7

💻 Код программы

package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "time"

    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/bson/primitive"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
    "go.mongodb.org/mongo-driver/mongo/readpref"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  1. МОДЕЛИ ДАННЫХ (теги bson!)                        ║
// ╚══════════════════════════════════════════════════════════╝

// Product — товар в каталоге.
// Теги bson работают как json-теги, но для MongoDB.
// omitempty — не сохранять поле, если оно пустое.
type Product struct {
    // primitive.ObjectID — стандартный тип для _id в MongoDB
    ID          primitive.ObjectID `bson:"_id,omitempty" json:"id"`
    Name        string             `bson:"name" json:"name"`
    Description string             `bson:"description,omitempty" json:"description,omitempty"`
    Price       float64            `bson:"price" json:"price"` // В реальности — int64 (центы)!
    Category    string             `bson:"category" json:"category"`
    Tags        []string           `bson:"tags,omitempty" json:"tags,omitempty"`
    InStock     bool               `bson:"in_stock" json:"in_stock"`
    StockCount  int                `bson:"stock_count" json:"stock_count"`
    Rating      *float64           `bson:"rating,omitempty" json:"rating,omitempty"` // Указатель для NULL
    CreatedAt   time.Time          `bson:"created_at" json:"created_at"`
    UpdatedAt   time.Time          `bson:"updated_at" json:"updated_at"`
}

// Order — заказ (с вложенным документом)
type Order struct {
    ID         primitive.ObjectID `bson:"_id,omitempty" json:"id"`
    CustomerID string             `bson:"customer_id" json:"customer_id"`
    Items      []OrderItem        `bson:"items" json:"items"` // Вложенный массив документов
    TotalPrice float64            `bson:"total_price" json:"total_price"`
    Status     string             `bson:"status" json:"status"`
    CreatedAt  time.Time          `bson:"created_at" json:"created_at"`
}

// OrderItem — элемент заказа (вложенный документ)
type OrderItem struct {
    ProductID primitive.ObjectID `bson:"product_id" json:"product_id"`
    Name      string             `bson:"name" json:"name"`
    Quantity  int                `bson:"quantity" json:"quantity"`
    Price     float64            `bson:"price" json:"price"`
}

// ProductRepository — репозиторий для работы с товарами
type ProductRepository struct {
    col *mongo.Collection
}

// OrderRepository — репозиторий для работы с заказами
type OrderRepository struct {
    col *mongo.Collection
}

// ╔══════════════════════════════════════════════════════════╗
// ║  2. ПОДКЛЮЧЕНИЕ К MONGODB                              ║
// ╚══════════════════════════════════════════════════════════╝

func connectMongo(ctx context.Context, uri, dbName string) (*mongo.Database, error) {
    // Настройка клиента
    clientOpts := options.Client().
        ApplyURI(uri).
        SetMaxPoolSize(100).          // Максимальный размер пула соединений
        SetMinPoolSize(10).           // Минимальный размер
        SetMaxConnIdleTime(5 * time.Minute).
        SetConnectTimeout(10 * time.Second)

    // Подключение
    client, err := mongo.Connect(ctx, clientOpts)
    if err != nil {
        return nil, fmt.Errorf("mongo connect: %w", err)
    }

    // Проверка соединения (ping)
    if err := client.Ping(ctx, readpref.Primary()); err != nil {
        return nil, fmt.Errorf("mongo ping: %w", err)
    }

    log.Println("✅ Подключено к MongoDB")
    return client.Database(dbName), nil
}

// ╔══════════════════════════════════════════════════════════╗
// ║  3. СОЗДАНИЕ ИНДЕКСОВ                                  ║
// ╚══════════════════════════════════════════════════════════╝

func createIndexes(ctx context.Context, db *mongo.Database) error {
    productCol := db.Collection("products")
    orderCol := db.Collection("orders")

    // Индексы для продуктов
    productIndexes := []mongo.IndexModel{
        {
            // Обычный индекс по категории
            Keys: bson.D{{Key: "category", Value: 1}},
        },
        {
            // Составной индекс: категория + цена (для сортировки)
            Keys: bson.D{
                {Key: "category", Value: 1},
                {Key: "price", Value: -1}, // -1 = по убыванию
            },
        },
        {
            // Уникальный индекс по имени (не может быть двух товаров с одинаковым именем)
            Keys:    bson.D{{Key: "name", Value: 1}},
            Options: options.Index().SetUnique(true),
        },
        {
            // Текстовый индекс для полнотекстового поиска
            Keys: bson.D{
                {Key: "name", Value: "text"},
                {Key: "description", Value: "text"},
            },
            Options: options.Index().SetName("text_search_idx"),
        },
        {
            // TTL-индекс: автоматически удаляет документы через 90 дней
            Keys:    bson.D{{Key: "created_at", Value: 1}},
            Options: options.Index().SetExpireAfterSeconds(90 * 24 * 3600),
        },
    }

    if _, err := productCol.Indexes().CreateMany(ctx, productIndexes); err != nil {
        return fmt.Errorf("create product indexes: %w", err)
    }

    // Индексы для заказов
    orderIndexes := []mongo.IndexModel{
        {
            Keys: bson.D{{Key: "customer_id", Value: 1}},
        },
        {
            Keys: bson.D{{Key: "status", Value: 1}},
        },
        {
            Keys:    bson.D{{Key: "items.product_id", Value: 1}},
            Options: options.Index().SetName("items_product_idx"),
        },
    }

    if _, err := orderCol.Indexes().CreateMany(ctx, orderIndexes); err != nil {
        return fmt.Errorf("create order indexes: %w", err)
    }

    log.Println("  ✓ Индексы созданы")
    return nil
}

// ╔══════════════════════════════════════════════════════════╗
// ║  4. CRUD ОПЕРАЦИИ                                      ║
// ╚══════════════════════════════════════════════════════════╝

// NewProductRepository — конструктор
func NewProductRepository(db *mongo.Database) *ProductRepository {
    return &ProductRepository{col: db.Collection("products")}
}

// Create — создаёт товар
func (r *ProductRepository) Create(ctx context.Context, product *Product) error {
    product.ID = primitive.NewObjectID()
    product.CreatedAt = time.Now()
    product.UpdatedAt = time.Now()

    result, err := r.col.InsertOne(ctx, product)
    if err != nil {
        return fmt.Errorf("insert product: %w", err)
    }

    product.ID = result.InsertedID.(primitive.ObjectID)
    return nil
}

// GetByID — находит товар по ID
func (r *ProductRepository) GetByID(ctx context.Context, id primitive.ObjectID) (*Product, error) {
    var product Product
    err := r.col.FindOne(ctx, bson.M{"_id": id}).Decode(&product)
    if err != nil {
        if errors.Is(err, mongo.ErrNoDocuments) {
            return nil, fmt.Errorf("product not found")
        }
        return nil, fmt.Errorf("find product: %w", err)
    }
    return &product, nil
}

// List — список товаров с фильтрацией и пагинацией
func (r *ProductRepository) List(ctx context.Context, filter bson.M, limit, offset int64) ([]Product, error) {
    opts := options.Find().
        SetLimit(limit).
        SetSkip(offset).
        SetSort(bson.D{{Key: "created_at", Value: -1}}) // Сначала новые

    cursor, err := r.col.Find(ctx, filter, opts)
    if err != nil {
        return nil, fmt.Errorf("find products: %w", err)
    }
    defer cursor.Close(ctx)

    // cursor.All — декодирует все документы в слайс
    var products []Product
    if err := cursor.All(ctx, &products); err != nil {
        return nil, fmt.Errorf("decode products: %w", err)
    }

    if products == nil {
        products = []Product{}
    }
    return products, nil
}

// Update — обновляет товар
func (r *ProductRepository) Update(ctx context.Context, id primitive.ObjectID, update bson.M) error {
    update["updated_at"] = time.Now()

    result, err := r.col.UpdateOne(
        ctx,
        bson.M{"_id": id},
        bson.M{"$set": update},
    )
    if err != nil {
        return fmt.Errorf("update product: %w", err)
    }
    if result.MatchedCount == 0 {
        return fmt.Errorf("product not found")
    }
    return nil
}

// Delete — удаляет товар
func (r *ProductRepository) Delete(ctx context.Context, id primitive.ObjectID) error {
    result, err := r.col.DeleteOne(ctx, bson.M{"_id": id})
    if err != nil {
        return fmt.Errorf("delete product: %w", err)
    }
    if result.DeletedCount == 0 {
        return fmt.Errorf("product not found")
    }
    return nil
}

// Search — текстовый поиск
func (r *ProductRepository) Search(ctx context.Context, query string) ([]Product, error) {
    filter := bson.M{
        "$text": bson.M{"$search": query},
    }
    // Сортировка по релевантности (текстовый счёт)
    opts := options.Find().SetSort(bson.D{{Key: "score", Value: bson.M{"$meta": "textScore"}}})

    cursor, err := r.col.Find(ctx, filter, opts)
    if err != nil {
        return nil, fmt.Errorf("search products: %w", err)
    }
    defer cursor.Close(ctx)

    var products []Product
    if err := cursor.All(ctx, &products); err != nil {
        return nil, fmt.Errorf("decode search: %w", err)
    }
    return products, nil
}

// ╔══════════════════════════════════════════════════════════╗
// ║  5. АГРЕГАЦИИ                                          ║
// ╚══════════════════════════════════════════════════════════╝

// AggregationResult — результат агрегации
type AggregationResult struct {
    Category    string  `bson:"_id" json:"category"`
    Count       int     `bson:"count" json:"count"`
    AvgPrice    float64 `bson:"avg_price" json:"avg_price"`
    TotalStock  int     `bson:"total_stock" json:"total_stock"`
}

// AggregateByCategory — агрегация: группировка по категориям
func (r *ProductRepository) AggregateByCategory(ctx context.Context) ([]AggregationResult, error) {
    pipeline := mongo.Pipeline{
        // Стадия 1: только товары в наличии
        {{Key: "$match", Value: bson.M{"in_stock": true}}},
        // Стадия 2: группировка
        {{Key: "$group", Value: bson.M{
            "_id":         "$category",
            "count":       bson.M{"$sum": 1},
            "avg_price":   bson.M{"$avg": "$price"},
            "total_stock": bson.M{"$sum": "$stock_count"},
        }}},
        // Стадия 3: сортировка по количеству
        {{Key: "$sort", Value: bson.M{"count": -1}}},
    }

    cursor, err := r.col.Aggregate(ctx, pipeline)
    if err != nil {
        return nil, fmt.Errorf("aggregate: %w", err)
    }
    defer cursor.Close(ctx)

    var results []AggregationResult
    if err := cursor.All(ctx, &results); err != nil {
        return nil, fmt.Errorf("decode aggregate: %w", err)
    }
    return results, nil
}

// NewOrderRepository — конструктор
func NewOrderRepository(db *mongo.Database) *OrderRepository {
    return &OrderRepository{col: db.Collection("orders")}
}

// CreateOrder — создаёт заказ
func (r *OrderRepository) CreateOrder(ctx context.Context, order *Order) error {
    order.ID = primitive.NewObjectID()
    order.CreatedAt = time.Now()
    order.Status = "pending"

    _, err := r.col.InsertOne(ctx, order)
    return err
}

// GetOrdersWithProducts — агрегация с $lookup (заказы + товары)
func (r *OrderRepository) GetOrdersWithProducts(ctx context.Context, customerID string) ([]bson.M, error) {
    pipeline := mongo.Pipeline{
        // Фильтр по клиенту
        {{Key: "$match", Value: bson.M{"customer_id": customerID}}},
        // Разворачиваем массив items
        {{Key: "$unwind", Value: "$items"}},
        // JOIN с коллекцией products
        {{Key: "$lookup", Value: bson.M{
            "from":         "products",
            "localField":   "items.product_id",
            "foreignField": "_id",
            "as":           "product_details",
        }}},
        // Разворачиваем product_details
        {{Key: "$unwind", Value: bson.M{
            "path":                       "$product_details",
            "preserveNullAndEmptyArrays": true,
        }}},
        // Группируем обратно в заказы
        {{Key: "$group", Value: bson.M{
            "_id":          "$_id",
            "customer_id":  bson.M{"$first": "$customer_id"},
            "status":       bson.M{"$first": "$status"},
            "total_price":  bson.M{"$first": "$total_price"},
            "created_at":   bson.M{"$first": "$created_at"},
            "items":        bson.M{"$push": bson.M{
                "name":     "$items.name",
                "quantity": "$items.quantity",
                "price":    "$items.price",
                "product":  "$product_details",
            }},
        }}},
        {{Key: "$sort", Value: bson.M{"created_at": -1}}},
    }

    cursor, err := r.col.Aggregate(ctx, pipeline)
    if err != nil {
        return nil, fmt.Errorf("aggregate orders: %w", err)
    }
    defer cursor.Close(ctx)

    var results []bson.M
    if err := cursor.All(ctx, &results); err != nil {
        return nil, fmt.Errorf("decode orders: %w", err)
    }
    return results, nil
}

// ╔══════════════════════════════════════════════════════════╗
// ║  MAIN                                                    ║
// ╚══════════════════════════════════════════════════════════╝

func main() {
    log.SetFlags(log.LstdFlags | log.Lmicroseconds)
    log.Println("🍃 MongoDB CRUD + индексы + агрегации")

    ctx := context.Background()

    // Подключение
    mongoURI := "mongodb://admin:secret@localhost:27017"
    db, err := connectMongo(ctx, mongoURI, "shopdb")
    if err != nil {
        log.Fatalf("Подключение: %v", err)
    }

    // Создание индексов
    if err := createIndexes(ctx, db); err != nil {
        log.Fatalf("Индексы: %v", err)
    }

    productRepo := NewProductRepository(db)
    orderRepo := NewOrderRepository(db)

    // ==========================================
    // 1. CREATE — добавляем товары
    // ==========================================
    log.Println("\n── CREATE ──")
    products := []Product{
        {Name: "MacBook Pro", Description: "Ноутбук Apple", Price: 1999.99, Category: "electronics", Tags: []string{"apple", "laptop"}, InStock: true, StockCount: 15},
        {Name: "iPhone 15", Description: "Смартфон Apple", Price: 999.99, Category: "electronics", Tags: []string{"apple", "phone"}, InStock: true, StockCount: 25},
        {Name: "AirPods Pro", Description: "Наушники", Price: 249.99, Category: "electronics", Tags: []string{"apple", "audio"}, InStock: true, StockCount: 50},
        {Name: "Книга «Go для профи»", Description: "Учебник", Price: 49.99, Category: "books", Tags: []string{"go", "programming"}, InStock: true, StockCount: 100},
        {Name: "Кофемашина", Description: "Для офиса", Price: 599.99, Category: "appliances", Tags: []string{"coffee", "office"}, InStock: false, StockCount: 0},
    }

    for _, p := range products {
        p := p // Копия для безопасной передачи указателя
        if err := productRepo.Create(ctx, &p); err != nil {
            log.Printf("  Ошибка создания %s: %v", p.Name, err)
        } else {
            log.Printf("  ✓ Создан: %s ($%.2f) [%s]", p.Name, p.Price, p.ID.Hex())
        }
    }

    // ==========================================
    // 2. READ — поиск и фильтрация
    // ==========================================
    log.Println("\n── FIND ──")

    // Все товары в категории electronics
    electronics, err := productRepo.List(ctx, bson.M{"category": "electronics"}, 10, 0)
    if err != nil {
        log.Printf("  Ошибка: %v", err)
    } else {
        log.Printf("  Товары в electronics (%d):", len(electronics))
        for _, p := range electronics {
            log.Printf("    - %s: $%.2f (в наличии: %d)", p.Name, p.Price, p.StockCount)
        }
    }

    // Товары дороже $500
    expensive, _ := productRepo.List(ctx, bson.M{"price": bson.M{"$gte": 500}}, 10, 0)
    log.Printf("  Дорогие товары (≥$500): %d", len(expensive))

    // Текстовый поиск
    results, _ := productRepo.Search(ctx, "apple")
    log.Printf("  Поиск 'apple': %d результатов", len(results))

    // ==========================================
    // 3. UPDATE
    // ==========================================
    log.Println("\n── UPDATE ──")
    if len(electronics) > 0 {
        err := productRepo.Update(ctx, electronics[0].ID, bson.M{
            "price":      1899.99,
            "stock_count": 10,
        })
        if err != nil {
            log.Printf("  Ошибка обновления: %v", err)
        } else {
            log.Printf("  ✓ Обновлён: %s", electronics[0].Name)
        }
    }

    // ==========================================
    // 4. АГРЕГАЦИИ
    // ==========================================
    log.Println("\n── АГРЕГАЦИИ ──")
    aggResults, err := productRepo.AggregateByCategory(ctx)
    if err != nil {
        log.Printf("  Ошибка агрегации: %v", err)
    } else {
        log.Println("  Группировка по категориям:")
        for _, r := range aggResults {
            log.Printf("    %s: %d товаров, средняя цена $%.2f, всего на складе %d",
                r.Category, r.Count, r.AvgPrice, r.TotalStock)
        }
    }

    // ==========================================
    // 5. СОЗДАНИЕ ЗАКАЗА
    // ==========================================
    log.Println("\n── ЗАКАЗЫ ──")
    if len(electronics) >= 2 {
        order := &Order{
            CustomerID: "user-42",
            Items: []OrderItem{
                {ProductID: electronics[0].ID, Name: electronics[0].Name, Quantity: 1, Price: electronics[0].Price},
                {ProductID: electronics[1].ID, Name: electronics[1].Name, Quantity: 2, Price: electronics[1].Price},
            },
            TotalPrice: electronics[0].Price + 2*electronics[1].Price,
        }
        if err := orderRepo.CreateOrder(ctx, order); err != nil {
            log.Printf("  Ошибка создания заказа: %v", err)
        } else {
            log.Printf("  ✓ Заказ создан: %s (сумма $%.2f)", order.ID.Hex(), order.TotalPrice)
        }
    }

    log.Println("\n✅ Демонстрация завершена!")
}

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

# Запускаем MongoDB
docker run -d --name mongo-lesson15 \
  -e MONGO_INITDB_ROOT_USERNAME=admin \
  -e MONGO_INITDB_ROOT_PASSWORD=secret \
  -p 27017:27017 \
  mongo:7

# Запускаем программу
go run main.go

# Подключаемся к MongoDB для проверки
docker exec -it mongo-lesson15 mongosh -u admin -p secret

# В mongosh:
use shopdb
db.products.find().pretty()
db.products.getIndexes()
db.orders.find().pretty()

📊 bson-типы: D, M, A

ТипСинтаксисКогда использовать
bson.Dbson.D{{Key: "name", Value: "Alice"}}Когда важен порядок полей (индексы, агрегации)
bson.Mbson.M{"name": "Alice"}Фильтры, обновления (удобнее как map)
bson.Abson.A{"apple", "banana"}Массивы (в агрегациях)

🚀 Запуск программы

go run main.go

# Сборка
go build -o mongo-demo main.go
./mongo-demo
⚠️ Типичные ошибки:
💡 Практический совет:

💡 Best practices от сеньоров:

🔑 Ключевые концепции

Что нужно запомнить из этого урока:

💡 Для Node.js разработчика:

← Предыдущий урок Следующий урок →