Как стать автором
Поиск
Написать публикацию
Обновить

Функциональные опции на стероидах

Время на прочтение9 мин
Количество просмотров4.2K
Привет, Хабр! Представляю вашему вниманию перевод статьи Functional options on steroids от автора Márk Sági-Kazár.

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, чем альтернативы.
Теги:
Хабы:
Всего голосов 7: ↑6 и ↓1+9
Комментарии7

Публикации

Ближайшие события