Привет, Хабр! Представляю вашему вниманию перевод статьи Functional options on steroids от автора Mark Sagi-Kazar.

image

Функциональные опции — это парадигма в Go для чистых и расширяемых API. Она популяризирована Дейвом Чейни и Робом Пайком. Этот пост о практиках, которые появились вокруг шаблона с тех пор как он был впервые представлен.

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

Введение — что такое функциональные опции?


Обычно, когда вы создаете «объект», вы вызываете конструктор с передачей ему необходимых аргументов:

obj := New(arg1, arg2)

(Давайте на минуту проигнорируем тот факт, что в Go нет традиционных конструкторов.)

Функциональные опции позволяют расширять API дополнительными параметрами, превращая приведенную выше строку в следующее:

// I can still do this...
obj := New(arg1, arg2)

// ...but this works too
obj := New(arg1, arg2, myOption1, myOption2)

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

Чтобы лучше продемонстрировать шаблон, давайте взглянем на реалистичный пример (без функциональных опций):

type Server struct {
    addr string
}

// NewServer initializes a new Server listening on addr.
func NewServer(addr string) *Server {
    return &Server {
        addr: addr,
    }
}

После добавления опции таймаута код выглядит так:

type Server struct {
    addr string

    // default: no timeout
    timeout time.Duration
}

// Timeout configures a maximum length of idle connection in Server.
func Timeout(timeout time.Duration) func(*Server) {
    return func(s *Server) {
        s.timeout = timeout
    }
}

// NewServer initializes a new Server listening on addr with optional configuration.
func NewServer(addr string, opts ...func(*Server)) *Server {
    server := &Server {
        addr: addr,
    }

    // apply the list of options to Server
    for _, opt := range opts {
        opt(server)
    }

    return server
}

Полученный API прост в использовании и легко читается:

// no optional paramters, use defaults
server := NewServer(":8080")

// configure a timeout in addition to the address
server := NewServer(":8080", Timeout(10 * time.Second))

// configure a timeout and TLS in addition to the address
server := NewServer(":8080", Timeout(10 * time.Second), TLS(&TLSConfig{}))


Для сравнения, вот как выглядят инициализация с использованием конструктора и с использованием конфигурационной структуры config:

// constructor variants
server := NewServer(":8080")
server := NewServerWithTimeout(":8080", 10 * time.Second)
server := NewServerWithTimeoutAndTLS(":8080", 10 * time.Second, &TLSConfig{})


// config struct
server := NewServer(":8080", Config{})
server := NewServer(":8080", Config{ Timeout: 10 * time.Second })
server := NewServer(":8080", Config{ Timeout: 10 * time.Second, TLS: &TLSConfig{} })

Преимущество использования функциональных опций перед конструктором, наверное, очевидно: их легче поддерживать и читать/писать. Функциональные параметры также превосходят конфигурационную структуру, когда в конструктор не передаются параметры. В следующих разделах я покажу еще больше примеров, когда структура конфигурации может не оправдать ожиданий.

Прочитайте полную историю функциональных опций перейдя по ссылкам во введении. (примечание переводчика — блог с оригинальной статьей Дейва Чейни не всегда доступен в России и для его просмотра может потребоваться подключение через VPN. Также информация из статьи доступна в видео его доклада на dotGo 2014)

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


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

Напишите мне, если вы считате, что чего-то не хватает.

Тип опций


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

// Option configures a Server.
type Option func(s *Server)

Хотя это может показаться не таким уж большим улучшением, но код становится более читаемым благодаря использованию имени типа вместо определения функции:

func Timeout(timeout time.Duration) func(*Server) { /*...*/ }

// reads: a new server accepts an address
//      and a set of functions that accepts the server itself
func NewServer(addr string, opts ...func(s *Server)) *Server

// VS

func Timeout(timeout time.Duration) Option { /*...*/ }

// reads: a new server accepts an address and a set of options
func NewServer(addr string, opts ...Option) *Server

Еще одним преимуществом наличия типа опции является то, что Godoc помещает функции опции под типом:

image

Список типов опций


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

defaultOptions := []Option{Timeout(5 * time.Second)}

server1 := NewServer(":8080", append(defaultOptions, MaxConnections(10))...)

server2 := NewServer(":8080", append(defaultOptions, RateLimit(10, time.Minute))...)

server3 := NewServer(":8080", append(defaultOptions, Timeout(10 * time.Second))...)

Это не очень читаемый код, особенно учитывая, что смысл использования функциональных опций заключается в создании дружелюбных API. К счастью, есть способ упростить это. Нам просто нужно сделать срез []Option непосредственно опцией:

// Options turns a list of Option instances into an Option.
func Options(opts ...Option) Option {
    return func(s *Server) {
        for _, opt := range opts {
            opt(s)
        }
    }
}

После замены среза на функцию Options приведенный выше код становится:

defaultOptions := Options(Timeout(5 * time.Second))

server1 := NewServer(":8080", defaultOptions, MaxConnections(10))

server2 := NewServer(":8080", defaultOptions, RateLimit(10, time.Minute))

server3 := NewServer(":8080", defaultOptions, Timeout(10 * time.Second))

Префиксы опций With/Set


Опции часто являются сложными типами, в отличие от таймаута или максимального количества соединений. Например, в пакете сервера можно определить интерфейс Logger в качестве опции (и использовать для отсутствия логгера по умолчанию):

type Logger interface {
    Info(msg string)
    Error(msg string)
}

Очевидно, что имя Logger не может быть использовано в качестве названия опции, так как оно уже используется интерфейсом. Можно назвать опцию LoggerOption, но это не совсем дружелюбное имя. Если посмотреть на конструктор как на предложение, в нашем случае на ум приходит слово with: WithLogger.

func WithLogger(logger Logger) Option {
    return func(s *Server) {
        s.logger = logger
    }
}

// reads: create a new server that listens on :8080 with a logger
NewServer(":8080", WithLogger(logger))

Другим распространенным примером опции сложного типа является слайс значений:

type Server struct {
    // ...

    whitelistIPs []string
}

func WithWhitelistedIP(ip string) Option {
    return func(s *Server) {
        s.whitelistIPs = append(s.whitelistIPs, ip)
    }
}

NewServer(":8080", WithWhitelistedIP("10.0.0.0/8"), WithWhitelistedIP("172.16.0.0/12"))

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

func SetWhitelistedIP(ip string) Option {
    return func(s *Server) {
        s.whitelistIPs = []string{ip}
    }
}

NewServer(
    ":8080",
    WithWhitelistedIP("10.0.0.0/8"),
    WithWhitelistedIP("172.16.0.0/12"),
    SetWhitelistedIP("192.168.0.0/16"), // overwrites any previous values
)

Шаблон пресетов


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

// PublicPreset configures a Server for public usage.
func PublicPreset() Option {
    return Options(
        WithTimeout(10 * time.Second),
        MaxConnections(10),
    )
}

// InternalPreset configures a Server for internal usage.
func InternalPreset() Option {
    return Options(
        WithTimeout(20 * time.Second),
        WithWhitelistedIP("10.0.0.0/8"),
    )
}

Несмотря на то, что пресеты могут быть полезны в некоторых случаях, они, вероятно, имеют большую ценность во внутренних библиотеках, а в публичных библиотеках их ценность меньше.

Значения типов по умолчанию vs значения пресетов по умолчанию


В Go всегда есть значения по умолчанию. Для чисел это обычно ноль, для булевых типов это false и так далее. В опциональной конфигурации считается хорошей практикой полагаться на значения по умолчанию. Например, нулевое значение должно означать неограниченный timeout вместо его отсутствия (что обычно бессмысленно).

В некоторых случаях дефолтное значение типа не является хорошим значением по умолчанию. Например, значением по умолчанию для Logger является nil, что может привести к панике (если вы не защищаете вызовы логгера с помощью условных проверок).

В этих случаях установка значения в конструкторе (до применения параметров) — хороший способ определить запасной вариант:

func NewServer(addr string, opts ...func(*Server)) *Server {
    server := &Server {
        addr:   addr,
        logger: noopLogger{},
    }

    // apply the list of options to Server
    for _, opt := range opts {
        opt(server)
    }

    return server
}

Я видел примеры с пресетами по умолчанию (при использовании шаблона, описанного в предыдущем разделе). Однако я не считаю это хорошей практикой. Это гораздо менее выразительно, чем просто установка значений по умолчанию в конструкторе:

func NewServer(addr string, opts ...func(*Server)) *Server {
    server := &Server {
        addr:   addr,
    }

    // what are the defaults?
    opts = append([]Option{DefaultPreset()}, opts...)

    // apply the list of options to Server
    for _, opt := range opts {
        opt(server)
    }

    return server
}

Опция структуры конфигурации


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

type Config struct {
    Timeout time.Duration
}

type Option func(c *Config)

type Server struct {
    // ...

    config Config
}

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

config := Config{
    Timeout: 10 * time.Second
    // ...
    // lots of other options
}

NewServer(":8080", WithConfig(config))

Другой вариант использования этого шаблона — установка значений по умолчанию:

config := Config{
    Timeout: 10 * time.Second
    // ...
    // lots of other options
}

NewServer(":8080", WithConfig(config), WithTimeout(20 * time.Second))

Расширенные шаблоны


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

Например, что если бы мы могли определить типы и использовать их в качестве параметров:

type Timeout time.Duration

NewServer(":8080", Timeout(time.Minute))

(Обратите внимание, что использование API остается прежним)

Оказывается, что, изменяя тип Option, мы можем легко сделать это:

// Option configures a Server.
type Option interface {
    // apply is unexported,
    // so only the current package can implement this interface.
    apply(s *Server)
}

Переопределение опциональной функции в качестве интерфейса открывает двери для ряда новых способов реализации функциональных опций:

Различные встроенные типы могут использоваться в качестве параметров без функции-оболочки:

// Timeout configures a maximum length of idle connection in Server.
type Timeout time.Duration

func (t Timeout) apply(s *Server) {
    s.timeout = time.Duration(t)
}

Списки опций и структуры конфигурации (см. в предыдущих разделах) также могут быть переопределены следующим образом:

// Options turns a list of Option instances into an Option.
type Options []Option

func (o Options) apply(s *Server) {
    for _, opt := range o {
        o.apply(s)
    }
}

type Config struct {
    Timeout time.Duration
}

func (c Config) apply(s *Server) {
    s.config = c
}

Мой личный фаворит — возможность повторно использовать опцию в нескольких конструкторах:

// ServerOption configures a Server.
type ServerOption interface {
    applyServer(s *Server)
}

// ClientOption configures a Client.
type ClientOption interface {
    applyClient(c *Client)
}

// Option configures a Server or a Client.
type Option interface {
    ServerOption
    ClientOption
}

func WithLogger(logger Logger) Option {
    return withLogger{logger}
}

type withLogger struct {
    logger Logger
}

func (o withLogger) applyServer(s *Server) {
    s.logger = o.logger
}

func (o withLogger) applyClient(c *Client) {
    c.logger = o.logger
}

NewServer(":8080", WithLogger(logger))
NewClient("http://localhost:8080", WithLogger(logger))

Резюме


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