MidТеория5 min

Template Method

Паттерн Template Method в Go: каркас алгоритма через embedding и интерфейсы, без наследования

Template Method (Шаблонный метод)

Проблема

Несколько алгоритмов имеют одинаковую структуру, но отличаются в деталях. Например: импорт данных из CSV, JSON, XML -- все проходят этапы: открытие, парсинг, валидация, сохранение. Template Method определяет каркас алгоритма, позволяя подклассам переопределять отдельные шаги.

В Go нет наследования! Template Method реализуется через интерфейс + функцию, а не через abstract class.

Диаграмма

    Template function:
    func ProcessData(p DataProcessor) error {
        data := p.Read()        // step 1: varies
        parsed := p.Parse(data) // step 2: varies
        validate(parsed)        // step 3: fixed
        save(parsed)            // step 4: fixed
    }

    +------------------+
    | DataProcessor    | <-- interface (variable steps)
    +------------------+
    | Read() []byte    |
    | Parse() []Record |
    +------------------+
          ^
     +----+-----+
     |          |
   CSVProc   JSONProc

    Template function provides the fixed skeleton.
    Interface provides the variable steps.

Реализация

Интерфейс для вариативных шагов

package dataminer

import (
    "context"
    "fmt"
    "log/slog"
    "time"
)

// Record represents a parsed data record.
type Record struct {
    Fields map[string]string
}

// DataProcessor defines the variable steps of data processing.
type DataProcessor interface {
    // Read retrieves raw data from the source.
    Read(ctx context.Context) ([]byte, error)

    // Parse converts raw data into structured records.
    Parse(data []byte) ([]Record, error)

    // Name returns the processor name (for logging).
    Name() string
}

// ProcessData is the template method.
// It defines the fixed algorithm skeleton.
func ProcessData(ctx context.Context, p DataProcessor, logger *slog.Logger) ([]Record, error) {
    start := time.Now()
    logger.InfoContext(ctx, "starting data processing",
        "processor", p.Name(),
    )

    // Step 1: Read (varies by processor)
    data, err := p.Read(ctx)
    if err != nil {
        return nil, fmt.Errorf("read %s: %w", p.Name(), err)
    }
    logger.InfoContext(ctx, "data read",
        "processor", p.Name(),
        "bytes", len(data),
    )

    // Step 2: Parse (varies by processor)
    records, err := p.Parse(data)
    if err != nil {
        return nil, fmt.Errorf("parse %s: %w", p.Name(), err)
    }

    // Step 3: Validate (fixed step -- same for all)
    valid := make([]Record, 0, len(records))
    for _, r := range records {
        if validateRecord(r) {
            valid = append(valid, r)
        }
    }

    // Step 4: Log results (fixed step)
    logger.InfoContext(ctx, "processing complete",
        "processor", p.Name(),
        "total", len(records),
        "valid", len(valid),
        "duration", time.Since(start),
    )

    return valid, nil
}

// validateRecord is a fixed step -- not customizable.
func validateRecord(r Record) bool {
    // Must have at least one non-empty field
    for _, v := range r.Fields {
        if v != "" {
            return true
        }
    }
    return false
}

Конкретные процессоры

package dataminer

import (
    "context"
    "encoding/csv"
    "encoding/json"
    "fmt"
    "io"
    "os"
    "strings"
)

// --- CSV Processor ---

type CSVProcessor struct {
    filePath  string
    delimiter rune
}

func NewCSVProcessor(filePath string, delimiter rune) *CSVProcessor {
    return &CSVProcessor{filePath: filePath, delimiter: delimiter}
}

func (p *CSVProcessor) Name() string { return "CSV" }

func (p *CSVProcessor) Read(ctx context.Context) ([]byte, error) {
    return os.ReadFile(p.filePath)
}

func (p *CSVProcessor) Parse(data []byte) ([]Record, error) {
    reader := csv.NewReader(strings.NewReader(string(data)))
    reader.Comma = p.delimiter

    // First row is headers
    headers, err := reader.Read()
    if err != nil {
        return nil, fmt.Errorf("read headers: %w", err)
    }

    var records []Record
    for {
        row, err := reader.Read()
        if err == io.EOF {
            break
        }
        if err != nil {
            return nil, fmt.Errorf("read row: %w", err)
        }

        fields := make(map[string]string, len(headers))
        for i, header := range headers {
            if i < len(row) {
                fields[header] = row[i]
            }
        }
        records = append(records, Record{Fields: fields})
    }

    return records, nil
}

// --- JSON Processor ---

type JSONProcessor struct {
    filePath string
}

func NewJSONProcessor(filePath string) *JSONProcessor {
    return &JSONProcessor{filePath: filePath}
}

func (p *JSONProcessor) Name() string { return "JSON" }

func (p *JSONProcessor) Read(ctx context.Context) ([]byte, error) {
    return os.ReadFile(p.filePath)
}

func (p *JSONProcessor) Parse(data []byte) ([]Record, error) {
    var items []map[string]string
    if err := json.Unmarshal(data, &items); err != nil {
        return nil, fmt.Errorf("unmarshal JSON: %w", err)
    }

    records := make([]Record, len(items))
    for i, item := range items {
        records[i] = Record{Fields: item}
    }
    return records, nil
}

// --- HTTP API Processor ---

type APIProcessor struct {
    url    string
    client *http.Client
}

func NewAPIProcessor(url string) *APIProcessor {
    return &APIProcessor{
        url:    url,
        client: &http.Client{Timeout: 10 * time.Second},
    }
}

func (p *APIProcessor) Name() string { return "API" }

func (p *APIProcessor) Read(ctx context.Context) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
    if err != nil {
        return nil, err
    }
    resp, err := p.client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    return io.ReadAll(resp.Body)
}

func (p *APIProcessor) Parse(data []byte) ([]Record, error) {
    // Same as JSON
    var items []map[string]string
    if err := json.Unmarshal(data, &items); err != nil {
        return nil, err
    }
    records := make([]Record, len(items))
    for i, item := range items {
        records[i] = Record{Fields: item}
    }
    return records, nil
}

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

func main() {
    ctx := context.Background()
    logger := slog.Default()

    // Same algorithm, different data sources
    processors := []dataminer.DataProcessor{
        dataminer.NewCSVProcessor("users.csv", ','),
        dataminer.NewJSONProcessor("users.json"),
        dataminer.NewAPIProcessor("https://api.example.com/users"),
    }

    for _, p := range processors {
        records, err := dataminer.ProcessData(ctx, p, logger)
        if err != nil {
            logger.Error("processing failed", "processor", p.Name(), "error", err)
            continue
        }
        fmt.Printf("%s: %d valid records\n", p.Name(), len(records))
    }
}

Template Method с хуками

Хуки -- опциональные шаги, которые процессор может переопределить:

// ProcessorWithHooks extends DataProcessor with optional hooks.
type ProcessorWithHooks interface {
    DataProcessor

    // BeforeRead is called before reading (optional hook).
    BeforeRead(ctx context.Context) error

    // AfterParse is called after parsing (optional hook).
    AfterParse(records []Record) []Record
}

// DefaultHooks provides no-op implementations.
type DefaultHooks struct{}

func (h DefaultHooks) BeforeRead(_ context.Context) error { return nil }
func (h DefaultHooks) AfterParse(r []Record) []Record     { return r }

// CSVProcessor with hooks
type CSVProcessorWithHooks struct {
    CSVProcessor
    DefaultHooks // embed default no-op hooks
}

Go vs Java: Template Method

Аспект Java Go
Механизм Abstract class + наследование Interface + функция
Фиксированные шаги Protected final methods Обычные функции (не экспортируемые)
Вариативные шаги Abstract methods Методы интерфейса
Хуки Protected methods с default impl Отдельный интерфейс + embedding

Когда использовать

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

  • Несколько алгоритмов с одинаковой структурой
  • Фиксированный каркас с вариативными шагами
  • Нужно избежать дублирования общей логики
  • ETL-конвейеры, импорт/экспорт данных

Не используйте, когда:

  • Алгоритмы полностью различаются (используйте Strategy)
  • Структура часто меняется
  • Только один "шаблон" без вариаций

Сравнение с похожими паттернами

Паттерн Отличие
Strategy Полная замена алгоритма; Template Method -- замена шагов
Factory Method Создание объекта; Template Method -- шаги алгоритма
Builder Пошаговое создание объекта; Template Method -- пошаговое выполнение

Проверь себя

Как Template Method реализуется в Go без наследования?

Чем Template Method отличается от Strategy?