Design Pattern trong Go: Các pattern thiết yếu và câu hỏi phỏng vấn cho lập trình viên Go

Nắm vững các design pattern Go: Functional Options, Strategy, Factory và Observer. Ví dụ mã thực tế, thực hành tốt nhất theo phong cách idiomatic và các câu hỏi phỏng vấn thường gặp cho lập trình viên Go.

Minh họa các design pattern Go với những hình khối hình học trừu tượng thể hiện kiến trúc phần mềm

Các design pattern trong Go khác biệt về cơ bản so với những phiên bản tương ứng trong các ngôn ngữ hướng đối tượng. Không có class hay kế thừa, Go dựa vào composition, interface và hàm hạng nhất để đạt được sự linh hoạt về cấu trúc tương đương, thường với ít rườm rà hơn và rõ ràng hơn.

Nguyên tắc idiomatic của Go

Go ưu tiên composition hơn kế thừa. Mỗi design pattern cổ điển đều phải được điều chỉnh để hoạt động với interface, struct embedding và hàm hạng nhất, thay vì với các hệ phân cấp class.

Pattern Functional Options cho cấu hình linh hoạt

Pattern Functional Options giải quyết một vấn đề phổ biến trong Go: khởi tạo đối tượng với nhiều tham số tùy chọn. Khác với các ngôn ngữ có method overloading hay tham số mặc định, Go không hỗ trợ cả hai. Pattern Builder có thể dùng được nhưng lại dài dòng. Functional Options cung cấp một API gọn gàng, dễ mở rộng và vẫn tương thích ngược khi yêu cầu phát triển thêm.

Pattern này, được Dave Cheney phổ biến và nay đã trở thành tiêu chuẩn trong toàn bộ hệ sinh thái Go, dùng tham số variadic để cấu hình một struct.

server.gogo
package server

import (
	"time"
	"log/slog"
)

// Server holds the HTTP server configuration.
type Server struct {
	host         string
	port         int
	timeout      time.Duration
	maxConns     int
	logger       *slog.Logger
}

// Option defines a functional option for Server.
type Option func(*Server)

// WithPort sets the server port.
func WithPort(port int) Option {
	return func(s *Server) {
		s.port = port
	}
}

// WithTimeout sets the request timeout.
func WithTimeout(d time.Duration) Option {
	return func(s *Server) {
		s.timeout = d
	}
}

// WithMaxConns sets the maximum concurrent connections.
func WithMaxConns(n int) Option {
	return func(s *Server) {
		s.maxConns = n
	}
}

// New creates a Server with sensible defaults and applies options.
func New(host string, opts ...Option) *Server {
	srv := &Server{
		host:     host,
		port:     8080,           // default port
		timeout:  30 * time.Second, // default timeout
		maxConns: 100,            // default max connections
		logger:   slog.Default(),
	}
	for _, opt := range opts {
		opt(srv)
	}
	return srv
}

Bên gọi chỉ cần chỉ định những gì khác với giá trị mặc định. Việc thêm một tùy chọn mới không bao giờ phá vỡ các điểm gọi đã có. Pattern này xuất hiện trong các thư viện production như google.golang.org/grpcgo.uber.org/zap.

Pattern Strategy thông qua việc thỏa mãn interface

Pattern Strategy đóng gói các thuật toán có thể thay thế cho nhau sau một interface chung. Trong Go, điều này ánh xạ trực tiếp tới tính đa hình dựa trên interface, không cần class trừu tượng hay chuỗi kế thừa.

Một hệ thống xử lý thanh toán minh họa cho pattern này. Mỗi phương thức thanh toán triển khai cùng một interface, và dịch vụ checkout chọn strategy tại thời điểm runtime.

payment.gogo
package payment

import "fmt"

// Processor defines the strategy interface.
type Processor interface {
	Pay(amount float64) (string, error)
}

// CreditCard implements Processor for card payments.
type CreditCard struct {
	CardNumber string
	Expiry     string
}

func (c *CreditCard) Pay(amount float64) (string, error) {
	// Charge the card via payment gateway
	return fmt.Sprintf("charged %.2f to card ending %s", amount, c.CardNumber[len(c.CardNumber)-4:]), nil
}

// BankTransfer implements Processor for wire transfers.
type BankTransfer struct {
	IBAN string
}

func (b *BankTransfer) Pay(amount float64) (string, error) {
	return fmt.Sprintf("initiated transfer of %.2f to %s", amount, b.IBAN), nil
}

// Checkout processes a payment using the given strategy.
func Checkout(p Processor, amount float64) error {
	receipt, err := p.Pay(amount)
	if err != nil {
		return fmt.Errorf("payment failed: %w", err)
	}
	fmt.Println(receipt)
	return nil
}

Interface trong Go được thỏa mãn một cách ngầm định, không cần từ khóa implements. Bất kỳ kiểu nào có phương thức Pay(float64) (string, error) đều đủ điều kiện là một Processor. Điều này giữ cho coupling ở mức thấp và việc kiểm thử trở nên đơn giản: chỉ cần truyền vào một Processor giả lập trả về kết quả có thể dự đoán.

Hàm factory và pattern khởi tạo trong Go

Go không có constructor. Sự thay thế idiomatic là một hàm factory, thường được đặt tên New hoặc NewXxx, trả về một struct đã được khởi tạo. Các hàm factory bắt buộc tuân thủ những bất biến mà việc khởi tạo bằng zero-value không thể bảo đảm.

Sự khác biệt này quan trọng đối với việc chuẩn bị phỏng vấn: lập trình viên Go cần nhận ra khi nào một hàm factory là cần thiết so với khi nào zero value đã hữu ích theo mặc định.

connpool.gogo
package pool

import (
	"errors"
	"sync"
)

// ConnPool manages a pool of reusable connections.
type ConnPool struct {
	mu       sync.Mutex
	conns    []Conn
	maxSize  int
}

// Conn represents a database connection.
type Conn struct {
	ID     int
	Active bool
}

// NewConnPool validates parameters and returns an initialized pool.
func NewConnPool(maxSize int) (*ConnPool, error) {
	if maxSize <= 0 {
		return nil, errors.New("pool: maxSize must be positive")
	}
	return &ConnPool{
		conns:   make([]Conn, 0, maxSize),
		maxSize: maxSize,
	}, nil
}

// Acquire returns a connection from the pool.
func (p *ConnPool) Acquire() (*Conn, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	for i := range p.conns {
		if !p.conns[i].Active {
			p.conns[i].Active = true
			return &p.conns[i], nil
		}
	}
	if len(p.conns) >= p.maxSize {
		return nil, errors.New("pool: no available connections")
	}
	c := Conn{ID: len(p.conns) + 1, Active: true}
	p.conns = append(p.conns, c)
	return &p.conns[len(p.conns)-1], nil
}

Hàm factory NewConnPool kiểm tra tham số maxSize và cấp phát trước slice. Việc khởi tạo struct trực tiếp (&ConnPool{}) sẽ bỏ qua bước kiểm tra này, có khả năng dẫn tới panic lúc runtime. Pattern này mở rộng một cách tự nhiên: kết hợp nó với Functional Options cho các kịch bản cấu hình phức tạp hơn.

Sẵn sàng chinh phục phỏng vấn Go?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Pattern Observer với channel và goroutine

Pattern Observer thông báo cho nhiều subscriber khi trạng thái thay đổi. Trong Java hay C#, điều này liên quan tới event listener và việc đăng ký callback. Go đưa ra một hướng đi idiomatic hơn: channel. Mỗi subscriber nhận sự kiện qua channel riêng của nó, và goroutine xử lý việc phân phối một cách đồng thời.

Cách tiếp cận này tận dụng trực tiếp các nguyên thủy concurrency của Go, tránh được mớ callback rối rắm.

eventbus.gogo
package events

import "sync"

// Event carries a topic and payload.
type Event struct {
	Topic   string
	Payload any
}

// Bus manages subscriptions and event dispatch.
type Bus struct {
	mu          sync.RWMutex
	subscribers map[string][]chan Event
}

// NewBus creates an event bus.
func NewBus() *Bus {
	return &Bus{
		subscribers: make(map[string][]chan Event),
	}
}

// Subscribe returns a channel that receives events for a topic.
func (b *Bus) Subscribe(topic string) <-chan Event {
	ch := make(chan Event, 16) // buffered to avoid blocking publisher
	b.mu.Lock()
	b.subscribers[topic] = append(b.subscribers[topic], ch)
	b.mu.Unlock()
	return ch
}

// Publish sends an event to all subscribers of the topic.
func (b *Bus) Publish(topic string, payload any) {
	b.mu.RLock()
	defer b.mu.RUnlock()
	for _, ch := range b.subscribers[topic] {
		select {
		case ch <- Event{Topic: topic, Payload: payload}:
		default:
			// subscriber too slow, drop event
		}
	}
}

Câu lệnh select với nhánh default ngăn một subscriber chậm chạp làm nghẽn toàn bộ bus. Trong các hệ thống production, việc bổ sung cơ chế hủy dựa trên context và một phương thức Unsubscribe sẽ hoàn thiện phần triển khai.

Pattern Middleware cho pipeline xử lý request HTTP

Các chuỗi middleware là xương sống của máy chủ HTTP trong Go. Pattern này bao một http.Handler bằng hành vi bổ sung (ghi log, xác thực, giới hạn tốc độ) mà không sửa đổi chính handler đó. Interface http.Handler của thư viện chuẩn khiến việc kết hợp này trở nên cực kỳ dễ dàng.

middleware.gogo
package middleware

import (
	"log/slog"
	"net/http"
	"time"
)

// Logging records request duration and status.
func Logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		wrapped := &statusWriter{ResponseWriter: w, status: 200}
		next.ServeHTTP(wrapped, r)
		slog.Info("request",
			"method", r.Method,
			"path", r.URL.Path,
			"status", wrapped.status,
			"duration", time.Since(start),
		)
	})
}

// statusWriter captures the HTTP status code.
type statusWriter struct {
	http.ResponseWriter
	status int
}

func (w *statusWriter) WriteHeader(code int) {
	w.status = code
	w.ResponseWriter.WriteHeader(code)
}

// Chain applies middleware in order: first listed = outermost.
func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
	for i := len(mw) - 1; i >= 0; i-- {
		h = mw[i](h)
	}
	return h
}

Hàm Chain áp dụng middleware theo thứ tự khai báo. Mỗi middleware bao lấy middleware kế tiếp, tạo thành một pipeline. Pattern này giống hệt trong các router phổ biến như chi, vốn chấp nhận func(http.Handler) http.Handler làm middleware.

Composition thay cho kế thừa với struct embedding

Go không hỗ trợ kế thừa. Thay vào đó, struct embedding nâng các phương thức của kiểu bên trong lên kiểu bên ngoài, đạt được sự tái sử dụng mã mà không cần coupling chặt. Đây không phải là kế thừa: kiểu được embed không hề biết tới kiểu chứa nó, và không có virtual dispatch.

Hiểu rõ sự khác biệt này là điều then chốt đối với các câu hỏi phỏng vấn Go. Ứng viên nhầm lẫn embedding với kế thừa sẽ bộc lộ sự hiểu biết hời hợt về hệ thống kiểu của Go.

models.gogo
package models

import "time"

// Timestamps provides common audit fields.
type Timestamps struct {
	CreatedAt time.Time
	UpdatedAt time.Time
}

// User embeds Timestamps to gain CreatedAt/UpdatedAt.
type User struct {
	Timestamps
	ID    int
	Email string
}

// Order also embeds Timestamps.
type Order struct {
	Timestamps
	ID     int
	UserID int
	Total  float64
}

Giờ đây cả UserOrder đều phơi bày trực tiếp các trường CreatedAtUpdatedAt. Mọi phương thức được định nghĩa trên Timestamps cũng được nâng lên. Quy tắc cốt lõi: embedding cung cấp sự ủy quyền, không phải sự thay thế. Một User không phải là Timestamps; nó có một Timestamps.

Câu hỏi phỏng vấn về design pattern trong Go

Các câu hỏi về design pattern trong phỏng vấn Go kiểm tra xem ứng viên có thể điều chỉnh các pattern cổ điển cho phù hợp với hệ thống kiểu của Go hay không. Những câu hỏi sau đây xuất hiện thường xuyên trong các vòng sàng lọc kỹ thuật và vòng phỏng vấn trực tiếp.

H: Khi nào một hàm factory nên trả về error thay vì panic?

Một hàm factory nên trả về error mỗi khi việc kiểm tra phụ thuộc vào dữ liệu đầu vào lúc runtime (cấu hình do người dùng cung cấp, biến môi trường, dữ liệu bên ngoài). Panic được dành cho lỗi của lập trình viên, tức những tình huống cho thấy có bug, chẳng hạn truyền một con trỏ nil ở nơi mà hợp đồng API cấm. Thư viện chuẩn tuân theo quy ước này: os.Open trả về error, trong khi regexp.MustCompile panic vì nó kỳ vọng một mẫu hằng số tại thời điểm biên dịch.

H: Pattern Functional Options cải thiện sự tiến hóa của API như thế nào?

Việc thêm một hàm WithXxx mới là một thay đổi không phá vỡ. Các bên gọi hiện có vẫn tiếp tục hoạt động mà không cần sửa đổi. Điều này trái ngược với struct cấu hình, nơi thêm một trường bắt buộc sẽ phá vỡ mọi điểm gọi, hoặc với tham số theo vị trí, nơi việc sắp xếp lại các đối số gây ra bug.

H: Điều gì làm cho interface của Go khác với interface của Java hay C#?

Interface của Go được thỏa mãn một cách ngầm định. Một kiểu triển khai một interface chỉ đơn giản bằng cách có các phương thức cần thiết, không cần khai báo implements. Điều này hiện thực hóa nguyên tắc "nhận interface, trả về struct": định nghĩa interface hẹp tại điểm gọi, chứ không phải tại điểm triển khai. Kết quả là coupling thấp hơn và khả năng kiểm thử cao hơn.

Mẫu phỏng vấn

Người phỏng vấn thường yêu cầu ứng viên tái cấu trúc một dependency cụ thể thành một interface. Bài kiểm tra: ứng viên có thể xác định tập phương thức tối thiểu cần thiết và trích xuất nó ở phía người tiêu dùng thay vì phía nhà sản xuất hay không?

H: Observer dựa trên channel khác với Observer dựa trên callback như thế nào?

Channel tách rời publisher và subscriber cả về thời gian lẫn không gian. Publisher không giữ tham chiếu tới các hàm subscriber; nó gửi tới channel. Subscriber có thể xử lý sự kiện theo nhịp độ riêng nhờ channel có buffer. Câu lệnh select cho phép xử lý timeout, hủy thông qua context và ghép kênh giữa nhiều nguồn sự kiện, những điều mà callback không có sẵn miễn phí.

Lỗi thường gặp

Quên đóng channel của subscriber gây rò rỉ goroutine. Mỗi Subscribe phải có một Unsubscribe tương ứng để đóng channel và gỡ nó khỏi bus.

Sẵn sàng chinh phục phỏng vấn Go?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Kết luận

  • Pattern Functional Options thay thế builder và struct cấu hình bằng một API gọn gàng, dễ mở rộng và không bao giờ phá vỡ các bên gọi hiện có
  • Strategy trong Go ánh xạ tới tính đa hình dựa trên interface: định nghĩa interface ở phía người tiêu dùng, không phải phía nhà cung cấp
  • Hàm factory bắt buộc tuân thủ những bất biến mà khởi tạo bằng zero-value không thể bảo đảm; trả về error cho dữ liệu đầu vào runtime, chỉ panic đối với bug của lập trình viên
  • Observer dựa trên channel tận dụng goroutine và select để phân phối sự kiện đồng thời, tách rời mà không cần chuỗi callback
  • Các chuỗi middleware HTTP được kết hợp qua signature func(http.Handler) http.Handler, giống hệt nhau giữa thư viện chuẩn và các router bên thứ ba
  • Struct embedding cung cấp sự ủy quyền, không phải kế thừa: hiểu rõ sự khác biệt này phân tách các lập trình viên thông thạo Go khỏi những người dịch nguyên xi pattern Java
  • Thành công khi phỏng vấn đòi hỏi chứng minh khả năng điều chỉnh pattern theo cách idiomatic cho hệ thống kiểu của Go, chứ không phải đọc thuộc lòng các định nghĩa Gang of Four theo kiểu sách giáo khoa

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Thẻ

#go
#design-patterns
#best-practices
#interview
#software-architecture

Chia sẻ

Bài viết liên quan