go mod搭建开发环境
基础语法要熟悉
gin框架与公司的trpc-go框架
在真正开始之前,首先需要掌握基本理论知识,包括但不限于:
《Go语言圣经(中文版)》
https://books.studygolang.com/gopl-zh/
《Go程序设计语言》The Go programming language
Go语言是从Ken Thompson发明的B语言、Dennis M. Ritchie发明的C语言逐步演化过来的,是C语言家族的成员,因此很多人将Go语言称为21世纪的C语言。纵观这几年来的发展趋势,Go语言已经成为云计算、云存储时代最重要的基础编程语言。
同时,单凭阅读和学习其语法结构并不能真正地掌握一门编程语言,必须进行足够多的编程实践——亲自编写一些程序并研究学习别人写的程序。要从利用Go语言良好的特性使得程序模块化,充分利用Go的标准函数库以Go语言自己的风格来编写程序。
Go语言由来自Google公司的Robert Griesemer,Rob Pike和Ken Thompson三位大牛于2007年9月开始设计和实现,然后于2009年的11月对外正式发布。
Go语言中和并发编程相关的特性是全新的也是有效的,同时对数据抽象和面向对象编程的支持也很灵活。 Go语言同时还集成了自动垃圾收集技术用于更好地管理内存。
Go语言已经成为受欢迎的作为无类型的脚本语言的替代者: 因为Go编写的程序通常比脚本语言运行的更快也更安全,而且很少会发生意外的类型错误。
Go从C语言继承了相似的表达式语法、控制流结构、基础数据类型、调用参数传值、指针等很多思想,还有C语言一直所看中的编译后机器码的运行效率以及和现有操作系统的无缝适配。
“软件的复杂性是乘法级相关的”,通过增加一个部分的复杂性来修复问题通常将慢慢地增加其他部分的复杂性。通过增加功能、选项和配置是修复问题的最快的途径,但是这很容易让人忘记简洁的内涵,即从长远来看,简洁依然是好软件的关键因素。
Go语言的这些地方都做的还不错:拥有自动垃圾回收、一个包系统、函数作为一等公民、词法作用域、系统调用接口、只读的UTF8字符串等。
但是Go语言本身只有很少的特性,也不太可能添加太多的特性。例如,它没有隐式的数值转换,没有构造函数和析构函数,没有运算符重载,没有默认参数,也没有继承,没有泛型,没有异常,没有宏,没有函数修饰,更没有线程局部存储。但是,语言本身是成熟和稳定的,而且承诺保证向后兼容:用之前的Go语言编写程序可以用新版本的Go语言编译器和标准库直接构建而不需要修改代码。
先了解几个Go程序,涉及的主题从简单的文件处理、图像处理到互联网客户端和服务端并发。
Go语言在代码格式上采取了很强硬的态度。gofmt
工具把代码格式化为标准格式(译注:这个格式化工具没有任何可以调整代码格式的参数,Go语言就是这么任性),并且go
工具中的fmt
子命令会对指定包,否则默认为当前目录中所有.go源文件应用gofmt
命令。
Go语言的代码通过包(package)组织,包类似于其它语言里的库(libraries)或者模块(modules)。一个包由位于单个目录下的一个或多个.go源代码文件组成,目录定义包的作用。每个源文件都以一条package
声明语句开始,这个例子里就是package main
,表示该文件属于哪个包,紧跟着一系列导入(import)的包,之后是存储在这个文件里的程序语句。
import
声明必须跟在文件的package
声明之后。随后,则是组成程序的函数、变量、常量、类型的声明语句(分别由关键字func
、var
、const
、type
定义)。
一个函数的声明由func
关键字、函数名、参数列表、返回值列表以及包含在大括号里的函数体组成。
os
包以跨平台的方式,提供了一些与操作系统交互的函数和变量。程序的命令行参数可从os包的Args变量获取;os包外部使用os.Args访问该变量。os.Args变量是一个字符串(string)的切片(slice)。os.Args的第一个元素:os.Args[0],是命令本身的名字;其它的元素则是程序启动时传给它的参数。
注释语句以//
开头。按照惯例,我们在每个包的包声明前添加注释;对于main package
,注释包含一句或几句话,从整体角度对程序做个描述。
符号:=
是短变量声明(short variable declaration)的一部分,这是定义一个或多个变量并根据它们的初始值为这些变量赋予适当类型的语句。
Go语言只有for循环这一种循环语句。for循环有多种形式,其中一种如下所示:
for initialization; condition; post {
// zero or more statements
}
condition
是一个布尔表达式(boolean expression),其值在每次循环迭代开始时计算。如果为true
则执行循环体语句。post
语句在循环体执行结束后执行,之后再次对condition
求值。condition
值为false
时,循环结束。
range
产生一对值;索引以及在该索引处的元素值。(blank identifier),即_
(也就是下划线)。空标识符可用于在任何语法需要变量名但程序逻辑不需要的时候(如:在循环里)丢弃不需要的循环索引
声明一个变量有好几种方式,主要用下面的前两种,下面这些都等价:
s := "" // 短变量声明,最简洁,但只能用在函数内部,而不能用于包变量
var s string // 依赖于字符串的默认初始化零值机制,被初始化为""
var s = ""
var s string = ""
从功能和实现上说,Go
的map
类似于Java
语言中的HashMap
,Python语言中的dict
,通常使用hash
实现。map
中不含某个键时不用担心,首次读到新行时,等号右边的表达式counts[line]
的值将被计算为其类型的零值,对于int
即0。
bufio
包,它使处理输入和输出方便又高效。Scanner
类型是该包最有用的特性之一,它读取输入并将其拆成行或单词;通常是处理行形式的输入最简单的方法。
每次调用input.Scan()
,即读入下一行,并移除行末的换行符;读取的内容可以调用input.Text()
得到。Scan
函数在读到一行时返回true
,不再有输入时返回false
。
fmt.Printf
函数对一些表达式产生格式化输出。该函数的首个参数是个格式字符串,指定后续参数被如何格式化。各个参数的格式取决于“转换字符”(conversion character),形式为百分号后跟一个字母。举个例子,%d
表示以十进制形式打印一个整型操作数,而%s
则表示把字符串型操作数的值展开。
默认情况下,Printf
不会换行。按照惯例,以字母f
结尾的格式化函数,如log.Printf
和fmt.Errorf
,都采用fmt.Printf
的格式化准则。
而以ln
结尾的格式化函数,则遵循Println
的方式,以跟%v
差不多的方式格式化参数,并在最后添加一个换行符。(译注:后缀f
指format
,ln
指line
。)
Printf
有一大堆这种转换,Go程序员称之为动词(verb)。下面的表格虽然远不是完整的规范,但展示了可用的很多特性:
%d 十进制整数
%x, %o, %b 十六进制,八进制,二进制整数。
%f, %g, %e 浮点数: 3.141593 3.141592653589793 3.141593e+00
%t 布尔:true或false
%c 字符(rune) (Unicode码点)
%s 字符串
%q 带双引号的字符串"abc"或带单引号的字符'c'
%v 变量的自然形式(natural format)
%T 变量的类型
%% 字面上的百分号标志(无操作数)
os.Open
函数返回两个值。第一个值是被打开的文件(*os.File
),其后被Scanner
读取。
os.Open
返回的第二个值是内置error
类型的值。如果err
的值不是nil
,说明打开文件时出错了。如果err
等于内置值nil
(译注:相当于其它语言里的NULL),那么文件被成功打开。读取文件,直到文件结束,然后调用Close
关闭该文件,并释放占用的所有资源。
对于很多现代应用来说,访问互联网上的信息和访问本地文件系统一样重要。Go语言在net这个强大package的帮助下提供了一系列的package来做这件事情,使用这些包可以更简单地用网络收发信息,还可以建立更底层的网络连接,编写服务器程序。在这些情景下,Go语言原生的并发特性(在第八章中会介绍)显得尤其好用。
// Fetch prints the content found at a URL.
package main
import (
"fmt"
"io/ioutil"
"net/http"
"os"
)
func main() {
for _, url := range os.Args[1:] {
resp, err := http.Get(url)
if err != nil {
fmt.Fprintf(os.Stderr, "fetch: %v\n", err)
os.Exit(1)
}
b, err := ioutil.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "fetch: reading %s: %v\n", url, err)
os.Exit(1)
}
fmt.Printf("%s", b)
}
}
Go语言最有意思并且最新奇的特性就是对并发编程的支持。
goroutine是一种函数的并发执行方式,
而channel是用来在goroutine之间进行参数传递。
main函数本身也运行在一个goroutine中,而go function则表示创建一个新的goroutine,并在这个新的goroutine中执行这个函数。
// Fetchall fetches URLs in parallel and reports their times and sizes.
package main
import (
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"time"
)
func main() {
start := time.Now()
ch := make(chan string)
for _, url := range os.Args[1:] {
go fetch(url, ch) // start a goroutine
}
for range os.Args[1:] {
fmt.Println(<-ch) // receive from channel ch
}
fmt.Printf("%.2fs elapsed\n", time.Since(start).Seconds())
}
func fetch(url string, ch chan<- string) {
start := time.Now()
resp, err := http.Get(url)
if err != nil {
ch <- fmt.Sprint(err) // send to channel ch
return
}
nbytes, err := io.Copy(ioutil.Discard, resp.Body)
resp.Body.Close() // don't leak resources
if err != nil {
ch <- fmt.Sprintf("while reading %s: %v", url, err)
return
}
secs := time.Since(start).Seconds()
ch <- fmt.Sprintf("%.2fs %7d %s", secs, nbytes, url)
}
main函数中用make函数创建了一个传递string类型参数的channel,对每一个命令行参数,我们都用go这个关键字来创建一个goroutine,并且让函数在这个goroutine异步执行http.Get方法。这个程序里的io.Copy会把响应的Body内容拷贝到ioutil.Discard输出流中(译注:可以把这个变量看作一个垃圾桶,可以向里面写一些不需要的数据),因为我们需要这个方法返回的字节数,但是又不想要其内容。每当请求返回内容时,fetch函数都会往ch这个channel里写入一个字符串,由main函数里的第二个for循环来处理并打印channel里的这个字符串。
if控制和for,进一步提到switch多路选择。
switch coinflip() {
case "heads":
heads++
case "tails":
tails++
default:
fmt.Println("landed on edge!")
}
// example: tagless switch
func Signum(x int) int {
switch {
case x > 0:
return +1
default:
return 0
case x < 0:
return -1
}
}
break和continue语句会改变控制流。和其它语言中的break和continue一样,break会中断当前的循环,并开始执行循环之后的内容,而continue会跳过当前循环,并开始执行下一次循环。这两个语句除了可以控制for循环,还可以用来控制switch和select语句(之后会讲到)
多行注释可以用 /* ... */
现成的包
在你开始写一个新程序之前,最好先去检查一下是不是已经有了现成的库可以帮助你更高效地完成这件事情。你可以在 https://golang.org/pkg 和 https://godoc.org 中找到标准库和社区写的package。
godoc这个工具可以让你直接在本地命令行阅读标准库的文档。比如下面这个例子。
命名规则:一个名字必须以一个字母(Unicode字母)或下划线开头,后面可以跟任意数量的字母、数字或下划线。
如果一个名字是在函数内部定义,那么它就只在函数内部有效。如果是在函数外部定义,那么将在当前包的所有文件中都可以访问。
名字的开头字母的大小写决定了名字在包外的可见性。如果一个名字是大写字母开头的(译注:必须是在函数外部定义的包级名字;包级函数名本身也是包级名字),那么它将是导出的,也就是说可以被外部的包访问,例如fmt包的Printf函数就是导出的,可以在fmt包外部访问。包本身的名字一般总是用小写字母。
Go语言主要有四种类型的声明语句:var、const、type和func,分别对应变量、常量、类型和函数实体对象的声明。
一个Go语言编写的程序对应一个或多个以.go为文件后缀名的源文件。每个源文件中以包的声明语句开始,说明该源文件是属于哪个包。包声明语句之后是import语句导入依赖的其它包,然后是包一级的类型、变量、常量、函数的声明语句,
声明一个变量有好几种方式,主要用下面的前两种,下面这些都等价:
s := "" // 短变量声明,最简洁,但只能用在函数内部,而不能用于包变量
var s string // 依赖于字符串的默认初始化零值机制,被初始化为""
var s = ""
var s string = ""
可以在一个声明语句中同时声明一组变量,或用一组初始化表达式声明并初始化一组变量。
一组变量也可以通过调用一个函数,由函数返回的多个返回值初始化:
var i, j, k int // int, int, int
var b, f, s = true, 2.3, "four" // bool, float64, string
var f, err = os.Open(name) // os.Open returns a file and an error
请记住“:=”是一个变量声明语句,而“=”是一个变量赋值操作。
在下面的代码中,第一个语句声明了in和err两个变量。在第二个语句只声明了out一个变量,然后对已经声明的err进行了赋值操作。
in, err := os.Open(infile)
// ...
out, err := os.Create(outfile)
简短变量声明语句中必须至少要声明一个新的变量,下面的代码将不能编译通过:
f, err := os.Open(infile)
// ...
f, err := os.Create(outfile) // compile error: no new variables
解决的方法是第二个简短变量声明语句改用普通的多重赋值语句。
一个指针的值是另一个变量的地址。一个指针对应变量在内存中的存储位置。
并不是每一个值都会有一个内存地址,但是对于每一个变量必然有对应的内存地址。
通过指针,我们可以直接读或更新对应变量的值,而不需要知道该变量的名字(如果变量有名字的话)。
*int
,指针被称之为“指向int类型的指针”。*p
表达式对应p指针指向的变量的值。一般*p
表达式读取指针指向的变量的值,这里为int类型的值,同时因为*p
对应一个变量,所以该表达式也可以出现在赋值语句的左边,表示更新指针所指向的变量的值。x := 1
p := &x // p, of type *int, points to x
fmt.Println(*p) // "1"
*p = 2 // equivalent to x = 2
fmt.Println(x) // "2"
变量有时候被称为可寻址的值。
另一个创建变量的方法是调用内建的new函数。表达式new(T)将创建一个T类型的匿名变量,初始化为T类型的零值,然后返回变量地址,返回的指针类型为*T
。
p := new(int) // p, *int 类型, 指向匿名的 int 变量
fmt.Println(*p) // "0"
*p = 2 // 设置 int 匿名变量的值为 2
fmt.Println(*p) // "2"
new函数类似是一种语法糖,而不是一个新的基础概念。下面的两个newInt函数有着相同的行为:
func newInt() *int {
return new(int)
}
func newInt() *int {
var dummy int
return &dummy
}
每次调用new函数都是返回一个新的变量的地址,因此下面两个地址是不同的:
p := new(int)
q := new(int)
fmt.Println(p == q) // "false"
new函数使用通常相对比较少,因为对于结构体来说,直接用字面量语法创建新变量的方法会更灵活
&&
的优先级比||
高
布尔值并不会隐式转换为数字值0或1,反之亦然。必须使用一个显式的if语句辅助转换:
i := 0
if b {
i = 1
}
第i个字节并不一定是字符串的第i个字符,因为对于非ASCII字符的UTF8编码会要两个或多个字节。
因为字符串是不可修改的,因此尝试修改字符串内部数据的操作也是被禁止的。但是可以 += 拼接新的字符串。
整数转为字符串,一种方法是用fmt.Sprintf返回一个格式化的字符串;另一个方法是用strconv.Itoa(“整数到ASCII”):
x := 123
y := fmt.Sprintf("%d", x)
z := strconv.Itoa(x)) // "123"
字符串解析为整数,可以使用strconv包的Atoi或ParseInt函数,还有用于解析无符号整数的ParseUint函数:
x, err := strconv.Atoi("123") // x is an int
y, err := strconv.ParseInt("123", 10, 64) // base 10, up to 64 bits
const (
e = 2.71828182845904523536028747135266249775724709369995957496696763
pi = 3.14159265358979323846264338327950288419716939937510582097494459
)
如果是批量声明的常量,除了第一个外其它的常量右边的初始化表达式都可以省略,如果省略初始化表达式则表示使用前面常量的初始化表达式写法,对应的常量类型也一样的。例如:
const (
a = 1
b
c = 2
d
)
fmt.Println(a, b, c, d) // "1 1 2 2"
定义了一个Weekday命名类型,然后为一周的每天定义了一个常量,从周日0开始。在其它编程语言中,这种类型一般被称为枚举类型。
type Weekday int
const (
Sunday Weekday = iota
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
)
var q [3]int = [3]int{1, 2, 3}
var r [3]int = [3]int{1, 2}
fmt.Println(r[2]) // "0"
“...”省略号,则表示数组的长度是根据初始化值的个数来计算。
q := [...]int{1, 2, 3}
fmt.Printf("%T\n", q) // "[3]int"
数组、slice、map和结构体字面值的写法都很相似。
symbol := [...]string{USD: "$", EUR: "€", GBP: "£", RMB: "¥"}
r := [...]int{99: -1} // 定义了一个含有100个元素的数组r,最后一个元素被初始化为-1,其它元素都是用0初始化。
一个slice由三个部分构成:指针、长度和容量。
slice的元素是间接引用的,一个固定的slice值(译注:指slice本身的值,不是元素的值)在不同的时刻可能包含不同的元素,因为底层数组的元素可能会被修改。
如果你需要测试一个slice是否是空的,使用len(s) == 0来判断
内置的make函数创建一个指定元素类型、长度和容量的slice。容量部分可以省略,在这种情况下,容量将等于长度。
make([]T, len)
make([]T, len, cap) // same as make([]T, cap)[:len]
内置的append函数则可以追加多个元素,甚至追加一个slice。
var x []int
x = append(x, 1)
x = append(x, 2, 3)
x = append(x, 4, 5, 6)
x = append(x, x...) // append the slice x
一个map就是一个哈希表的引用,map类型可以写为map[K]V,其中K和V分别对应key和value。
map中所有的key都有相同的类型,所有的value也有着相同的类型,但是key和value之间可以是不同的数据类型。
map中的元素并不是一个变量,因此我们不能对map的元素进行取址操作
ages := make(map[string]int) // mapping from strings to ints
ges := map[string]int{
"alice": 31,
"charlie": 34,
}
delete(ages, "alice") // remove element ages["alice"]
ages["bob"]++ // 等价于ages["bob"] += 1
/*所有这些操作是安全的,即使这些元素不在map中也没有关系;如果一个查找失败将返回value类型对应的零值,例如,即使map中不存在“bob”下面的代码也可以正常工作,因为ages["bob"]失败时将返回0。*/
ages["bob"] = ages["bob"] + 1 // happy birthday!
Map的迭代顺序是不确定的.
如果要按顺序遍历key/value对,我们必须显式地对key进行排序,可以使用sort包的Strings函数对字符串slice进行排序。下面是常见的处理方式
// 我们必须显式地对key进行排序,可以使用sort包的Strings函数对字符串slice进行排序。
import "sort"
var names []string
sort.Strings(names)
因为我们一开始就知道names的最终大小,因此给slice分配一个合适的大小将会更有效。下面的代码创建了一个空的slice,但是slice的容量刚好可以放下map中全部的key:
names := make([]string, 0, len(ages))
type Employee struct {
ID int
Name string
Address string
DoB time.Time
Position string
Salary int
ManagerID int
}
var dilbert Employee
position := &dilbert.Position
*position = "Senior " + *position // promoted, for outsourcing to Elbonia
var employeeOfTheMonth *Employee = &dilbert
employeeOfTheMonth.Position += " (proactive team player)"
结构体值也可以用结构体字面值表示,结构体字面值可以指定每个成员的值。
type Point struct{ X, Y int }
p := Point{1, 2}
函数的类型被称为函数的签名。
如果两个函数形式参数列表和返回值列表中的变量类型一一对应,那么这两个函数被认为有相同的类型或签名。
在Go的错误处理中,错误是软件包API和应用程序用户界面的一个重要组成部分
内置的error是接口类型。error类型可能是nil或者non-nil。nil意味着函数运行成功,non-nil表示失败。
对于non-nil的error类型,我们可以通过调用error的Error函数或者输出函数获得字符串类型的错误信息。
fmt.Println(err)
fmt.Printf("%v", err)
最常用的方式是传播错误。这意味着函数中某个子程序的失败,会变成该函数的失败。
doc, err := html.Parse(resp.Body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("parsing %s as HTML: %v", url,err)
}
fmt.Errorf函数使用fmt.Sprintf格式化错误信息并返回。我们使用该函数添加额外的前缀上下文信息到原始错误信息。当错误最终由main函数处理时,错误信息应提供清晰的从原因到后果的因果链。
由于错误信息经常是以链式组合在一起的,所以错误信息中应避免大写和换行符。
第二种策略:重试机制
// WaitForServer attempts to contact the server of a URL.
// It tries for one minute using exponential back-off.
// It reports an error if all attempts fail.
func WaitForServer(url string) error {
const timeout = 1 * time.Minute
deadline := time.Now().Add(timeout)
for tries := 0; time.Now().Before(deadline); tries++ {
_, err := http.Head(url)
if err == nil {
return nil // success
}
log.Printf("server not responding (%s);retrying…", err)
time.Sleep(time.Second << uint(tries)) // exponential back-off
}
return fmt.Errorf("server %s failed to respond after %s", url, timeout)
}
第三种策略:输出错误信息并结束程序。这种策略只应在main中执行
if err := WaitForServer(url); err != nil {
log.Fatalf("Site is down: %v\n", err)
}
第四种策略:有时,我们只需要输出错误信息就足够了,不需要中断程序的运行。我们可以通过log包提供函数
if err := Ping(); err != nil {
log.Printf("ping failed: %v; networking disabled",err)
}
或者标准错误流输出错误信息。log包中的所有函数会为没有换行符的字符串增加换行符。
if err := Ping(); err != nil {
fmt.Fprintf(os.Stderr, "ping failed: %v; networking disabled\n", err)
}
第五种,也是最后一种策略:我们可以直接忽略掉错误。
dir, err := ioutil.TempDir("", "scratch")
if err != nil {
return fmt.Errorf("failed to create temp dir: %v",err)
}
// ...use temp dir…
os.RemoveAll(dir) // ignore errors; $TMPDIR is cleaned periodically
Go中大部分函数的代码结构几乎相同,首先是一系列的初始检查,防止错误发生,之后是函数的实际逻辑
在声明可变参数函数时,需要在参数列表的最后一个参数类型之前加上省略符号“...”,这表示该函数会接收任意数量的该类型参数。
gopl.io/ch5/sum
func sum(vals ...int) int {
total := 0
for _, val := range vals {
total += val
}
return total
}