Opciones funcionales con esteroides.

Hola Habr! Le presento la traducción del artículo Opciones funcionales sobre esteroides de Márk Sági-Kazár .

imagen

Las opciones funcionales son el paradigma de Go para API limpias y extensibles. Ella es popularizada por Dave Cheney y Rob Pike . Esta publicación trata sobre prácticas que han estado alrededor de la plantilla desde que se introdujo por primera vez.

Las opciones funcionales han surgido como una forma de crear API buenas y limpias con una configuración que incluye parámetros opcionales. Hay muchas formas obvias de hacer esto (constructor, estructura de configuración, setters, etc.), pero cuando necesita pasar docenas de opciones, se leen mal y no ofrecen API tan buenas como opciones funcionales.

Introducción: ¿cuáles son las opciones funcionales?


Por lo general, cuando crea un "objeto", llama al constructor con los argumentos necesarios:

obj := New(arg1, arg2)

(Ignoremos el hecho de que no hay constructores tradicionales en Go). Las

opciones funcionales le permiten ampliar la API con parámetros adicionales, convirtiendo la línea anterior en la siguiente:

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

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

Las opciones funcionales son básicamente argumentos de una función variable que toma como parámetros un tipo de configuración compuesto o intermedio. Debido a su naturaleza variable, es perfectamente aceptable llamar al constructor sin ninguna opción, manteniéndolo limpio, incluso si desea utilizar los valores predeterminados.

Para demostrar mejor el patrón, echemos un vistazo a un ejemplo realista (sin opciones funcionales):

type Server struct {
    addr string
}

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

Después de agregar la opción de tiempo de espera, el código se ve así:

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
}

La API resultante es fácil de usar y fácil de leer:

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


A modo de comparación, así es como se ve la inicialización utilizando el constructor y la estructura de configuración de configuración:

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

La ventaja de usar opciones funcionales sobre el constructor es probablemente obvia: son más fáciles de mantener y leer / escribir. Los parámetros funcionales también exceden la estructura de configuración cuando los parámetros no se pasan al constructor. En las siguientes secciones, mostraré aún más ejemplos en los que la estructura de configuración puede no estar a la altura de las expectativas.

Lea el historial completo de opciones funcionales haciendo clic en los enlaces de la introducción. (Nota del traductor: un blog con un artículo original de Dave Cheney no siempre está disponible en Rusia y puede requerir una conexión VPN para verlo. Además, la información del artículo está disponible en el video de su informe sobre dotGo 2014 )

Prácticas de opciones funcionales


Las opciones funcionales en sí mismas no son más que funciones pasadas al constructor. La facilidad de uso de funciones simples brinda flexibilidad y tiene un gran potencial. Por lo tanto, no es sorprendente que a lo largo de los años hayan aparecido muchas prácticas en torno a la plantilla. A continuación proporcionaré una lista de lo que considero las prácticas más populares y útiles.

Envíame un correo electrónico si crees que falta algo.

Tipo de opciones


Lo primero que puede hacer al aplicar la plantilla de opciones funcionales es determinar el tipo para la función opcional:

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

Aunque esto puede no parecer una gran mejora, el código se vuelve más legible al usar un nombre de tipo en lugar de definir una función:

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

Otra ventaja de tener un tipo de opción es que Godoc coloca las funciones de opción debajo del tipo:

imagen

Lista de tipos de opciones


Por lo general, los parámetros funcionales se utilizan para crear una sola instancia de algo, pero este no es siempre el caso. La reutilización de la lista de parámetros predeterminada al crear varias instancias tampoco es infrecuente:

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

Este no es un código muy legible, especialmente teniendo en cuenta que el punto de usar opciones funcionales es crear API amigables. Afortunadamente, hay una manera de simplificar esto. Solo necesitamos cortar la opción [] directamente con la opción:

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

Después de reemplazar el segmento con la función Opciones, el código anterior se convierte en:

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

Prefijos de las opciones Con / Establecer


Las opciones suelen ser tipos complejos, a diferencia del tiempo de espera o el número máximo de conexiones. Por ejemplo, en el paquete del servidor, puede definir la interfaz del registrador como una opción (y usarla si no hay un registrador predeterminado):

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

Obviamente, el nombre del registrador no se puede usar como el nombre de la opción, ya que la interfaz ya lo usa. Puede llamar a la opción LoggerOption, pero no es un nombre muy amigable. Si nos fijamos en el constructor como una oración, en nuestro caso viene a la mente la palabra con: 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))

Otro ejemplo común de una opción de tipo complejo es una porción 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"))

En este caso, la opción generalmente agrega los valores a los existentes, en lugar de sobrescribirlos. Si necesita sobrescribir un conjunto de valores existente, puede usar la palabra establecida en el nombre de la opción:

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
)

Plantilla preestablecida


Los casos de uso especiales suelen ser lo suficientemente universales como para admitirlos en la biblioteca. En el caso de una configuración, esto puede significar un conjunto de parámetros agrupados y utilizados como preajustes para su uso. En nuestro ejemplo, el Servidor puede tener escenarios de uso abiertos e internos que establecen tiempos de espera, límites de velocidad, número de conexiones, etc. de diferentes maneras.

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

Aunque los ajustes preestablecidos pueden ser útiles en algunos casos, probablemente sean de gran valor en las bibliotecas internas, y en las bibliotecas públicas su valor es menor.

Valores de tipo predeterminados frente a valores predeterminados predeterminados


Ir siempre tiene valores predeterminados. Para los números, esto suele ser cero, para los tipos booleanos, es falso, etc. En una configuración opcional, se considera una buena práctica confiar en los valores predeterminados. Por ejemplo, un valor nulo debería significar un tiempo de espera ilimitado en lugar de su ausencia (que generalmente no tiene sentido).

En algunos casos, el valor de tipo predeterminado no es un buen valor predeterminado. Por ejemplo, el valor predeterminado para el registrador es nulo, lo que puede generar pánico (si no protege las llamadas del registrador con verificaciones condicionales).

En estos casos, establecer el valor en el constructor (antes de aplicar los parámetros) es una buena manera de determinar la reserva:

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 ejemplos con preajustes predeterminados (usando la plantilla descrita en la sección anterior). Sin embargo, no considero que esta sea una buena práctica. Esto es mucho menos expresivo que simplemente establecer valores predeterminados en el constructor:

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
}

Opción de estructura de configuración


Tener una estructura de configuración como una opción funcional probablemente no sea tan común, pero esto a veces se encuentra. La idea es que las opciones funcionales se refieran a la estructura de configuración en lugar de referirse al objeto real que se está creando:

type Config struct {
    Timeout time.Duration
}

type Option func(c *Config)

type Server struct {
    // ...

    config Config
}

Esta plantilla es útil cuando tiene muchas opciones, y crear una estructura de configuración parece más limpio que enumerar todas las opciones en una llamada de función:

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

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

Otro caso de uso para esta plantilla es establecer valores predeterminados:

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

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

Plantillas avanzadas


Después de escribir docenas de opciones funcionales, puede preguntarse si hay una mejor manera de hacerlo. No en términos de uso, sino en términos de soporte a largo plazo.

Por ejemplo, ¿qué pasaría si pudiéramos definir tipos y usarlos como parámetros?

type Timeout time.Duration

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

(Tenga en cuenta que el uso de la API sigue siendo el mismo)

Resulta que al cambiar el tipo de opción, podemos hacer esto fácilmente:

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

Anular una función opcional como interfaz abre la puerta a una serie de nuevas formas de implementar opciones funcionales:

Se pueden usar varios tipos incorporados como parámetros sin una función de contenedor:

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

Las listas de opciones y estructuras de configuración (ver secciones anteriores) también se pueden redefinir de la siguiente manera:

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

Mi favorito personal es la capacidad de reutilizar la opción en múltiples constructores:

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

Resumen


Los parámetros funcionales son una plantilla poderosa para crear API limpias y extensibles con docenas de parámetros. Aunque esto hace un poco más de trabajo que soportar una estructura de configuración simple, proporciona mucha más flexibilidad y proporciona API mucho más limpias que las alternativas.

All Articles