golang中的介面

slowquery發表於2022-10-19

0.1、索引

waterflow.link/articles/1666171320...

1、概念

介面提供了一種指定物件行為的方法。 我們使用介面來建立多個物件可以實現的通用抽象。 Go 介面不同的原因在於它們是隱式的。 沒有像 implements 這樣的顯式關鍵字來標記物件 A實現了介面B。

為了理解介面的強大,我們可以看下標準庫中兩個常用的介面:io.Reader 和 io.Writer。 io 包為 I/O 原語提供抽象。 在這些抽象中,io.Reader 從資料來源讀取資料,io.Writer 將資料寫入目標。

io.Reader 包含一個 Read 方法:

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

io.Reader 介面的自定義實現應該接收一個位元組切片p,把資料讀取到p中並返回讀取的位元組數或錯誤。

io.Writer 定義了一個方法,Write:

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

io.Writer 的自定義實現應該將來自切片的資料p寫入底層資料流並返回寫入的位元組數或錯誤。

因此,這兩個介面都提供了基本的抽象:

  • io.Reader 從一個源物件讀取資料
  • io.Writer 將資料寫到一個目標物件

假設我們需要實現一個將一個檔案的內容複製到另一個檔案的函式。 我們可以建立一個特定的函式copyFile,它將使用 io.Reader 和 io.Writer 抽象建立一個更通用的函式:

package main

import (
    "io"
    "log"
    "os"
)

func main() {
  // 1 開啟一個原始檔
    source, err := os.Open("a.txt")
    if err != nil {
        log.Fatal(err)
    }
    defer source.Close()

  // 2 建立一個目標檔案
    dest, err := os.Create("b.txt")
    if err != nil {
        log.Fatal(err)
    }
    defer dest.Close()

  // 從把源資料複製到目標
    err = CopyFile(source, dest)
    if err != nil {
        log.Fatal(err)
    }
}

// 複製
func CopyFile(source io.Reader, dest io.Writer) error {
    var buffer = make([]byte, 1024)
    for {
        n, err := source.Read(buffer)
        if err == nil {
            _, err = dest.Write(buffer[:n])
            if err != nil {
                return err
            }
        }
        if err == io.EOF {
            _, err = dest.Write(buffer)
            if err != nil {
                return err
            }
            return nil
        }
        return err
    }
}
  1. 我們利用os.Open開啟一個檔案,該函式返回一個*os.File控制程式碼,*os.File 實現了 io.Reader 和 io.Writer
  2. 我們使用os.Create建立一個新的檔案,該函式返回一個*os.File控制程式碼
  3. 我們使用copyFile函式,該函式有兩個引數,source為一個實現io.Reader介面的引數,dest為一個實現 io.Writer介面的引數

該函式適用於 *os.File 引數(因為 *os.File 實現了 io.Reader 和 io.Writer)以及任何其他可以實現這些介面的型別。 例如,我們可以建立自己的 io.Writer 寫入資料庫,並且程式碼將保持不變。 它增加了函式的通用性; 因此,他是可重用的,這很重要。

此外,為這個函式編寫單元測試更容易,因為我們可以使用提供有用實現的字串和位元組包,而不是處理檔案:

package main

import (
    "bytes"
    "strings"
    "testing"
)

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

    err := CopyFile(source, dest)
    if err != nil {
        t.Fatal(err)
    }

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

在上面例子中,source 是 *strings.Reader,而 dest 是 *bytes.Buffer。 在這裡,我們在不建立任何檔案的情況下測試 CopyFile ,歸功於CopyFile的引數使用的是介面,只要我們引數實現了這倆個介面就可以執行單元測試。

2、什麼時候使用介面

我們什麼時候應該在 Go 中建立介面? 讓我們看一下通常認為介面帶來價值的三個具體用例:

  • 通用行為
  • 解耦
  • 限制行為

2.1、通用行為

在多種型別實現共同行為時使用介面。 在這種情況下,我們可以分解出介面內部的行為。 如果我們檢視標準庫,我們可以找到許多此類用例的示例。 例如,可以透過2個方法讓共享資源變得安全:

  • 給共享資源加鎖
  • 給共享資源釋放鎖

因此,sync包中新增了以下介面:

type Locker interface {
    Lock()
    Unlock()
}

該介面具有強大的可重用潛力,因為它包含對任何共享資源進行不同方式保護的常見行為。

我們都知道sync.Mutex是不支援鎖的可重入的,但是有時我們希望同一個協程可以給資源重複上鎖,而不會引起報錯。因此,加鎖和解鎖就可以被抽象化,我們可以依賴 sync.Locker。

所以我們就可以很輕鬆的實現可重入鎖,像下面這樣:

package main

import (
    "fmt"
    "github.com/petermattis/goid"
    "log"
    "sync"
    "sync/atomic"
)

type RecursiveMutex struct {
    sync.Mutex
    owner int64
    recursion int32
}

// 1
func (m *RecursiveMutex) Lock()  {
    gid := goid.Get()
    if atomic.LoadInt64(&m.owner) == gid {
        m.recursion++
        return
    }

    m.Mutex.Lock()
    atomic.StoreInt64(&m.owner, gid)
    m.recursion = 1
}

// 2
func (m *RecursiveMutex) Unlock()  {
    gid := goid.Get()

    if atomic.LoadInt64(&m.owner) != gid {
        panic(fmt.Sprintf("Wrong the owner (%d): %d!", m.owner, gid))
    }

    m.recursion--
    if m.recursion != 0 {
        return
    }
    atomic.StoreInt64(&m.owner, -1)
    m.Mutex.Unlock()
}

func main()  {
    l := &RecursiveMutex{}
    foo1(l)
}

func foo1(l *RecursiveMutex) {
    log.Println("in foo")
    l.Lock()
    bar1(l)
    l.Unlock()
}

func bar1(l *RecursiveMutex) {
    l.Lock()
    log.Println("in bar")
    l.Unlock()
}
  1. 實現sync.Locker的Lock方法
  2. 實現sync.Locker的Unlock方法

2.2、解耦

如果我們依賴抽象而不是具體的實現,則可以用另一個具體實現取替換,甚至不必更改我們的程式碼。 這就是 里氏替換原則。

解耦的好處之一可能與單元測試有關。 假設我們要實現一個 StoreCourseware 方法來建立一個課件。 我們決定直接依賴具體實現:

// 課件模型
type Courseware struct {
    id int64
}

type Store struct {
}
func (s Store) StoreCourseware(courseware Courseware) error {
    // 需要走資料庫
    return nil
}

type CoursewareService struct {
    store Store
}

func (cw CoursewareService) CreateCourseware(id int64) error {
    courseware := Courseware{id: id}
    return cw.store.StoreCourseware(courseware)
}

現在,如果我們想測試這個方法怎麼辦? 因為 CoursewareService 依賴於實際實現來儲存課件,所以我們不得不透過整合測試對其進行測試,這需要啟動 MySQL 例項(除非我們使用諸如 go-sqlmock 之類的替代技術,但這不是本節要討論的內容)。 儘管整合測試很有幫助,但這並不總是我們想要做的。 為了使我們程式碼有更大的靈活性,我們應該將 CoursewareService 與實際實現分離,這可以透過如下介面完成:

// 課件模型
type Courseware struct {
    id int64
}

// 新增課件的一種實現
type Store struct {
}
func (s Store) StoreCourseware(courseware Courseware) error {
    // 需要走資料庫
    return nil
}

// 新增課件的介面,只要實現介面不管走mysql還是記憶體
type CoursewareStorer interface {
    StoreCourseware (courseware Courseware) error
}

type CoursewareService struct {
    store CoursewareStorer
}

func (cw CoursewareService) CreateCourseware(id int64) error {
    courseware := Courseware{id: id}
    return cw.store.StoreCourseware(courseware)
}

因為現在儲存客戶是透過一個介面完成的,這給了我們更多的靈活性來測試我們想要的方法。 例如,我們可以:

  • 透過整合測試使用具體實現
  • 透過單元測試使用模擬(或任何型別的測試替身)

2.3、限制行為

假設我們實現了一個自定義配置包來處理動態配置。 我們透過一個 Config 結構儲存配置,該結構還公開了兩種方法:Get 和 Set。 以下是該程式碼的實現:

type Config struct {
    rabbitmq string
      cpu int
}

func (c *Config) Rabbitmq() string {
    return c.rabbitmq
}

func (c *Config) SetRabbitmq(value string) {
    c.rabbitmq = value
}

現在,假設Config有個cpu配置,但是在我們的程式碼中,我們不希望更新他,讓他只讀。 如果我們不想更改配置包,如何從語義上強制執行此配置是隻讀的? 透過建立一個將行為限制為只讀的抽象:

type ConfigCPUGetter interface {
    Get() int
}

然後,在我們的程式碼中,我們可以依賴 ConfigCPUGetter 而不是具體的實現:

type Foo struct {
    threshold ConfigCPUGetter
}

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

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

在這個例子中,配置 getter 被注入到 NewFoo 工廠方法中。 它不會影響此函式的客戶端,因為它仍然可以在實現 ConfigCPUGetter 時傳遞 Config 結構。 然後,我們只能讀取 Bar 方法中的配置,不能修改它。 因此,我們還可以出於各種原因使用介面將型別限制為特定行為。

3、介面汙染

在 Go 專案中過度使用介面是很常見的。也許開發人員的背景是 C# 或 Java,他們發現在具體型別之前建立介面是很自然的。然而,這不是 Go 中的工作方式。

正如我們所討論的,介面是用來建立抽象的。當程式設計遇到抽象時,主要的警告是記住應該發現抽象,而不是建立抽象。這是什麼意思?這意味著如果沒有直接的理由,我們不應該開始在我們的程式碼中建立抽象。我們不應該使用介面進行設計,而是等待具體的需求。換句話說,我們應該在需要時建立介面,而不是在我們預見到可能需要它時。

如果我們過度使用介面,主要問題是什麼?答案是它們使程式碼流更加複雜。新增無用的間接級別不會帶來任何價值;它建立了一個毫無價值的抽象,使程式碼更難閱讀、理解和推理。如果我們沒有充分的理由新增介面,並且不清楚介面如何使程式碼變得更好,我們應該挑戰這個介面的目的。為什麼不直接呼叫實現呢?

注意當透過介面呼叫方法時,我們也可能會遇到效能開銷。它需要在雜湊表的資料結構中查詢以找到介面指向的具體型別。但這在許多情況下都不是問題,因為開銷很小。

總之,在我們的程式碼中建立抽象時我們應該小心——應該發現抽象,而不是建立抽象。對於我們軟體開發人員來說,透過根據我們認為以後可能需要的東西來猜測完美的抽象級別是什麼來過度設計我們的程式碼是很常見的。應該避免這個過程,因為在大多數情況下,它會用不必要的抽象汙染我們的程式碼,使其閱讀起來更加複雜。

我們不要試圖抽象地解決問題,而是解決現在必須解決的問題。最後但同樣重要的是,如果不清楚介面如何使程式碼變得更好,我們可能應該考慮刪除它以使我們的程式碼更簡單。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章