Functional options in Go
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 без перегрузки конструкторами.