Functional options in Go#

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

Конструкторы в 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 через go install, описать структуру для которой требуется создать опции, и запустить генератор. В результате 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 к изменениям. Теперь не обязательно создавать новый конструктор или мажорную версию модуля или клон пакета ради добавления/удаления параметра. Просто добавляем очередной, депрекейтим старый и обрабатываем все косяки в конструкторе.
  • Снижение вероятности ошибок: автоматическая генерация функций сокращает риск ошибок, связанных с некорректной реализацией опций. Теперь на кодревью можно не смотреть дальше определения структуры Options.

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

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

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