CyStack logo
  • Sản phẩm & Dịch vụ
  • Giải pháp
  • Bảng giá
  • Công ty
  • Tài liệu
Vi

vi

Mục lục

Trang chủBlogFunctional Options trong ...
Go

Functional Options trong Go: Dùng thế nào cho hiệu quả

12 phút đọc02/03/2026
CyStack Author
Dương Trần

Tech enthusiast on a lifelong quest to break, build, and secure cool stuff. Known in the team as the go-to rubber duck 🦆.

0 lượt xem
Reading Time: 12 minutes

Functional Options là gì và cách để sử dụng hiệu quả, chuẩn chỉ

💡 Phần lớn tài liệu về functional options chỉ dừng lại ở việc định nghĩa pattern. Bài viết này phân tích sâu hơn – bối cảnh áp dụng, giới hạn của pattern, và các trường hợp không nên sử dụng – dựa trên codebase thực tế của grpc-go, zap, và aws-sdk-go-v2.


Vấn đề thiết kế API trong quá trình phát triển phần mềm

Xét một constructor được thiết kế ban đầu với hai tham số:

func NewServer(host string, port int) *Server

Theo thời gian, khi yêu cầu hệ thống mở rộng, constructor tích lũy thêm các tham số: timeout, TLS, giới hạn connection, retry policy. Sau sáu tháng phát triển, hàm khởi tạo có thể trở thành:

func NewServer(
    host          string,
    port          int,
    timeout       time.Duration,
    tlsConfig     *tls.Config,
    maxConns      int,
    retryPolicy   RetryPolicy,
    logger        Logger,
    enableMetrics bool,
) *Server

Hệ quả là mọi caller đều phải truyền đủ 8 argument, kể cả khi chỉ cần sử dụng 2 trong số đó. Nghiêm trọng hơn, việc bổ sung tham số thứ 9 sẽ phá vỡ toàn bộ code đang import thư viện này.

Đây là bài toán API evolution – một trong những thách thức thiết kế phổ biến nhất khi xây dựng thư viện. Functional options là giải pháp được cộng đồng Go đúc kết qua thực tiễn để giải quyết bài toán này.


Các phương pháp tiếp cận trước đây và hạn chế

Trước khi functional options được phổ biến rộng rãi, có một số phương án được sử dụng. Mỗi phương án đều có những hạn chế đáng kể.

Boolean parameters

func NewServer(host string, port int, enableTLS bool, enableMetrics bool, enableRetry bool) *Server

Tại vị trí gọi hàm, đoạn code trở nên khó hiểu:

NewServer("localhost", 8080, false, true, false)

Giá trị true ở vị trí thứ tư không có ý nghĩa gì. Người đọc buộc phải tra cứu function signature để hiểu ý nghĩa của từng argument. Cách tiếp cận này không khả thi khi số lượng flag tăng lên.

Config struct

type ServerConfig struct {
    Host      string
    Port      int
    Timeout   time.Duration
    TLSConfig *tls.Config
}

func NewServer(cfg ServerConfig) *Server

Cách tiếp cận này giúp code dễ đọc hơn nhờ named fields. Tuy nhiên, vẫn còn một vấn đề: không thể phân biệt giữa “người dùng chủ động gán giá trị zero” và “người dùng không gán giá trị”. Khi Timeout bằng zero, không thể xác định đây là cố ý hay vô tình bỏ sót. Ngoài ra, tất cả fields phải được export, khiến cấu trúc nội bộ trở thành một phần của public API – làm hạn chế khả năng tái cấu trúc trong tương lai.

Nil parameters

func NewServer(host string, port int, tlsConfig *tls.Config) *Server
// truyền nil nếu không sử dụng TLS

Phương án này chấp nhận được với một tham số tùy chọn duy nhất. Khi số lượng tham số tùy chọn tăng lên, code sẽ trở nên khó đọc hơn.

Tại sao functional options giải quyết được vấn đề này

Pattern functional options được bác Rob Pike giới thiệu và được bác Dave Cheney phổ biến qua bài viết năm 2014. Pattern này giải quyết đồng thời các hạn chế trên: mỗi option tự mô tả ý nghĩa của mình, các option có thể kết hợp linh hoạt, backward-compatible theo mặc định, và không yêu cầu export cấu trúc nội bộ.


Cách hoạt động

Dưới đây là toàn bộ pattern ở dạng tối giản:

type Server struct {
    host    string
    port    int
    timeout time.Duration
}

// Option là một hàm nhận con trỏ Server và thay đổi cấu hình của nó.
type Option func(*Server)

func WithTimeout(t time.Duration) Option {
    return func(s *Server) {
        s.timeout = t
    }
}

func WithPort(port int) Option {
    return func(s *Server) {
        s.port = port
    }
}

func NewServer(host string, opts ...Option) *Server {
    // Start with sensible defaults.
    s := &Server{
        host:    host,
        port:    8080,
        timeout: 30 * time.Second,
    }
    // Apply each option on top of the defaults.
    for _, opt := range opts {
        opt(s)
    }
    return s
}

Tại vị trí gọi hàm, mỗi tham số được đặt tên rõ ràng:

srv := NewServer("localhost",
    WithPort(9090),
    WithTimeout(10*time.Second),
)

Khi cần bổ sung option mới, chỉ cần định nghĩa thêm một hàm With... mà không ảnh hưởng đến bất kỳ caller nào hiện có.


Ứng dụng thực tế

Cách tốt nhất để đánh giá một pattern là xem các dự án lớn áp dụng nó như thế nào trong môi trường production.

grpc-go – thư viện gRPC của Google

📄 Nguồn: grpc/grpc-go — server.go, L198–L228

Thay vì sử dụng kiểu hàm đơn giản, grpc-go định nghĩa ServerOption dưới dạng interface với method apply không được export:

// 
type ServerOption interface {
    apply(*serverOptions)
}

// 
type funcServerOption struct {
    f func(*serverOptions)
}

func (fdo *funcServerOption) apply(do *serverOptions) {
    fdo.f(do)
}

// 
func newFuncServerOption(f func(*serverOptions)) *funcServerOption {
    return &funcServerOption{f: f}
}

Việc sử dụng interface thay vì type Option func(*serverOptions) xuất phát từ hai yêu cầu thiết kế cụ thể.

Yêu cầu thứ nhất: kiểm soát nguồn gốc của option. Do applyunexported method, không có code nào bên ngoài package có thể implement ServerOption. Đây là tính chất của Go: interfaceunexported method chỉ có thể được thỏa mãn bởi code trong cùng package. Theo quy tắc này, caller bên ngoài có thể sử dụng các option grpc-go cung cấp – như grpc.MaxRecvMsgSize – nhưng không thể tự định nghĩa thêm.

Yêu cầu thứ hai: khả năng mở rộng thông qua type assertion. Với kiểu hàm thuần, một option không mang thêm thông tin nào ngoài logic thực thi. Với interface, grpc-go có thể kiểm tra tại runtime xem một option có implement thêm behavior nào không:

// grpc-go có thể mở rộng theo hướng này trong tương lai:
if s, ok := opt.(fmt.Stringer); ok {
    log.Println("applying option:", s.String())
}

Khả năng mở rộng này chỉ có được khi option là interface – kiểu hàm thuần không hỗ trợ điều này.

Tại vị trí gọi hàm, API vẫn giữ được sự gọn gàng:

grpc.NewServer(
    grpc.MaxRecvMsgSize(1024),
    grpc.Creds(credentials.NewTLS(tlsCfg)),
)

📄 Để hiểu cách từng option như MaxRecvMsgSize được xây dựng, có thể tìm kiếm newFuncServerOption trong server.go – mỗi lần gọi hàm này tạo ra một concrete option.

uber-go/zap – thư viện logging của Uber

📄 Nguồn: uber-go/zap — options.go, L30–L40

zap áp dụng cách tiếp cận interface tương tự:

// 
type Option interface {
    apply(*Logger)
}

// 
type optionFunc func(*Logger)

func (f optionFunc) apply(log *Logger) {
    f(log)
}

Các option được truyền vào tại thời điểm khởi tạo thông qua zap.New:

logger := zap.New(core,
    zap.WithCaller(true),
    zap.AddCallerSkip(1),
)

📄 Tham khảo WithCaller tại L98WrapCore tại L42 – mỗi option là một hàm ngắn gọn trả về optionFunc.

📄 Uber Go Style Guide trình bày chi tiết lý do lựa chọn interface thay vì closure thuần.

aws-sdk-go-v2 – AWS SDK

📄 Nguồn: aws/aws-sdk-go-v2 — api_op_PutObject.go, L160

AWS SDK v2 áp dụng biến thể đơn giản hơn – nhận trực tiếp ...func(*Options) mà không định nghĩa kiểu interface có tên:

func (c *Client) PutObject(
    ctx context.Context,
    params *PutObjectInput,
    optFns ...func(*Options),   // ← hàm thuần, không đặt tên kiểu
) (*PutObjectOutput, error)

Đây là dạng tối giản của pattern. SDK bao gồm hàng trăm service và phần lớn code được sinh tự động, do đó ưu tiên tính đồng nhất hơn khả năng discoverability. Toàn bộ SDK – S3, EC2, DynamoDB – tuân theo cùng một cấu trúc.


Ví dụ thực tế: HTTP client

Phần này trình bày cách xây dựng một HTTP client hoàn chỉnh theo pattern này.

Bước 1 – Định nghĩa cấu trúc cấu hình nội bộ

package httpclient

import (
    "net/http"
    "time"
)

type client struct {
    baseURL    string
    timeout    time.Duration
    retries    int
    headers    map[string]string
    httpClient *http.Client
}

Tất cả fields đều không được export. Caller chỉ có thể cấu hình thông qua các hàm option được cung cấp.

Bước 2 – Định nghĩa kiểu Option

type Option func(*client)

Bước 3 – Định nghĩa các hàm option

func WithTimeout(d time.Duration) Option {
    return func(c *client) {
        c.timeout = d
    }
}

func WithRetries(n int) Option {
    return func(c *client) {
        c.retries = n
    }
}

func WithHeader(key, value string) Option {
    return func(c *client) {
        c.headers[key] = value
    }
}

func WithHTTPClient(hc *http.Client) Option {
    return func(c *client) {
        c.httpClient = hc
    }
}

Bước 4 – Constructor với giá trị mặc định

func New(baseURL string, opts ...Option) *client {
    c := &client{
        baseURL:    baseURL,
        timeout:    30 * time.Second,
        retries:    3,
        headers:    make(map[string]string),
        httpClient: &http.Client{},
    }
    for _, opt := range opts {
        opt(c)
    }
    // Apply the configured timeout to the underlying http.Client.
    c.httpClient.Timeout = c.timeout
    return c
}

Bước 5 – Sử dụng

client := httpclient.New("",
    httpclient.WithTimeout(5*time.Second),
    httpclient.WithRetries(5),
    httpclient.WithHeader("Authorization", "Bearer "+token),
)

Không có giá trị nil, không phụ thuộc vào thứ tự argument. Việc bổ sung option mới trong tương lai không ảnh hưởng đến bất kỳ caller nào hiện có.


Kỹ thuật nâng cao

1. Preset options

Thay vì để caller tự ghép từng option lại, có thể đóng gói một tập hợp option dưới một tên có ý nghĩa rõ ràng:

func ProductionDefaults() Option {
    return func(c *client) {
        c.timeout = 10 * time.Second
        c.retries = 5
        c.headers["User-Agent"] = "myapp/1.0"
    }
}

// Áp dụng preset, sau đó ghi đè một giá trị cụ thể.
client := New(url, ProductionDefaults(), WithHeader("X-Debug", "true"))

Cách tiếp cận này phù hợp khi cần duy trì các cấu hình chuẩn cho từng môi trường triển khai.

2. Option có thể trả về lỗi

Khi giá trị cấu hình không hợp lệ có thể gây ra lỗi nghiêm trọng, option có thể được thiết kế để trả về error, cho phép constructor dừng sớm:

type Option func(*client) error

func WithRetries(n int) Option {
    return func(c *client) error {
        if n < 0 {
            return fmt.Errorf("retries must be non-negative, got %d", n)
        }
        c.retries = n
        return nil
    }
}

func New(baseURL string, opts ...Option) (*client, error) {
    c := &client{
        baseURL:    baseURL,
        timeout:    30 * time.Second,
        retries:    3,
        headers:    make(map[string]string),
        httpClient: &http.Client{},
    }
    for _, opt := range opts {
        if err := opt(c); err != nil {
            return nil, err
        }
    }
    return c, nil
}

Lưu ý: cách tiếp cận này thay đổi hoàn toàn signature của kiểu Option và không thể kết hợp với kiểu không trả về lỗi. Cần lựa chọn một convention và áp dụng nhất quán trong toàn bộ package. Nên cân nhắc kỹ trước khi áp dụng – chỉ sử dụng khi việc truyền giá trị không hợp lệ thực sự gây hậu quả nghiêm trọng.

3. Option có khả năng tự mô tả

Bằng cách bổ sung method String() vào interface, mỗi option có thể ghi lại thông tin mô tả trong quá trình khởi tạo, hỗ trợ việc chẩn đoán sự cố:

type Option interface {
    apply(*client)
    String() string // human-readable description of what this option does
}

for _, opt := range opts {
    log.Println("applying option:", opt.String())
    opt.apply(c)
}

Đánh đổi là mỗi option phải được định nghĩa dưới dạng struct thay vì closure đơn giản, làm tăng lượng code cần viết. Cách tiếp cận này chỉ nên áp dụng khi yêu cầu về observability quan trọng hơn sự ngắn gọn – chẳng hạn trong thư viện mà người dùng thường gặp khó khăn khi chẩn đoán cấu hình.

4. Tạo bản sao với cấu hình được điều chỉnh

Method With cho phép tạo một bản sao của đối tượng với một số option được ghi đè, mà không ảnh hưởng đến đối tượng gốc:

func (c *client) With(opts ...Option) *client {
    copy := *c // shallow copy
    for _, opt := range opts {
        opt(©)
    }
    return ©
}

Lưu ý quan trọng: đây là shallow copy. Các field là map hoặc pointer sẽ được chia sẻ giữa bản gốc và bản sao. Cần thực hiện deep copy thủ công cho những field đó trước khi áp dụng option nếu muốn hai đối tượng hoàn toàn độc lập.


Ưu nhược điểm

✅ Ưu điểm

  • Backward compatible – việc bổ sung option mới không ảnh hưởng đến bất kỳ caller nào hiện có, vì signature của constructor không thay đổi
  • Code dễ đọc tại nơi gọi hàmWithTimeout(5*time.Second) mang ngữ nghĩa rõ ràng hơn nhiều so với truyền tham số theo vị trí
  • Giá trị mặc định được tập trung tại constructor – constructor chịu trách nhiệm thiết lập giá trị mặc định; caller chỉ cần chỉ định những gì cần thay đổi
  • Composable – các option là hàm thông thường, có thể lưu trữ, kết hợp và tái sử dụng
  • Bảo vệ cấu trúc nội bộ – cấu trúc cấu hình không được export, giúp kiểm soát hoàn toàn những gì có thể được cấu hình từ bên ngoài

Cách sử dụng Functional Options trong Go một cách hiệu quả, ưu nhược điểm trong việc sử dụng phần này

❌ Nhược điểm

  • Khó biết có những option nào – IDE có thể gợi ý autocomplete cho các field của struct tốt hơn so với variadic options; người mới tham gia dự án khó biết được các option nào tồn tại nếu không có tài liệu đầy đủ
  • Lỗi thứ tự không được cảnh báo – khi hai option cùng thiết lập một field, option được áp dụng sau sẽ ghi đè, không có cảnh báo từ compiler
  • Lượng code lặp lại – một struct với 10 fields đòi hỏi phải định nghĩa 10 hàm With... tương ứng
  • Khó kiểm thử trực tiếp – không thể trực tiếp kiểm tra giá trị bên trong closure; cần sử dụng kiểm thử theo hành vi thay vì so sánh trực tiếp giá trị cấu hình

Tiêu chí lựa chọn

Nên áp dụng khi

  • Xây dựng thư viện để các dự án khác import, đặc biệt khi cần đảm bảo backward compatibility lâu dài
  • Constructor có nhiều hơn 3-4 tham số tùy chọn và số lượng này có xu hướng tăng theo thời gian
  • Cần giữ kiểu cấu hình không được export để kiểm soát hoàn toàn API surface

Không nên áp dụng khi

  • Chỉ có 1-2 tham số tùy chọn – config struct đơn giản hơn và đáp ứng đủ nhu cầu mà không cần thêm tầng trừu tượng
  • Code nội bộ của ứng dụng, không yêu cầu versioning – việc thay đổi trực tiếp signature vẫn kiểm soát được
  • Nhóm phát triển đang làm quen với Go và chưa quen thuộc với pattern này – learning curve có thể làm giảm tốc độ phát triển
  • Tất cả các fields đều bắt buộc và không có giá trị mặc định hợp lý – pattern này được thiết kế để xử lý cấu hình tùy chọn; áp dụng cho các fields bắt buộc chỉ làm tăng thêm boilerplate không cần thiết

Các vấn đề thường gặp

1. Lỗi thứ tự không được cảnh báo

// Hai dòng này cho kết quả khác nhau; compiler không phát sinh cảnh báo.
srv := New(url, WithTimeout(5*time.Second), WithTimeout(30*time.Second))   // timeout = 30s
srv := New(url, WithTimeout(30*time.Second), WithTimeout(5*time.Second))   // timeout = 5s

Option được áp dụng sau sẽ ghi đè. Khi các option đến từ nhiều vị trí khác nhau trong codebase và cùng thiết lập một field, kết quả phụ thuộc vào thứ tự truyền vào – loại lỗi này rất khó phát hiện trong quá trình review.

Giải pháp: bổ sung field timeoutSet bool để bỏ qua các option trùng lặp:

type client struct {
    baseURL     string
    timeout     time.Duration
    timeoutSet  bool  // add this field
    retries     int
    headers     map[string]string
    httpClient  *http.Client
}

func WithTimeout(d time.Duration) Option {
    return func(c *client) {
        if c.timeoutSet {
            return // ignore later calls
        }
        c.timeout = d
        c.timeoutSet = true
    }
}

2. Nil option gây panic

var opt Option  // nil function value
srv := New(url, opt)  // panics when opt(c) is called

Khi nhận option từ bên ngoài hoặc lưu trong slice, cần kiểm tra nil trước khi gọi:

for _, opt := range opts {
    if opt != nil {
        opt(c)
    }
}

3. Kiểm thử cấu hình

Không thể trực tiếp kiểm tra giá trị bên trong closure. Đoạn code sau không hoạt động:

// Does NOT work — cannot inspect a closure.
opt := WithTimeout(5 * time.Second)
assert.Equal(t, 5*time.Second, opt.timeout) // ❌ no such field

Có hai hướng tiếp cận với các đánh đổi khác nhau.

Hướng A – Expose getter. Bổ sung method được export để lộ giá trị nội bộ:

func (c *client) Timeout() time.Duration {
    return c.timeout
}

Cho phép kiểm thử trực tiếp:

c := New("", WithTimeout(5*time.Second))
assert.Equal(t, 5*time.Second, c.Timeout()) // ✅ works

Hạn chế: mỗi getter bổ sung là một phần mở rộng của public API, theo thời gian làm lộ cấu trúc nội bộ ra ngoài.

Hướng B – Kiểm thử theo hành vi. Thay vì kiểm tra giá trị cấu hình, kiểm tra hành vi thực tế của đối tượng. Với timeout, có thể dựng một test server không phản hồi và xác nhận rằng client báo lỗi đúng thời điểm:

func TestWithTimeout(t *testing.T) {
    // A server that never responds.
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(10 * time.Second)
    }))
    defer srv.Close()

    c := New(srv.URL, WithTimeout(100*time.Millisecond))
    _, err := c.Get("/")

    // We do not care about the exact timeout value — we care that it timed out.
    assert.ErrorIs(t, err, context.DeadlineExceeded)
}

Cách này thực tế hơn vì nó xác nhận option thực sự có tác dụng, không chỉ là field được gán đúng giá trị. Getter test có thể pass ngay cả khi WithTimeout gán field nhưng field đó không được áp dụng vào http.Client bên dưới – behavioral test sẽ phát hiện đúng lỗi này.

Khuyến nghị: trong hầu hết trường hợp, kiểm thử theo hành vi hiệu quả hơn vì bắt được các lỗi tích hợp mà getter test bỏ qua.

4. Caller tự định nghĩa option khi sử dụng kiểu hàm thuần

Vấn đề này phát sinh khi kiểu cấu hình được export – như Options trong AWS SDK. Khi đó, nếu Option là kiểu hàm thuần, package bên ngoài hoàn toàn có thể tự định nghĩa option function và truyền vào, vượt ngoài tầm kiểm soát của tác giả thư viện.

Khi kiểu cấu hình không được export như client trong ví dụ này, caller bên ngoài không thể tham chiếu đến tên kiểu nên không thể viết option – vấn đề không phát sinh. Tuy nhiên, với kiểu cấu hình được export, sử dụng interface với unexported method là giải pháp kiểm soát chặt chẽ nhất:

type Option interface {
    apply(*Server) // unexported — only your package can implement this
}

Đây là cách tiếp cận của grpc-go, phù hợp với bất kỳ public API nào cần kiểm soát hoàn toàn option surface.

5. Bẫy biến trong vòng lặp

Lỗi thường gặp khi tạo option bên trong vòng lặp:

opts := make([]Option, len(timeouts))
for i, t := range timeouts {
    // Bug: all closures share the same t variable.
    // By the time they run, t holds the last value from the loop.
    opts[i] = func(c *client) { c.timeout = t }
}

Giải pháp: tạo biến cục bộ mới trong mỗi iteration:

for i, t := range timeouts {
    t := t  // new variable, scoped to this iteration
    opts[i] = func(c *client) { c.timeout = t }
}

Go 1.22 trở lên đã xử lý vấn đề này theo mặc định. Với các dự án còn hỗ trợ phiên bản cũ hơn, cần chú ý đặc biệt – tham khảo thêm tại đây.


So sánh: Functional Options, Builder Pattern, và Config Struct

Functional Options Builder Pattern Config Struct
Backward compatible ✅ Xuất sắc ✅ Tốt ✅ Tốt – nhưng zero value có thể mơ hồ
Dễ đọc tại nơi gọi hàm ✅ Tốt ✅ Rất tốt (method chaining) ✅ Rất tốt (named fields)
Bảo vệ cấu trúc nội bộ ✅ Có ✅ Có ❌ Thường không
Khả năng kiểm thử ⚠️ Behavioral only ✅ Có ✅ Có
Idiomatic Go ✅ Có ⚠️ Ít phổ biến hơn ✅ Có
Phù hợp nhất cho Thư viện & SDK Builder phức tạp Code nội bộ

Tóm lại: với code được thiết kế để các dự án khác import, functional options là lựa chọn phù hợp. Với code nội bộ của ứng dụng, config struct đơn giản hơn và đáp ứng đủ nhu cầu.


Tài liệu tham khảo

Về tác giả

Dương Trần
Dương TrầnTech enthusiast on a lifelong quest to break, build, and secure cool stuff. Known in the team as the go-to rubber duck 🦆.

Tech enthusiast on a lifelong quest to break, build, and secure cool stuff. Known in the team as the go-to rubber duck 🦆.

Cập nhật thông tin mới nhấtNhận các thông tin mới nhất về mối đe dọa, báo cáo an ninh mạng từ CyStack về hòm thư điện tử của bạn

Thảo luận (0)

Đăng nhập để thảo luận

Bài viết liên quan