OpçÔes funcionais em esteróides

OlĂĄ Habr! Apresento a vocĂȘ a tradução do artigo OpçÔes funcionais sobre esterĂłides de MĂĄrk SĂĄgi-KazĂĄr .

imagem

As opçÔes funcionais são o paradigma da Go para APIs limpas e extensíveis. Ela é popularizada por Dave Cheney e Rob Pike . Esta postagem é sobre pråticas que existem em torno do modelo desde que foi introduzido pela primeira vez.

As opçÔes funcionais surgiram como uma maneira de criar APIs boas e limpas com uma configuração que inclui parĂąmetros opcionais. HĂĄ muitas maneiras Ăłbvias de fazer isso (construtor, estrutura de configuração, setters etc.), mas quando vocĂȘ precisa passar por dezenas de opçÔes, elas sĂŁo mal lidas e nĂŁo fornecem APIs tĂŁo boas quanto as opçÔes funcionais.

Introdução - o que são opçÔes funcionais?


Geralmente, quando vocĂȘ cria um "objeto", chama o construtor com os argumentos necessĂĄrios:

obj := New(arg1, arg2)

(Vamos ignorar o fato de que nĂŁo hĂĄ construtores tradicionais no Go.)

As opçÔes funcionais permitem estender a API com parùmetros adicionais, transformando a linha acima no seguinte:

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

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

As opçÔes funcionais sĂŁo basicamente argumentos de uma função variĂĄvel que assume como parĂąmetros um tipo de configuração composto ou intermediĂĄrio. Devido Ă  sua natureza variĂĄvel, Ă© perfeitamente aceitĂĄvel chamar o construtor sem opçÔes, mantendo-o limpo, mesmo se vocĂȘ quiser usar os valores padrĂŁo.

Para demonstrar melhor o padrão, vamos dar uma olhada em um exemplo realista (sem opçÔes funcionais):

type Server struct {
    addr string
}

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

Depois de adicionar a opção de tempo limite, o código fica assim:

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
}

A API resultante Ă© fĂĄcil de usar e fĂĄcil de ler:

// 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{}))


Para comparação, eis a aparĂȘncia da inicialização usando o construtor e a estrutura de configuração da configuração:

// 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{} })

A vantagem de usar opçÔes funcionais sobre o construtor Ă© provavelmente Ăłbvia: elas sĂŁo mais fĂĄceis de manter e ler / gravar. Os parĂąmetros funcionais tambĂ©m excedem a estrutura de configuração quando os parĂąmetros nĂŁo sĂŁo passados ​​para o construtor. Nas seçÔes a seguir, mostrarei ainda mais exemplos em que a estrutura de configuração pode nĂŁo corresponder Ă s expectativas.

Leia o histĂłrico completo das opçÔes funcionais clicando nos links da introdução. (nota do tradutor - um blog com um artigo original de Dave Cheney nem sempre estĂĄ disponĂ­vel na RĂșssia e pode exigir uma conexĂŁo VPN para visualizĂĄ-la. AlĂ©m disso, as informaçÔes do artigo estĂŁo disponĂ­veis no vĂ­deo de seu relatĂłrio no dotGo 2014 )

Pråticas de OpçÔes Funcionais


As opçÔes funcionais em si nada mais sĂŁo do que funçÔes passadas ao construtor. A facilidade de uso de funçÔes simples oferece flexibilidade e possui grande potencial. Portanto, nĂŁo surpreende que, ao longo dos anos, muitas prĂĄticas tenham aparecido em torno do modelo. Abaixo, fornecerei uma lista do que considero as prĂĄticas mais populares e Ășteis.

Envie-me um e-mail se achar que algo estĂĄ faltando.

Tipo de opçÔes


A primeira coisa que vocĂȘ pode fazer ao aplicar o modelo de opçÔes funcionais Ă© determinar o tipo da função opcional:

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

Embora isso possa não parecer uma grande melhoria, o código se torna mais legível usando um nome de tipo em vez de definir uma função:

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

Outra vantagem de ter um tipo de opção é que Godoc coloca as funçÔes de opção sob o tipo:

imagem

Lista de tipos de opção


Normalmente, parĂąmetros funcionais sĂŁo usados ​​para criar uma Ășnica instĂąncia de algo, mas esse nem sempre Ă© o caso. Reutilizar a lista de parĂąmetros padrĂŁo ao criar vĂĄrias instĂąncias tambĂ©m nĂŁo Ă© incomum:

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))...)

Esse código não é muito legível, especialmente considerando que o objetivo do uso de opçÔes funcionais é criar APIs amigåveis. Felizmente, hå uma maneira de simplificar isso. Só precisamos dividir a opção [] diretamente com a opção:

// 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)
        }
    }
}

Depois de substituir a fatia pela função OpçÔes, o código acima se torna:

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))

Prefixos das opçÔes With / Set


As opçÔes geralmente sĂŁo tipos complexos, diferentemente de um tempo limite ou do nĂșmero mĂĄximo de conexĂ”es. Por exemplo, no pacote do servidor, vocĂȘ pode definir a interface do Agente de Log como uma opção (e usĂĄ-la se nĂŁo houver um agente de log padrĂŁo):

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

Obviamente, o nome do criador de logs nĂŁo pode ser usado como o nome da opção, pois jĂĄ Ă© usado pela interface. VocĂȘ pode chamar a opção LoggerOption, mas nĂŁo Ă© um nome muito amigĂĄvel. Se vocĂȘ olhar para o construtor como uma frase, no nosso caso a palavra com: WithLogger vem Ă  mente.

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))

Outro exemplo comum de uma opção de tipo complexo é uma fatia de valores:

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"))

Nesse caso, a opção geralmente acrescenta os valores aos existentes, em vez de substituĂ­-los. Se vocĂȘ precisar sobrescrever um conjunto de valores existente, poderĂĄ usar a palavra set no nome da opção:

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
)

Predefinição de modelo


Casos de uso especiais sĂŁo geralmente universais o suficiente para apoiĂĄ-los na biblioteca. No caso de uma configuração, isso pode significar um conjunto de parĂąmetros agrupados e usados ​​como uma predefinição para uso. Em nosso exemplo, o servidor pode ter cenĂĄrios de uso aberto e interno que definem tempos limite, limites de velocidade, nĂșmero de conexĂ”es etc. de diferentes maneiras.

// 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"),
    )
}

Embora as predefiniçÔes possam ser Ășteis em alguns casos, elas provavelmente sĂŁo de grande valor nas bibliotecas internas e, nas bibliotecas pĂșblicas, seu valor Ă© menor.

Valores de tipo padrĂŁo x valores predefinidos padrĂŁo


Ir sempre tem valores padrĂŁo. Para nĂșmeros, geralmente Ă© zero, para tipos booleanos, Ă© falso e assim por diante. Em uma configuração opcional, Ă© considerado uma boa prĂĄtica confiar nos valores padrĂŁo. Por exemplo, um valor nulo deve significar um tempo limite ilimitado em vez de sua ausĂȘncia (o que geralmente nĂŁo faz sentido).

Em alguns casos, o valor do tipo padrĂŁo nĂŁo Ă© um bom valor padrĂŁo. Por exemplo, o valor padrĂŁo para o Agente de Log Ă© nulo, o que pode levar ao pĂąnico (se vocĂȘ nĂŁo proteger as chamadas do agente com verificaçÔes condicionais).

Nesses casos, definir o valor no construtor (antes de aplicar os parĂąmetros) Ă© uma boa maneira de determinar o fallback:

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
}

Vi exemplos com predefiniçÔes padrão (usando o modelo descrito na seção anterior). No entanto, não considero isso uma boa pråtica. Isso é muito menos expressivo do que apenas definir valores padrão no construtor:

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
}

Opção de estrutura de configuração


Ter uma estrutura Config como uma opção funcional provavelmente não é tão comum, mas isso às vezes é encontrado. A ideia é que as opçÔes funcionais se refiram à estrutura de configuração em vez de se referirem ao objeto real que estå sendo criado:

type Config struct {
    Timeout time.Duration
}

type Option func(c *Config)

type Server struct {
    // ...

    config Config
}

Esse modelo Ă© Ăștil quando vocĂȘ tem muitas opçÔes, e a criação de uma estrutura de configuração parece mais limpa do que listar todas as opçÔes em uma chamada de função:

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

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

Outro caso de uso para este modelo Ă© definir valores padrĂŁo:

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

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

Modelos avançados


Depois de escrever dezenas de opçÔes funcionais, vocĂȘ pode se perguntar se existe uma maneira melhor de fazer isso. NĂŁo em termos de uso, mas em termos de suporte a longo prazo.

Por exemplo, e se pudéssemos definir tipos e uså-los como parùmetros:

type Timeout time.Duration

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

(Observe que o uso da API permanece o mesmo). Ao

alterar o tipo de opção, podemos fazer isso facilmente:

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

A substituição de uma função opcional como interface abre a porta para vårias novas maneiras de implementar opçÔes funcionais:

Vários tipos internos podem ser usados ​​como parñmetros sem uma função de invólucro:

// 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)
}

As listas de opçÔes e estruturas de configuração (consulte as seçÔes anteriores) também podem ser redefinidas da seguinte maneira:

// 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
}

Meu favorito pessoal é a capacidade de reutilizar a opção em vårios construtores:

// 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))

SumĂĄrio


Parùmetros funcionais é um modelo poderoso para criar APIs limpas e extensíveis com dezenas de parùmetros. Embora isso faça um pouco mais de trabalho do que o suporte a uma estrutura de configuração simples, ele oferece muito mais flexibilidade e APIs muito mais limpas do que as alternativas.

All Articles