云原生系列Go语言篇-类型、方法和接口 Part 2

本文涉及的产品
Serverless 应用引擎 SAE,800核*时 1600GiB*时
容器服务 Serverless 版 ACK Serverless,317元额度 多规格
容器服务 Serverless 版 ACK Serverless,952元额度 多规格
简介: 虽然Go并发(在并发一章讲解)是聚光灯下的宠儿,便Go设计中真正的明星是其隐式接口,也是Go中唯一的抽象类型。下面就来学习它的伟大之处。

接口快速教程

虽然Go并发(在并发一章讲解)是聚光灯下的宠儿,便Go设计中真正的明星是其隐式接口,也是Go中唯一的抽象类型。下面就来学习它的伟大之处。

我们先快速学习如何声明接口。接口的内在很简单。和其它自定义类型一样,可以使用type关键字进行定义。

以下是fmt包中Stringer接口的定义:

type Stringer interface {
    String() string
}

在接口声明中,接口字面量(interface)位于接口类型之后。其中包含具象类型实现接口所必须定义的方法。接口中定义的方法称为接口的方法集。

和其它类型一样,接口可在任意作用域中声明。

接口通常以er结尾。像刚刚看到的fmt.Stringer,但还有很多,比如io.Readerio.Closerio.ReadCloserjson.Marshalerhttp.Handler

接口是类型安全的鸭子类型

至此的内容和其它语言中的接口还没有什么分别。Go接口的特别之处在于其隐式实现。具象类型不声明它实现了某个接口。如果具象类型的方法集包含了某个接口方法集中的所有方法,则具象类型实现了该接口。也就是说具象类型可以赋值给声明为接口类型的变量或字段。

这一隐式行为将接口变为Go语言中最有魅力的类型,因为它同时保障了类型安全和解耦,桥接了静态语言和动态语言的功能。

要理解背后的原因,就要先讨论编程语言中为什么有接口。前面提到《设计模式》教育开发者组合优于继承。该书中的另一条建议是“面向接口而非实现。”这样做就依赖于行为而不是实现,在需要时可以切换实现。代码可以不段演进,因为需求总是在变。

Python、Ruby和JavaScript这样的动态语言没有接口。这些语言的开发者使用“鸭子类型”,这基于一个表达“如果像鸭子一样走也像鸭子一样叫,那么就是鸭子。”概念就是只函数能找到调用方法就可以对其使用某一类型的实例传参:

class Logic:
    def process(self, data):
        # business logic
def program(logic):
    # get data from somewhere
    logic.process(data)
logicToUse = Logic()
program(logicToUse)

鸭子类型一开始听起来可能很怪,但它已成功用于构建大型系统了。如果你使用静态类型语言编程,这听起来简直离经叛道。不指定显式类型,很难知道会有什么样的功能。因为新开发者接收项目或是老开发忘了代码的功能,就要查代码确定真正的依赖。

Java开发者使用另一种模式。他们定义接口,创建该接口的实现,但仅在客户端代码中引用接口:

public interface Logic {
    String process(String data);
}
public class LogicImpl implements Logic {
    public String process(String data) {
        // business logic
    }
}
public class Client {
    private final Logic logic;
    // this type is the interface, not the implementation
    public Client(Logic logic) {
        this.logic = logic;
    }
    public void program() {
        // get data from somewhere
        this.logic.process(data);
    }
}
public static void main(String[] args) {
    Logic logic = new LogicImpl();
    Client client = new Client(logic);
    client.program();
}

动态语言开发者看到Java中的显式接口会纳闷在有显式依赖时如何在未来重构代码。切换到不同逻辑的新实现意味着要按新的接口来重写代码。

Go语言的开发者觉得两种都没有错。如果应用要不断增长和变化,需要有灵活性来改变实现。但为了让人们理解代码的功能(因为会有新人来维护同样的代码),需要指明代码的依赖。这便出现了隐式接口。Go是上述两种方式的合体:

type LogicProvider struct {}
func (lp LogicProvider) Process(data string) string {
    // business logic
}
type Logic interface {
    Process(data string) string
}
type Client struct{
    L Logic
}
func(c Client) Program() {
    // get data from somewhere
    c.L.Process(data)
}
main() {
    c := Client{
        L: LogicProvider{},
    }
    c.Program()
}

在这段Go代码中,有一个接口,但仅有调用者(Client)知道,LogicProvider中没有进行任何声明来表示它实现了该接口。这足以为未来新逻辑的编写者提供可执行文档来确保传入client的类型符合其要求。

小贴士:接口指定了调用者之需。客户端代码定义接口指定其所需的功能。

这不是说接口无法共享。我们已经看到在标准库中有多个接口用于输入和输出。标准接口很强大,如果编写代码使用io.Readerio.Writer,不论是写入本地磁盘的文件还是在内存中写值都可正常操作。

此外,使用标准接口鼓励采用装饰器模式。Go中随处可见接收接口实例并返回实现相同接口其它类型的工厂函数。例如,定义了如下的函数:

func process(r io.Reader) error

可以使用如下代码处理文件中的数据:

r, err := os.Open(fileName)
if err != nil {
    return err
}
defer r.Close()
return process(r)

os.Open返回的os.File实例符合io.Reader接口,可在任何代码中用于读取数据。如果为gzip压缩文件,可以将io.Reader封装在另一个io.Reader中:

r, err := os.Open(fileName)
if err != nil {
    return err
}
defer r.Close()
gz, err = gzip.NewReader(r)
if err != nil {
    return err
}
defer gz.Close()
return process(gz)

这时读取非压缩文件同样的代码可用于读取压缩文件。

小贴士:如果标准库中的接口描述的是你代码所需的,那么就使用它!

实现了某个接口的类型完全可以指定不属于接口的其它方法。一组客户端代码可能用不到它们,但另一组可能就需要了。例如,io.File类型同时实现了io.Writer接口。如果你的代码只需要读取文件,使用io.Reader接口来引用文件实例,忽略其它方法。

嵌套和接口

就像可以在结构体中嵌套类型一样,也可以在接口中嵌套接口。例如,io.ReadCloser接口由io.Readerio.Closer组成:

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

注:就像我们可以在结构体中嵌套具象类型一样,也可以在结构体中嵌套接口。我们在编写测试一章的Go语言的Stub一节会看到用法。

接收接口,返回结构体

经常会听到Go语言老开发说代码应该“接收接口,返回结构体。”意思是函数所调用的业务逻辑应由接口触发,但函数的输出应为具象类型。多们已经讲解过为什么函数应接收接口:接口会让代码更灵活并显式声明具体要使用的功能。

如果创建一个返回接口的API,就会浪费掉隐式接口的一个主要优势:解耦。应当限制客户端代码所依赖的第三方接口,因为代码会永久依赖包含这些接口的模块,以及该模块的所有依赖。(在模块、包和导入一章中会讲解模块和依赖。)这会限制未来的灵活性。为避免出现耦合,需要编写另一个接口并通过类型转换将一个接口转换为另一个。依赖具象类型会产生依赖,而在应用层中使用依赖注入层又会影响效果。我们会在隐式接口让依赖注入更简单一节中进一步讨论依赖注入。

另一个避免返回接口的原因是版本化。如果返回了具象类型,可添加新方法和新字段,已有代码可正常运行。但对接口情况就不一样了。对接口添加方法意味着需要更新该接口的所有实现,否则代码会崩溃。如果API向后不兼容,应增加主版本号。

不要写根据入参返回接口背后不同实例的单个工厂函数,而尝试为每个具象类型编写单独的工厂函数。在某些场景下(比如可返回一种或多种类型词法单元的解析器),不可避免地要返回接口。

错误就是一种例外。我们在错误处理一章中会学到,Go的方法和函数声明error接口类型的返回参数。针对error,很可能返回接口的不同实现,因此需要使用接口处理所有可能的选项,因为接口是Go语言中唯一的抽象类型。

这一模式有一个潜在的不足。在指针一章的降低垃圾回收器的工作量一节中讨论过,减少堆内存分配会通过减少垃圾回收器的工作量而提升性能。返回结构体避免堆内存的分配,因此是好事。但在调用带接口类型参数的函数时,每个接口参数都会发生堆内存分配。权衡抽象更重要还是性能更重要可能会常伴你的编程生涯。如果程序太慢且进做过性能测试,又发现性能问题是由于接口参数所导致的堆内存分配,那么应重写为使用具象类型参数的函数。如果对函数传入了同一接口的多种实现,这表示在创建带有重复逻辑的多个函数。

接口和nil

指针一章中讨论过指针,我们还讨论过指针类型的零值nil。我们还使用nil来表示接口类型的零值,这并不简单因为它用于具象类型。

为使用接口为nil,其类型和值都必须为nil。以下代码前两行打印true,最后一行打倒false

var s *string
fmt.Println(s == nil) // prints true
var i interface{}
fmt.Println(i == nil) // prints true
i = s
fmt.Println(i == nil) // prints false

读者可在The Go Playground中自行运行。

在Go运行时中,接口通过指针对实现,一个为其底层类型,另一个为底层值。只要类型为非nil,那么接口也不是nil。(因变量不能没类型,如果值指针非nil,类型指针要一定是非nil。)

对于接口nil表明我们是否可调用其方法。前面讲到可对nil具象实例调用方法,貌似在对接口变量赋nil具象实例时可以调用其方法。如果接口为nil,调用其任意方法会panic(我们在错误处理一章的panic和recover中会讨论)。如果接口非nil,则可调用其方法。(但注意如果值为nil,而所赋类型的方法无法正确处理nil,仍会panic。)

因非nil类型的接口实例不等于nil,很容易在类型不是nil时知道接口关联值是否为nil。必须要使用反射才能知道(在使用反射检查接口值是否为nil一节中讨论)。

接口可比较

复合类型一文中,我们学习了可比较类型,可通过==进行比较。你可能没想到接口也能进行比较。就像接口只有其类型和值字段都为nil时才为nil,两个接口类型的实例只有在类型和值都相等时才相等。这就带来了一个问题:如果类型不可比较怎么办?我们用一个简单示例来探讨这一概念。我们先从接口定义和两个接口实现开始:

type Doubler interface {
    Double()
}
type DoubleInt int
func (d *DoubleInt) Double() {
    *d = *d * 2
}
type DoubleIntSlice []int
func (d DoubleIntSlice) Double() {
    for i := range d {
        d[i] = d[i] * 2
    }
}

DoubleIntDouble方法使用指针接收器声明,因为我们会修改其整型值。可以对DoubleIntSliceDouble方法使用值接收器,原因我们在字典和切片的区别一节中提到过,我们可以修改切片类型参数项的值。*DoubleInt类型是可比较的(所有指针都可比较),而DoubleIntSlice类型是不可比较的(切片不可比较)。

也可以有一个接收两个类型为Doubler的参数的函数,打印它们是否相等:

func DoublerCompare(d1, d2 Doubler) {
    fmt.Println(d1 == d2)
}

下面定义4个变量:

var di DoubleInt = 10
var di2 DoubleInt = 10
var dis = DoubleIntSlice{1, 2, 3}
var dis2 = DoubleIntSlice{1, 2, 3}

我们会调用该函数3次。第一次调用:

DoublerCompare(&di, &di2)

它会打印false。类型匹配(都是*DoubleInt),但我们比较的是指针,而不是值,而指针指向的是不同的实例。

接着,我们比较*DoubleInDoubleIntSlice

DoublerCompare(&di, dis)

这会打印false,因为类型不一致。

最后是一个存在问题的案例:

DoublerCompare(dis, dis2)

这段代码可顺利编译,但在运行时会panic:

panic: runtime error: comparing uncomparable type main.DoubleIntSlice

可在第7章的GitHub代码库sample_code/compare目录中查看这段代码。

同时注意字典的键必须可比较,所以字典可以定义切片为键:

m := map[Doubler]int{}

如果对字典添加键值对,而键不可比较,就会触发panic。

综上,在对切片使用==!=,又或将切片用作字典的键时要格外小心,因为很容易出现意外导致程序崩溃的panic。虽然当前我们所有的接口实现都可比较,但无法预知其他人在使用或修改代码时会发生什么情况,也无法指定接口只能由可比较类型实现。如果希望增强安全性,可以对reflect.Value使用Comparable方法,在用==!=前检查接口。(在反射使我们可以在运行时操作类型一节中会学习更多相关知识)。

空接口无信息量

有时在静态类型语言中,需要有方式说明变量存储任意类型的值。Go使用interface{}来进行表示:

var i interface{}
i = 20
i = "hello"
i = struct {
    FirstName string
    LastName string
} {"Fred", "Fredson"}

应该注意interface{}不是特例语法。空接口类型只是说明变量可以存储实现了零个或多个方法类型的值。只是它能匹配Go中的所有类型。因为空接口并没有所表示值的任何信息,并不能做些什么。空接口的一个常见用途是作为从外部数据源读取的模式不定的数据的占位符,比如说JSON文件:

// 一对花括号表示interface{}类型,另一个是实例化map实例
data := map[string]interface{}{}
contents, err := ioutil.ReadFile("testdata/sample.json")
if err != nil {
    return err
}
defer contents.Close()
json.Unmarshal(contents, &data)
// 内容现在就放到data中了

interface{}的另一个用法是作为存储用户创建数据结构中值的一种方式。这是因为当前缺乏用户定义的泛型(1.18版本中已添加泛型)。如查需要切片、数组或map之外的数据结构,而又不希望只支持一种类型,需要使用类型为interface{}的字段来存储其值。可以尝试在The Go Playground中运行如下代码:

type LinkedList struct {
    Value interface{}
    Next    *LinkedList
}
func (ll *LinkedList) Insert(pos int, val interface{}) *LinkedList {
    if ll == nil || pos == 0 {
        return &LinkedList{
            Value: val,
            Next:    ll,
        }
    }
    ll.Next = ll.Next.Insert(pos-1, val)
    return ll
}

警告:对于链表插入而言这不是一种高效的实现,但对以学习足够了。不要在真实代码中使用它。

如果看到函数接收空接口,很可是使用了反射(在恶龙三剑客:反射、Unsafe 和 Cgo一章中讨论)来输入或读者数据。有上面的例子中,json.Unmarshal函数的第二个参数声明为interface{}类型。

这些场景应该相对少见。避免使用interface{}。我们看到,Go设计为强类型语言,而尝试绕过这一点是不纯正的做法。

如果发现需要将值存入空接口,可能会想如何读回该值。这时需要使用类型断言和类型判断 。

类型断言和类型判断

Go提供了两种方式查看接口类型变量是否有具体的具象类型或具象类型是否实现了其它接口。我们先来学习类型断言。类型断言说明某具象类型是否实现了该接口,或是接口具象类型是否也实现了另一个接口。可在The Go Playground:中运行如下代码:

type MyInt int
func main() {
    var i interface{}
    var mine MyInt = 20
    i = mine
    i2 := i.(MyInt)
    fmt.Println(i2 + 1)
}

以上代码中,变量i2的类型为MyInt

你可能会想如果类型断言出错会发生什么。那样代码会panic。可在The Go Playground中运行如下代码:

i2 := i.(string)
fmt.Println(i2)

运行上述代码会产生以下的panic:

panic: interface conversion: interface {} is main.MyInt, not string

可以看到Go对于具象类型还是很谨慎的。即使两种炮灰攻的春天的底层类型一致,类型断言也必须匹配底层值的类型。下面的代码会panic。可在The Go Playground中运行如下代码:

i2 := i.(int)
fmt.Println(i2 + 1)

显然崩溃非我们之所欲。应当使用逗号ok语法来进行避免,在逗号ok语句一节中我们在检测字典中是否为零值是使用过:

i2, ok := i.(int)
if !ok {
    return fmt.Errorf("unexpected type for %v",i)
}
fmt.Println(i2 + 1)

如果类型转换成功布尔值ok设为true。而如果失败,ok会设为false,另一个变量(本例中为i2)设为零值。然后在if语句中处理预期外的条件,但在纯正的Go语言中,我们对错误处理代码进行缩进。我们会在错误处理一章中讨论。

注:类型断言与类型转换不同。类型转换可用于具象类型和接口,在编译时进行检查。类型断言只能用于接口类型,在运行时检查。因其在运行时检查,可能会出现失败。转换修改类型,断言揭示问题。

即使是绝对确定类型断言有效,也请使用逗号ok语句。我们无法预知其他人(或是半年后的你)会如何复用这段代码。迟早未验证的类型断言会在运行时出错。

在接口可能为多种类型之一时,使用类型判断:

func doThings(i interface{}) {
    switch j := i.(type) {
    case nil:
        // i为nil,j的类型为interface{}
    case int:
        // j的类型为int
    case MyInt:
        // j的类型为MyInt
    case io.Reader:
        // j的类型为io.Reader
    case string:
        // j是string
    case bool, rune:
        // i为bool或rune类型,因此j的类型为interface{}
    default:
        // 不知道i是什么类型,因此j的类型为interface{}
    }
}

类型判断和switch语句很像,我们在代码块,遮蔽和控制结构一章中学习过。取代指定布尔运算,我们指定一个接口类型的变量在其后接.(type)。通常将待检测变量赋给另一个仅在switch中有效的变量。

注:因类型判断的目的是从已有变量获取新变量,将进行判断的变量赋值给同名变量是一种纯正的做法(i := i.(type)),这也是代码遮蔽是好做法的极少的案例之一。为了让注释可读性更强,本例没有使用代码遮蔽。

新变量类型取决于匹配得是哪个分支。可在一个分支中使用nil来查看该接口是否没有关联类型。如果在一个分支中有多个类型,新变量的类型为interface{}。和switch语句一样,可有一个default分支在没有指定类型时进行匹配。否则新变量为匹配分支的类型。

小贴士:如果不知道底层类型,需要使用反射。在恶龙三剑客:反射、Unsafe 和 Cgo一章中会讨论反射。

少用类型断言和类型判断

虽然从接口变量中提取具象实现看起来很方便,但应减少使用这种技术。大部分情况,对参数或返回值作所提供类型对待,而不是其它类型。不然函数的API无法精确声明其执行任务所需的类型。如果需要另一种类型,则应进行指定。

话虽这么说,但类型断言和类型判断在有些场景下是非常有用的。类型断言的一个常见用途是查看接口后的具象类型是否实现了另一个接口。这允许我们指定可选接口。例如,标准库使用了这一技术来在调用io.Copy函数时做更高效的拷贝。这个函数有两个类型分别为io.Writerio.Reader的类型,调用io.copyBuffer函数来完成工作。如果io.Writer参数还实现了io.WriterTo,或是io.Reader参数还实现了io.ReaderFrom,函数中大部分的工作都可以略过:

// copyBuffer is the actual implementation of Copy and CopyBuffer.
// if buf is nil, one is allocated.
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
    // If the reader has a WriteTo method, use it to do the copy.
    // Avoids an allocation and a copy.
    if wt, ok := src.(WriterTo); ok {
        return wt.WriteTo(dst)
    }
    // Similarly, if the writer has a ReadFrom method, use it to do the copy.
    if rt, ok := dst.(ReaderFrom); ok {
        return rt.ReadFrom(src)
    }
    // function continues...
}

另一个可选接口的用处是演进API。在上下文一章中我们会讨论上下文。上下文是传递给函数的参数,可用于管理取消等标准方式。这在Go 1.7中添加,也就是老代码不支持。包括旧的数据库驱动。

在Go 1.8中,在database/sql/driver包中定义了与已有接口上下文感知相似的内容。例如,StmtExecContext定义了一个名为ExecContext的方法,它是StmtExec方法具有上下文感知的替代。在将Stmt的实现传入标准库数据库代码时,它检查其是否实现了StmtExecContext。如果实现了则调用ExecContext。若未实现,Go标准库提供了新代码提供的取消支持的备用实现:

func ctxDriverStmtExec(ctx context.Context, si driver.Stmt,
                       nvdargs []driver.NamedValue) (driver.Result, error) {
    if siCtx, is := si.(driver.StmtExecContext); is {
        return siCtx.ExecContext(ctx, nvdargs)
    }
    // fallback code is here
}

可选接口技术有一个不足。我们前面学过接口实现使用装饰器模式封装相同接口行为层其它实现是常见行为。问题是如果可选接口由其中一个封装实现进行实现,就无法通过类型断言或类型判断进行检测了。例如,标准库有bufio包提供带缓冲读取。可以通过将其传递给bufio.NewReader函数来缓冲其它的io.Reader实现,并使用返回的*bufio.Reader。如果传入的io.Reader还实现了io.ReaderFrom,将其封装到带缓冲读取接口则会截断优化。

在错误处理中也存在这种情况。在前面讲到,它们实现了error接口。错误可通过封装其它错误来包含额外的信息。类型判断或类型断言无法检测或匹配封装的错误。如果希望在处理返回错误的不同具体实现时有不同的行为,使用errors.Iserrors.As来测试或访问封装的错误。

类型判断语句提供了区分接口要求有不同处理的各实现的能力。只对接口所能提供某些有效类型最为有用。确保在处理开发时尚不知道的实现时对switch添加一个default分支。这会防止我们在添加新的接口实现时忘记更新switch语句:

func walkTree(t *treeNode) (int, error) {
    switch val := t.val.(type) {
    case nil:
        return 0, errors.New("invalid expression")
    case number:
        // we know that t.val is of type number, so return the
        // int value
        return int(val), nil
    case operator:
        // we know that t.val is of type operator, so
        // find the values of the left and right children, then
        // call the process() method on operator to return the
        // result of processing their values.
        left, err := walkTree(t.lchild)
        if err != nil {
            return 0, err
        }
        right, err := walkTree(t.rchild)
        if err != nil {
            return 0, err
        }
        return val.process(left, right), nil
    default:
        // if a new treeVal type is defined, but walkTree wasn't updated
        // to process it, this detects it
        return 0, errors.New("unknown node type")
    }
}

可在The Go Playground中查看完整的实现。

注:可以进一步保护自己不出现意外的接口实现,通过不导出接口以及至少不导出其中一个方法,如果导出接口,就可以在另一个包的结构体中嵌套它,让结构体实现该接口。我们会在中模块、包和导入一章中讨论包及标识符导出。

函数类型是接口的桥梁

关于类型声明还差一件事没有讨论。很容易陷入对整型或字符串添加方法,但Go对任意自定义类型添加方法,包括自定义的函数类型。这听起来像是学术上的钻牛角尖,但实际上却是非常有用的。这会允许函数实现接口。最常见的用法是HTTP处理器。HTTP handler用于处理HTTP服务请求。由接口定义:

type Handler interface {
    ServeHTTP(http.ResponseWriter, *http.Request)
}

通过将类型转换为http.HandlerFunc,任何签名为func(http.ResponseWriter,*http.Request)的函数都可以用作http.Handler

type HandlerFunc func(http.ResponseWriter, *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    f(w, r)
}

这样可以使用函数、方法或闭包实现HTTP处理器,用完全相同的代码路径作为符合http.Handler接口的其它类型。

Go中的函数是一级概念,因此传入为函数的参数。同时,Go鼓励使用小接口,仅一个方法的接口可轻易替换函数类型的参数。问题是:什么时候使用或方法指定函数类型的入参,什么时候使用接口呢?

如果一个函数可能依赖入参中未指定的其它的函数或状态,使用接口参数、定义函数类型来成为函数到接口的桥梁。这正是http包中的做法,很可能Handler只是需要配置的外加工调用的入口。但如果它是一个简单函数(类似sort.Slice所用的那个),那么函数类型的参数是个好选择。

隐式接口让依赖注入更简单

只要做过编程,不论老手还是新手都可以很快知道应用需要随时间发生变化。一种用于让解耦变轻松的技术称为依赖注入。依赖注入的概念是代码应显式指定其需执行任务的功能。这比想象中要更古早,1996年Robert Martin写了一篇名为依赖反转原理的文章。

Go显式接口一个出人意料的好处是它让依赖注入成为解耦代码的优秀方式。虽然其它语言的开发者经常使用大型、复杂的框架来注入依赖,事实是Go不需要其它库就可以轻松实现依赖注入。我们通过简单示例来了解如何使用隐式接口借助依赖注入编写应用。

为更好理解这一概念并学习如何在Go中实现依赖注入,我们构建一个简单的web应用。(我们会在标准库一章中讲解Go内置的HTTP服务器,这里可以当成是预览。)先来编写一个工具函数,日志工具:

func LogOutput(message string) {
    fmt.Println(message)
}

应用还需要数据存储。我们来创建一个简单版本:

type SimpleDataStore struct {
    userData map[string]string
}
func (sds SimpleDataStore) UserNameForID(userID string) (string, bool) {
    name, ok := sds.userData[userID]
    return name, ok
}

再定义一个工厂函数来创建一个SimpleDataStore实例:

func NewSimpleDataStore() SimpleDataStore {
    return SimpleDataStore{
        userData: map[string]string{
            "1": "Fred",
            "2": "Mary",
            "3": "Pat",
        },
    }
}

接收来我们会编写一些业务逻辑查找用户并进行问候和道别。我们的业务逻辑需要用到一些数据,因此需要有数据存储。我们还想要业务逻辑记录何时调用,因此需要日志工具。但是我们不想强制它依赖于LogOutputSimpleDataStore,因为我们未来可能会使用其它日志工具或数据存储。业务逻辑需要的正是描述其依赖的接口:

type DataStore interface {
    UserNameForID(userID string) (string, bool)
}
type Logger interface {
    Log(message string)
}

为让LogOutput函数符合接口,我们定义一个函数类型并添加方法:

type LoggerAdapter func(message string)
func (lg LoggerAdapter) Log(message string) {
    lg(message)
}

非常巧的是,我们的LoggerAdapterSimpleDataStore刚好符合业务逻辑所需要的接口,但两种类型都不知道它的功能。

现在依赖已定义好我们来看业务逻辑的实现:

type SimpleLogic struct {
    l  Logger
    ds DataStore
}
func (sl SimpleLogic) SayHello(userID string) (string, error) {
    sl.l.Log("in SayHello for " + userID)
    name, ok := sl.ds.UserNameForID(userID)
    if !ok {
        return "", errors.New("unknown user")
    }
    return "Hello, " + name, nil
}
func (sl SimpleLogic) SayGoodbye(userID string) (string, error) {
    sl.l.Log("in SayGoodbye for " + userID)
    name, ok := sl.ds.UserNameForID(userID)
    if !ok {
        return "", errors.New("unknown user")
    }
    return "Goodbye, " + name, nil
}

我们的结构体有两个字段,一个是Logger,另一个是DataStoreSimpleLogic中没有具象类型,因为它们没有依赖。稍后切换为其它提供者的新实现不会有问题,因为提供者与接口无关。这与Java这样的显式接口完全不同。虽然Java使用接口来解耦对其的实现,显式接口同时绑定客户端和服务提供者。这会让在Java(以及其它带显式接口的编程语言)中替换依赖远比在Go中要困难。

在我们需要SimpleLogic实例时,会调用工厂函数,传入接口、返回结构体:

func NewSimpleLogic(l Logger, ds DataStore) SimpleLogic {
    return SimpleLogic{
        l:    l,
        ds: ds,
    }
}

注:SimpleLogic中的字段未导出。也就是说仅可用由SimpleLogic相同包的代码访问。我们无法强制Go中的不可变性,但限制哪段代码可访问这些字段使得不太可能出现意外修改。我们会模块、包和导入一章中导入或非导入标识符。

现在到API了。我们只有一个端点/hello,对提供了ID的用户进行问候。(在真实应用中请不要使用查询参数进行身份认证,这里只是一个快速示例)。我们的控制器需要问候的业务逻辑,因此这样定义接口:

type Logic interface {
    SayHello(userID string) (string, error)
}

SimpleLogic结构上已有这个方法,但具象类型是感知不到该接口的。此外,SimpleLogic的其它方法SayGoodbye没在接口中,因为控制器用不到它。接口由客户端代码持有,因此方法集按照客户端代码需求自定义:

type Controller struct {
    l     Logger
    logic Logic
}
func (c Controller) SayHello(w http.ResponseWriter, r *http.Request) {
    c.l.Log("In SayHello")
    userID := r.URL.Query().Get("user_id")
    message, err := c.logic.SayHello(userID)
    if err != nil {
        w.WriteHeader(http.StatusBadRequest)
        w.Write([]byte(err.Error()))
        return
    }
    w.Write([]byte(message))
}

和其它类型的工厂方法一样,我们来编写Controller

 

func NewController(l Logger, logic Logic) Controller {
    return Controller{
        l:     l,
        logic: logic,
    }
}

同样是接收接口返回结构体。

最后在main函数中拼装这些组件,启动服务端:

func main() {
    l := LoggerAdapter(LogOutput)
    ds := NewSimpleDataStore()
    logic := NewSimpleLogic(l, ds)
    c := NewController(l, logic)
    http.HandleFunc("/hello", c.SayHello)
    http.ListenAndServe(":8080", nil)
}

main函数是这里唯一知道所有具象类型是哪些的。如果希望切换为不同的实现,只需在这里修改。通过依赖注入外部分依赖表示我们限制了演进代码时所需做的修改。

依赖注入也是让测试变简单的伟大模式。这一点不奇怪,因为测试需要复用其它环境中的代码,这些环境中输入和输出都受验证功能约束。例如,我们可以通过注入捕获日志输出的类型并实现Logger接口来验证日志输出。在编写测试一章中会进一步讨论。

注:http.HandleFunc("/hello", c.SayHello)演示前面所说的两个部分。

首先,我们把SayHello方法看成函数。

其次,http.HandleFunc函数接收函数并将其转换为http.HandlerFunc函数类型,它声明了一个方法来实现http.Handler接口,这是用于表示Go请求处理器的类型。我们从一个类型中取出方法,转换为另一个带此方法的类型。干净利落。

Wire

如果觉得手动编写依赖注入代码工作量太大,可以使用Wire,它是Google编写的依赖注入辅助工具。它使用代码生成自动生成我们在main中所编写的具体类型声明。

Go并不是面向对象(这很好)

我们已经学了Go中类型的纯正用法,可以看到很难将Go归类为某种具体语言类型。很明显它不是严格意义上的过程化语言。同时,Go中没有方法重载、继承或是对象,所以它也不是面向对象语言。Go具有函数类型和闭包,但它也不是函数式语言。如果硬要把Go向这些分类靠,写出的代码会不伦不类。

如果一定要给Go打个标签,那最好的词是实用。它吸收了各处的概念,旨在打造一款简单、易读且可供大团队长期维护的语言。

小结

本章中我们讲解了类型、方法、接口以及它们的最佳实践。下一章中我们会学习使用Go最具争议的特性(错误处理)。

本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
相关文章
|
2月前
|
存储 安全 编译器
go语言中进行不安全的类型操作
【5月更文挑战第10天】Go语言中的`unsafe`包提供了一种不安全但强大的方式来处理类型转换和底层内存操作。包含两个文档用途的类型和八个函数,本文也比较了不同变量和结构体的大小与对齐系数,强调了字段顺序对内存分配的影响。
105 8
go语言中进行不安全的类型操作
|
19天前
|
存储 自然语言处理 Go
详尽分享详解Go中的rune类型
详尽分享详解Go中的rune类型
10 0
|
24天前
|
Go
go反射获取变量类型、值、结构体成员、结构体方法
go反射获取变量类型、值、结构体成员、结构体方法
20 0
|
1月前
|
JSON Go 数据格式
Go 语言基础之指针、复合类型【数组、切片、指针、map、struct】(4)
Go 语言基础之指针、复合类型【数组、切片、指针、map、struct】
|
1月前
|
Java 编译器 Go
Go 语言基础之指针、复合类型【数组、切片、指针、map、struct】(3)
Go 语言基础之指针、复合类型【数组、切片、指针、map、struct】
|
1月前
|
存储 安全 Go
Go 语言基础之指针、复合类型【数组、切片、指针、map、struct】(2)
Go 语言基础之指针、复合类型【数组、切片、指针、map、struct】
|
1月前
|
Java Go 索引
Go 语言基础之指针、复合类型【数组、切片、指针、map、struct】(1)
Go 语言基础之指针、复合类型【数组、切片、指针、map、struct】
|
3天前
|
运维 Cloud Native 云计算
云原生架构的演进之路
云原生技术,作为现代软件开发和运维的核心,正引领着数字化转型的浪潮。本文将深入探讨云原生概念的起源、核心价值、关键技术以及面临的挑战,同时结合具体的行业应用案例,展示云原生如何助力企业实现敏捷性、可扩展性和资源优化。通过分析云原生技术的发展趋势,我们将展望其对未来IT生态的深远影响。 【7月更文挑战第17天】
17 3
|
4天前
|
机器学习/深度学习 Cloud Native 持续交付
云原生架构的演进与挑战
随着云计算技术的飞速发展,云原生架构成为推动企业数字化转型的核心动力。本文深入探讨了云原生技术从起步到成熟的演变过程,并分析了当前面临的主要挑战,如安全性、多云管理、成本控制等。通过实际案例分析,本文旨在为读者提供对云原生架构演进的全面理解和未来趋势的洞察。
|
5天前
|
Kubernetes Cloud Native 持续交付
云原生架构的核心组成部分通常包括容器化(如Docker)、容器编排(如Kubernetes)、微服务架构、服务网格、持续集成/持续部署(CI/CD)、自动化运维(如Prometheus监控和Grafana可视化)等。
云原生架构的核心组成部分通常包括容器化(如Docker)、容器编排(如Kubernetes)、微服务架构、服务网格、持续集成/持续部署(CI/CD)、自动化运维(如Prometheus监控和Grafana可视化)等。