Functional options in Go

90e45d73892ce01d0b91d7669df28f9a

Необходимость конструкторов

Конструкторы в Go нужны,  чтобы инкапсулировать логику создания экземпляров структур и предоставлять удобный и безопасный способ их инициализации. Хотя Go не имеет встроенного синтаксиса для конструкторов,  как,  например,  в языках с объектно-ориентированной моделью,  создание функций-конструкторов становится необходимым в следующих ситуациях:

Установка значений по умолчанию

Если для структуры требуются значения по умолчанию,  использование конструктора позволяет задать их централизованно. В Go нет возможности указать значения по умолчанию прямо в определении полей структуры,  поэтому создание функции-конструктора является способом инициализировать поля конкретными значениями.

func New(timeout time.Duration) *Client {
	if timeout <= 0 {
		timeout = 3 * time.Second 
	}
	
	return &Client{Timeout: timeout}
}

Проверка и валидация полей

Конструкторы позволяют выполнять проверку и валидацию значений,  обеспечивая корректное состояние объекта при создании. Например,  если значение не должно быть отрицательным или нулевым,  это можно обработать в конструкторе.

func New(timeout time.Duration) (*Client, error) {
	if timeout <= 0 {
		return nil, fmt.Errorf("timeout must be greater than 0")
	}
	
	return &Client{Timeout: timeout}, nil
}

Создание структур с приватными полями

Если структура имеет поля,  которые не должны меняться после инициализации,  или недоступны для клиентского кода,  конструктор может помочь сделать эти поля приватными,  позволяя задать их только один раз при создании. В этом случае конструктор может быть единственным способом инициализировать такие поля.

type List struct {
	root Element
	len  int
}

// New returns an initialized list.
func New() *List { 
	l := new(List)
	l.root.next = &l.root
	l.root.prev = &l.root
	l.len = 0
	return l
}

Сокрытие деталей реализации

В некоторых случаях создание объекта может потребовать нескольких этапов,  которые лучше скрыть от клиента. Конструктор позволяет инкапсулировать такие детали,  оставляя внешний интерфейс структуры простым и понятным.

type Storage struct {
	conn *pgx.Conn
}

func New(dsn string) (*Storage, error) {
	conn, err := pgx.Connect(context.TODO(), dsn)
	if err != nil { return nil, err }

	return &Storage{conn: conn}, nil
}

Инициализация внутренних структур

Часто — мьютексы и другие примитивы синхронизации,  а так же массивы и словари инициализируются в конструкторе. Это гарантирует,  что они будут явно проинициализированы до начала использования.

type Data struct {
	mu    *sync.Mutex
	data  map[string]struct{}
}

func New() *Data {
	return &Data{
		mu: new(sync.Mutex),
		data: make(map[string]struct{}),
	}
}

Варианты реализации

В Go-конструкторах разработчики часто сталкиваются с проблемой управления параметрами и поддержкой настраиваемых значений. Рассмотрим несколько способов,  которые обычно применяются в Go для передачи параметров,  и их особенности:

Многочисленные параметры в конструкторе

Один из подходов — передача всех параметров сразу в конструктор. Это может быть приемлемо,  если у структуры всего несколько полей,  но в реальных проектах структуры часто имеют десятки полей,  что ведет к сложности использования конструктора и увеличивает вероятность ошибок.

Пример конструктора с множеством параметров:

type Server struct {
	Address string
	Port    int
	Timeout time.Duration
}

func New(address string, port int, timeout time.Duration) *Server {
	return &Server{Address: address, Port: port, Timeout: timeout}
}

Этот способ быстро становится неудобным при добавлении новых параметров или изменении порядка. Если в дальнейшем потребуется добавить новые настройки,  например,  логин и пароль,  конструктор начнет перегружаться параметрами и станет неудобным для поддержки. Помимо этого есть проблема с обратной совместимостью,  особенно если вы работаете над библиотекой в компании или в опен-сорс.

Использование конфигурационной структуры

Другой способ заключается в использовании конфигурационной структуры,  которую передают в конструктор. Этот подход позволяет сгруппировать все параметры и добавлять новые значения,  не меняя сигнатуру конструктора.

type ServerConfig struct {
	Address string
	Port    int
	Timeout int
}

func New(config ServerConfig) *Server {
	return &Server{
		Address: config.Address,
		Port:    config.Port,
		Timeout: config.Timeout,
	}
}

Этот метод частично решает проблему перегрузки конструктора параметрами,  но требует создания дополнительных структур для каждой новой конфигурации и не позволяет пользователю выбирать только те параметры,  которые ему нужны. Кроме того,  настройка значений по-умолчанию требует отдельной инициализации внутри конструктора.

Чейнинг методов и конструктор с пустыми значениями

Еще один подход — создание конструктора с пустыми значениями,  которые затем настраиваются через отдельные методы конфигурации. Это позволяет постепенно настраивать экземпляр объекта,  но в случае обязательных параметров может привести к неполной или некорректной инициализации.

type Server struct {
	Address string
	Port    int
	Timeout int
}

func New() *Server {
	return &Server{}
}

func (s *Server) SetAddress(address string) *Server {
	s.Address = address
	return s
}

func (s *Server) SetPort(port int) *Server {
	s.Port = port
	return s
}

func (s *Server) SetTimeout(timeout int) *Server {
	s.Timeout = timeout
	return s
}

Проблема этого подхода заключается в необходимости вызова дополнительных методов после создания экземпляра,  что может быть неочевидно и потребует проверки на каждый этап конфигурации. Кроме того,  пользователь библиотеки может вызвать методы конфигурации в любой момент,  что может повлечь неожиданное поведение методов,  которые привязаны к этой структуре.

Функциональные опции

Подход позволяет создавать объекты с настраиваемыми параметрами,  используя функции для изменения свойств объекта при его создании. Каждая функция,  представляющая опцию,  принимает указатель на объект и изменяет его состояние. Таким образом,  можно гибко передавать параметры,  не создавая длинные списки аргументов конструктора.

// Server represents a server configuration.
type Server struct {
	Address string
	Port    int
	Timeout time.Duration
}

// Option defines a functional option for configuring the Server.
type Option func(*Server)

// WithAddress sets the server address.
func WithAddress(address string) Option {
	return func(s *Server) {
		s.Address = address
	}
}

// WithPort sets the server port.
func WithPort(port int) Option {
	return func(s *Server) {
		s.Port = port
	}
}

// WithTimeout sets the server timeout.
func WithTimeout(timeout time.Duration) Option {
	return func(s *Server) {
		s.Timeout = timeout
	}
}

// NewServer creates a new Server with functional options.
func NewServer(options ...Option) *Server {
	server := &Server{
		Address: "localhost", // default address
		Port:    8080,        // default port
		Timeout: 30 * time.Second, // default timeout
	}

	for _, opt := range options {
		opt(server)
	}

	return server
}

Основной недостаток паттерна функциональных опций — сложность инициализации с большим количеством опций,  что может сделать код менее читабельным и трудным для поддержки. Этот подход также добавляет накладные расходы на создание каждой опции.

Все подходы имеют свои преимущества и недостатки. Однако,  если искать общее решение — с обратной совместимостью,  значениями по-умолчанию и валидацией — функциональные опции подходят отлично. Только вот писать эти опции очень утомительно.

Генератор функциональных опций

options-gen — это инструмент,  который автоматически генерирует код функциональных опций,  позволяя сосредоточиться на функциональности приложения,  избегая рутинного написания повторяющихся фрагментов кода. Основная идея options-gen заключается в создании шаблонных функций для каждой опции указанной структуры,  что упрощает процесс настройки и инициализации объектов. Помимо этого — встроенная поддержка валидации (go-playground/validator),  обязательные и опциональные аргументы в конструкторе,  дефолтные значения из тегов / структуры или отдельной функции и поддержка generic типов данных.

Пример использования

Для работы необходимо установить options-gen,  описать структуру для которой требуется создать опции,  и запустить генератор. В результате options-gen создаст набор функций для каждого поля структуры. У разработчика — структура в конструкторе,  а у пользователя библиотеки — функциональные опции.

package client

import (
	"log/slog"
	"net/http"
)

//go:generate options-gen -out-filename=options_generated.go -from-struct=Options
type Options struct {
	baseURL string       `option:"mandatory" validate:"required,http_url"`
	logger  *slog.Logger
	http    *http.Client
}

После генерации для этой структуры будут созданы функции WithLogger и WithClient,  а baseURL будет нужно указать явно. Вот так будет выглядеть конструктор для структуры Options:

package client

type OptOptionsSetter func(o *Options)

func NewOptions(baseURL string, options ...OptOptionsSetter) Options {
	o := Options{}
	// Setting defaults from field tag (if present)
	o.baseURL = baseURL
	for _, opt := range options {
		opt(&o)
	}
	return o
}

func WithLogger(opt *slog.Logger) OptOptionsSetter { return func(o *Options) { o.logger = opt } }

func WithHttp(opt *http.Client) OptOptionsSetter { return func(o *Options) { o.http = opt } }

Здесь мы избавились от рутины,  получили типизированный код на основе структуры,  обязательные и не обязательные параметры. Этот подход особенно хорошо работает для конструкторов сервисов/подсистем/клиентов. Чего-то,  что создается всего несколько раз за жизненный цикл приложения.

Валидация

Помимо параметров,  мы так же получаем дополнительный метод Validate() error. Этот метод проверит,  что все поля проходят валидацию (через go-playground/validator). Стоит уточнить,  что options-gen не выполняет строгой проверки обязательных полей на этапе компиляции,  а создает возможность их проверки.

package client

import "fmt"

type Client struct {
	opts Options
}

func New(opts Options) (*Client, error) {
	if err := opts.Validate(); err != nil {
		return nil, fmt.Errorf("bad configuration: %w", err)
	}
	
	return &Client{opts: opts}, nil
}

На вызывающей стороне мы получаем соответственно:

package main

func main() {
	c, err := client.New(client.NewOptions(
		"http://127.0.0.1:8000", 
		client.WithLogger(slog.New()),
		client.WithHttp(http.DefaultClient),
	))
}

Преимущества использования генератора

  • Сокращение шаблонного кода:  options-gen экономит наше время, генерируя функции автоматически на основе структуры.

  • Стабильность и предсказуемость API: использование функциональных опций позволяет добавлять новые параметры без модификации существующих конструкторов, что повышает устойчивость API к изменениям.

  • Снижение вероятности ошибок: автоматическая генерация функций сокращает риск ошибок, связанных с некорректной реализацией опций.

Вклад в сообщество Go

Проект options-gen создан для упрощения работы с функциональными опциями и экономии времени. Инструмент предназначен для широкого круга разработчиков,  и в особенности для авторов публичных (или внутренних, корпоративных) библиотек.

Связанные материалы

© Habrahabr.ru