- Sản phẩm & Dịch vụSản phẩm & Dịch vụ
- Giải phápGiải pháp
- Bảng giáBảng giá
- Công tyCông ty
- Tài liệuTài liệu
vi
vi
Chuyên mục với các bài viết hướng dẫn, nghiên cứu và phân tích chi tiết về kỹ thuật, các xu hướng công nghệ mới nhất dành cho lập trình viên.
Tech 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 🦆.
Thảo luận (0)
Đăng nhập để thảo luận

💡 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.
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.
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ể.
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.
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.
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.
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ộ.
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ó.
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:
// <https://github.com/grpc/grpc-go/blob/master/server.go#L198>
type ServerOption interface {
apply(*serverOptions)
}
// <https://github.com/grpc/grpc-go/blob/master/server.go#L215>
type funcServerOption struct {
f func(*serverOptions)
}
func (fdo *funcServerOption) apply(do *serverOptions) {
fdo.f(do)
}
// <https://github.com/grpc/grpc-go/blob/master/server.go#L223>
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 apply là unexported 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: interface có unexported 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ự:
// <https://github.com/uber-go/zap/blob/master/options.go#L30>
type Option interface {
apply(*Logger)
}
// <https://github.com/uber-go/zap/blob/master/options.go#L35>
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 L98 và WrapCore 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.
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.
package httpclientimport ( "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.
type Option func(*client)
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
}
}
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
}
client := httpclient.New("<https://api.example.com>",
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ó.
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.
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) errorfunc 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.
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.
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.
WithTimeout(5*time.Second) mang ngữ nghĩa rõ ràng hơn nhiều so với truyền tham số theo vị trí
With... tương ứng// 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
}
}
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)
}
}
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("<https://api.example.com>", 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.
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.
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.
| 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.
grpc-go source: https://github.com/grpc/grpc-go/blob/master/server.gouber-go/zap source: https://github.com/uber-go/zap/blob/master/options.goaws-sdk-go-v2: https://github.com/aws/aws-sdk-go-v2