Functional options in Go
- пятница, 22 ноября 2024 г. в 00:00:06
Конструкторы в 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 к изменениям.
Снижение вероятности ошибок: автоматическая генерация функций сокращает риск ошибок, связанных с некорректной реализацией опций.
Проект options-gen
создан для упрощения работы с функциональными опциями и экономии времени. Инструмент предназначен для широкого круга разработчиков, и в особенности для авторов публичных (или внутренних, корпоративных) библиотек.
Оригинальная идея: статья Dave Cheney: Functional options for friendly APIs
Репозиторий на GitHub: https://github.com/kazhuravlev/options-gen
Хабр: @Ad_augusta_per_angusta: Функциональные опции в Go: реализация шаблона опций в Golang