接口本身是调用方和实现方均需要遵守的一种协议,大家按照统一的方法命名参数类型和数量来协调逻辑处理的过程。
Go 语言中使用组合实现对象特性的描述。对象的内部使用结构体内嵌组合对象应该具有的特性,对外通过接口暴露能使用的特性。
Go 语言的接口设计是非侵入式的,接口编写者无须知道接口被哪些类型实现。而接口实现者只需知道实现的是什么样子的接口,但无须指明实现哪一个接口。编译器知道最终编译时使用哪个类型实现哪个接口,或者接口应该由谁来实现。
其它编程语言中的接口
接口是一种较为常见的特性,很多语言都有接口特性。C/C++、C# 语言中的接口都可以多重派生实现接口组合;在苹果的 Objective C 中与接口类似的功能被称为 Protocol,这种叫法比接口更形象、具体。
非侵入式设计是 Go 语言设计师经过多年的大项目经验总结出来的设计之道。只有让接口和实现者真正解耦,编译速度才能真正提高,项目之间的耦合度也会降低不少。
本章内容:
7.1 Go语言接口声明(定义)
Go语言不是一种 “传统” 的面向对象编程语言:它里面没有类和继承的概念。
但是Go语言里有非常灵活的接口概念,通过它可以实现很多面向对象的特性。很多面向对象的语言都有相似的接口概念,但Go语言中接口类型的独特之处在于它是满足隐式实现的。也就是说,我们没有必要对于给定的具体类型定义所有满足的接口类型;简单地拥有一些必需的方法就足够了。
这种设计可以让你创建一个新的接口类型满足已经存在的具体类型却不会去改变这些类型的定义;当我们使用的类型来自于不受我们控制的包时这种设计尤其有用。
接口类型是对其它类型行为的抽象和概括;因为接口类型不会和特定的实现细节绑定在一起,通过这种抽象的方式我们可以让我们的函数更加灵活和更具有适应能力。
接口是双方约定的一种合作协议。接口实现者不需要关心接口会被怎样使用,调用者也不需要关心接口的实现细节。接口是一种类型,也是一种抽象结构,不会暴露所含数据的格式、类型及结构。
接口声明的格式
每个接口类型由数个方法组成。接口的形式代码如下:
- type 接口类型名 interface{
- 方法名1( 参数列表1 ) 返回值列表1
- 方法名2( 参数列表2 ) 返回值列表2
- …
- }
对各个部分的说明:
- 接口类型名:使用 type 将接口定义为自定义的类型名。Go语言的接口在命名时,一般会在单词后面添加 er,如有写操作的接口叫 Writer,有字符串功能的接口叫 Stringer,有关闭功能的接口叫 Closer 等。
- 方法名:当方法名首字母是大写时,且这个接口类型名首字母也是大写时,这个方法可以被接口所在的包(package)之外的代码访问。
- 参数列表、返回值列表:参数列表和返回值列表中的参数变量名可以被忽略,例如:
- type writer interface{
- Write([]byte) error
- }
开发中常见的接口及写法
Go语言提供的很多包中都有接口,例如 io 包中提供的 Writer 接口:
- type Writer interface {
- Write(p []byte) (n int, err error)
- }
这个接口可以调用 Write() 方法写入一个字节数组([]byte),返回值告知写入字节数(n int)和可能发生的错误(err error)。
类似的,还有将一个对象以字符串形式展现的接口,只要实现了这个接口的类型,在调用 String() 方法时,都可以获得对象对应的字符串。在 fmt 包中定义如下:
- type Stringer interface {
- String() string
- }
Stringer 接口在Go语言中的使用频率非常高,功能类似于 Java 或者 C# 语言里的 ToString 的操作。
Go语言的每个接口中的方法数量不会很多。Go语言希望通过一个接口精准描述它自己的功能,而通过多个接口的嵌入和组合的方式将简单的接口扩展为复杂的接口。本章后面的小节中会介绍如何使用组合来扩充接口。
7.2 Go语言实现接口的条件
如果一个任意类型 T 的方法集为一个接口类型的方法集的超集,则我们说类型 T 实现了此接口类型。T 可以是一个非接口类型,也可以是一个接口类型。
实现关系在Go语言中是隐式的。两个类型之间的实现关系不需要在代码中显式地表示出来。Go语言中没有类似于 implements 的关键字。 Go编译器将自动在需要的时候检查两个类型之间的实现关系。
接口定义后,需要实现接口,调用方才能正确编译通过并使用接口。接口的实现需要遵循两条规则才能让接口可用。
接口被实现的条件一:接口的方法与实现接口的类型方法格式一致
在类型中添加与接口签名一致的方法就可以实现该方法。签名包括方法中的名称、参数列表、返回参数列表。也就是说,只要实现接口类型中的方法的名称、参数列表、返回参数列表中的任意一项与接口要实现的方法不一致,那么接口的这个方法就不会被实现。
为了抽象数据写入的过程,定义 DataWriter 接口来描述数据写入需要实现的方法,接口中的 WriteData() 方法表示将数据写入,写入方无须关心写入到哪里。实现接口的类型实现 WriteData 方法时,会具体编写将数据写入到什么结构中。这里使用file结构体实现 DataWriter 接口的 WriteData 方法,方法内部只是打印一个日志,表示有数据写入,详细实现过程请参考下面的代码。
数据写入器的抽象:
- package main
- import (
- "fmt"
- )
- // 定义一个数据写入器
- type DataWriter interface {
- WriteData(data interface{}) error
- }
- // 定义文件结构,用于实现DataWriter
- type file struct {
- }
- // 实现DataWriter接口的WriteData方法
- func (d *file) WriteData(data interface{}) error {
- // 模拟写入数据
- fmt.Println("WriteData:", data)
- return nil
- }
- func main() {
- // 实例化file
- f := new(file)
- // 声明一个DataWriter的接口
- var writer DataWriter
- // 将接口赋值f,也就是*file类型
- writer = f
- // 使用DataWriter接口进行数据写入
- writer.WriteData("data")
- }
代码说明如下:
- 第 8 行,定义 DataWriter 接口。这个接口只有一个方法,即 WriteData(),输入一个 interface{} 类型的 data,返回一个 error 结构表示可能发生的错误。
- 第 17 行,file 的 WriteData() 方法使用指针接收器。输入一个 interface{} 类型的 data,返回 error。
- 第 27 行,实例化 file 赋值给 f,f 的类型为 *file。
- 第 30 行,声明 DataWriter 类型的 writer 接口变量。
- 第 33 行,将 *file 类型的 f 赋值给 DataWriter 接口的 writer,虽然两个变量类型不一致。但是 writer 是一个接口,且 f 已经完全实现了 DataWriter() 的所有方法,因此赋值是成功的。
- 第 36 行,DataWriter 接口类型的 writer 使用 WriteData() 方法写入一个字符串。
运行代码,输出如下:
WriteData: data
本例中调用及实现关系如下图所示。
图:WriteWriter的实现过程
当类型无法实现接口时,编译器会报错,下面列出常见的几种接口无法实现的错误。
1) 函数名不一致导致的报错
在以上代码的基础上尝试修改部分代码,造成编译错误,通过编译器的报错理解如何实现接口的方法。首先,修改 file 结构的 WriteData() 方法名,将这个方法签名(第17行)修改为:
- func (d *file) WriteDataX(data interface{}) error {
编译代码,报错:
cannot use f (type *file) as type DataWriter in assignment:
*file does not implement DataWriter (missing WriteData method)
报错的位置在第 33 行。报错含义是:不能将 f 变量(类型*file)视为 DataWriter 进行赋值。原因:*file 类型未实现 DataWriter 接口(丢失 WriteData 方法)。
WriteDataX 方法的签名本身是合法的。但编译器扫描到第 33 行代码时,发现尝试将 *file 类型赋值给 DataWriter 时,需要检查 *file 类型是否完全实现了 DataWriter 接口。显然,编译器因为没有找到 DataWriter 需要的 WriteData() 方法而报错。
2) 实现接口的方法签名不一致导致的报错
将修改的代码恢复后,再尝试修改 WriteData() 方法,把 data 参数的类型从 interface{} 修改为 int 类型,代码如下:
- func (d *file) WriteData(data int) error {
编译代码,报错:
cannot use f (type *file) as type DataWriter in assignment:
*file does not implement DataWriter (wrong type for WriteData method)
have WriteData(int) error
want WriteData(interface {}) error
这次未实现 DataWriter 的理由变为(错误的 WriteData() 方法类型)发现 WriteData(int)error,期望 WriteData(interface{})error。
这种方式的报错就是由实现者的方法签名与接口的方法签名不一致导致的。
接口被实现的条件二:接口中所有方法均被实现
当一个接口中有多个方法时,只有这些方法都被实现了,接口才能被正确编译并使用。
在本节开头的代码中,为 DataWriter中 添加一个方法,代码如下:
- // 定义一个数据写入器
- type DataWriter interface {
- WriteData(data interface{}) error
- // 能否写入
- CanWrite() bool
- }
新增 CanWrite() 方法,返回 bool。此时再次编译代码,报错:
cannot use f (type *file) as type DataWriter in assignment:
*file does not implement DataWriter (missing CanWrite method)
需要在 file 中实现 CanWrite() 方法才能正常使用 DataWriter()。
Go语言的接口实现是隐式的,无须让实现接口的类型写出实现了哪些接口。这个设计被称为非侵入式设计。
实现者在编写方法时,无法预测未来哪些方法会变为接口。一旦某个接口创建出来,要求旧的代码来实现这个接口时,就需要修改旧的代码的派生部分,这一般会造成雪崩式的重新编译。
提示
传统的派生式接口及类关系构建的模式,让类型间拥有强耦合的父子关系。这种关系一般会以“类派生图”的方式进行。经常可以看到大型软件极为复杂的派生树。随着系统的功能不断增加,这棵“派生树”会变得越来越复杂。
对于Go语言来说,非侵入式设计让实现者的所有类型均是平行的、组合的。如何组合则留到使用者编译时再确认。因此,使用GO语言时,不需要同时也不可能有“类派生图”,开发者唯一需要关注的就是“我需要什么?”,以及“我能实现什么?”。
7.3 Go语言类型与接口的关系
在Go语言中类型和接口之间有一对多和多对一的关系,下面将列举出这些常见的概念,以方便读者理解接口与类型在复杂环境下的实现关系。
一个类型可以实现多个接口
一个类型可以同时实现多个接口,而接口间彼此独立,不知道对方的实现。
网络上的两个程序通过一个双向的通信连接实现数据的交换,连接的一端称为一个 Socket。Socket 能够同时读取和写入数据,这个特性与文件类似。因此,开发中把文件和 Socket 都具备的读写特性抽象为独立的读写器概念。
Socket 和文件一样,在使用完毕后,也需要对资源进行释放。
把 Socket 能够写入数据和需要关闭的特性使用接口来描述,请参考下面的代码:
- type Socket struct {
- }
- func (s *Socket) Write(p []byte) (n int, err error) {
- return 0, nil
- }
- func (s *Socket) Close() error {
- return nil
- }
Socket 结构的 Write() 方法实现了 io.Writer 接口:
- type Writer interface {
- Write(p []byte) (n int, err error)
- }
同时,Socket 结构也实现了 io.Closer 接口:
- type Closer interface {
- Close() error
- }
使用 Socket 实现的 Writer 接口的代码,无须了解 Writer 接口的实现者是否具备 Closer 接口的特性。同样,使用 Closer 接口的代码也并不知道 Socket 已经实现了 Writer 接口,如下图所示。
图:接口的使用和实现过程
在代码中使用 Socket 结构实现的 Writer 接口和 Closer 接口代码如下:
- // 使用io.Writer的代码, 并不知道Socket和io.Closer的存在
- func usingWriter( writer io.Writer){
- writer.Write( nil )
- }
- // 使用io.Closer, 并不知道Socket和io.Writer的存在
- func usingCloser( closer io.Closer) {
- closer.Close()
- }
- func main() {
- // 实例化Socket
- s := new(Socket)
- usingWriter(s)
- usingCloser(s)
- }
usingWriter() 和 usingCloser() 完全独立,互相不知道对方的存在,也不知道自己使用的接口是 Socket 实现的。
多个类型可以实现相同的接口
一个接口的方法,不一定需要由一个类型完全实现,接口的方法可以通过在类型中嵌入其他类型或者结构体来实现。也就是说,使用者并不关心某个接口的方法是通过一个类型完全实现的,还是通过多个结构嵌入到一个结构体中拼凑起来共同实现的。
Service 接口定义了两个方法:一个是开启服务的方法(Start()),一个是输出日志的方法(Log())。使用 GameService 结构体来实现 Service,GameService 自己的结构只能实现 Start() 方法,而 Service 接口中的 Log() 方法已经被一个能输出日志的日志器(Logger)实现了,无须再进行 GameService 封装,或者重新实现一遍。所以,选择将 Logger 嵌入到 GameService 能最大程度地避免代码冗余,简化代码结构。详细实现过程如下:
- // 一个服务需要满足能够开启和写日志的功能
- type Service interface {
- Start() // 开启服务
- Log(string) // 日志输出
- }
- // 日志器
- type Logger struct {
- }
- // 实现Service的Log()方法
- func (g *Logger) Log(l string) {
- }
- // 游戏服务
- type GameService struct {
- Logger // 嵌入日志器
- }
- // 实现Service的Start()方法
- func (g *GameService) Start() {
- }
代码说明如下:
- 第 2 行,定义服务接口,一个服务需要实现 Start() 方法和日志方法。
- 第 8 行,定义能输出日志的日志器结构。
- 第 12 行,为 Logger 添加 Log() 方法,同时实现 Service 的 Log() 方法。
- 第 17 行,定义 GameService 结构。
- 第 18 行,在 GameService 中嵌入 Logger 日志器,以实现日志功能。
- 第 22 行,GameService 的 Start() 方法实现了 Service 的 Start() 方法。
此时,实例化 GameService,并将实例赋给 Service,代码如下:
- var s Service = new(GameService)
- s.Start()
- s.Log(“hello”)
s 就可以使用 Start() 方法和 Log() 方法,其中,Start() 由 GameService 实现,Log() 方法由 Logger 实现。
7.4 Go语言接口的nil判断
7.5 Go语言类型断言简述
类型断言(Type Assertion)是一个使用在接口值上的操作,用于检查接口类型变量所持有的值是否实现了期望的接口或者具体的类型。
在Go语言中类型断言的语法格式如下:
value, ok := x.(T)
其中,x 表示一个接口的类型,T 表示一个具体的类型(也可为接口类型)。
该断言表达式会返回 x 的值(也就是 value)和一个布尔值(也就是 ok),可根据该布尔值判断 x 是否为 T 类型:
- 如果 T 是具体某个类型,类型断言会检查 x 的动态类型是否等于具体类型 T。如果检查成功,类型断言返回的结果是 x 的动态值,其类型是 T。
- 如果 T 是接口类型,类型断言会检查 x 的动态类型是否满足 T。如果检查成功,x 的动态值不会被提取,返回值是一个类型为 T 的接口值。
- 无论 T 是什么类型,如果 x 是 nil 接口值,类型断言都会失败。
示例代码如下:
- package main
- import (
- "fmt"
- )
- func main() {
- var x interface{}
- x = 10
- value, ok := x.(int)
- fmt.Print(value, ",", ok)
- }
运行结果如下:
10,true
需要注意如果不接收第二个参数也就是上面代码中的 ok,断言失败时会直接造成一个 panic。如果 x 为 nil 同样也会 panic。
示例代码如下:
- package main
- import (
- "fmt"
- )
- func main() {
- var x interface{}
- x = "Hello"
- value := x.(int)
- fmt.Println(value)
- }
运行结果如下:
panic: interface conversion: interface {} is string, not int
类型断言还可以配合 switch 使用,示例代码如下:
- package main
- import (
- "fmt"
- )
- func main() {
- var a int
- a = 10
- getType(a)
- }
- func getType(a interface{}) {
- switch a.(type) {
- case int:
- fmt.Println("the type of a is int")
- case string:
- fmt.Println("the type of a is string")
- case float64:
- fmt.Println("the type of a is float")
- default:
- fmt.Println("unknown type")
- }
- }
运行结果如下:
the type of a is int
7.6 Go语言实现日志系统(支持多种输出方式)
7.7 Go语言排序(借助sort.Interface接口)
排序操作和字符串格式化一样是很多程序经常使用的操作。尽管一个最短的快排程序只要 15 行就可以搞定,但是一个健壮的实现需要更多的代码,并且我们不希望每次我们需要的时候都重写或者拷贝这些代码。
幸运的是,sort 包内置的提供了根据一些排序函数来对任何序列排序的功能。它的设计非常独到。在很多语言中,排序算法都是和序列数据类型关联,同时排序函数和具体类型元素关联。
相比之下,Go语言的 sort.Sort 函数不会对具体的序列和它的元素做任何假设。相反,它使用了一个接口类型 sort.Interface 来指定通用的排序算法和可能被排序到的序列类型之间的约定。这个接口的实现由序列的具体表示和它希望排序的元素决定,序列的表示经常是一个切片。
一个内置的排序算法需要知道三个东西:序列的长度,表示两个元素比较的结果,一种交换两个元素的方式;这就是 sort.Interface 的三个方法:
- package sort
- type Interface interface {
- Len() int // 获取元素数量
- Less(i, j int) bool // i,j是序列元素的指数。
- Swap(i, j int) // 交换元素
- }
为了对序列进行排序,我们需要定义一个实现了这三个方法的类型,然后对这个类型的一个实例应用 sort.Sort 函数。思考对一个字符串切片进行排序,这可能是最简单的例子了。下面是这个新的类型 MyStringList 和它的 Len,Less 和 Swap 方法
- type MyStringList []string
- func (p MyStringList ) Len() int { return len(m) }
- func (p MyStringList ) Less(i, j int) bool { return m[i] < m[j] }
- func (p MyStringList ) Swap(i, j int) { m[i], m[j] = m[j], m[i] }
使用sort.Interface接口进行排序
对一系列字符串进行排序时,使用字符串切片([]string)承载多个字符串。使用 type 关键字,将字符串切片([]string)定义为自定义类型 MyStringList。为了让 sort 包能识别 MyStringList,能够对 MyStringList 进行排序,就必须让 MyStringList 实现 sort.Interface 接口。
下面是对字符串排序的详细代码(代码1):
- package main
- import (
- "fmt"
- "sort"
- )
- // 将[]string定义为MyStringList类型
- type MyStringList []string
- // 实现sort.Interface接口的获取元素数量方法
- func (m MyStringList) Len() int {
- return len(m)
- }
- // 实现sort.Interface接口的比较元素方法
- func (m MyStringList) Less(i, j int) bool {
- return m[i] < m[j]
- }
- // 实现sort.Interface接口的交换元素方法
- func (m MyStringList) Swap(i, j int) {
- m[i], m[j] = m[j], m[i]
- }
- func main() {
- // 准备一个内容被打乱顺序的字符串切片
- names := MyStringList{
- "3. Triple Kill",
- "5. Penta Kill",
- "2. Double Kill",
- "4. Quadra Kill",
- "1. First Blood",
- }
- // 使用sort包进行排序
- sort.Sort(names)
- // 遍历打印结果
- for _, v := range names {
- fmt.Printf("%s\n", v)
- }
- }
代码输出结果:
1. First Blood
2. Double Kill
3. Triple Kill
4. Quadra Kill
5. Penta Kill
代码说明如下:
常见类型的便捷排序
通过实现 sort.Interface 接口的排序过程具有很强的可定制性,可以根据被排序对象比较复杂的特性进行定制。例如,需要多种排序逻辑的需求就适合使用 sort.Interface 接口进行排序。但大部分情况中,只需要对字符串、整型等进行快速排序。Go语言中提供了一些固定模式的封装以方便开发者迅速对内容进行排序。
1) 字符串切片的便捷排序
sort 包中有一个 StringSlice 类型,定义如下:
- type StringSlice []string
- func (p StringSlice) Len() int { return len(p) }
- func (p StringSlice) Less(i, j int) bool { return p[i] < p[j] }
- func (p StringSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
- // Sort is a convenience method.
- func (p StringSlice) Sort() { Sort(p) }
sort 包中的 StringSlice 的代码与 MyStringList 的实现代码几乎一样。因此,只需要使用 sort 包的 StringSlice 就可以更简单快速地进行字符串排序。将代码1中的排序代码简化后如下所示:
- names := sort.StringSlice{
- "3. Triple Kill",
- "5. Penta Kill",
- "2. Double Kill",
- "4. Quadra Kill",
- "1. First Blood",
- }
- sort.Sort(names)
简化后,只要两句代码就实现了字符串排序的功能。
2) 对整型切片进行排序
除了字符串可以使用 sort 包进行便捷排序外,还可以使用 sort.IntSlice 进行整型切片的排序。sort.IntSlice 的定义如下:
- type IntSlice []int
- func (p IntSlice) Len() int { return len(p) }
- func (p IntSlice) Less(i, j int) bool { return p[i] < p[j] }
- func (p IntSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
- // Sort is a convenience method.
- func (p IntSlice) Sort() { Sort(p) }
sort 包在 sort.Interface 对各类型的封装上还有更进一步的简化,下面使用 sort.Strings 继续对代码1进行简化,代码如下:
- names := []string{
- "3. Triple Kill",
- "5. Penta Kill",
- "2. Double Kill",
- "4. Quadra Kill",
- "1. First Blood",
- }
- sort.Strings(names)
- // 遍历打印结果
- for _, v := range names {
- fmt.Printf("%s\n", v)
- }
代码说明如下:
- 第 1 行,需要排序的字符串切片。
- 第 9 行,使用 sort.Strings 直接对字符串切片进行排序。
3) sort包内建的类型排序接口一览
Go语言中的 sort 包中定义了一些常见类型的排序方法,如下表所示。
类 型 | 实现 sort.lnterface 的类型 | 直接排序方法 | 说 明 |
---|---|---|---|
字符串(String) | StringSlice | sort.Strings(a [] string) | 字符 ASCII 值升序 |
整型(int) | IntSlice | sort.Ints(a []int) | 数值升序 |
双精度浮点(float64) | Float64Slice | sort.Float64s(a []float64) | 数值升序 |
编程中经常用到的 int32、int64、float32、bool 类型并没有由 sort 包实现,使用时依然需要开发者自己编写。
对结构体数据进行排序
除了基本类型的排序,也可以对结构体进行排序。结构体比基本类型更为复杂,排序时不能像数值和字符串一样拥有一些固定的单一原则。结构体的多个字段在排序中可能会存在多种排序的规则,例如,结构体中的名字按字母升序排列,数值按从小到大的顺序排序。一般在多种规则同时存在时,需要确定规则的优先度,如先按名字排序,再按年龄排序等。
1) 完整实现sort.Interface进行结构体排序
将一批英雄名单使用结构体定义,英雄名单的结构体中定义了英雄的名字和分类。排序时要求按照英雄的分类进行排序,相同分类的情况下按名字进行排序,详细代码实现过程如下。
结构体排序代码(代码2):
- package main
- import (
- "fmt"
- "sort"
- )
- // 声明英雄的分类
- type HeroKind int
- // 定义HeroKind常量, 类似于枚举
- const (
- None HeroKind = iota
- Tank
- Assassin
- Mage
- )
- // 定义英雄名单的结构
- type Hero struct {
- Name string // 英雄的名字
- Kind HeroKind // 英雄的种类
- }
- // 将英雄指针的切片定义为Heros类型
- type Heros []*Hero
- // 实现sort.Interface接口取元素数量方法
- func (s Heros) Len() int {
- return len(s)
- }
- // 实现sort.Interface接口比较元素方法
- func (s Heros) Less(i, j int) bool {
- // 如果英雄的分类不一致时, 优先对分类进行排序
- if s[i].Kind != s[j].Kind {
- return s[i].Kind < s[j].Kind
- }
- // 默认按英雄名字字符升序排列
- return s[i].Name < s[j].Name
- }
- // 实现sort.Interface接口交换元素方法
- func (s Heros) Swap(i, j int) {
- s[i], s[j] = s[j], s[i]
- }
- func main() {
- // 准备英雄列表
- heros := Heros{
- &Hero{"吕布", Tank},
- &Hero{"李白", Assassin},
- &Hero{"妲己", Mage},
- &Hero{"貂蝉", Assassin},
- &Hero{"关羽", Tank},
- &Hero{"诸葛亮", Mage},
- }
- // 使用sort包进行排序
- sort.Sort(heros)
- // 遍历英雄列表打印排序结果
- for _, v := range heros {
- fmt.Printf("%+v\n", v)
- }
- }
代码输出如下:
&{Name:关羽 Kind:1}
&{Name:吕布 Kind:1}
&{Name:李白 Kind:2}
&{Name:貂蝉 Kind:2}
&{Name:妲己 Kind:3}
&{Name:诸葛亮 Kind:3}
代码说明如下:
- 第 9 行,将 int 声明为 HeroKind 英雄类型,后面会将这个类型当做枚举来使用。
- 第 13 行,定义一些英雄类型常量,可以理解为枚举的值。
- 第 26 行,为了方便实现 sort.Interface 接口,将 []*Hero 定义为 Heros 类型。
- 第 29 行,Heros 类型实现了 sort.Interface 的 Len() 方法,返回英雄的数量。
- 第 34 行,Heros 类型实现了 sort.Interface 的 Less() 方法,根据英雄字段的比较结果决定如何排序。
- 第 37 行,当英雄的分类不一致时,优先按分类的枚举数值从小到大排序。
- 第 42 行,英雄分类相等的情况下,默认根据英雄的名字字符升序排序。
- 第 46 行,Heros 类型实现了 sort.Interface 的 Swap() 方法,交换英雄元素的位置。
- 第 53~60 行,准备一系列英雄数据。
- 第 63 行,使用 sort 包进行排序。
- 第 66 行,遍历所有排序完成的英雄数据。
2) 使用sort.Slice进行切片元素排序
从 Go 1.8 开始,Go语言在 sort 包中提供了 sort.Slice() 函数进行更为简便的排序方法。sort.Slice() 函数只要求传入需要排序的数据,以及一个排序时对元素的回调函数,类型为 func(i,j int)bool,sort.Slice() 函数的定义如下:
- func Slice(slice interface{}, less func(i, j int) bool)
使用 sort.Slice() 函数,对代码2重新优化的完整代码如下:
- package main
- import (
- "fmt"
- "sort"
- )
- type HeroKind int
- const (
- None = iota
- Tank
- Assassin
- Mage
- )
- type Hero struct {
- Name string
- Kind HeroKind
- }
- func main() {
- heros := []*Hero{
- {"吕布", Tank},
- {"李白", Assassin},
- {"妲己", Mage},
- {"貂蝉", Assassin},
- {"关羽", Tank},
- {"诸葛亮", Mage},
- }
- sort.Slice(heros, func(i, j int) bool {
- if heros[i].Kind != heros[j].Kind {
- return heros[i].Kind < heros[j].Kind
- }
- return heros[i].Name < heros[j].Name
- })
- for _, v := range heros {
- fmt.Printf("%+v\n", v)
- }
- }
第 33 行到第 39 行加粗部分是新添加的 sort.Slice() 及回调函数部分。对比前面的代码,这里去掉了 Heros 及接口实现部分的代码。
使用 sort.Slice() 不仅可以完成结构体切片排序,还可以对各种切片类型进行自定义排序。
7.8 Go语言接口的嵌套组合
在Go语言中,不仅结构体与结构体之间可以嵌套,接口与接口间也可以通过嵌套创造出新的接口。
一个接口可以包含一个或多个其他的接口,这相当于直接将这些内嵌接口的方法列举在外层接口中一样。只要接口的所有方法被实现,则这个接口中的所有嵌套接口的方法均可以被调用。
系统包中的接口嵌套组合
Go语言的 io 包中定义了写入器(Writer)、关闭器(Closer)和写入关闭器(WriteCloser)3 个接口,代码如下:
- type Writer interface {
- Write(p []byte) (n int, err error)
- }
- type Closer interface {
- Close() error
- }
- type WriteCloser interface {
- Writer
- Closer
- }
代码说明如下:
- 第 1 行定义了写入器(Writer),如这个接口较为常用,常用于 I/O 设备的数据写入。
- 第 5 行定义了关闭器(Closer),如有非托管内存资源的对象,需要用关闭的方法来实现资源释放。
- 第 9 行定义了写入关闭器(WriteCloser),这个接口由 Writer 和 Closer 两个接口嵌入。也就是说,WriteCloser 同时拥有了 Writer 和 Closer 的特性。
在代码中使用接口嵌套组合
在代码中使用 io.Writer、io.Closer 和 io.WriteCloser 这 3 个接口时,只需要按照接口实现的规则实现 io.Writer 接口和 io.Closer 接口即可。而 io.WriteCloser 接口在使用时,编译器会根据接口的实现者确认它们是否同时实现了 io.Writer 和 io.Closer 接口,详细实现代码如下:
- package main
- import (
- "io"
- )
- // 声明一个设备结构
- type device struct {
- }
- // 实现io.Writer的Write()方法
- func (d *device) Write(p []byte) (n int, err error) {
- return 0, nil
- }
- // 实现io.Closer的Close()方法
- func (d *device) Close() error {
- return nil
- }
- func main() {
- // 声明写入关闭器, 并赋予device的实例
- var wc io.WriteCloser = new(device)
- // 写入数据
- wc.Write(nil)
- // 关闭设备
- wc.Close()
- // 声明写入器, 并赋予device的新实例
- var writeOnly io.Writer = new(device)
- // 写入数据
- writeOnly.Write(nil)
- }
代码说明如下:
- 第 8 行定义了 device 结构体,用来模拟一个虚拟设备,这个结构会实现前面提到的 3 种接口。
- 第 12 行,实现了 io.Writer 的 Write() 方法。
- 第 17 行,实现了 io.Closer 的 Close() 方法。
- 第 24 行,对 device 实例化,由于 device 实现了 io.WriteCloser 的所有嵌入接口,因此 device 指针就会被隐式转换为 io.WriteCloser 接口。
- 第 27 行,调用了 wc(io.WriteCloser接口)的 Write() 方法,由于 wc 被赋值 *device,因此最终会调用 device 的 Write() 方法。
- 第 30 行,与 27 行类似,最终调用 device 的 Close() 方法。
- 第 33 行,再次创建一个 device 的实例,writeOnly 是一个 io.Writer 接口,这个接口只有 Write() 方法。
- 第 36 行,writeOnly 只能调用 Write() 方法,没有 Close() 方法。
为了整理思路,将上面的实现、调用关系使用图方式来展现,参见图 1 和图 2。
1) io.WriteCloser的实现及调用过程如图 1 所示。
图1:io.WriteCloser 的实现及调用过程
2) io.Writer 的实现调用过程如图 2 所示。
图2:io.Write 的实现及调用过程
给 io.WriteCloser 或 io.Writer 更换不同的实现者,可以动态地切换实现代码。
7.9 Go语言接口和类型之间的转换
Go语言中使用接口断言(type assertions)将接口转换成另外一个接口,也可以将接口转换为另外的类型。接口的转换在开发中非常常见,使用也非常频繁。
类型断言的格式
类型断言是一个使用在接口值上的操作。语法上它看起来像 i.(T) 被称为断言类型,这里 i 表示一个接口的类型和 T 表示一个类型。一个类型断言检查它操作对象的动态类型是否和断言的类型匹配。
类型断言的基本格式如下:
- t := i.(T)
其中,i 代表接口变量,T 代表转换的目标类型,t 代表转换后的变量。
这里有两种可能。第一种,如果断言的类型 T 是一个具体类型,然后类型断言检查 i 的动态类型是否和 T 相同。如果这个检查成功了,类型断言的结果是 i 的动态值,当然它的类型是 T。换句话说,具体类型的类型断言从它的操作对象中获得具体的值。如果检查失败,接下来这个操作会抛出 panic。例如:
- var w io.Writer
- w = os.Stdout
- f := w.(*os.File) // 成功: f == os.Stdout
- c := w.(*bytes.Buffer) // 死机:接口保存*os.file,而不是*bytes.buffer
第二种,如果相反断言的类型 T 是一个接口类型,然后类型断言检查是否 i 的动态类型满足 T。如果这个检查成功了,动态值没有获取到;这个结果仍然是一个有相同类型和值部分的接口值,但是结果有类型 T。换句话说,对一个接口类型的类型断言改变了类型的表述方式,改变了可以获取的方法集合(通常更大),但是它保护了接口值内部的动态类型和值的部分。
在下面的第一个类型断言后,w 和 rw 都持有 os.Stdout 因此它们每个有一个动态类型 *os.File,但是变量 w 是一个 io.Writer 类型只对外公开出文件的 Write 方法,然而 rw 变量也只公开它的 Read 方法。
- var w io.Writer
- w = os.Stdout
- rw := w.(io.ReadWriter) // 成功:*os.file具有读写功能
- w = new(ByteCounter)
- rw = w.(io.ReadWriter) // 死机:*字节计数器没有读取方法
如果断言操作的对象是一个 nil 接口值,那么不论被断言的类型是什么这个类型断言都会失败。几乎不需要对一个更少限制性的接口类型(更少的方法集合)做断言,因为它表现的就像赋值操作一样,除了对于 nil 接口值的情况。
如果 i 没有完全实现 T 接口的方法,这个语句将会触发宕机。触发宕机不是很友好,因此上面的语句还有一种写法:
- t,ok := i.(T)
这种写法下,如果发生接口未实现时,将会把 ok 置为 false,t 置为 T 类型的 0 值。正常实现时,ok 为 true。这里 ok 可以被认为是:i 接口是否实现 T 类型的结果。
将接口转换为其他接口
实现某个接口的类型同时实现了另外一个接口,此时可以在两个接口间转换。
鸟和猪具有不同的特性,鸟可以飞,猪不能飞,但两种动物都可以行走。如果使用结构体实现鸟和猪,让它们具备自己特性的 Fly() 和 Walk() 方法就让鸟和猪各自实现了飞行动物接口(Flyer)和行走动物接口(Walker)。
将鸟和猪的实例创建后,被保存到 interface{} 类型的 map 中。interface{} 类型表示空接口,意思就是这种接口可以保存为任意类型。对保存有鸟或猪的实例的 interface{} 变量进行断言操作,如果断言对象是断言指定的类型,则返回转换为断言对象类型的接口;如果不是指定的断言类型时,断言的第二个参数将返回 false。例如下面的代码:
- var obj interface = new(bird)
- f, isFlyer := obj.(Flyer)
代码中,new(bird) 产生 *bird 类型的 bird 实例,这个实例被保存在 interface{} 类型的 obj 变量中。使用 obj.(Flyer) 类型断言,将 obj 转换为 Flyer 接口。f 为转换成功时的 Flyer 接口类型,isFlyer 表示是否转换成功,类型就是 bool。
下面是详细的代码(代码1):
- package main
- import "fmt"
- // 定义飞行动物接口
- type Flyer interface {
- Fly()
- }
- // 定义行走动物接口
- type Walker interface {
- Walk()
- }
- // 定义鸟类
- type bird struct {
- }
- // 实现飞行动物接口
- func (b *bird) Fly() {
- fmt.Println("bird: fly")
- }
- // 为鸟添加Walk()方法, 实现行走动物接口
- func (b *bird) Walk() {
- fmt.Println("bird: walk")
- }
- // 定义猪
- type pig struct {
- }
- // 为猪添加Walk()方法, 实现行走动物接口
- func (p *pig) Walk() {
- fmt.Println("pig: walk")
- }
- func main() {
- // 创建动物的名字到实例的映射
- animals := map[string]interface{}{
- "bird": new(bird),
- "pig": new(pig),
- }
- // 遍历映射
- for name, obj := range animals {
- // 判断对象是否为飞行动物
- f, isFlyer := obj.(Flyer)
- // 判断对象是否为行走动物
- w, isWalker := obj.(Walker)
- fmt.Printf("name: %s isFlyer: %v isWalker: %v\n", name, isFlyer, isWalker)
- // 如果是飞行动物则调用飞行动物接口
- if isFlyer {
- f.Fly()
- }
- // 如果是行走动物则调用行走动物接口
- if isWalker {
- w.Walk()
- }
- }
- }
代码说明如下:
- 第 6 行定义了飞行动物的接口。
- 第 11 行定义了行走动物的接口。
- 第 16 和 30 行分别定义了鸟和猪两个对象,并分别实现了飞行动物和行走动物接口。
- 第 41 行是一个 map,映射对象名字和对象实例,实例是鸟和猪。
- 第 47 行开始遍历 map,obj 为 interface{} 接口类型。
- 第 50 行中,使用类型断言获得 f,类型为 Flyer 及 isFlyer 的断言成功的判定。
- 第 52 行中,使用类型断言获得 w,类型为 Walker 及 isWalker 的断言成功的判定。
- 第 57 和 62 行,根据飞行动物和行走动物两者是否断言成功,调用其接口。
代码输出如下:
name: pig isFlyer: false isWalker: true
pig: walk
name: bird isFlyer: true isWalker: true
bird: fly
bird: walk
将接口转换为其他类型
在代码 1 中,可以实现将接口转换为普通的指针类型。例如将 Walker 接口转换为 *pig 类型,请参考下面的代码:
- p1 := new(pig)
- var a Walker = p1
- p2 := a.(*pig)
- fmt.Printf("p1=%p p2=%p", p1, p2)
对代码的说明如下:
- 第 3 行,由于 pig 实现了 Walker 接口,因此可以被隐式转换为 Walker 接口类型保存于 a 中。
- 第 4 行,由于 a 中保存的本来就是 *pig 本体,因此可以转换为 *pig 类型。
- 第 6 行,对比发现,p1 和 p2 指针是相同的。
如果尝试将上面这段代码中的 Walker 类型的 a 转换为 *bird 类型,将会发出运行时错误,请参考下面的代码:
- p1 := new(pig)
- var a Walker = p1
- p2 := a.(*bird)
运行时报错:
panic: interface conversion: main.Walker is *main.pig, not *main.bird
报错意思是:接口转换时,main.Walker 接口的内部保存的是 *main.pig,而不是 *main.bird。
因此,接口在转换为其他类型时,接口内保存的实例对应的类型指针,必须是要转换的对应的类型指针。
总结
接口和其他类型的转换可以在Go语言中自由进行,前提是已经完全实现。
接口断言类似于流程控制中的 if。但大量类型断言出现时,应使用更为高效的类型分支 switch 特性。
7.10 Go语言空接口类型(interface{})
空接口是接口类型的特殊形式,空接口没有任何方法,因此任何类型都无须实现空接口。从实现的角度看,任何值都满足这个接口的需求。因此空接口类型可以保存任何值,也可以从空接口中取出原值。
提示
空接口类型类似于 C# 或 Java 语言中的 Object、C语言中的 void*、C++ 中的 std::any。在泛型和模板出现前,空接口是一种非常灵活的数据抽象保存和使用的方法。
空接口的内部实现保存了对象的类型和指针。使用空接口保存一个数据的过程会比直接用数据对应类型的变量保存稍慢。因此在开发中,应在需要的地方使用空接口,而不是在所有地方使用空接口。
将值保存到空接口
空接口的赋值如下:
- var any interface{}
- any = 1
- fmt.Println(any)
- any = "hello"
- fmt.Println(any)
- any = false
- fmt.Println(any)
代码输出如下:
1
hello
false
对代码的说明:
- 第 1 行,声明 any 为 interface{} 类型的变量。
- 第 3 行,为 any 赋值一个整型 1。
- 第 4 行,打印 any 的值,提供给 fmt.Println 的类型依然是 interface{}。
- 第 6 行,为 any 赋值一个字符串 hello。此时 any 内部保存了一个字符串。但类型依然是 interface{}。
- 第 9 行,赋值布尔值。
从空接口获取值
保存到空接口的值,如果直接取出指定类型的值时,会发生编译错误,代码如下:
- // 声明a变量, 类型int, 初始值为1
- var a int = 1
- // 声明i变量, 类型为interface{}, 初始值为a, 此时i的值变为1
- var i interface{} = a
- // 声明b变量, 尝试赋值i
- var b int = i
第8行代码编译报错:
cannot use i (type interface {}) as type int in assignment: need type assertion
编译器告诉我们,不能将i变量视为int类型赋值给b。
在代码第 15 行中,将 a 的值赋值给 i 时,虽然 i 在赋值完成后的内部值为 int,但 i 还是一个 interface{} 类型的变量。类似于无论集装箱装的是茶叶还是烟草,集装箱依然是金属做的,不会因为所装物的类型改变而改变。
为了让第 8 行的操作能够完成,编译器提示我们得使用 type assertion,意思就是类型断言。
使用类型断言修改第 8 行代码如下:
- var b int = i.(int)
修改后,代码可以编译通过,并且 b 可以获得 i 变量保存的 a 变量的值:1。
空接口的值比较
==
1) 类型不同的空接口间的比较结果不相同
保存有类型不同的值的空接口进行比较时,Go语言会优先比较值的类型。因此类型不同,比较结果也是不相同的,代码如下:
- // a保存整型
- var a interface{} = 100
- // b保存字符串
- var b interface{} = "hi"
- // 两个空接口不相等
- fmt.Println(a == b)
代码输出如下:
false
2) 不能比较空接口中的动态值
当接口中保存有动态类型的值时,运行时将触发错误,代码如下:
- // c保存包含10的整型切片
- var c interface{} = []int{10}
- // d保存包含20的整型切片
- var d interface{} = []int{20}
- // 这里会发生崩溃
- fmt.Println(c == d)
代码运行到第8行时发生崩溃:
panic: runtime error: comparing uncomparable type []int
这是一个运行时错误,提示 []int 是不可比较的类型。下表中列举出了类型及比较的几种情况。
类 型 | 说 明 |
---|---|
map | 宕机错误,不可比较 |
切片([]T) | 宕机错误,不可比较 |
通道(channel) | 可比较,必须由同一个 make 生成,也就是同一个通道才会是 true,否则为 false |
数组([容量]T) | 可比较,编译期知道两个数组是否一致 |
结构体 | 可比较,可以逐个比较结构体的值 |
函数 | 可比较 |
7.11 Go语言使用空接口实现可以保存任意值的字典
7.12 Go语言类型分支(switch判断空接口中变量的类型)
type-switch 流程控制的语法或许是Go语言中最古怪的语法。 它可以被看作是类型断言的增强版。它和 switch-case 流程控制代码块有些相似。 一个 type-switch 流程控制代码块的语法如下所示:
- switch t := areaIntf.(type) {
- case *Square:
- fmt.Printf("Type Square %T with value %v\n", t, t)
- case *Circle:
- fmt.Printf("Type Circle %T with value %v\n", t, t)
- case nil:
- fmt.Printf("nil value: nothing to check?\n")
- default:
- fmt.Printf("Unexpected type %T\n", t)
- }
输出结构如下:
Type Square *main.Square with value &{5}
变量 t 得到了 areaIntf 的值和类型, 所有 case 语句中列举的类型(nil 除外)都必须实现对应的接口,如果被检测类型没有在 case 语句列举的类型中,就会执行 default 语句。
如果跟随在某个 case 关键字后的条目为一个非接口类型(用一个类型名或类型字面表示),则此非接口类型必须实现了断言值 x 的(接口)类型。
类型断言的书写格式
switch 实现类型分支时的写法格式如下:
- switch 接口变量.(type) {
- case 类型1:
- // 变量是类型1时的处理
- case 类型2:
- // 变量是类型2时的处理
- …
- default:
- // 变量不是所有case中列举的类型时的处理
- }
对各个部分的说明:
- 接口变量:表示需要判断的接口类型的变量。
- 类型1、类型2……:表示接口变量可能具有的类型列表,满足时,会指定 case 对应的分支进行处理。
使用类型分支判断基本类型
下面的例子将一个 interface{} 类型的参数传给 printType() 函数,通过 switch 判断 v 的类型,然后打印对应类型的提示,代码如下:
- package main
- import (
- "fmt"
- )
- func printType(v interface{}) {
- switch v.(type) {
- case int:
- fmt.Println(v, "is int")
- case string:
- fmt.Println(v, "is string")
- case bool:
- fmt.Println(v, "is bool")
- }
- }
- func main() {
- printType(1024)
- printType("pig")
- printType(true)
- }
代码输出如下:
1024 is int
pig is string
true is bool
代码第 9 行中,v.(type) 就是类型分支的典型写法。通过这个写法,在 switch 的每个 case 中写的将是各种类型分支。
代码经过 switch 时,会判断 v 这个 interface{} 的具体类型从而进行类型分支跳转。
switch 的 default 也是可以使用的,功能和其他的 switch 一致。
使用类型分支判断接口类型
多个接口进行类型断言时,可以使用类型分支简化判断过程。
现在电子支付逐渐成为人们普遍使用的支付方式,电子支付相比现金支付具备很多优点。例如,电子支付能够刷脸支付,而现金支付容易被偷等。使用类型分支可以方便地判断一种支付方法具备哪些特性,具体请参考下面的代码。
电子支付和现金支付:
- package main
- import "fmt"
- // 电子支付方式
- type Alipay struct {
- }
- // 为Alipay添加CanUseFaceID()方法, 表示电子支付方式支持刷脸
- func (a *Alipay) CanUseFaceID() {
- }
- // 现金支付方式
- type Cash struct {
- }
- // 为Cash添加Stolen()方法, 表示现金支付方式会出现偷窃情况
- func (a *Cash) Stolen() {
- }
- // 具备刷脸特性的接口
- type CantainCanUseFaceID interface {
- CanUseFaceID()
- }
- // 具备被偷特性的接口
- type ContainStolen interface {
- Stolen()
- }
- // 打印支付方式具备的特点
- func print(payMethod interface{}) {
- switch payMethod.(type) {
- case CantainCanUseFaceID: // 可以刷脸
- fmt.Printf("%T can use faceid\n", payMethod)
- case ContainStolen: // 可能被偷
- fmt.Printf("%T may be stolen\n", payMethod)
- }
- }
- func main() {
- // 使用电子支付判断
- print(new(Alipay))
- // 使用现金判断
- print(new(Cash))
- }
代码说明如下:
- 第 6~19 行,分别定义 Alipay 和 Cash 结构,并为它们添加具备各自特点的方法。
- 第 22~29 行,定义两种特性,即刷脸和被偷。
- 第 32 行,传入支付方式的接口。
- 第 33 行,使用类型分支进行支付方法的特性判断。
- 第 34~37 行,分别对刷脸和被偷的特性进行打印。
运行代码,输出如下:
*main.Alipay can use faceid
*main.Cash may be stolen
7.13 Go语言error接口:返回错误信息
错误处理在每个编程语言中都是一项重要内容,通常开发中遇到的分为异常与错误两种,Go语言中也不例外。本节我们主要来学习一下Go语言中的错误处理。
在C语言中通过返回 -1 或者 NULL 之类的信息来表示错误,但是对于使用者来说,如果不查看相应的 API 说明文档,根本搞不清楚这个返回值究竟代表什么意思,比如返回 0 是成功还是失败?
针对这样的情况,Go语言中引入 error 接口类型作为错误处理的标准模式,如果函数要返回错误,则返回值类型列表中肯定包含 error。error 处理过程类似于C语言中的错误码,可逐层返回,直到被处理。
error 基本用法
Go语言中返回的 error 类型究竟是什么呢?查看Go语言的源码就会发现 error 类型是一个非常简单的接口类型,如下所示:
- // The error built-in interface type is the conventional interface for
- // representing an error condition, with the nil value representing no error.
- type error interface {
- Error() string
- }
error 接口有一个签名为 Error() string 的方法,所有实现该接口的类型都可以当作一个错误类型。Error() 方法给出了错误的描述,在使用 fmt.Println 打印错误时,会在内部调用 Error() string 方法来得到该错误的描述。
一般情况下,如果函数需要返回错误,就将 error 作为多个返回值中的最后一个(但这并非是强制要求)。
创建一个 error 最简单的方法就是调用 errors.New 函数,它会根据传入的错误信息返回一个新的 error,示例代码如下:
- package main
- import (
- "errors"
- "fmt"
- "math"
- )
- func Sqrt(f float64) (float64, error) {
- if f < 0 {
- return -1, errors.New("math: square root of negative number")
- }
- return math.Sqrt(f), nil
- }
- func main() {
- result, err := Sqrt(-13)
- if err != nil {
- fmt.Println(err)
- } else {
- fmt.Println(result)
- }
- }
运行结果如下:
math: square root of negative number
上面代码中简单介绍了使用 errors.New 来返回一个错误信息,与其他语言的异常相比,Go语言的方法相对更加容易、直观。
自定义错误类型
除了上面的 errors.New 用法之外,我们还可以使用 error 接口自定义一个 Error() 方法,来返回自定义的错误信息。
- package main
- import (
- "fmt"
- "math"
- )
- type dualError struct {
- Num float64
- problem string
- }
- func (e dualError) Error() string {
- return fmt.Sprintf("Wrong!!!,because \"%f\" is a negative number", e.Num)
- }
- func Sqrt(f float64) (float64, error) {
- if f < 0 {
- return -1, dualError{Num: f}
- }
- return math.Sqrt(f), nil
- }
- func main() {
- result, err := Sqrt(-13)
- if err != nil {
- fmt.Println(err)
- } else {
- fmt.Println(result)
- }
- }
运行结果如下:
Wrong!!!,because "-13.000000" is a negative number
7.14 Go语言接口内部实现
7.15 Go语言表达式求值器
7.16 Go语言实现Web服务器
Go语言里面提供了一个完善的 net/http 包,通过 net/http 包我们可以很方便的搭建一个可以运行的 Web 服务器。同时使用 net/http 包能很简单地对 Web 的路由,静态文件,模版,cookie 等数据进行设置和操作。
Web服务器的工作方式
我们平时浏览网页的时候,会打开浏览器,然后输入网址后就可以显示出想要浏览的内容。这个看似简单的过程背后却隐藏了非常复杂的操作。
对于普通的上网过程,系统其实是这样做的:
- 浏览器本身是一个客户端,当在浏览器中输入 URL (网址)的时候,首先浏览器会去请求 DNS 服务器,通过 DNS 获取相应的域名对应的 IP,然后通过 IP 地址找到对应的服务器后,要求建立 TCP 连接;
- 与服务器建立连接后,浏览器会向服务器发送 HTTP Request (请求)包;
- 服务器接收到请求包之后开始处理请求包,并调用自身服务,返回 HTTP Response(响应)包;
- 客户端收到来自服务器的响应后开始渲染这个 Response 包里的主体(body),等收到全部的内容后断开与该服务器之间的 TCP 连接。
图:用户访问一个站点的过程
通过上图可以将 Web 服务器的工作原理简单地归纳为:
- 客户机通过 TCP/IP 协议与服务器建立 TCP 连接;
- 客户端向服务器发送 HTTP 协议请求包,请求服务器里的资源文档;
- 服务器向客户机发送 HTTP 协议应答包,如果请求的资源包含有动态语言的内容,那么服务器会调用动态语言的解释引擎负责处理“动态内容”,并将处理得到的数据返回给客户端;
- 客户机与服务器断开,由客户端解释 HTML 文档,在客户端屏幕上渲染图形结果。
搭建一个简单的 Web 服务器
前面简单介绍了 Web 服务器的工作原理,那么如何用Go语言搭建一个 Web 服务器呢?示例代码如下:
- package main
- import (
- "fmt"
- "log"
- "net/http"
- )
- func main() {
- http.HandleFunc("/", index) // index 为向 url发送请求时,调用的函数
- log.Fatal(http.ListenAndServe("localhost:8000", nil))
- }
- func index(w http.ResponseWriter, r *http.Request) {
- fmt.Fprintf(w, "C语言中文网")
- }
go run
go run main.go
运行之后并没有什么提示信息,但是命令行窗口会被占用(不能再输入其它命令)。这时我们在浏览器中输入 localhost:8000 可以看到下图所示的内容,则说明我们的服务器成功运行了。
图:localhost:8000
提示:运行 Web 服务器会占用命令行窗口,我们可以使用 Ctrl+C 组合键来退出。
上面的代码只是展示了 Web 服务器的简单应用,下面我们来完善一下,为这个服务器添加一个页面并设置访问的路由。
首先我们准备一个 html 文件,并命名为 index.html,代码如下所示:
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <title>C语言中文网</title>
- </head>
- <body>
- <h1>C语言中文网</h1>
- </body>
- </html>
然后将我们上面写的 Web 服务器的代码简单修改一下,如下所示:
- package main
- import (
- "io/ioutil"
- "log"
- "net/http"
- )
- func main() {
- // 在/后面加上 index ,来指定访问路径
- http.HandleFunc("/index", index)
- log.Fatal(http.ListenAndServe("localhost:8000", nil))
- }
- func index(w http.ResponseWriter, r *http.Request) {
- content, _ := ioutil.ReadFile("./index.html")
- w.Write(content)
- }
go run
go run main.go
运行成功后,在浏览器中输入 localhost:8000/index 就可以看到我们所添加的页面了,如下图所示:
图:浏览 localhost:8000/index 页面
7.17 部署Go语言程序到Linux服务器
7.18 Go语言音乐播放器
7.19 Go语言实现有限状态机(FSM)
7.20 Go语言二叉树数据结构的应用