Pilihan fungsional pada steroid

Halo, Habr! Saya sajikan kepada Anda terjemahan artikel Pilihan fungsional steroid oleh Márk Sági-Kazár .

gambar

Opsi fungsional adalah paradigma Go untuk API yang bersih dan dapat diperluas. Dia dipopulerkan oleh Dave Cheney dan Rob Pike . Posting ini adalah tentang praktik yang telah ada di sekitar templat sejak pertama kali diperkenalkan.

Opsi fungsional telah muncul sebagai cara untuk membuat API yang baik dan bersih dengan konfigurasi yang mencakup parameter opsional. Ada banyak cara yang jelas untuk melakukan ini (konstruktor, struktur konfigurasi, setter, dll.), Tetapi ketika Anda harus melewati lusinan opsi, opsi tersebut buruk dibaca dan tidak memberikan API sebagus opsi fungsional.

Pendahuluan - apa saja opsi fungsional?


Biasanya, ketika Anda membuat "objek", Anda memanggil konstruktor dengan argumen yang diperlukan:

obj := New(arg1, arg2)

(Mari kita abaikan fakta bahwa tidak ada konstruktor tradisional di Go.)

Opsi fungsional memungkinkan Anda untuk memperpanjang API dengan parameter tambahan, mengubah baris di atas menjadi sebagai berikut:

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

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

Opsi fungsional pada dasarnya adalah argumen dari fungsi variabel yang mengambil sebagai parameter tipe konfigurasi komposit atau menengah. Karena sifat variabelnya, dapat diterima untuk memanggil konstruktor tanpa opsi apa pun, menjaganya tetap bersih, bahkan jika Anda ingin menggunakan nilai default.

Untuk mendemonstrasikan polanya dengan lebih baik, mari kita lihat contoh realistis (tanpa opsi fungsional):

type Server struct {
    addr string
}

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

Setelah menambahkan opsi batas waktu, kode tersebut terlihat seperti ini:

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
}

API yang dihasilkan mudah digunakan dan mudah dibaca:

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


Sebagai perbandingan, inilah inisialisasi yang terlihat seperti menggunakan konstruktor dan menggunakan struktur konfigurasi konfigurasi:

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

Keuntungan menggunakan opsi fungsional di atas konstruktor mungkin jelas: mereka lebih mudah dipelihara dan dibaca / ditulis. Parameter fungsional juga melebihi struktur konfigurasi ketika parameter tidak diteruskan ke konstruktor. Pada bagian berikut ini saya akan menunjukkan lebih banyak contoh di mana struktur konfigurasi mungkin tidak memenuhi harapan.

Baca riwayat lengkap opsi fungsional dengan mengklik tautan di pendahuluan. (catatan penerjemah - blog dengan artikel asli oleh Dave Cheney tidak selalu tersedia di Rusia dan Anda mungkin perlu terhubung melalui VPN untuk melihatnya. Juga, informasi dari artikel tersebut tersedia dalam video laporannya tentang dotGo 2014 )

Praktek Opsi Fungsional


Opsi fungsional sendiri tidak lebih dari fungsi yang diteruskan ke konstruktor. Kemudahan penggunaan fungsi sederhana memberikan fleksibilitas dan memiliki potensi besar. Oleh karena itu, tidak mengherankan bahwa selama bertahun-tahun banyak praktik telah muncul di sekitar templat. Di bawah ini saya akan memberikan daftar apa yang saya anggap praktik yang paling populer dan bermanfaat.

Kirimkan email kepada saya jika Anda merasa ada yang kurang.

Jenis opsi


Hal pertama yang dapat Anda lakukan ketika menerapkan templat opsi fungsional adalah menentukan jenis untuk fungsi opsional:

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

Meskipun ini mungkin tidak tampak seperti perbaikan besar, kode menjadi lebih mudah dibaca dengan menggunakan nama jenis daripada mendefinisikan fungsi:

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

Keuntungan lain dari memiliki jenis opsi adalah bahwa Godoc menempatkan fungsi opsi di bawah jenis itu:

gambar

Daftar Jenis Opsi


Biasanya, parameter fungsional digunakan untuk membuat satu instance dari sesuatu, tetapi ini tidak selalu terjadi. Menggunakan kembali daftar parameter default saat membuat banyak instance juga tidak jarang:

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

Ini bukan kode yang sangat mudah dibaca, terutama mengingat bahwa titik menggunakan opsi fungsional adalah untuk membuat API yang ramah. Untungnya, ada cara untuk menyederhanakan ini. Kami hanya perlu mengiris Opsi [] langsung dengan opsi:

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

Setelah mengganti irisan dengan fungsi Opsi, kode di atas menjadi:

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

Awalan dari Opsi With / Set


Opsi seringkali tipe yang kompleks, tidak seperti batas waktu atau jumlah koneksi maksimum. Misalnya, dalam paket server, Anda dapat mendefinisikan antarmuka Logger sebagai opsi (dan menggunakannya jika tidak ada logger default):

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

Jelas, nama Logger tidak dapat digunakan sebagai nama opsi, karena sudah digunakan oleh antarmuka. Anda dapat memanggil opsi LoggerOption, tetapi itu bukan nama yang sangat ramah. Jika Anda melihat konstruktor sebagai kalimat, dalam kasus kami kata dengan: WithLogger muncul di benak Anda.

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

Contoh umum lain dari opsi tipe kompleks adalah sepotong nilai:

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

Dalam hal ini, opsi biasanya menambahkan nilai ke nilai yang sudah ada, daripada menimpanya. Jika Anda perlu menimpa set nilai yang ada, Anda dapat menggunakan kata yang diatur dalam nama opsi:

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
)

Template Preset


Kasing khusus biasanya cukup universal untuk mendukungnya di perpustakaan. Dalam hal konfigurasi, ini dapat berarti seperangkat parameter yang dikelompokkan bersama dan digunakan sebagai preset untuk digunakan. Dalam contoh kami, Server dapat memiliki skenario penggunaan internal dan terbuka yang menetapkan batas waktu, batas kecepatan, jumlah koneksi, dll. Dengan berbagai cara.

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

Meskipun preset mungkin berguna dalam beberapa kasus, mereka mungkin bernilai tinggi di perpustakaan internal, dan di perpustakaan umum nilainya kurang.

Nilai Jenis Default vs Nilai Preset Default


Go selalu memiliki nilai default. Untuk angka, ini biasanya nol, untuk tipe Boolean, ini salah, dan sebagainya. Dalam konfigurasi opsional, dianggap praktik yang baik untuk mengandalkan nilai default. Sebagai contoh, nilai nol harus berarti batas waktu yang tidak terbatas alih-alih tidak ada (yang biasanya tidak berarti).

Dalam beberapa kasus, nilai tipe default bukan nilai default yang baik. Misalnya, nilai default untuk Logger adalah nihil, yang dapat menyebabkan kepanikan (jika Anda tidak melindungi panggilan logger dengan pemeriksaan bersyarat).

Dalam kasus ini, menetapkan nilai dalam konstruktor (sebelum menerapkan parameter) adalah cara yang baik untuk menentukan fallback:

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
}

Saya melihat contoh dengan preset default (menggunakan templat yang dijelaskan di bagian sebelumnya). Namun, saya tidak menganggap ini praktik yang baik. Ini jauh lebih ekspresif daripada hanya menetapkan nilai default di 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
}

Opsi struktur konfigurasi


Memiliki struktur Config sebagai opsi fungsional mungkin tidak biasa, tetapi ini kadang-kadang ditemukan. Idenya adalah bahwa opsi fungsional merujuk pada struktur konfigurasi alih-alih merujuk ke objek aktual yang sedang dibuat:

type Config struct {
    Timeout time.Duration
}

type Option func(c *Config)

type Server struct {
    // ...

    config Config
}

Templat ini berguna ketika Anda memiliki banyak opsi, dan membuat struktur konfigurasi tampaknya lebih bersih daripada mendaftar semua opsi dalam panggilan fungsi:

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

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

Kasus penggunaan lain untuk template ini adalah untuk menetapkan nilai default:

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

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

Template Tingkat Lanjut


Setelah menulis lusinan opsi fungsional, Anda mungkin bertanya-tanya apakah ada cara yang lebih baik untuk melakukan ini. Bukan dalam hal penggunaan, tetapi dalam hal dukungan jangka panjang.

Sebagai contoh, bagaimana jika kita dapat mendefinisikan tipe dan menggunakannya sebagai parameter:

type Timeout time.Duration

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

(Perhatikan bahwa penggunaan API tetap sama)

Ternyata dengan mengubah jenis Opsi, kita dapat dengan mudah melakukan ini:

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

Mengganti fungsi opsional sebagai antarmuka membuka pintu bagi sejumlah cara baru untuk menerapkan opsi fungsional:

Berbagai tipe bawaan dapat digunakan sebagai parameter tanpa fungsi pembungkus:

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

Daftar opsi dan struktur konfigurasi (lihat bagian sebelumnya) juga dapat didefinisikan ulang sebagai berikut:

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

Favorit pribadi saya adalah kemampuan untuk menggunakan kembali opsi dalam banyak konstruktor:

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

Ringkasan


Parameter fungsional adalah template yang kuat untuk membuat API yang bersih dan dapat diperluas dengan puluhan parameter. Meskipun ini sedikit lebih berhasil daripada mendukung struktur konfigurasi yang sederhana, ini memberikan lebih banyak fleksibilitas dan menyediakan API yang lebih bersih daripada alternatif.

All Articles