Options fonctionnelles sur les stéroïdes

Bonjour, Habr! Je vous présente la traduction de l'article Options fonctionnelles sur les stéroïdes par Márk Sági-Kazár .

image

Les options fonctionnelles sont le paradigme de Go pour des API propres et extensibles. Elle est popularisée par Dave Cheney et Rob Pike . Ce message traite des pratiques qui ont été autour du modèle depuis sa première introduction.

Les options fonctionnelles sont apparues comme un moyen de créer de bonnes API propres avec une configuration qui inclut des paramètres facultatifs. Il existe de nombreuses façons évidentes de le faire (constructeur, structure de configuration, paramètres, etc.), mais lorsque vous devez passer des dizaines d'options, elles sont mal lues et ne donnent pas de bonnes API comme options fonctionnelles.

Introduction - quelles sont les options fonctionnelles?


Habituellement, lorsque vous créez un «objet», vous appelez le constructeur avec les arguments nécessaires:

obj := New(arg1, arg2)

(Ignorons le fait qu'il n'y a pas de constructeurs traditionnels dans Go.)

Les options fonctionnelles vous permettent d'étendre l'API avec des paramètres supplémentaires, transformant la ligne ci-dessus en ce qui suit:

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

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

Les options fonctionnelles sont essentiellement des arguments d'une fonction variable qui prend comme paramètres un type de configuration composite ou intermédiaire. En raison de sa nature variable, il est parfaitement acceptable d'appeler le constructeur sans aucune option, en le gardant propre, même si vous souhaitez utiliser les valeurs par défaut.

Pour mieux illustrer le modèle, regardons un exemple réaliste (sans options fonctionnelles):

type Server struct {
    addr string
}

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

Après avoir ajouté l'option timeout, le code ressemble à ceci:

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
}

L'API résultante est facile à utiliser et facile à lire:

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


À titre de comparaison, voici à quoi ressemble l'initialisation en utilisant le constructeur et en utilisant la structure de configuration de 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{} })

L'avantage d'utiliser des options fonctionnelles par rapport au constructeur est probablement évident: elles sont plus faciles à maintenir et à lire / écrire. Les paramètres fonctionnels dépassent également la structure de configuration lorsque les paramètres ne sont pas transmis au constructeur. Dans les sections suivantes, je montrerai encore plus d'exemples où la structure de configuration peut ne pas répondre aux attentes.

Lisez l'historique complet des options fonctionnelles en cliquant sur les liens dans l'introduction. (Note du traducteur - un blog avec un article original de Dave Cheney n'est pas toujours disponible en Russie et vous devrez peut-être vous connecter via VPN pour le consulter. De plus, les informations de l'article sont disponibles dans la vidéo de son rapport sur dotGo 2014 )

Pratiques d'options fonctionnelles


Les options fonctionnelles elles-mêmes ne sont rien de plus que des fonctions passées au constructeur. La facilité d'utilisation de fonctions simples donne de la flexibilité et a un grand potentiel. Par conséquent, il n'est pas surprenant qu'au fil des ans, de nombreuses pratiques soient apparues autour du modèle. Ci-dessous, je fournirai une liste de ce que je considère comme les pratiques les plus populaires et les plus utiles.

Envoyez-moi un e-mail si vous pensez qu'il manque quelque chose.

Type d'options


La première chose que vous pouvez faire lors de l'application du modèle d'options fonctionnelles consiste à déterminer le type de la fonction facultative:

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

Bien que cela ne semble pas être une grande amélioration, le code devient plus lisible en utilisant un nom de type au lieu de définir une fonction:

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

Un autre avantage d'avoir un type d'option est que Godoc place les fonctions d'option sous le type:

image

Liste des types d'options


En règle générale, les paramètres fonctionnels sont utilisés pour créer une seule instance de quelque chose, mais ce n'est pas toujours le cas. La réutilisation de la liste de paramètres par défaut lors de la création de plusieurs instances n'est pas rare non plus:

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

Ce code n'est pas très lisible, d'autant plus que l'intérêt d'utiliser des options fonctionnelles est de créer des API conviviales. Heureusement, il existe un moyen de simplifier cela. Nous avons juste besoin de couper l'option [] directement avec l'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)
        }
    }
}

Après avoir remplacé la tranche par la fonction Options, le code ci-dessus devient:

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

Préfixes des options With / Set


Les options sont souvent de types complexes, contrairement à un délai d'expiration ou à un nombre maximal de connexions. Par exemple, dans le package serveur, vous pouvez définir l'interface de l'enregistreur comme une option (et l'utiliser s'il n'y a pas d'enregistreur par défaut):

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

De toute évidence, le nom de l'enregistreur ne peut pas être utilisé comme nom d'option, car il est déjà utilisé par l'interface. Vous pouvez appeler l'option LoggerOption, mais ce n'est pas un nom très convivial. Si vous regardez le constructeur comme une phrase, dans notre cas, le mot avec: WithLogger vient à l'esprit.

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

Un autre exemple courant d'une option de type complexe est une tranche de valeurs:

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

Dans ce cas, l'option ajoute généralement les valeurs aux valeurs existantes, plutôt que de les écraser. Si vous devez remplacer un ensemble de valeurs existant, vous pouvez utiliser le mot set dans le nom de l'option:

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
)

Modèle prédéfini


Les cas d'utilisation spéciaux sont généralement assez universels pour les prendre en charge dans la bibliothèque. Dans le cas d'une configuration, cela peut signifier un ensemble de paramètres regroupés et utilisés comme préréglage à utiliser. Dans notre exemple, le serveur peut avoir des scénarios d'utilisation ouverts et internes qui définissent les délais d'expiration, les limites de vitesse, le nombre de connexions, etc. de différentes manières.

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

Bien que les préréglages puissent être utiles dans certains cas, ils sont probablement d'une grande valeur dans les bibliothèques internes et dans les bibliothèques publiques, leur valeur est moindre.

Valeurs de type par défaut vs valeurs de préréglage par défaut


Go a toujours des valeurs par défaut. Pour les nombres, c'est généralement zéro, pour les types booléens, c'est faux, et ainsi de suite. Dans une configuration facultative, il est considéré comme une bonne pratique de s'appuyer sur les valeurs par défaut. Par exemple, une valeur nulle devrait signifier un délai d'expiration illimité au lieu de son absence (ce qui n'a généralement aucun sens).

Dans certains cas, la valeur de type par défaut n'est pas une bonne valeur par défaut. Par exemple, la valeur par défaut de Logger est nil, ce qui peut conduire à la panique (si vous ne protégez pas les appels de l'enregistreur avec des vérifications conditionnelles).

Dans ces cas, la définition de la valeur dans le constructeur (avant d'appliquer les paramètres) est un bon moyen de déterminer le repli:

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
}

J'ai vu des exemples avec des préréglages par défaut (en utilisant le modèle décrit dans la section précédente). Cependant, je ne considère pas cela comme une bonne pratique. C'est beaucoup moins expressif que de simplement définir des valeurs par défaut dans le constructeur:

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
}

Option de structure de configuration


Avoir une structure de configuration comme option fonctionnelle n'est probablement pas aussi courant, mais cela se trouve parfois. L'idée est que les options fonctionnelles font référence à la structure de configuration au lieu de faire référence à l'objet réel en cours de création:

type Config struct {
    Timeout time.Duration
}

type Option func(c *Config)

type Server struct {
    // ...

    config Config
}

Ce modèle est utile lorsque vous disposez de nombreuses options, et la création d'une structure de configuration semble plus propre que la liste de toutes les options dans un appel de fonction:

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

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

Un autre cas d'utilisation de ce modèle consiste à définir des valeurs par défaut:

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

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

Modèles avancés


Après avoir écrit des dizaines d'options fonctionnelles, vous vous demandez peut-être s'il existe une meilleure façon de procéder. Pas en termes d'utilisation, mais en termes de support à long terme.

Par exemple, si nous pouvions définir des types et les utiliser comme paramètres:

type Timeout time.Duration

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

(Notez que l'utilisation de l'API reste la même)

Il s'avère qu'en changeant le type d'option, nous pouvons facilement faire ceci:

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

Remplacer une fonction facultative en tant qu'interface ouvre la porte à un certain nombre de nouvelles façons de mettre en œuvre des options fonctionnelles:

Différents types intégrés peuvent être utilisés comme paramètres sans fonction d'encapsuleur:

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

Les listes d'options et de structures de configuration (voir les sections précédentes) peuvent également être redéfinies comme suit:

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

Mon préféré est la possibilité de réutiliser l'option dans plusieurs constructeurs:

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

Sommaire


Les paramètres fonctionnels sont un modèle puissant pour créer des API propres et extensibles avec des dizaines de paramètres. Bien que cela fasse un peu plus de travail que la prise en charge d'une structure de configuration simple, cela offre beaucoup plus de flexibilité et fournit des API beaucoup plus propres que les alternatives.

All Articles