在 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
作為參數。 您可以將 Square
或 Circle
物件傳送給這個函式,儘管輸出會有所不同,但都行得通。 您的 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 應用程式) 一文。