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 .
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:
obj := New(arg1, arg2)
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
}
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
timeout time.Duration
}
func Timeout(timeout time.Duration) func(*Server) {
return func(s *Server) {
s.timeout = timeout
}
}
func NewServer(addr string, opts ...func(*Server)) *Server {
server := &Server {
addr: addr,
}
for _, opt := range opts {
opt(server)
}
return server
}
L'API résultante est facile à utiliser et facile à lire:
server := NewServer(":8080")
server := NewServer(":8080", Timeout(10 * time.Second))
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:
server := NewServer(":8080")
server := NewServerWithTimeout(":8080", 10 * time.Second)
server := NewServerWithTimeoutAndTLS(":8080", 10 * time.Second, &TLSConfig{})
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:
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) { }
func NewServer(addr string, opts ...func(s *Server)) *Server
func Timeout(timeout time.Duration) Option { }
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:
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:
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
}
}
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"),
)
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.
func PublicPreset() Option {
return Options(
WithTimeout(10 * time.Second),
MaxConnections(10),
)
}
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{},
}
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,
}
opts = append([]Option{DefaultPreset()}, opts...)
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
}
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
}
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:
type Option 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:
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:
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:
type ServerOption interface {
applyServer(s *Server)
}
type ClientOption interface {
applyClient(c *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.