Khi một constructor cần nhiều tham số cấu hình mà phần lớn có giá trị mặc định hợp lý, Go không có tham số mặc định hay tham số đặt tên như Python. Mẫu thiết kế thanh lịch mà cộng đồng Go hội tụ về là functional options — dùng closure làm tham số cấu hình. Bạn thấy nó khắp thư viện chuẩn và bên thứ ba (grpc.Dial, http.Server...). Bài trước đã chạm mẫu cơ bản; bài này đi sâu vào các kỹ thuật nâng cao: option tự validate và trả lỗi, tương thích ngược, và đo thật chi phí thực sự của closure.

Option là closure, và có thể trả lỗi

Cốt lõi: định nghĩa một kiểu hàm Option nhận con trỏ tới struct cấu hình. Kỹ thuật nâng cao đầu tiên là cho option trả về error để tự validate:

type Option func(*Server) error // kiểu hàm đặt tên -> API sạch

func WithTimeout(d time.Duration) Option {
	return func(s *Server) error {
		if d <= 0 { return errors.New("timeout phải > 0") } // option TỰ validate
		s.timeout = d
		return nil
	}
}
func WithTLS() Option { return func(s *Server) error { s.tls = true; return nil } }

WithTimeout là một hàm trả về một closure. Closure đó bắt giữ giá trị d và khi được gọi sẽ sửa Server — nhưng trước tiên kiểm tra d hợp lệ. Đây là điểm mạnh so với config struct: validation nằm ngay tại option, không phải rải rác sau khi gán.

Ảnh chụp đoạn mã Go nền tối minh hoạ functional options nâng cao closure làm tham số cấu hình, Option là closure sửa struct có thể trả lỗi type Option func con trỏ Server error kiểu hàm đặt tên func WithTimeout d time Duration Option return func s con trỏ Server error nếu d nhỏ hơn bằng 0 return errors New timeout phải lớn hơn 0 tự validate s timeout bằng d return nil func WithTLS Option return func s con trỏ Server error s tls bằng true return nil, constructor default hợp lý cộng áp option gộp lỗi func NewServer addr string opts biến thiên Option trả con trỏ Server error s bằng Server addr addr timeout 30 giây retries 3 default for opt range opts nếu err bằng opt s err khác nil return nil fmt Errorf cấu hình server phần trăm w bọc lỗi return s nil, gọi chỉ khai cái cần đổi s bằng NewServer localhost 8080 WithTimeout 5 giây WithTLS còn lại default s2 bằng NewServer localhost 9090 toàn default, vì sao hơn config struct thêm option mới không phá API cũ tương thích ngược option tự validate gộp lỗi tại constructor phân biệt không đặt và đặt bằng zero value rõ ràng option có thể làm việc phức tạp mở kết nối đọc env

Hình 1: Option là kiểu func(*Server) error — closure sửa struct và tự validate. Constructor đặt default hợp lý rồi áp lần lượt các option, bọc lỗi bằng %w. Người gọi chỉ khai cái cần đổi.

Constructor: default hợp lý, gộp lỗi

Constructor đặt giá trị mặc định trước, rồi áp lần lượt các option. Nếu option nào trả lỗi, dừng ngay và bọc lỗi bằng %w để giữ chuỗi lỗi:

func NewServer(addr string, opts ...Option) (*Server, error) {
	s := &Server{addr: addr, timeout: 30 * time.Second, retries: 3} // default
	for _, opt := range opts {
		if err := opt(s); err != nil {
			return nil, fmt.Errorf("cấu hình server: %w", err)
		}
	}
	return s, nil
}

Đo thật (Go 1.23), ba tình huống:

  • NewServer("localhost:8080", WithTimeout(5*time.Second), WithTLS()) → {timeout:5s retries:3 tls:true} — chỉ đổi cái khai, retries giữ default 3.
  • NewServer("localhost:8080", WithTimeout(-1)) → lỗi cấu hình server: timeout phải > 0 — option tự bắt giá trị xấu.
  • NewServer("localhost:9090") → {timeout:30s retries:3 tls:false} — không option nào, toàn default.

Người gọi chỉ khai đúng thứ cần đổi. Đây là lý do functional options thắng config struct về trải nghiệm: không phải điền cả struct với zero value cho những trường không quan tâm, và không lẫn "không đặt" với "đặt = 0".

Đo thật chi phí: closure có đắt không?

Câu hỏi công bằng: closure và variadic có làm chậm không? Benchmark options (2 option) so với truyền config struct:

Ảnh chụp bảng kết quả đo thật nền tối options tự validate chậm hơn config struct 6ns nhưng closure không thoát heap, go run cộng go test bench cộng escape analysis Go 1.23 arm64 10 core, chạy thật default override và validate OK addr localhost 8080 timeout 5s retries 3 tls true WithTimeout cộng WithTLS đổi retries giữ default 3 Lỗi validate cấu hình server timeout phải lớn hơn 0 WithTimeout âm 1 option tự bắt lỗi bọc bằng phần trăm w Default addr localhost 9090 timeout 30s retries 3 tls false không option nào toàn default hợp lý, benchmark options vs config struct functional options 2 option 19,28 ns 32 byte 1 alloc config struct 13,61 ns 32 byte 1 alloc cùng 1 cấp phát chỉ struct Server options chậm hơn 6ns vì tạo closure cộng duyệt slice không đáng với constructor gọi 1 lần, escape dùng trực tiếp thì closure không thoát heap esc go 11 28 dấu ba chấm argument does not escape slice variadic esc go 11 34 func literal does not escape closure option esc go 7 7 và S rỗng escapes to heap chỉ struct trả về khi option dùng ngay trong lời gọi New compiler stack cấp phát cả slice lẫn closure chỉ struct trả về mới lên heap 1 alloc, cốt lõi Option func con trỏ T error closure sửa struct tự validate default constructor đặt mặc định option chỉ đổi cái cần tương thích thêm option mới không phá chữ ký cũ chi phí 6ns mỗi lần so config struct cùng 1 alloc vô nghĩa với init escape closure không thoát heap khi dùng trực tiếp

Hình 2: functional options 19,28 ns so với config struct 13,61 ns — cùng một cấp phát (32 B, chỉ struct Server). Escape analysis: khi dùng trực tiếp, slice variadic và closure option không thoát heap (stack-allocated), chỉ struct trả về lên heap.

  • functional options: 19,28 ns/op, 32 B, 1 cấp phát.
  • config struct: 13,61 ns/op, 32 B, 1 cấp phát.

Options chậm hơn ~6 ns, nhưng — điểm quan trọng — cùng một cấp phát (32 B là chính struct Server, cả hai cách đều phải cấp phát nó). ~6 ns là chi phí tạo closure và duyệt slice, hoàn toàn vô nghĩa với một constructor gọi một lần lúc khởi tạo.

Escape analysis giải thích vì sao không có cấp phát thừa: khi option dùng ngay trong lời gọi New(WithT(...)), compiler thấy ... argument does not escape (slice variadic) và func literal does not escape (closure) — cả hai được stack-allocated. Chỉ &Server{} trả về mới lên heap. Nghĩa là mẫu functional options gần như miễn phí về bộ nhớ khi dùng đúng cách.

Đánh đổi cần cân nhắc

Functional options tỏa sáng ở API thư viện công khai. Lợi ích lớn nhất là tương thích ngược: thêm một option mới (WithRetries) không phá vỡ code gọi cũ, vì chữ ký NewServer(addr, ...Option) không đổi. Với struct config, thêm một trường có thể ổn, nhưng đổi kiểu hay thứ tự thì phá. Đây là lý do gRPC, zap, http dùng options — API của chúng phải sống nhiều năm mà không làm hỏng người dùng.

Với struct nội bộ đơn giản, config struct trực tiếp hơn. Nếu đây là code nội bộ, ít trường, không cần validate phức tạp, một struct config đọc dễ và ít máy móc hơn — không phải viết một hàm WithX cho mỗi trường. Đừng áp functional options chỉ vì nó "đúng chuẩn"; cân theo việc API có phải công khai và ổn định lâu dài không.

Option có thể làm việc phức tạp — cẩn thận tác dụng phụ. Vì option là hàm tùy ý, nó có thể mở kết nối, đọc biến môi trường, cấp phát tài nguyên. Điều này mạnh nhưng nguy hiểm: một option gây tác dụng phụ khó test và khó suy luận thứ tự. Giữ option thuần túy (chỉ gán trường + validate) khi có thể; nếu phải có tác dụng phụ, tài liệu rõ và cân nhắc trả lỗi để người gọi xử lý.

Ba ý mang về

  1. Functional options dùng closure làm tham số cấu hình: Option là kiểu func(*T) error, mỗi hàm WithX trả về một closure sửa struct và tự validate — constructor đặt default hợp lý rồi áp lần lượt, bọc lỗi bằng %w, người gọi chỉ khai cái cần đổi.
  2. Chi phí closure là không đáng kể: đo thật options 19,28 ns so với config struct 13,61 ns (chênh ~6 ns) nhưng cùng một cấp phát — escape analysis cho thấy slice variadic và closure không thoát heap khi dùng trực tiếp, chỉ struct trả về lên heap.
  3. Ưu thế thật là tương thích ngược: thêm option mới không phá chữ ký constructor cũ, nên hợp với API thư viện công khai sống lâu — nhưng với struct nội bộ đơn giản, config struct trực tiếp hơn; và giữ option thuần túy để tránh tác dụng phụ khó suy luận.

Phần sau ta quay lại tổ chức tầng dữ liệu với một mẫu phổ biến: Phần sau mổ xẻ Repository pattern — cách tách tầng truy cập dữ liệu khỏi logic nghiệp vụ bằng interface, và những chỗ nó giúp lẫn chỗ nó thành lớp trừu tượng thừa.