Hallo Habr! Ich präsentiere Ihnen die Übersetzung des Artikels Funktionsoptionen für Steroide von Márk Sági-Kazár .
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 denFunktionsoptionen können Sie die API um zusätzliche Parameter erweitern und die obige Zeile wie folgt umwandeln:
obj := New(arg1, arg2)
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
}
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
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
}
Die resultierende API ist einfach zu verwenden und leicht zu lesen:
server := NewServer(":8080")
server := NewServer(":8080", Timeout(10 * time.Second))
server := NewServer(":8080", Timeout(10 * time.Second), TLS(&TLSConfig{}))
Zum Vergleich sehen Sie hier, wie die Initialisierung mit dem Konstruktor und der Konfigurationskonfigurationsstruktur aussieht:
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{} })
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:
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) { }
func NewServer(addr string, opts ...func(s *Server)) *Server
func Timeout(timeout time.Duration) Option { }
func NewServer(addr string, opts ...Option) *Server
Ein weiterer Vorteil eines Optionstyps besteht darin, dass Godoc Optionsfunktionen unter dem Typ platziert:
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:
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
}
}
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"),
)
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.
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"),
)
}
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{},
}
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,
}
opts = append([]Option{DefaultPreset()}, opts...)
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
}
NewServer(":8080", WithConfig(config))
Ein weiterer Anwendungsfall für diese Vorlage ist das Festlegen von Standardwerten:config := Config{
Timeout: 10 * time.Second
}
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:
type Option 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:
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:
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:
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))
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.