Files
PriceForge/csv_export.md
2026-02-08 13:14:12 +03:00

10 KiB
Raw Blame History

CSV Export Pattern (Go + GORM)

Архитектура (3-слойная)

1. Handler Layer (HTTP)

Задачи: Обработка HTTP-запроса, установка заголовков, инициация экспорта

func (h *PricelistHandler) ExportCSV(c *gin.Context) {
    // 1. Валидация параметров
    id, err := strconv.ParseUint(c.Param("id"), 10, 32)

    // 2. Получение метаданных для формирования имени файла
    pl, err := h.service.GetByID(uint(id))

    // 3. Установка HTTP-заголовков для скачивания
    filename := fmt.Sprintf("pricelist_%s.csv", pl.Version)
    c.Header("Content-Type", "text/csv; charset=utf-8")
    c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))

    // 4. UTF-8 BOM для Excel-совместимости
    c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})

    // 5. Настройка CSV writer
    writer := csv.NewWriter(c.Writer)
    writer.Comma = ';'  // Точка с запятой для Excel
    defer writer.Flush()

    // 6. Динамические заголовки (зависят от типа данных)
    isWarehouse := strings.ToLower(pl.Source) == "warehouse"
    var header []string
    if isWarehouse {
        header = []string{"Артикул", "Категория", "Описание", "Доступно", "Partnumbers", "Цена, $", "Настройки"}
    } else {
        header = []string{"Артикул", "Категория", "Описание", "Цена, $", "Настройки"}
    }
    writer.Write(header)

    // 7. Streaming в batches через callback
    err = h.service.StreamItemsForExport(uint(id), 500, func(items []models.PricelistItem) error {
        for _, item := range items {
            row := buildRow(item, isWarehouse)
            if err := writer.Write(row); err != nil {
                return err
            }
        }
        writer.Flush()  // Flush после каждого batch
        return nil
    })
}

2. Service Layer

Задачи: Оркестрация, делегирование в репозиторий

func (s *Service) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
    if s.repo == nil {
        return fmt.Errorf("offline mode: cannot stream pricelist items")
    }
    return s.repo.StreamItemsForExport(pricelistID, batchSize, callback)
}

3. Repository Layer (Критичный)

Задачи: Batch-загрузка из БД, оптимизация запросов, enrichment

func (r *PricelistRepository) StreamItemsForExport(pricelistID uint, batchSize int, callback func(items []models.PricelistItem) error) error {
    if batchSize <= 0 {
        batchSize = 500  // Default batch size
    }

    // Проверка типа pricelist для conditional enrichment
    var pl models.Pricelist
    isWarehouse := false
    if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil {
        isWarehouse = pl.Source == string(models.PricelistSourceWarehouse)
    }

    offset := 0
    for {
        var items []models.PricelistItem

        // ⚡ КЛЮЧЕВОЙ МОМЕНТ: JOIN для избежания N+1 запросов
        err := r.db.Table("qt_pricelist_items AS pi").
            Select("pi.*, COALESCE(l.lot_description, '') AS lot_description, COALESCE(l.lot_category, '') AS category").
            Joins("LEFT JOIN lot AS l ON l.lot_name = pi.lot_name").
            Where("pi.pricelist_id = ?", pricelistID).
            Order("pi.lot_name").
            Offset(offset).
            Limit(batchSize).
            Scan(&items).Error

        if err != nil || len(items) == 0 {
            break
        }

        // Conditional enrichment для warehouse данных
        if isWarehouse {
            r.enrichWarehouseItems(items)  // Добавление qty, partnumbers
        }

        // Вызов callback для обработки batch
        if err := callback(items); err != nil {
            return err
        }

        if len(items) < batchSize {
            break  // Последний batch
        }

        offset += batchSize
    }

    return nil
}

Ключевые паттерны

1. Streaming (не загружать все в память)

// ❌ НЕ ТАК:
var allItems []Item
db.Find(&allItems)  // Может упасть на миллионах записей

// ✅ ТАК:
for offset := 0; ; offset += batchSize {
    var batch []Item
    db.Offset(offset).Limit(batchSize).Find(&batch)
    processBatch(batch)
    if len(batch) < batchSize {
        break
    }
}

2. Callback Pattern для гибкости

// Service не знает о CSV - может использоваться для любого экспорта
func StreamItems(callback func([]Item) error) error

3. JOIN для избежания N+1

// ❌ N+1 problem:
items := getItems()
for _, item := range items {
    description := getLotDescription(item.LotName)  // N запросов
}

// ✅ JOIN:
db.Table("items AS i").
    Select("i.*, COALESCE(l.description, '') AS description").
    Joins("LEFT JOIN lots AS l ON l.name = i.lot_name")

4. UTF-8 BOM для Excel

// Excel на Windows требует BOM для корректного отображения UTF-8
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})

5. Точка с запятой для Excel

writer := csv.NewWriter(c.Writer)
writer.Comma = ';'  // Excel в русской локали использует ;

6. Graceful Error Handling

// После начала streaming нельзя вернуть JSON
if err != nil {
    // Уже начали писать CSV, поэтому пишем текст
    c.String(http.StatusInternalServerError, "Export failed: %v", err)
    return
}

Conditional Enrichment Pattern

// Для warehouse прайслистов добавляем дополнительные поля
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
    // 1. Собрать уникальные lot_names
    lots := make([]string, 0, len(items))
    seen := make(map[string]struct{})
    for _, item := range items {
        if _, ok := seen[item.LotName]; !ok {
            lots = append(lots, item.LotName)
            seen[item.LotName] = struct{}{}
        }
    }

    // 2. Batch-загрузка метрик (qty, partnumbers)
    qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true)

    // 3. Обогащение items
    for i := range items {
        if qty, ok := qtyByLot[items[i].LotName]; ok {
            items[i].AvailableQty = &qty
        }
        items[i].Partnumbers = partnumbersByLot[items[i].LotName]
    }

    return nil
}

Virtual Fields Pattern

type PricelistItem struct {
    // Stored fields
    ID       uint    `gorm:"primaryKey"`
    LotName  string  `gorm:"size:255"`
    Price    float64 `gorm:"type:decimal(12,2)"`

    // Virtual fields (populated via JOIN or programmatically)
    LotDescription string   `gorm:"-:migration" json:"lot_description,omitempty"`
    Category       string   `gorm:"-:migration" json:"category,omitempty"`
    AvailableQty   *float64 `gorm:"-" json:"available_qty,omitempty"`
    Partnumbers    []string `gorm:"-" json:"partnumbers,omitempty"`
}
  • gorm:"-:migration" - не создавать колонку в БД, но маппить при SELECT
  • gorm:"-" - полностью игнорировать при БД операциях

Checklist для CSV Export

  • HTTP заголовки: Content-Type, Content-Disposition
  • UTF-8 BOM для Excel (0xEF, 0xBB, 0xBF)
  • Разделитель (; для русской локали Excel)
  • Streaming с batch processing (не загружать всё в память)
  • JOIN для избежания N+1 запросов
  • Flush после каждого batch
  • Graceful error handling (нельзя JSON после начала streaming)
  • Динамические заголовки (если нужно)
  • Conditional enrichment (если данные зависят от типа)

Когда использовать этот паттерн

Используй когда:

  • Экспорт больших датасетов (>1000 записей)
  • Нужна Excel-совместимость
  • Связанные данные из нескольких таблиц
  • Conditional логика enrichment

Не нужен когда:

  • Малые датасеты (<100 записей) - можно загрузить всё сразу
  • Экспорт JSON/XML - другие подходы
  • Нет связанных данных - можно упростить

Пример роутинга (Gin)

// В файле роутера
func SetupRoutes(router *gin.Engine, handler *PricelistHandler) {
    api := router.Group("/api")
    {
        pricelists := api.Group("/pricelists")
        {
            pricelists.GET("/:id/export", handler.ExportCSV)
        }
    }
}

Импорты

import (
    "encoding/csv"
    "fmt"
    "strconv"
    "strings"

    "github.com/gin-gonic/gin"
    "gorm.io/gorm"
)

Performance Notes

  1. Batch Size: 500-1000 оптимально для большинства случаев
  2. JOIN vs N+1: JOIN на порядки быстрее при >100 записях
  3. Memory: Streaming позволяет экспортировать миллионы записей с минимальной памятью
  4. Indexes: Убедись что есть индексы на JOIN колонках

Источник

Реализовано в проекте PriceForge:

  • Handler: internal/handlers/pricelist.go:245-346
  • Service: internal/services/pricelist/service.go:373-379
  • Repository: internal/repository/pricelist.go:475-533
  • Models: internal/models/pricelist.go