在 Go 中使用介面

已完成

在 Go 中,介面是一種資料類型,用來表示其他類型的行為。 介面就像是藍圖或合約,物件應加以滿足其要求。 當您使用介面時,因為您撰寫的程式碼,不會繫結到特定的實作方式,所以您的程式碼基底會更具彈性,而且更容易調整。 也因此,您可以快速擴充程式的功能。 您將在本課程模組中了解其原因。

Go 與其他程式設計語言不同,其介面需求會間接獲得滿足。 Go 不提供可實作介面的關鍵字。 因此,若您熟悉其他程式設計語言的介面,但沒用過 Go,這種概念可能會讓您感到困惑。

在本課程模組中,我們將會使用多個範例來探索 Go 中的介面,並示範如何充分運用這些介面。

宣告介面

Go 中的介面就像一個藍圖。 是一種抽象類型,僅包含實體類型必須擁有或實作的方法。

假設您想要在幾何套件中建立介面,從而指出圖形要實作的方法。 您可以如下定義介面:

type Shape interface {
    Perimeter() float64
    Area() float64
}

Shape 介面表示無論您想要何種類型,Shape 都必須同時具有 Perimeter()Area() 方法。 例如,當您建立 Square 結構時,其必須同時實作這兩種方法,而不能只實作其中一種。 另外也請注意,介面不會包含這些方法的實作詳細資料 (例如,計算圖形周長與面積的實作詳細資料)。 介面就像合約。 三角形、圓形及正方形這些圖形,在計算面積與周長的方式皆不相同。

實作介面

如先前所討論,在 Go 中,您不用關鍵字實作介面。 在 Go 中,當類型具有介面需要的所有方法時,也等於滿足了介面的需求。

讓我們從 Shape 介面建立同時具有這兩種方法的 Square 結構,如下列範例程式碼所示:

type Square struct {
    size float64
}

func (s Square) Area() float64 {
    return s.size * s.size
}

func (s Square) Perimeter() float64 {
    return s.size * 4
}

請留意 Square 結構的方法簽章與 Shape 介面的簽章相同。 但另一個不同名稱的介面,可能使用了相同的方法。 那麼 Go 要何時或如何得知實體類型實作了哪一個介面? 當您在執行階段使用介面時,Go 就會知道。

您可以撰寫下列程式碼,從而示範如何使用介面:

func main() {
    var s Shape = Square{3}
    fmt.Printf("%T\n", s)
    fmt.Println("Area: ", s.Area())
    fmt.Println("Perimeter:", s.Perimeter())
}

當您執行上述程式時,會得到下列輸出:

main.Square
Area:  9
Perimeter: 12

此時,無論您是否使用介面,都沒有差別。 我們接著再建立另一種類型 (例如 Circle),然後探索為什麼介面可以帶來什麼幫助。 以下是 Circle 結構的程式碼:

type Circle struct {
    radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.radius * c.radius
}

func (c Circle) Perimeter() float64 {
    return 2 * math.Pi * c.radius
}

現在讓我們重構 main() 函式,並建立函式列印出其所接收的物件類型,以及物件的面積與周長,如下所示:

func printInformation(s Shape) {
    fmt.Printf("%T\n", s)
    fmt.Println("Area: ", s.Area())
    fmt.Println("Perimeter:", s.Perimeter())
    fmt.Println()
}

請留意 printInformation 函式使用了 Shape 作為參數。 您可以將 SquareCircle 物件傳送給這個函式,儘管輸出會有所不同,但都行得通。 您的 main() 函式看起來會像這樣:

func main() {
    var s Shape = Square{3}
    printInformation(s)

    c := Circle{6}
    printInformation(c)
}

請注意,對於 c 物件,我們並不會將其指定為 Shape 物件。 不過,printInformation 函式應有一個物件,實作 Shape 介面中定義的方法。

當您執行此程式時,應會得到下列輸出:

main.Square
Area:  9
Perimeter: 12

main.Circle
Area:  113.09733552923255
Perimeter: 37.69911184307752

請留意您不會收到錯誤,而且輸出會隨接收的物件類型而不同。 您也會發現,輸出中的物件類型並未提及任何有關於 Shape 介面的資訊。

使用介面的優點是,Shape 的所有新類型或實作,都不需要變更 printInformation 函式。 這就是為什麼我們在前文中說,使用介面可以讓您的程式碼變得更具彈性,而且更容易擴充。

實作 Stringer 介面

例如,若要擴充現有功能,最簡單的方法就是使用 Stringer,亦即 String() 方法的介面,如下所示:

type Stringer interface {
    String() string
}

fmt.Printf 函式會使用此介面列印出值,換言之,您可以撰寫自訂的 String() 方法來列印自訂字串,如下所示:

package main

import "fmt"

type Person struct {
    Name, Country string
}

func (p Person) String() string {
    return fmt.Sprintf("%v is from %v", p.Name, p.Country)
}
func main() {
    rs := Person{"John Doe", "USA"}
    ab := Person{"Mark Collins", "United Kingdom"}
    fmt.Printf("%s\n%s\n", rs, ab)
}

當您執行上述程式時,會得到下列輸出:

John Doe is from USA
Mark Collins is from United Kingdom

如您所見,您使用了自訂類型 (結構) 來撰寫自訂版本的 String() 方法。 這個技巧是 Go 中常見的實作介面方式,您可以在許多程式中看到這種例子,我們接下來將會加以探索。

擴充現有的實作

假設您有下列程式碼,想要利用可以操作特定資料的 Writer 方法撰寫自訂實作,以擴充程式碼的功能。

藉由使用下列程式碼,您就可以建立一個程式來取用 GitHub API,從 Microsoft 取得三個存放庫:

package main

import (
    "fmt"
    "io"
    "net/http"
    "os"
)

func main() {
    resp, err := http.Get("https://api.github.com/users/microsoft/repos?page=15&per_page=3")
    if err != nil {
        fmt.Println("Error:", err)
        os.Exit(1)
    }

    io.Copy(os.Stdout, resp.Body)
}

當您執行上述程式碼時,會得到類似下列輸出 (已縮短以方便理解):

[{"id":276496384,"node_id":"MDEwOlJlcG9zaXRvcnkyNzY0OTYzODQ=","name":"-Users-deepakdahiya-Desktop-juhibubash-test21zzzzzzzzzzz","full_name":"microsoft/-Users-deepakdahiya-Desktop-juhibubash-test21zzzzzzzzzzz","private":false,"owner":{"login":"microsoft","id":6154722,"node_id":"MDEyOk9yZ2FuaXphdGlvbjYxNTQ3MjI=","avatar_url":"https://avatars2.githubusercontent.com/u/6154722?v=4","gravatar_id":"","url":"https://api.github.com/users/microsoft","html_url":"https://github.com/micro
....

請注意,io.Copy(os.Stdout, resp.Body) 呼叫會將您從呼叫 GitHub API 時取得的內容,列印到終端機。 假設您想要撰寫自己的實作,以縮短您在終端機中看到的內容。 當您查看 io.Copy函式 的 來源時,會看到下列內容:

func Copy(dst Writer, src Reader) (written int64, err error)

若您深入探索第一個參數 dst Writer 的詳細資料,您會發現 Writer 是一個介面

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

您可以繼續探索 io 套件的原始程式碼,直到您找到Copy呼叫Write方法的位置為止,但我們現在先不討論這個探索。

因為 Writer 是介面,而且是 Copy 函式需要的物件,所以您可以自行撰寫 Write 方法的自訂實作。 因此,您可以自訂要列印到終端機的內容。

若要實作介面,第一件事就是建立自訂類型。 在此範例中,您可以建立空的結構,因為您只需要撰寫自訂的 Write 方法,如下所示:

type customWriter struct{}

現在您可以撰寫您的自訂 Write 函式了。 您也需要撰寫結構,以將 JSON 格式的 API 回應剖析為 Golang 物件。 您可以利用 JSON-to-Go 網站,從 JSON 承載來建立結構。 因此,Write 方法可能看起來像這樣:

type GitHubResponse []struct {
    FullName string `json:"full_name"`
}

func (w customWriter) Write(p []byte) (n int, err error) {
    var resp GitHubResponse
    json.Unmarshal(p, &resp)
    for _, r := range resp {
        fmt.Println(r.FullName)
    }
    return len(p), nil
}

最後,您必須修改 main() 函式,以使用您的自訂物件,如下所示:

func main() {
    resp, err := http.Get("https://api.github.com/users/microsoft/repos?page=15&per_page=5")
    if err != nil {
        fmt.Println("Error:", err)
        os.Exit(1)
    }

    writer := customWriter{}
    io.Copy(writer, resp.Body)
}

當您執行此程式時,應會得到下列輸出:

microsoft/aed-blockchain-learn-content
microsoft/aed-content-nasa-su20
microsoft/aed-external-learn-template
microsoft/aed-go-learn-content
microsoft/aed-learn-template

因為您撰寫了自訂的 Write 方法,輸出現在看起來好多了。 以下是該程式的最終版本:

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
)

type GitHubResponse []struct {
    FullName string `json:"full_name"`
}

type customWriter struct{}

func (w customWriter) Write(p []byte) (n int, err error) {
    var resp GitHubResponse
    json.Unmarshal(p, &resp)
    for _, r := range resp {
        fmt.Println(r.FullName)
    }
    return len(p), nil
}

func main() {
    resp, err := http.Get("https://api.github.com/users/microsoft/repos?page=15&per_page=5")
    if err != nil {
        fmt.Println("Error:", err)
        os.Exit(1)
    }

    writer := customWriter{}
    io.Copy(writer, resp.Body)
}

撰寫自訂伺服器 API

最後,讓我們來探索另一個介面使用案例,以在您建立伺服器 API 時提供協助。 撰寫 Web 伺服器的常見方式,是使用 net/http 套件中的 http.Handler 介面,如下所示 (您無須撰寫此程式碼):

package http

type Handler interface {
    ServeHTTP(w ResponseWriter, r *Request)
}

func ListenAndServe(address string, h Handler) error

請留意 ListenAndServe 函式應是伺服器位址 (例如 http://localhost:8000),會將呼叫的回應,分派給該伺服器位址的 Handler 執行個體。

現在我們要建立及探索下列程式:

package main

import (
    "fmt"
    "log"
    "net/http"
)

type dollars float32

func (d dollars) String() string {
    return fmt.Sprintf("$%.2f", d)
}

type database map[string]dollars

func (db database) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    for item, price := range db {
        fmt.Fprintf(w, "%s: %s\n", item, price)
    }
}

func main() {
    db := database{"Go T-Shirt": 25, "Go Jacket": 55}
    log.Fatal(http.ListenAndServe("localhost:8000", db))
}

在探索上述程式碼之前,讓我們先執行該程式碼,如下所示:

go run main.go

若您得不到任何輸出,這是個好的開始。 現在在新的瀏覽器視窗中開啟 http://localhost:8000,或在您的終端機中執行下列命令:

curl http://localhost:8000

您現在應該會得到下列輸出:

Go T-Shirt: $25.00
Go Jacket: $55.00

讓我們慢慢複習上述程式碼,以了解該程式碼的功用,並觀察 Go 介面的強大功能。 首先,您要先為 float32 類型建立自訂類型,並撰寫 String() 方法的自訂實作於稍後使用。

type dollars float32

func (d dollars) String() string {
    return fmt.Sprintf("$%.2f", d)
}

接著,我們為 http.Handler 撰寫了 ServeHTTP 方法的實作。 請留意我們又再建立了自訂類型,但這次是對應,而非結構。 接下來,我們使用 database 類型作為接收端來撰寫 ServeHTTP 方法。 此方法的實作方式會使用來自接收端的資料,並透過該資料進行迴圈,然後列印出每個項目。

type database map[string]dollars

func (db database) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    for item, price := range db {
        fmt.Fprintf(w, "%s: %s\n", item, price)
    }
}

最後,在 main() 函式中,我們將 database 類型具現化,並使用了某些值加以初始化。 我們使用了 http.ListenAndServe 函式啟動 HTTP 伺服器,並在該函式中定義了伺服器位址,包括要使用的連接埠,以及實作自訂版 ServeHTTP 方法的 db 物件。 當您執行程式時,Go 會使用您方法的實作,而這也就是您在伺服器 API 中,使用及實作介面的方式。

func main() {
    db := database{"Go T-Shirt": 25, "Go Jacket": 55}
    log.Fatal(http.ListenAndServe("localhost:8000", db))
}

當您使用 http.Handle 函式時,您可以在伺服器 API 中,找到其他介面的使用案例。 如需詳細資訊,請參閱 Go 網站的 Writing web applications (撰寫 Web 應用程式) 一文。