خيارات وظيفية على المنشطات

مرحبا يا هابر! أقدم لكم ترجمة المقال الخيارات الوظيفية على المنشطات بواسطة Márk Sági-Kazár .

صورة

الخيارات الوظيفية هي نموذج Go لواجهات برمجة تطبيقات نظيفة وقابلة للتوسيع. وهي شعبية من قبل ديف تشيني و روب بايك . تدور هذه المشاركة حول الممارسات التي كانت موجودة حول النموذج منذ تقديمه لأول مرة.

ظهرت الخيارات الوظيفية كوسيلة لإنشاء واجهات برمجة تطبيقات جيدة ونظيفة بتكوين يتضمن معلمات اختيارية. هناك العديد من الطرق الواضحة للقيام بذلك (المُنشئ ، وهيكل التهيئة ، والمستوطنين ، وما إلى ذلك) ، ولكن عندما تحتاج إلى اجتياز العشرات من الخيارات ، يتم قراءتها بشكل ضعيف ولا تعطي واجهات برمجة تطبيقات جيدة مثل الخيارات الوظيفية.

مقدمة - ما هي الخيارات الوظيفية؟


عادة ، عند إنشاء "كائن" ، يمكنك استدعاء المُنشئ بالحجج اللازمة:

obj := New(arg1, arg2)

(دعنا نتجاهل حقيقة عدم وجود مُنشئين تقليديين في Go.)

تسمح لك الخيارات الوظيفية بتوسيع API بمعلمات إضافية ، وتحويل السطر أعلاه إلى ما يلي:

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

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

الخيارات الوظيفية هي في الأساس وسيطات لدالة متغيرة تأخذ كمعلمات نوع تكوين مركب أو وسيط. نظرًا لطبيعتها المتغيرة ، من المقبول تمامًا استدعاء المُنشئ بدون أي خيارات ، والحفاظ على نظافته ، حتى إذا كنت ترغب في استخدام القيم الافتراضية.

لتوضيح النمط بشكل أفضل ، دعنا نلقي نظرة على مثال واقعي (بدون خيارات وظيفية):

type Server struct {
    addr string
}

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

بعد إضافة خيار المهلة ، يبدو الرمز كما يلي:

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
}

واجهة برمجة التطبيقات الناتجة سهلة الاستخدام وسهلة القراءة:

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


للمقارنة ، فيما يلي كيف تبدو التهيئة باستخدام المُنشئ واستخدام بنية تكوين التهيئة:

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

ربما تكون ميزة استخدام الخيارات الوظيفية على المنشئ واضحة: فهي أسهل في الصيانة والقراءة / الكتابة. تتجاوز المعلمات الوظيفية أيضًا بنية التكوين عندما لا يتم تمرير المعلمات إلى المنشئ. في الأقسام التالية سأعرض المزيد من الأمثلة حيث قد لا ترقى بنية التكوين إلى مستوى التوقعات.

اقرأ التاريخ الكامل للخيارات الوظيفية بالضغط على الروابط في المقدمة. (ملاحظة المترجم - مدونة بمقالة أصلية كتبها Dave Cheney ليست متاحة دائمًا في روسيا وقد تتطلب اتصال VPN لعرضها. أيضًا ، تتوفر معلومات من المقالة في فيديو تقريره عن dotGo 2014 )

ممارسات الخيارات الوظيفية


الخيارات الوظيفية نفسها ليست أكثر من وظائف تم تمريرها إلى المنشئ. توفر سهولة استخدام الوظائف البسيطة المرونة ولديها إمكانات كبيرة. لذلك ، ليس من المستغرب أنه على مر السنين ظهرت العديد من الممارسات حول القالب. فيما يلي ، سأقدم قائمة بما أعتبره أكثر الممارسات شعبية وإفادة.

أرسل لي إذا كنت تعتقد أن هناك شيء مفقود.

نوع الخيارات


أول شيء يمكنك القيام به عند تطبيق قالب الخيارات الوظيفية هو تحديد نوع الوظيفة الاختيارية:

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

على الرغم من أن هذا قد لا يبدو تحسنًا كبيرًا ، إلا أن الكود يصبح أكثر قابلية للقراءة باستخدام اسم النوع بدلاً من تحديد دالة:

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

ميزة أخرى لامتلاك نوع خيار هي أن Godoc يضع خيارات الخيار تحت النوع:

صورة

قائمة أنواع الخيارات


عادة ، يتم استخدام المعلمات الوظيفية لإنشاء مثيل واحد لشيء ما ، ولكن هذا ليس هو الحال دائمًا. إعادة استخدام قائمة المعلمات الافتراضية عند إنشاء مثيلات متعددة أمر غير شائع أيضًا:

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

هذا ليس رمزًا قابلاً للقراءة للغاية ، خاصة بالنظر إلى أن الهدف من استخدام الخيارات الوظيفية هو إنشاء واجهات برمجة تطبيقات ودية. لحسن الحظ ، هناك طريقة لتبسيط ذلك. نحتاج فقط إلى تقطيع الخيار [] مباشرةً باستخدام الخيار:

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

بعد استبدال الشريحة بوظيفة الخيارات ، يصبح الرمز أعلاه:

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

البادئات لخيارات مع / تعيين


غالبًا ما تكون الخيارات أنواعًا معقدة ، على عكس المهلة أو الحد الأقصى لعدد الاتصالات. على سبيل المثال ، في حزمة الخادم ، يمكنك تحديد واجهة Logger كخيار (واستخدامها إذا لم يكن هناك مسجل افتراضي):

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

من الواضح أنه لا يمكن استخدام اسم المسجل كاسم الخيار ، لأنه مستخدم بالفعل من قبل الواجهة. يمكنك استدعاء خيار LoggerOption ، ولكنه ليس اسمًا مألوفًا للغاية. إذا نظرت إلى المُنشئ كجملة ، في حالتنا ، تأتي الكلمة مع: WithLogger.

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

مثال آخر شائع لخيار نوع معقد هو شريحة من القيم:

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

في هذه الحالة ، يقوم الخيار عادةً بإلحاق القيم بالقيم الموجودة بدلاً من استبدالها. إذا كنت بحاجة إلى الكتابة فوق مجموعة قيم موجودة ، يمكنك استخدام مجموعة الكلمات في اسم الخيار:

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
)

قالب مسبق


عادة ما تكون حالات الاستخدام الخاصة شاملة بما يكفي لدعمها في المكتبة. في حالة التكوين ، قد يعني هذا مجموعة من المعلمات مجمعة معًا واستخدامها كإعداد مسبق للاستخدام. في مثالنا ، يمكن أن يكون للخادم سيناريوهات استخدام مفتوحة وداخلية تحدد المهلات وحدود السرعة وعدد الاتصالات وما إلى ذلك بطرق مختلفة.

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

على الرغم من أن الإعدادات المسبقة قد تكون مفيدة في بعض الحالات ، إلا أنها ربما تكون ذات قيمة كبيرة في المكتبات الداخلية ، وفي المكتبات العامة تكون قيمتها أقل.

قيم النوع الافتراضية مقابل القيم الافتراضية المعدة مسبقًا


Go دائمًا له قيم افتراضية. بالنسبة للأرقام ، يكون هذا عادةً صفرًا ، أما بالنسبة للأنواع المنطقية ، فهو خطأ ، وهكذا. في التكوين الاختياري ، يعتبر من الممارسات الجيدة الاعتماد على القيم الافتراضية. على سبيل المثال ، يجب أن تعني القيمة الخالية مهلة غير محدودة بدلاً من غيابها (وهو أمر لا معنى له عادةً).

في بعض الحالات ، لا تكون قيمة النوع الافتراضي قيمة افتراضية جيدة. على سبيل المثال ، القيمة الافتراضية لـ Logger هي لا شيء ، والتي يمكن أن تؤدي إلى الذعر (إذا لم تقم بحماية مكالمات المسجل من خلال عمليات الفحص المشروطة).

في هذه الحالات ، يعد تعيين القيمة في المُنشئ (قبل تطبيق المعلمات) طريقة جيدة لتحديد الاحتياطي:

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
}

لقد رأيت أمثلة مع الإعدادات المسبقة الافتراضية (باستخدام النموذج الموضح في القسم السابق). ومع ذلك ، لا أعتبر هذه ممارسة جيدة. هذا أقل تعبيرًا من مجرد تعيين القيم الافتراضية في المُنشئ:

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
}

خيار هيكل التكوين


ربما لا يكون وجود بنية تكوين كخيار وظيفي شائعًا ، ولكن يتم العثور على هذا في بعض الأحيان. الفكرة هي أن الخيارات الوظيفية تشير إلى هيكل التكوين بدلاً من الإشارة إلى الكائن الفعلي الذي يتم إنشاؤه:

type Config struct {
    Timeout time.Duration
}

type Option func(c *Config)

type Server struct {
    // ...

    config Config
}

هذا القالب مفيد عندما يكون لديك العديد من الخيارات ، ويبدو إنشاء بنية تكوين أنظف من سرد جميع الخيارات في استدعاء دالة:

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

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

حالة استخدام أخرى لهذا القالب هي تعيين القيم الافتراضية:

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

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

قوالب متقدمة


بعد كتابة العشرات من الخيارات الوظيفية ، قد تتساءل عما إذا كانت هناك طريقة أفضل للقيام بذلك. ليس من حيث الاستخدام ، ولكن من حيث الدعم على المدى الطويل.

على سبيل المثال ، ماذا لو تمكنا من تحديد الأنواع واستخدامها كمعلمات:

type Timeout time.Duration

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

(لاحظ أن استخدام واجهة برمجة التطبيقات لا يزال

كما هو ) . وتبين أنه من خلال تغيير نوع الخيار ، يمكننا القيام بذلك بسهولة:

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

يؤدي تجاوز وظيفة اختيارية كواجهة إلى فتح الباب لعدد من الطرق الجديدة لتنفيذ الخيارات الوظيفية:

يمكن استخدام أنواع مدمجة مختلفة كمعلمات بدون وظيفة غلاف:

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

يمكن أيضًا إعادة تحديد قوائم الخيارات وهياكل التكوين (انظر الأقسام السابقة) على النحو التالي:

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

المفضلة الشخصية هي القدرة على إعادة استخدام الخيار في العديد من المنشئات:

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

ملخص


تعد المعلمات الوظيفية نموذجًا قويًا لإنشاء واجهات برمجة تطبيقات نظيفة وقابلة للتوسيع مع عشرات المعلمات. على الرغم من أن هذا يقوم بعمل أكثر بقليل من دعم بنية تكوين بسيطة ، إلا أنه يوفر مرونة أكبر بكثير ويوفر واجهات برمجة تطبيقات أكثر نظافة من البدائل.

All Articles