Go 語言中常見問題 - 接口污染

在 Go 語言中,接口是我們設計和編寫代碼的基石。然而,像很多概念一樣,濫用它是不好的。接口污染是指用不必要的抽象來編寫代碼(刻意使用接口),使得代碼更難以理解。這是具有不同習慣,特別是有其它語言開發經驗的人會犯的一個常見錯誤。在深入討論接口污染之前,讓我們重新梳理一下 Go 語言的接口,然後分析何時使用接口以及在什麼時候使用會存在污染問題。

接口

接口約定了對象的行爲方法,用於創建多個對象可以實現的通用抽象,也就是說接口規範了對象的通用方法。Go 語言中接口有點特別,不像其它語言通過類似於 implements 關鍵字顯示的標記對象 X 實現了接口 Y, 它是隱式實現的。

接口如此靈活強大的原因是什麼呢?爲了搞清楚這個問題,我們從標準庫中選兩個廣泛使用的接口: io.Reader 和 io.Writer 進行舉例說明。

io 包爲 I/O 操作提供了抽象,I/O 有讀寫兩類操作。如下圖所示,io.Reader 是從數據源讀取數據接口, io.Writer 是將數據寫入目標接口。

io.Reader 接口包含一個 Read 方法。如果一個結構體要實現 io.Reader 接口,則需要實現下面的 Read 方法,該方法需要一個字節切片作爲入參,會將從數據源讀取的數據填充到入參切片中,同時返回讀取的字節數和錯誤信息。

type Reader interface {
        Read([]byte) (n int, err error)
}

io.Writer 接口包含一個 Write 方法。如果一個結構體要實現 io.Writer 接口,則需要實現下面的 Write 方法,該方法也需要一個字節切片作爲入參,會將入參切片中的數據寫入到目標中,並返回寫入的字節數和錯誤信息。

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

因此,這兩個接口都提供了對基本操作的抽象:

在編程時使用這兩個接口合理性在什麼地方呢?創建這些抽象意義在哪裏呢?下面通過一個例子進行說明。假設我們需要實現將一個文件內容複製到另一個文件中的函數,我們可以創建一個特定的函數,將兩個 *os.File 作爲輸入, 或者可以選擇使用 io.Reader 和 io.Writer 接口創建一個更通用的函數。

func copySourceToDest(source io.Reader, dest io.Writer) error {
        // ...
}

copySourceToDest 函數可以使用 * os.File 作爲入參(因爲 * os.File 實現了 io.Reader 和 io.Writer),也可以使用任何其他實現了這些接口的類型。例如,我們可以創建自己的 io.Writer 來將數據寫入到數據庫中,並且可以不用修改 copySourceToDest 代碼。這樣增加了函數的通用性,因此,上述函數是可重用的。

使用接口除了使函數更有通用性,還使得爲這個函數編寫單元測試更容易,因爲我們不必寫文件,可以使用標準庫中 strings 包和 bytes 包提供的功能實現測試。下面程序中 source 變量是 * strings.Buffer 類型,dest 變量是 * bytes.Buffer 類型,我們可以在不創建任何文件的情況下測試 copySourceToDest 的行爲。

func TestCopySourceToDest(t *testing.T) {
        const input = "foo"
        source := strings.NewReader(input)
        dest := bytes.NewBuffer(make([]byte, 0))

        err := copySourceToDest(source, dest)
        if err != nil {
                t.FailNow()
        }

        got := dest.String()
        if got != input {
                t.Errorf("expected: %s, got: %s", input, got)
        }
}

在設計接口時,不要忘了接口的粒度(接口中包含多少方法), Go 語言中有一句名言描述了接口粒度問題:

接口越大,抽象越弱

向接口中添加方法會降低它的可重用性。io.Reader 和 io.Writer 具有強大的抽象,因爲它們都包含 1 個方法,不能再變得更抽象了。可以組合細粒度的接口來創建更高級別的抽象。像下面的 ReadWriter 接口組合了 Reader 和 Writer 接口,兼有讀取和寫入功能。

type ReadWriter interface {
        Reader
        Writer
}

「NOTE: 正如愛因斯坦所說,“一切事情應該力求簡單,不過不能過於簡單”。應用到接口上,表示找到接口最佳粒度不一定是一個簡單的事情。」

什麼時候使用接口

編寫 Go 程序的時候,在什麼情況下該創建接口呢?本文將深入研究三個具體的場景,在這些場景中,可以看到使用接口可以帶給我們更多的收益。注意,對於使用接口的場景,本文沒法全部列舉完,因爲每個案例都依賴於上下文。雖然沒法全部列舉,但本文列舉的三個場景將給我們在什麼情況應該使用接口提供一個指引。

第一個討論的場景是在多種類型實現共同行爲時使用接口。這種場景下,將共同行爲抽取到接口中。如果我們查看標準庫,可以找到許多此類場景的示例。例如,可以通過實現排序接口的定義的方法對集合元素進行排序。

因此,在 sort 包中定義瞭如下接口:

type Interface interface {
        Len() int
        Less(i, j int) bool
        Swap(i, j int)
}

該接口具有強大的複用性,因爲它支持對任何基於索引的集合進行排序。在整個 sort 包中,可以找到很多種實現。例如,具體到某種類型,在某個時候當我們計算出了集合中元素的個數之後,我們需要對其進行排序,我們是否一定對實現類型感興趣?採用的是什麼排序算法,是歸併排序還是快速排序?在很多情況下,作爲調用方並不在乎。因此,排序行爲可以被抽象化,我們可以依賴於 sort.Interface.

找到合適的抽象來分解操作也會帶來很多好處,例如,sort 包提供了同樣依賴於 sort.Interface 的工具函數,像檢查一個集合是否已經是有序的。

func IsSorted(data Interface) bool {
        n := data.Len()
        for i := n - 1; i > 0; i-- {
                if data.Less(i, i-1) {
                        return false
                }
        }
        return true
}

第二討論的場景是對我們的代碼實現進行解耦。如果我們依賴抽象而不是具體的實現,那麼實現本身就可以被另一個實現替換,甚至不用更改當前的代碼。這就是里氏替換原則(SOLID 中的 L)。此外,解耦可以帶來單元測試的便利性。假設我們必須實現一個 CreateNewCustomer 方法來創建一個新客戶並保存它的信息,我們可以直接依賴具體的實現(比如 mysql.Store 結構), 代碼如下。

type CustomerService struct {
        store mysql.Store
}

func (cs CustomerService) CreateNewCustomer(id string) error {
        customer := Customer{id: id}
        return cs.store.StoreCustomer(customer)
}

現在,如果我們要對這個函數進行單元測試,由於 CustomerService 依賴於實際實現(MySQL)來存儲客戶信息。我們需要先啓動 MySQL 數據庫,才能對其進行測試(除非使用諸如 go-sqlmock 之類的替代方法)。儘管集成測試很有幫助,但它並不總是我們想要的。爲了使得代碼有更大的靈活性,應該將 CustomerService 與實際實現分離,可以通過如下接口完成:

type customerStorer interface {
        StoreCustomer(Customer) error
}

type CustomerService struct {
        storer customerStorer
}

func (cs CustomerService) CreateNewCustomer(id string) error {
        customer := Customer{id: id}
        return cs.storer.StoreCustomer(customer)
}

上述新版本存儲客戶信息是通過接口完成的,我們現在可以靈活地對其進行單元測試:

第三個討論的場景是通過接口限制特定的行爲,看起來有點違反直覺,可以結合下面的例子進行理解。假設我們已經實現了一個自定義配置包來處理動態配置,該包中定義了一個 IntConfig 結構體,用於存儲 int 配置信息,該結構體對外暴露了 Get 和 Set 兩個方法.

type IntConfig struct {
        // ...
}

func (c *IntConfig) Get() int {
        // Retrieve configuration
}

func (c *IntConfig) Set(value int) {
        // Update configuration
}

現在,假設我們獲取到一個 IntConfig 對象,它包含一些特定的配置,例如閾值設定。但是,在我們的代碼中,只對讀取配置感興趣,並且希望不要對其進行修改操作。如果不想修改上面的配置包中的代碼,怎麼限制執行這個配置是隻讀的呢?可以創建一個將行爲限制爲僅讀取配置值的抽象(即接口)。

type intConfigGetter interface {
        Get() int
}

然後,在代碼中,可以依賴 intConfigGetter 而不是具體的實現編碼。配置 getter 被注入到 NewFoo 工廠方法中,這樣做甚至能夠做到不會影響使用這個函數的客戶端,仍然可以傳遞一個 IntConfig 對象給 NewFoo,因爲 IntConfig 實現了接口 intConfigGetter,並且能夠實現在 Bar 方法中只能讀取不能修改配置信息的目的。

type Foo struct {
        threshold intConfigGetter
}

func NewFoo(threshold intConfigGetter) Foo {
        return Foo{threshold: threshold}
}

func (f Foo) Bar()  {
        threshold := f.threshold.Get()
        // ...
}

通過上面的例子可以看到,出於各種原因,我們可以使用接口來限制對象的特定行爲,像上面強制設置爲只讀語義。

接口污染

有其他語言經驗的人,像 C# 或 Java 背景的人,在具體類型之前創建接口對他們來說是很自然的。然而,在 Go 項目中這是在過度使用接口,不是推薦做法。

正如我們所討論的,接口是用來創建抽象的。當在編碼中遇到抽象時,記住一句話 “應該發現抽象,而不是創建抽象”,這是什麼意思呢?這句話想表達的意思是如果沒有直接的原因,我們不應該首先在代碼中創建抽象,不應該使用接口進行設計,而是等待具體的需求。也就是說,我們應該在需要時創建接口,而不是在我們預見到可能需要它時就創建。

過度使用接口,會產生什麼問題呢?答案是它使代碼流更加複雜。添加無用的間接層不會帶來任何價值:創建了一個沒有用的抽象,使代碼更難閱讀和理解。如果沒有充分的理由添加接口並且不清楚接口如何使代碼變得更好,我們應該主動對使用接口產生質疑,爲什麼不直接調用具體實現(非接口)呢?

「NOTE: 注意通過接口調用方法時的性能開銷,需要在哈希表數據結構中查找到實際指向的具體類型,然而,這在很多情況下不是什麼問題,因爲這種開銷很小。」

總結,在編碼的過程中使用接口應該謹慎,應該帶着發現抽象,而不是創建抽象的目的。對於軟件開發人員來說,根據當前情況猜測以後可能有什麼需求,來構建完美的抽象,過度設計代碼是很常見的,應該避免這樣做,因爲在大多數情況下,會用不必要的抽象污染當前的代碼。使其閱讀起來更加複雜。我們不要試圖通過抽象解決所有問題,而是解決現在必須解決的問題。最後但同樣重要的是,如果不清楚接口如何使代碼變得更好,我們可能應該考慮刪除它以使我們的代碼更簡單。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/4lBOs2PvbOGJHIljTqdC1Q