Interface trong Go: nhỏ, ngầm định và cách dùng đúng

1. Tổng quan

Interface trong Go khác Java hay C# ở một điểm cốt lõi: không có từ khóa implements. Một kiểu chỉ cần có đủ method là tự động thỏa mãn interface. Đặc điểm này dẫn tới một cách thiết kế riêng: interface nhỏ, được khai báo ở nơi dùng nó, không phải ở nơi cài đặt nó.

Đọc xong bài này, bạn sẽ biết cách viết interface đúng phong cách Go, dùng chúng để test mà không cần thư viện mock, và tránh những lỗi hay gặp như “nil mà không nil”.

2. Interface được thỏa mãn ngầm định

Hãy bắt đầu với một interface gửi thông báo cho khách hàng:

package main

import "fmt"

type Notifier interface {
	Notify(customer, message string) error
}

type EmailNotifier struct {
	From string
}

func (e EmailNotifier) Notify(customer, message string) error {
	fmt.Printf("[email từ %s] tới %s: %s\n", e.From, customer, message)
	return nil
}

type SMSNotifier struct{}

func (SMSNotifier) Notify(customer, message string) error {
	fmt.Printf("[sms] tới %s: %s\n", customer, message)
	return nil
}

func main() {
	notifiers := []Notifier{
		EmailNotifier{From: "shop@nonla.dev"},
		SMSNotifier{},
	}
	for _, n := range notifiers {
		_ = n.Notify("An", "Đơn hàng #42 đã được xác nhận")
	}
}

Kết quả:

[email từ shop@nonla.dev] tới An: Đơn hàng #42 đã được xác nhận
[sms] tới An: Đơn hàng #42 đã được xác nhận

Không chỗ nào trong EmailNotifier hay SMSNotifier nhắc tới Notifier. Compiler tự kiểm tra: kiểu nào có method Notify(string, string) error thì gán được vào biến kiểu Notifier.

Hệ quả quan trọng là bên cài đặt không cần biết interface tồn tại. Bạn có thể khai báo interface mới cho một kiểu có sẵn trong thư viện chuẩn hay thư viện bên thứ ba mà không phải sửa code của họ.

3. Giữ interface nhỏ

Thư viện chuẩn của Go là ví dụ tốt nhất. io.Reader và io.Writer mỗi interface chỉ có đúng một method:

type Writer interface {
	Write(p []byte) (n int, err error)
}

Nhờ nhỏ như vậy, rất nhiều kiểu thỏa mãn nó: *os.File, *bytes.Buffer, *strings.Builder, http.ResponseWriter, *gzip.Writer… Một hàm nhận io.Writer dùng được với tất cả các kiểu đó:

package main

import (
	"fmt"
	"io"
	"os"
	"strings"
)

type Order struct {
	ID       int
	Customer string
	Total    int64
}

func WriteReceipt(w io.Writer, o Order) error {
	_, err := fmt.Fprintf(w, "Đơn #%d - %s - %d VND\n", o.ID, o.Customer, o.Total)
	return err
}

func main() {
	order := Order{ID: 42, Customer: "An", Total: 350000}

	_ = WriteReceipt(os.Stdout, order) // in ra terminal

	var sb strings.Builder
	_ = WriteReceipt(&sb, order) // ghi vào bộ nhớ
	fmt.Printf("Độ dài hóa đơn: %d byte\n", sb.Len())
}
Đơn #42 - An - 350000 VND
Độ dài hóa đơn: 26 byte

WriteReceipt chỉ cần khả năng “ghi byte”, nên nó chỉ đòi đúng thứ đó. Muốn ghi ra file, HTTP response hay file nén, bạn không phải sửa hàm.

Khi cần nhiều hành vi, hãy ghép các interface nhỏ thay vì viết một interface lớn ngay từ đầu:

type OrderFinder interface {
	FindOrder(id int) (Order, error)
}

type OrderSaver interface {
	SaveOrder(o Order) error
}

type OrderStore interface {
	OrderFinder
	OrderSaver
}

Hàm hiển thị đơn hàng chỉ nhận OrderFinder. Hàm cập nhật trạng thái nhận OrderStore. Mỗi hàm khai báo rõ nó cần gì, và việc viết fake để test cũng nhẹ hơn nhiều.

4. Khai báo interface ở phía sử dụng

Trong Java, bạn thường viết PaymentGateway rồi StripePaymentGateway implements PaymentGateway, cả hai nằm cạnh nhau. Trong Go, cách làm phổ biến ngược lại: package cài đặt trả về struct cụ thể, package sử dụng tự khai báo interface nó cần.

Giả sử package payment cung cấp một client có nhiều method:

// package payment
type Client struct {
	apiKey string
}

func NewClient(apiKey string) *Client { return &Client{apiKey: apiKey} }

func (c *Client) Charge(customer string, amount int64) (string, error) {
	return fmt.Sprintf("txn-%s-%d", customer, amount), nil
}

func (c *Client) Refund(txnID string) error         { return nil }
func (c *Client) ListTransactions() ([]string, error) { return nil, nil }

Package checkout chỉ cần trừ tiền, nên nó khai báo interface một method:

// package checkout
var ErrInvalidTotal = errors.New("tổng tiền phải lớn hơn 0")

type PaymentGateway interface {
	Charge(customer string, amount int64) (string, error)
}

type Service struct {
	gateway PaymentGateway
}

func NewService(gw PaymentGateway) *Service {
	return &Service{gateway: gw}
}

func (s *Service) Checkout(o Order) (string, error) {
	if o.Total <= 0 {
		return "", ErrInvalidTotal
	}
	txnID, err := s.gateway.Charge(o.Customer, o.Total)
	if err != nil {
		return "", fmt.Errorf("thanh toán đơn #%d: %w", o.ID, err)
	}
	return txnID, nil
}

Khi khởi tạo ở main:

svc := checkout.NewService(payment.NewClient("sk_test_123"))
txn, err := svc.Checkout(Order{ID: 42, Customer: "An", Total: 350000})
fmt.Println(txn, err)
txn-An-350000 <nil>

*payment.Client thỏa mãn checkout.PaymentGateway một cách ngầm định. Package payment không import checkout và cũng không cần biết nó tồn tại.

Nguyên tắc này thường được tóm gọn là “accept interfaces, return structs”: hàm nhận interface để linh hoạt, nhưng trả về kiểu cụ thể để người gọi được dùng mọi method và field.

5. Test bằng fake, không cần thư viện mock

Interface nhỏ ở phía sử dụng giúp test rất gọn. Bạn chỉ cần viết một struct có đúng một method:

package checkout

import (
	"errors"
	"testing"
)

type fakeGateway struct {
	charged []int64
	err     error
}

func (f *fakeGateway) Charge(customer string, amount int64) (string, error) {
	if f.err != nil {
		return "", f.err
	}
	f.charged = append(f.charged, amount)
	return "txn-test", nil
}

func TestCheckout_ChargesOrderTotal(t *testing.T) {
	gw := &fakeGateway{}
	svc := NewService(gw)

	txn, err := svc.Checkout(Order{ID: 1, Customer: "An", Total: 200000})

	if err != nil {
		t.Fatalf("không mong đợi lỗi: %v", err)
	}
	if txn != "txn-test" {
		t.Errorf("txn = %q, muốn %q", txn, "txn-test")
	}
	if len(gw.charged) != 1 || gw.charged[0] != 200000 {
		t.Errorf("charged = %v, muốn [200000]", gw.charged)
	}
}

func TestCheckout_RejectsZeroTotal(t *testing.T) {
	gw := &fakeGateway{}
	svc := NewService(gw)

	_, err := svc.Checkout(Order{ID: 2, Customer: "Bình", Total: 0})

	if !errors.Is(err, ErrInvalidTotal) {
		t.Fatalf("err = %v, muốn ErrInvalidTotal", err)
	}
	if len(gw.charged) != 0 {
		t.Errorf("không được gọi Charge, nhưng charged = %v", gw.charged)
	}
}

func TestCheckout_WrapsGatewayError(t *testing.T) {
	gwErr := errors.New("thẻ bị từ chối")
	svc := NewService(&fakeGateway{err: gwErr})

	_, err := svc.Checkout(Order{ID: 3, Customer: "Chi", Total: 100000})

	if !errors.Is(err, gwErr) {
		t.Fatalf("err = %v, muốn bọc %v", err, gwErr)
	}
}

Chạy test:

go test ./checkout -v
=== RUN   TestCheckout_ChargesOrderTotal
--- PASS: TestCheckout_ChargesOrderTotal (0.00s)
=== RUN   TestCheckout_RejectsZeroTotal
--- PASS: TestCheckout_RejectsZeroTotal (0.00s)
=== RUN   TestCheckout_WrapsGatewayError
--- PASS: TestCheckout_WrapsGatewayError (0.00s)
PASS

Nếu PaymentGateway có mười method, fake cũng phải cài đủ mười method. Đó là dấu hiệu rõ nhất cho thấy interface đang quá lớn.

6. Type assertion, type switch và kiểm tra lúc compile

6.1. Interface tùy chọn bằng type assertion

Đôi khi bạn muốn dùng thêm một khả năng nếu kiểu cụ thể có hỗ trợ. Go dùng type assertion dạng hai giá trị:

type Refunder interface {
	Refund(txnID string) error
}

func (s *Service) Cancel(txnID string) error {
	r, ok := s.gateway.(Refunder)
	if !ok {
		return errors.New("cổng thanh toán không hỗ trợ hoàn tiền")
	}
	return r.Refund(txnID)
}

Với *payment.Client, ok là true vì nó có method Refund. Với fakeGateway, ok là false và hàm trả lỗi thay vì panic. Thư viện chuẩn dùng kỹ thuật này rất nhiều, ví dụ io.Copy kiểm tra xem tham số có phải io.WriterTo không để chọn đường nhanh hơn.

Lưu ý: Dạng một giá trị r := s.gateway.(Refunder) sẽ panic nếu kiểu không khớp. Hãy luôn dùng dạng r, ok := … trừ khi bạn chắc chắn tuyệt đối.

6.2. Type switch

Khi cần xử lý nhiều kiểu khác nhau, dùng type switch:

func describe(n Notifier) string {
	switch v := n.(type) {
	case EmailNotifier:
		return "email từ " + v.From
	case SMSNotifier:
		return "sms"
	default:
		return fmt.Sprintf("không rõ: %T", v)
	}
}

Nếu thấy mình viết type switch trên các kiểu nghiệp vụ ở nhiều nơi, có thể bạn đang thiếu một method trong interface.

6.3. Đảm bảo thỏa mãn interface lúc compile

Vì việc thỏa mãn là ngầm định, nếu ai đó đổi chữ ký method thì lỗi chỉ hiện ra ở chỗ gán. Để bắt lỗi sớm ngay tại file cài đặt, thêm dòng sau:

var _ checkout.PaymentGateway = (*payment.Client)(nil)

Dòng này không tạo ra giá trị nào khi chạy. Nó chỉ buộc compiler kiểm tra *payment.Client có thỏa mãn PaymentGateway hay không. Chỉ dùng khi package cài đặt thật sự muốn cam kết với interface đó. Nếu không, nó sẽ tạo ra một import ngược chiều không cần thiết.

7. Lỗi hay gặp

7.1. Interface chứa nil pointer không phải nil

Đây là lỗi kinh điển nhất của Go:

type ValidationError struct{ Field string }

func (e *ValidationError) Error() string {
	return "trường không hợp lệ: " + e.Field
}

func validate(o Order) error {
	var verr *ValidationError
	if o.Total <= 0 {
		verr = &ValidationError{Field: "Total"}
	}
	return verr // SAI
}

func main() {
	err := validate(Order{Total: 100})
	fmt.Println(err == nil)
}
false

Một giá trị interface gồm hai phần: kiểu và giá trị. Interface chỉ bằng nil khi cả hai đều rỗng. Ở đây kiểu là *ValidationError, còn giá trị là con trỏ nil, nên err != nil. Cách sửa là trả nil một cách tường minh:

func validate(o Order) error {
	if o.Total <= 0 {
		return &ValidationError{Field: "Total"}
	}
	return nil
}

7.2. Pointer receiver và method set

type AuditNotifier struct{ count int }

func (a *AuditNotifier) Notify(customer, message string) error {
	a.count++
	return nil
}

var n Notifier = AuditNotifier{} // lỗi compile
cannot use AuditNotifier{} (value of struct type AuditNotifier) as Notifier value
in variable declaration: AuditNotifier does not implement Notifier
(method Notify has pointer receiver)

Method có pointer receiver chỉ thuộc method set của *AuditNotifier. Bạn cần gán &AuditNotifier{}. Ngược lại, method có value receiver thì dùng được cho cả value lẫn pointer.

7.3. Viết interface trước khi cần

Kiểu code mang thói quen từ Java thường trông như sau:

type OrderService interface {
	Create(o Order) error
	Cancel(id int) error
	Find(id int) (Order, error)
	// … thêm 10 method nữa
}

type orderServiceImpl struct{ /* … */ }

func NewOrderService() OrderService { return &orderServiceImpl{} }

Cách này có ba vấn đề. Interface chỉ có một implementation. Nó nằm ở phía cài đặt thay vì phía sử dụng. Constructor trả về interface nên người gọi mất quyền truy cập kiểu cụ thể. Hãy trả về *OrderService là struct, và để mỗi package sử dụng tự khai báo interface nhỏ khi nó thực sự cần thay thế, chẳng hạn để test.

7.4. Lạm dụng any

any (bí danh của interface{}) chấp nhận mọi kiểu, nên compiler không giúp được gì nữa. Từ Go 1.18 trở đi, nếu bạn cần một hàm hoạt động trên nhiều kiểu, hãy cân nhắc generics trước:

func SumTotals[T interface{ GetTotal() int64 }](items []T) int64 {
	var sum int64
	for _, it := range items {
		sum += it.GetTotal()
	}
	return sum
}

Chỉ dùng any khi dữ liệu thực sự không biết trước kiểu, ví dụ khi decode JSON tùy ý.

8. Kết luận

Interface trong Go mạnh chính vì nó đơn giản. Những điểm cần nhớ:

  • Kiểu thỏa mãn interface một cách ngầm định, không cần implements.
  • Interface nên nhỏ, lý tưởng là một hoặc hai method. Khi cần nhiều hơn, hãy ghép các interface nhỏ lại.
  • Khai báo interface ở phía sử dụng. Hàm nhận interface, constructor trả về struct.
  • Interface nhỏ giúp test bằng fake viết tay, không cần thư viện mock.
  • Cẩn thận với interface chứa nil pointer và với method set của pointer receiver.

Lần tới khi định viết một interface, hãy tự hỏi: “Ai dùng nó, và họ thực sự cần những method nào?” Câu trả lời thường là ít hơn bạn nghĩ.

golanginterfacethiết kếtesting