Funktionsoptionen für Steroide

Hallo Habr! Ich präsentiere Ihnen die Übersetzung des Artikels Funktionsoptionen für Steroide von Márk Sági-Kazár .

Bild

Funktionsoptionen sind Go's Paradigma für saubere und erweiterbare APIs. Sie wird von Dave Cheney und Rob Pike populär gemacht . In diesem Beitrag geht es um Praktiken, die die Vorlage seit ihrer Einführung betreffen.

Es wurden funktionale Optionen entwickelt, um gute und saubere APIs mit einer Konfiguration zu erstellen, die optionale Parameter enthält. Es gibt viele offensichtliche Möglichkeiten, dies zu tun (Konstruktor, Konfigurationsstruktur, Setter usw.), aber wenn Sie Dutzende von Optionen übergeben müssen, werden diese schlecht gelesen und bieten keine so guten APIs wie funktionale Optionen.

Einführung - Was sind funktionale Optionen?


Wenn Sie ein „Objekt“ erstellen, rufen Sie normalerweise den Konstruktor mit den erforderlichen Argumenten auf:

obj := New(arg1, arg2)

(Ignorieren wir die Tatsache, dass es in Go keine herkömmlichen Konstruktoren gibt.) Mit den

Funktionsoptionen können Sie die API um zusätzliche Parameter erweitern und die obige Zeile wie folgt umwandeln:

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

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

Funktionsoptionen sind im Grunde Argumente einer variablen Funktion, die als Parameter einen zusammengesetzten oder Zwischenkonfigurationstyp verwendet. Aufgrund seiner variablen Natur ist es durchaus akzeptabel, den Konstruktor ohne Optionen aufzurufen und ihn sauber zu halten, selbst wenn Sie die Standardwerte verwenden möchten.

Schauen wir uns zur besseren Veranschaulichung des Musters ein realistisches Beispiel an (ohne funktionale Optionen):

type Server struct {
    addr string
}

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

Nach dem Hinzufügen der Timeout-Option sieht der Code folgendermaßen aus:

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
}

Die resultierende API ist einfach zu verwenden und leicht zu lesen:

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


Zum Vergleich sehen Sie hier, wie die Initialisierung mit dem Konstruktor und der Konfigurationskonfigurationsstruktur aussieht:

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

Der Vorteil der Verwendung von Funktionsoptionen gegenüber dem Konstruktor liegt wahrscheinlich auf der Hand: Sie sind einfacher zu warten und zu lesen / schreiben. Funktionsparameter überschreiten auch die Konfigurationsstruktur, wenn Parameter nicht an den Konstruktor übergeben werden. In den folgenden Abschnitten werde ich noch weitere Beispiele zeigen, bei denen die Konfigurationsstruktur möglicherweise nicht den Erwartungen entspricht.

Lesen Sie den vollständigen Verlauf der Funktionsoptionen, indem Sie auf die Links in der Einführung klicken. (Anmerkung des Übersetzers - Ein Blog mit einem Originalartikel von Dave Cheney ist in Russland nicht immer verfügbar, und Sie müssen möglicherweise eine Verbindung über VPN herstellen, um ihn anzuzeigen. Informationen aus dem Artikel finden Sie auch im Video seines Berichts über dotGo 2014. )

Praktiken für funktionale Optionen


Funktionsoptionen selbst sind nichts anderes als Funktionen, die an den Konstruktor übergeben werden. Die einfache Verwendung einfacher Funktionen bietet Flexibilität und großes Potenzial. Daher ist es nicht verwunderlich, dass im Laufe der Jahre viele Praktiken rund um die Vorlage aufgetaucht sind. Im Folgenden werde ich eine Liste der meiner Meinung nach beliebtesten und nützlichsten Praktiken bereitstellen.

Schicken Sie mir eine E-Mail, wenn Sie der Meinung sind, dass etwas fehlt.

Art der Optionen


Wenn Sie die Vorlage für Funktionsoptionen anwenden, können Sie zunächst den Typ für die optionale Funktion festlegen:

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

Obwohl dies keine große Verbesserung zu sein scheint, wird der Code besser lesbar, indem ein Typname verwendet wird, anstatt eine Funktion zu definieren:

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

Ein weiterer Vorteil eines Optionstyps besteht darin, dass Godoc Optionsfunktionen unter dem Typ platziert:

Bild

Optionstypliste


In der Regel werden Funktionsparameter verwendet, um eine einzelne Instanz von etwas zu erstellen. Dies ist jedoch nicht immer der Fall. Die Wiederverwendung der Standardparameterliste beim Erstellen mehrerer Instanzen ist ebenfalls keine Seltenheit:

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

Dies ist kein sehr lesbarer Code, insbesondere wenn man bedenkt, dass der Sinn der Verwendung von Funktionsoptionen darin besteht, benutzerfreundliche APIs zu erstellen. Glücklicherweise gibt es eine Möglichkeit, dies zu vereinfachen. Wir müssen nur die Option [] direkt mit der Option aufteilen:

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

Nach dem Ersetzen des Slice durch die Options-Option lautet der obige Code:

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äfixe der With / Set-Optionen


Optionen sind häufig komplexe Typen, im Gegensatz zu einer Zeitüberschreitung oder einer maximalen Anzahl von Verbindungen. Im Serverpaket können Sie beispielsweise die Logger-Schnittstelle als Option definieren (und verwenden, wenn kein Standardlogger vorhanden ist):

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

Offensichtlich kann der Logger-Name nicht als Optionsname verwendet werden, da er bereits von der Schnittstelle verwendet wird. Sie können die Option LoggerOption aufrufen, aber es ist kein sehr freundlicher Name. Wenn Sie den Konstruktor als Satz betrachten, fällt Ihnen in unserem Fall das Wort mit: WithLogger ein.

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

Ein weiteres häufiges Beispiel für eine komplexe Typoption ist ein Werteschnitt:

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

In diesem Fall hängt die Option die Werte normalerweise an vorhandene an, anstatt sie zu überschreiben. Wenn Sie einen vorhandenen Wertesatz überschreiben müssen, können Sie den Wortsatz im Optionsnamen verwenden:

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
)

Voreingestellte Vorlage


Spezielle Anwendungsfälle sind normalerweise universell genug, um sie in der Bibliothek zu unterstützen. Im Fall einer Konfiguration kann dies eine Reihe von Parametern bedeuten, die zusammengefasst und als Voreinstellung für die Verwendung verwendet werden. In unserem Beispiel kann der Server über offene und interne Nutzungsszenarien verfügen, in denen Zeitüberschreitungen, Geschwindigkeitsbegrenzungen, Anzahl der Verbindungen usw. auf unterschiedliche Weise festgelegt werden.

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

Obwohl Voreinstellungen in einigen Fällen nützlich sein können, sind sie in internen Bibliotheken wahrscheinlich von großem Wert, und in öffentlichen Bibliotheken ist ihr Wert geringer.

Standardtypwerte im Vergleich zu voreingestellten Standardwerten


Go hat immer Standardwerte. Für Zahlen ist dies normalerweise Null, für Boolesche Typen ist es falsch und so weiter. In einer optionalen Konfiguration wird empfohlen, sich auf die Standardwerte zu verlassen. Zum Beispiel sollte ein Nullwert eine unbegrenzte Zeitüberschreitung anstelle seiner Abwesenheit bedeuten (was normalerweise bedeutungslos ist).

In einigen Fällen ist der Standardtypwert kein guter Standardwert. Der Standardwert für Logger ist beispielsweise Null, was zu Panik führen kann (wenn Sie die Logger-Aufrufe nicht durch bedingte Überprüfungen schützen).

In diesen Fällen ist das Festlegen des Werts im Konstruktor (vor dem Anwenden der Parameter) eine gute Möglichkeit, den Fallback zu bestimmen:

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
}

Ich habe Beispiele mit Standardvoreinstellungen gesehen (unter Verwendung der im vorherigen Abschnitt beschriebenen Vorlage). Ich halte dies jedoch nicht für eine gute Praxis. Dies ist viel weniger aussagekräftig als nur das Festlegen von Standardwerten im Konstruktor:

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
}

Konfigurationsstrukturoption


Eine Konfigurationsstruktur als funktionale Option zu haben, ist wahrscheinlich nicht so häufig, aber dies wird manchmal gefunden. Die Idee ist, dass sich die Funktionsoptionen auf die Konfigurationsstruktur beziehen, anstatt auf das tatsächlich erstellte Objekt:

type Config struct {
    Timeout time.Duration
}

type Option func(c *Config)

type Server struct {
    // ...

    config Config
}

Diese Vorlage ist nützlich, wenn Sie viele Optionen haben und das Erstellen einer Konfigurationsstruktur sauberer erscheint als das Auflisten aller Optionen in einem Funktionsaufruf:

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

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

Ein weiterer Anwendungsfall für diese Vorlage ist das Festlegen von Standardwerten:

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

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

Erweiterte Vorlagen


Nachdem Sie Dutzende von Funktionsoptionen geschrieben haben, fragen Sie sich möglicherweise, ob es einen besseren Weg gibt, dies zu tun. Nicht in Bezug auf die Nutzung, sondern in Bezug auf die langfristige Unterstützung.

Was wäre zum Beispiel, wenn wir Typen definieren und als Parameter verwenden könnten:

type Timeout time.Duration

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

(Beachten Sie, dass die Verwendung der API gleich bleibt.)

Es stellt sich heraus, dass wir durch Ändern des Optionstyps auf einfache Weise Folgendes tun können:

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

Das Überschreiben einer optionalen Funktion als Schnittstelle eröffnet eine Reihe neuer Möglichkeiten zur Implementierung von Funktionsoptionen:

Verschiedene integrierte Typen können als Parameter ohne Wrapper-Funktion verwendet werden:

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

Listen von Optionen und Konfigurationsstrukturen (siehe vorherige Abschnitte) können auch wie folgt neu definiert werden:

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

Mein persönlicher Favorit ist die Möglichkeit, die Option in mehreren Konstruktoren wiederzuverwenden:

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

Zusammenfassung


Funktionsparameter sind eine leistungsstarke Vorlage zum Erstellen sauberer und erweiterbarer APIs mit Dutzenden von Parametern. Dies ist zwar etwas mehr Arbeit als die Unterstützung einer einfachen Konfigurationsstruktur, bietet jedoch viel mehr Flexibilität und viel sauberere APIs als Alternativen.

All Articles