Cách dùng interface{} đúng cách trong Golang

Interface đóng một vai trò quan trọng trong kiến trúc phần mềm. Điều khiến chúng trở thành một nguyên lý vững chắc của kiến trúc nằm ở khả năng trừu tượng hóa của chúng.

Một trong những khái niệm quen thuộc mà các kỹ sư phần mềm phải đối mặt trong các cuộc phỏng vấn là các nguyên tắc SOLID. Interface bao gồm hầu hết các phần triển khai của SOLID. Khi bạn cố gắng tuân theo nguyên tắc đóng/mở (open-closed principle) hoặc cố gắng đảo ngược sự phụ thuộc (dependency inversion), bạn cần một interface.

Hãy nhớ một quy tắc rằng, khi bạn muốn trừu tượng hóa một số chi tiết triển khai khỏi phía người gọi/máy khách (caller/client), bạn có thể sẽ sử dụng interface.

Từ suy luận logic này, chúng ta có thể kết luận rằng nếu chúng ta muốn trừu tượng hóa một package khỏi một package khác, chúng ta cần sử dụng interface.

Vậy hãy cùng đi sâu vào các vấn đề của việc sử dụng interface trong các ứng dụng Go.

Trong quá trình review code Go, tôi thường bắt gặp một quy ước viết code sử dụng interface như trong ví dụ sau. Một interface định nghĩa các phương thức và một struct private cụ thể duy nhất triển khai interface đó ngay bên dưới nó.

Go

// some_package.go

type AwesomeService interface {
    DoSomethingAwesome(ctx context.Context, data AwesomeData) error
    // ... more awesome methods
}

type awesomeServiceImpl struct {
    // ... dependencies
}

func NewAwesomeService(/* ... dependencies */) AwesomeService {
    return &awesomeServiceImpl{
        // ... initialize dependencies
    }
}

func (s *awesomeServiceImpl) DoSomethingAwesome(ctx context.Context, data AwesomeData) error {
    // ... implementation
    return nil
}

// ... more awesomeServiceImpl methods

Như bạn đã biết, chúng ta đã nhấn mạnh tầm quan trọng và vai trò của interface, đó là: trừu tượng hóa. Bây giờ, hãy nhìn lại đoạn code đó và tự hỏi mình câu hỏi sau: “Sự trừu tượng hóa ở đây là gì?”. Nó có ngăn chặn việc phơi bày chức năng của struct không? Không. Ngược lại, đó chính là mục đích.

Có thể có một lập luận kiểu như “ẩn chính struct đó khỏi bên ngoài package”. Nhưng, mục đích là gì? Tại sao chúng ta muốn ẩn một struct nếu chúng ta vẫn phơi bày chức năng của struct đó?

Thông thường chúng ta không muốn ẩn struct, chúng ta muốn “đóng gói” (encapsulation), tức là ẩn trạng thái bên trong của struct (các biến của struct). Chúng ta có lẽ không muốn bất kỳ ai thực hiện các thay đổi không kiểm soát đối với trạng thái của struct. Nhưng trong trường hợp này, chúng ta chỉ cần đặt các biến của struct ở chế độ private và truy cập chúng thông qua các phương thức của struct.

Một lập luận khác có thể là về unit test. Interface cung cấp khả năng kiểm thử chức năng của struct. Nhưng điều này lại đặt ra một câu hỏi khác, chúng ta cần kiểm thử ai? Chúng ta có lẽ muốn kiểm thử người gọi/máy khách (caller/client) của struct này. Bởi vì chúng ta có thể kiểm thử mọi chức năng của struct đó trong cùng một package mà không phụ thuộc vào việc định nghĩa một interface. Vì vậy, nếu chúng ta muốn kiểm thử phía người gọi, chúng ta cần một interface để mock các phụ thuộc bên ngoài của người gọi. Ở đây, chúng ta tìm thấy yêu cầu: Kiểm thử phía người gọi một cách độc lập với các phụ thuộc bên ngoài.

Sau khi làm rõ điều này, chúng ta vẫn còn lại với cùng một câu hỏi: “Mục đích của interface/sự trừu tượng hóa này ở đây là gì?”.

Câu trả lời rất rõ ràng: Hầu hết là vô nghĩa ‼️

Đây là một kiểu tối ưu hóa sớm (premature optimization), một thói quen xấu. Chúng ta không có và (hầu hết) sẽ không có một kiểu triển khai cụ thể nào khác của cùng một interface (ngay cả khi có, điều đó không có nghĩa là chúng ta cần định nghĩa một interface).

Được rồi, chúng ta đã thấy vấn đề, việc đặt interface trước mọi định nghĩa struct là sai, nhưng chúng ta cần làm gì? Cách tốt nhất để sử dụng interface là gì? Cách đúng để thực hiện trừu tượng hóa là gì?

Bạn cần tự hỏi mình:

📌 Tôi có thể sống thiếu interface không? 📌 Tôi cần trừu tượng hóa phần nào? 📌 Các phụ thuộc của tôi nên đi theo hướng nào? 📌 Tôi muốn kiểm thử cái gì?

Thông thường, bản thân unit test không đủ để trở thành một lý do mạnh mẽ cho việc tạo ra một interface (trừ khi chúng ta có các phụ thuộc bên ngoài).

Nhưng, trong hầu hết các trường hợp, để tránh các quy trình mock sâu và khó khăn, chúng ta có thể tạo một interface để dễ dàng mock một kiểu.

Ngoài unit test, câu trả lời cho các câu hỏi trước đó cho chúng ta một cái nhìn sâu sắc về các yêu cầu trừu tượng hóa, do đó chúng ta có thể dễ dàng quyết định nơi đặt interface của mình.

Bây giờ hãy lấy ví dụ code đầu tiên và quyết định nơi đặt interface/sự trừu tượng hóa của chúng ta.

Giả sử chúng ta có code Consumer sau và người gọi của nó. Giả sử Consumer tồn tại trong package “infrastructure”:

Go

// infrastructure/consumer.go
package infrastructure

import "fmt"

type Consumer struct {
    // ... dependencies
}

func NewConsumer(/* ... dependencies */) *Consumer {
    return &Consumer{
        // ... initialize dependencies
    }
}

func (c *Consumer) Consume(message string) error {
    fmt.Printf("Consuming message: %s\n", message)
    // ... actual message consuming logic
    return nil
}

Caller tồn tại trong tầng “application”:

Go

// application/caller.go
package application

import (
    "context"
    "your_project_path/infrastructure" // Direct dependency
)

type Caller struct {
    consumer *infrastructure.Consumer
}

func NewCaller(consumer *infrastructure.Consumer) *Caller {
    return &Caller{consumer: consumer}
}

func (c *Caller) DoSomething(ctx context.Context, data string) error {
    // ... some logic
    err := c.consumer.Consume(data) // Directly calling concrete type
    if err != nil {
        return err
    }
    // ... more logic
    return nil
}

Có một sự ghép nối chặt chẽ (strong coupling) giữa CallerConsumer bởi vì Caller phụ thuộc trực tiếp vào kiểu cụ thể Consumer. Điều này cũng mang lại sự ghép nối giữa các tầng. Nếu chúng ta muốn loại bỏ các chi tiết của Consumer, chúng ta cần một sự trừu tượng hóa: interface.

Hiện tại, chúng ta không thể dễ dàng unit test Caller vì phương thức consumer.Consume() sẽ được gọi và chúng ta không thể ngăn chặn điều đó xảy ra. Vì vậy, chúng ta cần tạo một sự trừu tượng hóa bằng cách sử dụng một interface.

Bây giờ hãy tạo sự trừu tượng hóa đó để khắc phục những vấn đề này. Để đạt được điều này, trong Go, chúng ta chỉ cần viết một interface thích hợp chứa hợp đồng (contract), các phương thức mà chúng ta sử dụng.

Chúng ta sẽ sửa đổi code Caller bằng cách định nghĩa một interface Consumer:

Go

// application/caller.go
package application

import (
    "context"
    // No direct dependency on infrastructure package for Consumer type
)

// Consumer interface defined by the caller (application layer)
type Consumer interface {
    Consume(message string) error
}

type Caller struct {
    consumer Consumer // Depends on the interface
}

func NewCaller(consumer Consumer) *Caller {
    return &Caller{consumer: consumer}
}

func (c *Caller) DoSomething(ctx context.Context, data string) error {
    // ... some logic
    err := c.consumer.Consume(data) // Calling interface method
    if err != nil {
        return err
    }
    // ... more logic
    return nil
}

Bằng cách tạo một interface ở phía Caller, chúng ta sẽ đơn giản tạo ra một sự trừu tượng hóa cho phụ thuộc của nó. Đồng thời, chúng ta cũng cắt đứt sự ghép nối trực tiếp giữa các tầng/package (nhờ vào việc triển khai interface kiểu “duck typing” của Go). Bây giờ chúng ta có thể dễ dàng viết unit test cho Caller của mình bằng cách tạo một mock struct cho interface Consumer trong cùng package với Caller.

Chúng ta không cần thay đổi bất kỳ phần nào khác của ứng dụng, gốc của sự kết hợp (composition root) (thường là main) sẽ vẫn giữ nguyên, nó vẫn tạo ra các kiểu cụ thể và truyền chúng dưới dạng tham số cho các hàm khởi tạo/hàm theo cùng một cách.

Bằng cách tuân theo cách tiếp cận đơn giản này, chúng ta có thể tạo ra các package được ghép nối lỏng lẻo, có thể kiểm thử, hoạt động tốt mà không cần các trừu tượng hóa và định nghĩa interface không cần thiết.

Kỹ thuật này được sử dụng chủ yếu khi triển khai kiến trúc Ports & Adapters. Các Port được định nghĩa bởi tầng sử dụng chúng. Hãy nhớ rằng, các adapter chính (driver) sử dụng/bao bọc các port và các adapter phụ (driven) triển khai các port.

Chúng ta đã được chỉ ra khi nào và làm thế nào interface nên được sử dụng để trừu tượng hóa. Cảm ơn bạn đã đọc đến đây, hẹn gặp lại lần sau 🚀

Nguồn: https://medium.com/goturkiye/you-are-misusing-interfaces-in-go-architecture-smells-wrong-abstractions-da0270192808

Leave a Comment