MidТеория6 min

Config Struct и Builder

Паттерны конфигурации: Config Struct, Builder с валидацией и method chaining

Config Struct -- самый простой подход

Config Struct -- это идиоматичный способ конфигурации в Go, особенно для внутреннего кода. Его суть: создать экспортируемую структуру с полями конфигурации и передавать её в конструктор.

Базовый паттерн

// Config holds database connection configuration.
type Config struct {
    Host            string
    Port            int
    Database        string
    User            string
    Password        string
    MaxOpenConns    int
    MaxIdleConns    int
    ConnMaxLifetime time.Duration
    SSLMode         string
}

// DefaultConfig returns a Config with sensible defaults.
func DefaultConfig() Config {
    return Config{
        Host:            "localhost",
        Port:            5432,
        MaxOpenConns:    25,
        MaxIdleConns:    5,
        ConnMaxLifetime: 5 * time.Minute,
        SSLMode:         "disable",
    }
}

// NewDatabase creates a database connection from config.
func NewDatabase(cfg Config) (*Database, error) {
    if cfg.Host == "" {
        return nil, fmt.Errorf("database host is required")
    }
    if cfg.Database == "" {
        return nil, fmt.Errorf("database name is required")
    }

    dsn := fmt.Sprintf("host=%s port=%d dbname=%s user=%s password=%s sslmode=%s",
        cfg.Host, cfg.Port, cfg.Database, cfg.User, cfg.Password, cfg.SSLMode,
    )

    db, err := sql.Open("pgx", dsn)
    if err != nil {
        return nil, fmt.Errorf("opening database: %w", err)
    }

    db.SetMaxOpenConns(cfg.MaxOpenConns)
    db.SetMaxIdleConns(cfg.MaxIdleConns)
    db.SetConnMaxLifetime(cfg.ConnMaxLifetime)

    return &Database{db: db}, nil
}

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

// Start with defaults, override what you need
cfg := DefaultConfig()
cfg.Database = "myapp"
cfg.User = "admin"
cfg.Password = os.Getenv("DB_PASSWORD")

db, err := NewDatabase(cfg)
if err != nil {
    log.Fatal(err)
}

Нулевое значение как дефолт

В Go нулевые значения типов ("", 0, false, nil) имеют определённый смысл. Это можно использовать:

// HTTPClientConfig configures an HTTP client.
// Zero value provides sensible defaults.
type HTTPClientConfig struct {
    // Timeout is the request timeout. Zero means 30 seconds.
    Timeout time.Duration

    // MaxRetries is the number of retries. Zero means no retries.
    MaxRetries int

    // UserAgent is the User-Agent header. Empty means "Go-HTTP-Client/1.0".
    UserAgent string
}

// NewHTTPClient creates a client applying zero-value defaults.
func NewHTTPClient(cfg HTTPClientConfig) *HTTPClient {
    if cfg.Timeout == 0 {
        cfg.Timeout = 30 * time.Second
    }
    if cfg.UserAgent == "" {
        cfg.UserAgent = "Go-HTTP-Client/1.0"
    }

    return &HTTPClient{
        client: &http.Client{Timeout: cfg.Timeout},
        agent:  cfg.UserAgent,
        retries: cfg.MaxRetries,
    }
}

// Usage: zero value works as-is
client := NewHTTPClient(HTTPClientConfig{})

// Override only what you need
client := NewHTTPClient(HTTPClientConfig{
    Timeout:    10 * time.Second,
    MaxRetries: 3,
})

Проблема: как отличить "не задано" от "задано нулевое значение"?

Если Timeout: 0 означает "использовать дефолт", то как задать "без таймаута"?

// Solution 1: use a pointer (nil = not set)
type Config struct {
    Timeout *time.Duration // nil = use default, 0 = no timeout
}

// Solution 2: use a sentinel value
const NoTimeout = -1 * time.Second

// Solution 3: use a separate "set" field
type Config struct {
    Timeout    time.Duration
    TimeoutSet bool
}

В практике чаще всего используют указатели или документируют поведение нулевого значения.

Builder Pattern

Builder полезен, когда конфигурация сложная, шаги имеют зависимости между собой, или нужна валидация на каждом этапе.

Builder с method chaining

// QueryBuilder builds SQL queries safely.
type QueryBuilder struct {
    table      string
    columns    []string
    conditions []string
    args       []any
    orderBy    string
    limit      int
    offset     int
    err        error // accumulate first error
}

// NewQuery starts building a query for the given table.
func NewQuery(table string) *QueryBuilder {
    return &QueryBuilder{
        table:   table,
        columns: []string{"*"},
    }
}

// Select sets the columns to retrieve.
func (q *QueryBuilder) Select(cols ...string) *QueryBuilder {
    if len(cols) > 0 {
        q.columns = cols
    }
    return q
}

// Where adds a WHERE condition with a placeholder argument.
func (q *QueryBuilder) Where(condition string, arg any) *QueryBuilder {
    q.conditions = append(q.conditions, condition)
    q.args = append(q.args, arg)
    return q
}

// OrderBy sets the ORDER BY clause.
func (q *QueryBuilder) OrderBy(col string) *QueryBuilder {
    q.orderBy = col
    return q
}

// Limit sets the LIMIT clause.
func (q *QueryBuilder) Limit(n int) *QueryBuilder {
    if n < 0 {
        q.err = fmt.Errorf("limit must be non-negative, got %d", n)
        return q
    }
    q.limit = n
    return q
}

// Offset sets the OFFSET clause.
func (q *QueryBuilder) Offset(n int) *QueryBuilder {
    q.offset = n
    return q
}

// Build generates the SQL query and arguments.
func (q *QueryBuilder) Build() (string, []any, error) {
    if q.err != nil {
        return "", nil, q.err
    }
    if q.table == "" {
        return "", nil, fmt.Errorf("table name is required")
    }

    var buf strings.Builder
    buf.WriteString("SELECT ")
    buf.WriteString(strings.Join(q.columns, ", "))
    buf.WriteString(" FROM ")
    buf.WriteString(q.table)

    if len(q.conditions) > 0 {
        buf.WriteString(" WHERE ")
        for i, cond := range q.conditions {
            if i > 0 {
                buf.WriteString(" AND ")
            }
            buf.WriteString(cond)
        }
    }

    if q.orderBy != "" {
        buf.WriteString(" ORDER BY ")
        buf.WriteString(q.orderBy)
    }

    if q.limit > 0 {
        buf.WriteString(fmt.Sprintf(" LIMIT %d", q.limit))
    }

    if q.offset > 0 {
        buf.WriteString(fmt.Sprintf(" OFFSET %d", q.offset))
    }

    return buf.String(), q.args, nil
}

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

query, args, err := NewQuery("users").
    Select("id", "name", "email").
    Where("age > $1", 18).
    Where("active = $2", true).
    OrderBy("name ASC").
    Limit(20).
    Build()

if err != nil {
    return fmt.Errorf("building query: %w", err)
}

// query: SELECT id, name, email FROM users WHERE age > $1 AND active = $2 ORDER BY name ASC LIMIT 20
// args: [18, true]

Builder с валидацией и Build()

// ServerBuilder constructs a validated Server.
type ServerBuilder struct {
    addr    string
    port    int
    tls     *tls.Config
    handler http.Handler
    errors  []error
}

// NewServerBuilder creates a new builder with required address.
func NewServerBuilder(addr string) *ServerBuilder {
    return &ServerBuilder{
        addr: addr,
        port: 8080,
    }
}

// Port sets the server port.
func (b *ServerBuilder) Port(port int) *ServerBuilder {
    if port < 1 || port > 65535 {
        b.errors = append(b.errors, fmt.Errorf("invalid port: %d", port))
        return b
    }
    b.port = port
    return b
}

// TLS configures TLS for the server.
func (b *ServerBuilder) TLS(cert, key string) *ServerBuilder {
    c, err := tls.LoadX509KeyPair(cert, key)
    if err != nil {
        b.errors = append(b.errors, fmt.Errorf("loading TLS cert: %w", err))
        return b
    }
    b.tls = &tls.Config{
        Certificates: []tls.Certificate{c},
        MinVersion:   tls.VersionTLS12,
    }
    return b
}

// Handler sets the HTTP handler.
func (b *ServerBuilder) Handler(h http.Handler) *ServerBuilder {
    b.handler = h
    return b
}

// Build creates the Server, returning all accumulated errors.
func (b *ServerBuilder) Build() (*http.Server, error) {
    if b.handler == nil {
        b.errors = append(b.errors, fmt.Errorf("handler is required"))
    }

    if len(b.errors) > 0 {
        return nil, fmt.Errorf("server build failed: %w", errors.Join(b.errors...))
    }

    s := &http.Server{
        Addr:      fmt.Sprintf("%s:%d", b.addr, b.port),
        Handler:   b.handler,
        TLSConfig: b.tls,
    }

    return s, nil
}

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

srv, err := NewServerBuilder("0.0.0.0").
    Port(443).
    TLS("cert.pem", "key.pem").
    Handler(mux).
    Build()

if err != nil {
    log.Fatal(err)
}

Пример: конфигурация HTTP-клиента тремя способами

Способ 1: Config Struct

type ClientConfig struct {
    BaseURL    string
    Timeout    time.Duration
    Retries    int
    AuthToken  string
    Debug      bool
}

func NewClient(cfg ClientConfig) *Client {
    if cfg.Timeout == 0 {
        cfg.Timeout = 30 * time.Second
    }
    return &Client{cfg: cfg}
}

// Usage
c := NewClient(ClientConfig{
    BaseURL:   "https://api.example.com",
    Timeout:   10 * time.Second,
    Retries:   3,
    AuthToken: token,
})

Способ 2: Functional Options

type Option func(*Client)

func WithTimeout(d time.Duration) Option { return func(c *Client) { c.timeout = d } }
func WithRetries(n int) Option           { return func(c *Client) { c.retries = n } }
func WithAuth(token string) Option       { return func(c *Client) { c.token = token } }
func WithDebug() Option                  { return func(c *Client) { c.debug = true } }

func NewClient(baseURL string, opts ...Option) *Client {
    c := &Client{baseURL: baseURL, timeout: 30 * time.Second}
    for _, opt := range opts {
        opt(c)
    }
    return c
}

// Usage
c := NewClient("https://api.example.com",
    WithTimeout(10*time.Second),
    WithRetries(3),
    WithAuth(token),
)

Способ 3: Builder

func NewClientBuilder(baseURL string) *ClientBuilder {
    return &ClientBuilder{baseURL: baseURL, timeout: 30 * time.Second}
}

func (b *ClientBuilder) Timeout(d time.Duration) *ClientBuilder {
    b.timeout = d
    return b
}

func (b *ClientBuilder) Retries(n int) *ClientBuilder {
    b.retries = n
    return b
}

func (b *ClientBuilder) Auth(token string) *ClientBuilder {
    b.token = token
    return b
}

func (b *ClientBuilder) Build() (*Client, error) {
    if b.baseURL == "" {
        return nil, fmt.Errorf("base URL is required")
    }
    return &Client{/* ... */}, nil
}

// Usage
c, err := NewClientBuilder("https://api.example.com").
    Timeout(10*time.Second).
    Retries(3).
    Auth(token).
    Build()

Таблица сравнения

Критерий Config Struct Functional Options Builder
Простота Самый простой Средняя Сложнее всего
Код для написания Минимум Функция на каждую опцию Метод на каждую опцию + Build
Дефолты Отдельная функция В конструкторе В конструкторе Builder
Валидация В конструкторе В каждой опции В Build()
API stability Новое поле = возможный breaking change Новая опция = safe Новый метод = safe
Читаемость при вызове Хорошая (named fields) Отличная (self-documenting) Отличная (method chain)
Подходит для Внутренний код, простые случаи Библиотеки, публичные API Сложная пошаговая конфигурация
Пример в экосистеме http.Transport{} grpc.Dial(opts...) strings.Builder

Рекомендации

  1. Config Struct -- используйте по умолчанию для внутреннего кода
  2. Functional Options -- для библиотек и публичных API, где важна обратная совместимость
  3. Builder -- когда конфигурация имеет шаги, зависимости или сложную валидацию

Часто подходы комбинируются:

// Config struct with functional options for overrides
type Config struct {
    Host string
    Port int
}

type Option func(*Config)

func WithPort(p int) Option {
    return func(c *Config) { c.port = p }
}

func NewService(cfg Config, opts ...Option) *Service {
    for _, opt := range opts {
        opt(&cfg)
    }
    return &Service{cfg: cfg}
}

Проверь себя

В чём главное преимущество Config Struct перед другими подходами?

Как отличить 'значение не задано' от 'задано нулевое значение' в Config Struct?

Как Builder обрабатывает ошибки валидации при method chaining?