在过去的几个月里,我一直在进行一项调查,询问人们在学习围棋时有什么困难。而在回答中不断出现的是界面的概念。
我明白这一点。Go是我使用的第一种有接口的语言,我记得当时整个概念让人很困惑。所以在这个教程中我想做几件事:
- 对什么是接口提供一个简单的英语解释。
- 解释为什么它们是有用的,以及你可能想在你的代码中使用它们。
- 谈谈什么是
interface{}(空接口)。 - 并介绍一些在标准库中找到的有用的接口类型。
那么什么是接口?
Go中的接口类型有点像一个定义。它定义并描述了一些其他类型必须具备的确切方法。
标准库中的一个接口类型的例子是 fmt.Stringer接口,它看起来像这样:
type Stringer interface {
String() string
}
如果某个东西有一个具有确切签名的方法,我们就说它满足了这个接口(或实现了这个接口)String() string 。
例如,下面这个Book 类型满足了这个接口,因为它有一个String() string 方法:
type Book struct {
Title string
Author string
}
func (b Book) String() string {
return fmt.Sprintf("Book: %s - %s", b.Title, b.Author)
}
这个Book 类型是什么或做什么其实并不重要。唯一重要的是,它有一个叫做String() 的方法,该方法返回一个string 的值。
或者,作为另一个例子,下面的Count 类型也满足了fmt.Stringer 接口--同样是因为它有一个具有确切签名的方法String() string :
type Count int
func (c Count) String() string {
return strconv.Itoa(int(c))
}
需要掌握的重要一点是,我们有两个不同的类型,Book 和Count ,它们做不同的事情。但是它们的共同点是它们都满足fmt.Stringer 接口。
你也可以反过来想一想。如果你知道一个对象满足fmt.Stringer 接口,你就可以依靠它有一个具有确切签名的方法String() string ,你可以调用。
现在是重要的部分。
只要你在Go中看到有接口类型的声明(如变量、函数参数或结构字段),你就可以使用任何类型的对象*,只要它满足接口的要求。
例如,假设你有下面这个函数:
func WriteLog(s fmt.Stringer) {
log.Print(s.String())
}
因为这个WriteLog() 函数在其参数声明中使用了fmt.Stringer 接口类型,我们可以传入任何满足fmt.Stringer 接口的对象。例如,我们可以把前面做的Book 和Count 类型中的任何一个传给WriteLog() 方法,代码就可以正常工作了。
此外,由于被传入的对象满足fmt.Stringer 接口,我们知道它有一个String() string 方法,WriteLog() 函数可以安全调用。
让我们把这些放在一个例子中,让我们窥探一下接口的力量:
package main
import (
"fmt"
"strconv"
"log"
)
// Declare a Book type which satisfies the fmt.Stringer interface.
type Book struct {
Title string
Author string
}
func (b Book) String() string {
return fmt.Sprintf("Book: %s - %s", b.Title, b.Author)
}
// Declare a Count type which satisfies the fmt.Stringer interface.
type Count int
func (c Count) String() string {
return strconv.Itoa(int(c))
}
// Declare a WriteLog() function which takes any object that satisfies
// the fmt.Stringer interface as a parameter.
func WriteLog(s fmt.Stringer) {
log.Print(s.String())
}
func main() {
// Initialize a Count object and pass it to WriteLog().
book := Book{"Alice in Wonderland", "Lewis Carrol"}
WriteLog(book)
// Initialize a Count object and pass it to WriteLog().
count := Count(3)
WriteLog(count)
}
这真是太酷了。在main 函数中,我们创建了不同的Book 和Count 类型,但把它们都传给了同一个 WriteLog() 函数。反过来,这又调用了它们相关的String() 函数并记录了结果。
如果你运行这段代码,你应该得到一些看起来像这样的输出:
2009/11/10 23:00:00 Book: Alice in Wonderland - Lewis Carrol
2009/11/10 23:00:00 3
我不想在这里过多地强调这一点。但关键的一点是,通过在我们的WriteLog() 函数声明中使用一个接口类型,我们已经使这个函数对它所接收的对象的确切类型变得不可知(或灵活)。重要的是它有哪些方法。
为什么它们是有用的?
在Go中使用接口有各种各样的原因,但根据我的经验,最常见的有三个:
- 为了帮助减少重复或模板代码。
- 在单元测试中更容易使用mock而不是真实对象。
- 作为一种架构工具,帮助强制执行代码库各部分之间的解耦。
让我们来看看这三个用例,并更详细地探讨它们。
减少模板代码
好吧,想象一下,我们有一个Customer 结构,包含一些关于客户的数据。在我们代码库的一部分,我们想把客户信息写到一个 bytes.Buffer,而在代码库的另一部分,我们想把客户信息写到一个 os.File磁盘上。但在这两种情况下,我们都想先将客户结构序列化为JSON。
这是一个我们可以使用Go的接口来帮助减少模板代码的场景。
你需要知道的第一件事是,Go有一个 io.Writer接口类型,它看起来像这样:
type Writer interface {
Write(p []byte) (n int, err error)
}
而我们可以利用以下事实 bytes.Buffer和 os.File类型都满足这个接口,因为它们有 bytes.Buffer.Write()和 os.File.Write()方法。
让我们看一下一个简单的实现。
package main
import (
"bytes"
"encoding/json"
"io"
"log"
"os"
)
// Create a Customer type
type Customer struct {
Name string
Age int
}
// Implement a WriteJSON method that takes an io.Writer as the parameter.
// It marshals the customer struct to JSON, and if the marshal worked
// successfully, then calls the relevant io.Writer's Write() method.
func (c *Customer) WriteJSON(w io.Writer) error {
js, err := json.Marshal(c)
if err != nil {
return err
}
_, err = w.Write(js)
return err
}
func main() {
// Initialize a customer struct.
c := &Customer{Name: "Alice", Age: 21}
// We can then call the WriteJSON method using a buffer...
var buf bytes.Buffer
err := c.WriteJSON(&buf)
if err != nil {
log.Fatal(err)
}
// Or using a file.
f, err := os.Create("/tmp/customer")
if err != nil {
log.Fatal(err)
}
defer f.Close()
err = c.WriteJSON(f)
if err != nil {
log.Fatal(err)
}
}
当然,这只是一个玩具的例子(我们还可以用其他方式来组织代码,以达到相同的最终结果)。但是它很好地说明了使用接口的好处--我们可以创建一次Customer.WriteJSON() 方法,并且我们可以在任何时候调用该方法来写入满足io.Writer 接口的东西。
但是如果你是Go的新手,这仍然会引出一些问题。你怎么知道io.Writer 接口的存在?你怎么知道bytes.Buffer 和os.File 都满足它?
这恐怕没有什么简单的捷径--你只需要积累经验,熟悉标准库中的接口和不同类型。花点时间彻底阅读标准库的文档,并看看其他人的代码会有帮助。但作为一个快速入门,我在这篇文章的结尾处列出了一些最有用的接口类型。
但即使你不使用标准库中的接口,也没有什么可以阻止你创建和使用自己的接口类型。我们接下来会介绍如何做到这一点。
单元测试和嘲弄
为了帮助说明接口是如何被用来协助单元测试的,让我们来看一个稍微复杂的例子。
假设你经营一家商店,你在PostgreSQL数据库中存储了关于客户数量和销售额的信息。你想写一些代码来计算过去24小时的销售率(即每个客户的销售额),四舍五入到小数点后2位。
一个最小的代码实现可以是这样的:
File: main.go
package main
import (
"fmt"
"log"
"time"
"database/sql"
_ "github.com/lib/pq"
)
type ShopDB struct {
*sql.DB
}
func (sdb *ShopDB) CountCustomers(since time.Time) (int, error) {
var count int
err := sdb.QueryRow("SELECT count(*) FROM customers WHERE timestamp > $1", since).Scan(&count)
return count, err
}
func (sdb *ShopDB) CountSales(since time.Time) (int, error) {
var count int
err := sdb.QueryRow("SELECT count(*) FROM sales WHERE timestamp > $1", since).Scan(&count)
return count, err
}
func main() {
db, err := sql.Open("postgres", "postgres://user:pass@localhost/db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
shopDB := &ShopDB{db}
sr, err := calculateSalesRate(shopDB)
if err != nil {
log.Fatal(err)
}
fmt.Printf(sr)
}
func calculateSalesRate(sdb *ShopDB) (string, error) {
since := time.Now().Add(-24 * time.Hour)
sales, err := sdb.CountSales(since)
if err != nil {
return "", err
}
customers, err := sdb.CountCustomers(since)
if err != nil {
return "", err
}
rate := float64(sales) / float64(customers)
return fmt.Sprintf("%.2f", rate), nil
}
现在,如果我们想为calculateSalesRate() 函数创建一个单元测试,以确保其中的数学逻辑工作正常,该怎么办?
目前,这是一个有点痛苦的问题。我们需要建立一个PostgreSQL数据库的测试实例,以及设置和拆除脚本,用假数据来构建数据库。当我们真正想做的是测试我们的数学逻辑时,这是相当多的工作。
那么我们能做什么呢?你猜对了--接口来拯救我们!
这里的解决方案是创建我们自己的接口类型,描述CountSales() 和CountCustomers() 方法,这些方法是calculateSalesRate() 函数所依赖的。然后我们可以更新calculateSalesRate() 的签名,以使用这个自定义的接口类型作为参数,而不是具体的*ShopDB 类型。
就像这样:
File: main.go
package main
import (
"database/sql"
"fmt"
"log"
"time"
_ "github.com/lib/pq"
)
// Create our own custom ShopModel interface. Notice that it is perfectly
// fine for an interface to describe multiple methods, and that it should
// describe input parameter types as well as return value types.
type ShopModel interface {
CountCustomers(time.Time) (int, error)
CountSales(time.Time) (int, error)
}
// The ShopDB type satisfies our new custom ShopModel interface, because it
// has the two necessary methods -- CountCustomers() and CountSales().
type ShopDB struct {
*sql.DB
}
func (sdb *ShopDB) CountCustomers(since time.Time) (int, error) {
var count int
err := sdb.QueryRow("SELECT count(*) FROM customers WHERE timestamp > $1", since).Scan(&count)
return count, err
}
func (sdb *ShopDB) CountSales(since time.Time) (int, error) {
var count int
err := sdb.QueryRow("SELECT count(*) FROM sales WHERE timestamp > $1", since).Scan(&count)
return count, err
}
func main() {
db, err := sql.Open("postgres", "postgres://user:pass@localhost/db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
shopDB := &ShopDB{db}
sr, err := calculateSalesRate(shopDB)
if err != nil {
log.Fatal(err)
}
fmt.Printf(sr)
}
// Swap this to use the ShopModel interface type as the parameter, instead of the
// concrete *ShopDB type.
func calculateSalesRate(sm ShopModel) (string, error) {
since := time.Now().Add(-24 * time.Hour)
sales, err := sm.CountSales(since)
if err != nil {
return "", err
}
customers, err := sm.CountCustomers(since)
if err != nil {
return "", err
}
rate := float64(sales) / float64(customers)
return fmt.Sprintf("%.2f", rate), nil
}
完成这些后,我们就可以直接创建一个满足我们的ShopModel 接口的模拟。然后我们可以在单元测试中使用这个模拟来测试我们的calculateSalesRate() 函数中的数学逻辑是否正常工作。就像这样。
File: main_test.go
package main
import (
"testing"
"time"
)
type MockShopDB struct{}
func (m *MockShopDB) CountCustomers(_ time.Time) (int, error) {
return 1000, nil
}
func (m *MockShopDB) CountSales(_ time.Time) (int, error) {
return 333, nil
}
func TestCalculateSalesRate(t *testing.T) {
// Initialize the mock.
m := &MockShopDB{}
// Pass the mock to the calculateSalesRate() function.
sr, err := calculateSalesRate(m)
if err != nil {
t.Fatal(err)
}
// Check that the return value is as expected, based on the mocked
// inputs.
exp := "0.33"
if sr != exp {
t.Fatalf("got %v; expected %v", sr, exp)
}
}
你现在可以运行这个测试,一切都应该是正常的。
应用架构
在前面的例子中,我们已经看到了接口是如何被用来解耦你的代码的某些部分,使之不依赖于具体的类型。例如,calculateSalesRate() 函数对于你传递给它的东西是完全灵活的--唯一重要的是它要满足ShopModel 接口的要求。
你可以扩展这个想法,在更大的项目中创建解耦的 "层"。
比方说,你正在构建一个与数据库交互的Web应用程序。如果你创建了一个描述与数据库交互的确切方法的接口,你就可以在整个HTTP处理程序中引用这个接口,而不是一个具体的类型。因为HTTP处理程序只引用一个接口,这有助于将HTTP层和数据库交互层解耦。这使得这两层的工作更容易独立进行,并在未来更换一层而不影响另一层。
我在之前的这篇博文中写过这种模式,其中有更多细节,并提供了一些实用的示例代码。
什么是空接口?
如果你用Go编程已经有一段时间了,你可能已经遇到了空接口类型:interface{} 。这可能有点令人困惑,但我将在这里尝试解释一下。
在这篇博文的开头,我说:
Go中的接口类型有点像一个定义。它定义并描述了一些其他类型必须拥有的确切方法。
空的接口类型本质上没有描述任何方法。它没有任何规则。正因为如此,任何和每一个对象都满足空接口的要求。
或者用一种更简单的方式来说,空接口类型interface{} ,有点像通配符。只要你在声明中看到它(如变量、函数参数或结构域),你就可以使用任何类型的对象。
看一下下面的代码:
package main
import "fmt"
func main() {
person := make(map[string]interface{}, 0)
person["name"] = "Alice"
person["age"] = 21
person["height"] = 167.64
fmt.Printf("%+v", person)
}
在这段代码中,我们初始化了一个person 地图,它使用string 类型作为键,使用空接口类型interface{} 作为值。我们指定了三种不同的类型作为映射的值(一个string ,一个int 和一个float32 )--这也是可以的。因为任何类型的对象都满足空接口的要求,所以代码可以正常工作。
你可以在这里试一试,当你运行它时,你应该看到一些看起来像这样的输出:
map[age:21 height:167.64 name:Alice]
但是,当涉及到从这个地图中检索和使用一个值时,有一件重要的事情需要指出。
例如,假设我们想获取"age" ,并将其递增1。如果你写了类似下面的代码,它将不能被编译:
package main
import "log"
func main() {
person := make(map[string]interface{}, 0)
person["name"] = "Alice"
person["age"] = 21
person["height"] = 167.64
person["age"] = person["age"] + 1
fmt.Printf("%+v", person)
}
而且你会得到以下错误信息:
invalid operation: person["age"] + 1 (mismatched types interface {} and int)
这是因为存储在地图中的值的类型是interface{} ,而不再是它原来的基本类型int 。因为它不再是int ,我们不能给它加1。
为了解决这个问题,你需要在使用它之前将该值转为int 。像这样:
package main
import "log"
func main() {
person := make(map[string]interface{}, 0)
person["name"] = "Alice"
person["age"] = 21
person["height"] = 167.64
age, ok := person["age"].(int)
if !ok {
log.Fatal("could not assert value to int")
return
}
person["age"] = age + 1
log.Printf("%+v", person)
}
如果你现在运行这个,一切都应该按预期工作:
2009/11/10 23:00:00 map[age:22 height:167.64 name:Alice]
那么,什么时候你应该在自己的代码中使用空接口类型呢?
答案是可能不那么经常。如果你发现自己正在使用它,请暂停一下,考虑一下使用interface{} 是否真的是正确的选择。一般来说,使用具体类型--或者非空接口类型--会更清晰、更安全、更有性能。在上面的代码片段中,定义一个Person 结构,其中有类似于这样的相关类型的字段,会更加合适:
type Person struct {
Name string
Age int
Height float32
}
尽管如此,空接口在你需要接受和处理不可预知的或用户定义的类型的情况下是很有用的。出于这个原因,你会在整个标准库的许多地方看到它的使用,比如在 gob.Encode, fmt.Print和 template.Execute函数中。
Comman和有用的类型
最后,这里列出了标准库中一些最常见和有用的接口。如果你对它们还不熟悉,那么我建议拿出一点时间来看看它们的相关文档。