Go编程时光

Contents:

前言

关于博客

这个网站是我个人开设的第二个博客,沿袭之前的博客名称(Python编程时光),这个博客名字叫《Go编程时光》,此博客于2020年3月15日发布完成

作者的话

Go 语言诞生于 2009 年,经过 11 年的发展,Golang 不仅没有被各种火热的编程语言比下去,凭借自己在云计算领域的独特优势,暗自发力,据我所知,有很多公司团队,已经在准备将自己的项目从 Python 转到 Golang(仅 web 领域)。

这个博客记录的是我本人发布于个人公众号「Go编程时光」的文章,主要是记录我学习 Golang 的一些学习笔记,虽然是笔记,但我会写得适合一个 Golang 纯小白的教程。

同时,由于我也是初学者,请你在阅读本站内容时,时刻持有一种质疑的态度,这样既能帮助自己思考,也能发现我文章的不足之处,如果文章中有什么错漏的地方请大家见谅,也欢迎大家随时加我微信帮我指正。感谢!


关注公众号,获取最新文章

第一章:基础知识

这一章介绍 Golang 最基础的入门知识点,如:开发环境搭建、数据类型、控制流程等

本章节,会持续更新,敬请关注…


1.1 一文搞定开发环境的搭建

1. 下载安装 Go语言

下载地址:https://golang.google.cn/dl/

image0

下载完成后,直接双击 msi 文件进行安装,我习惯将软件安装在我的 E 盘下的 Program Files 目录下

image1

后面就是一路点击 Next 即可,直到出现如下界面,安装完成。

image2

2. 配置 Goland 环境

学习编程语言,使用一个称心的 IDE,可以帮你省去很多麻烦。

开发 Python 项目,我习惯使用 PyCharm,因为已经习惯了 JetBrains 风格的IDE,可以替我省去很多熟悉新IDE的成本,所以这里我照样使用 JetBrains 专门为 Go语言开发的IDE:Goland

Goland 下载地址:https://download.jetbrains.com/go/goland-2019.2.3.exe

双击下载下来的 exe 文件,除了选择安装路径,我惯例更换成 E 盘之外,一路选择 Next , 直到如下界面,根据你的需要全选中(推荐全选)

image3

接着一路 Next,直到出现如下界面,安装完成,选择 Run Gogland 立即运行。

image4

此时如果你若没有购买 JetBrains 的激活码,此时是无法使用 Goland 的。

image5

为了让我们的学习更加顺畅,这里会教大家使用 破解的方式获得 Goland 的使用权。

首先下载相关的破解补丁:http://c.biancheng.net/uploads/course/go/Goland_Crack_Sinicization.zip

下载的 zip 包里包含三个文件

image6

  • jetbrains-agent.jar:破解补丁

  • resources_cn.jar:汉化补丁

  • 激活码.txt:激活码

将 jetbrains-agent.jar 拷贝到 你的 Goland 安装目录的bin文件夹下,我的路径是:E::raw-latex:`\Program `Files:raw-latex:`JetBrains`:raw-latex:GoLand `2019.2.3:raw-latex:bin`

然后用编辑器打开这两个文件

image7

在最后一行添加如下一行,你要根据自己路径对应修改后面的值

-javaagent:E:\Program Files\JetBrains\GoLand 2019.2.3\bin\jetbrains-agent.jar

接着打开回到你的 Goland 启动界面,点击 Activation code,复制 激活码.txt 中的激活码,填入再点击 OK

image8

此时你的 Goland 已经可以正常使用了,创建我的项目目录,顺便设置好 GOROOT 。

image9

创建好Project后,再点击 Files->Settings->GOPATH,添加我们的项目目录F:\Go-Player

image10

随便点击一个go文件,就能在下图箭头处看到配置入口,点击进入配置一下 Go运行器。

image11

按照如下指示进行配置。

image12

去掉参数提示

image13

设置 goproxy

image14

可先的代理有如下(注意后面的 direct 不要删除)

  • https://goproxy.io

  • https://goproxy.cn

  • https://mirrors.aliyun.com/goproxy/

设置 goimports(自动格式化插件),如果 你之前 没有安装 ,会提示你点击 yes 下载安装 。

image15

至此,环境配置完成。

在项目根目录下,创建如下三个文件夹,并在 src 目录下创建一个hello.go 的文件。

image16

点击运行按钮,在控制台我们看到了熟悉的 Hello, World!

image17

另:有关 Golang 最新版的破解教程,请关注这个博客:https://zhile.io

3. 配置 VS Code 环境

提前设置用户级的环境变量

GOPATH = F:\Go-Player
PATH = %GOPATH%\bin  # 以追加的方式

昨天评论区有人问,GOPATH 和 GOROOT 是什么?为什么需要设置?回想一下 你学 Python 的话,安装 Python 解释器的时候,是不是也要设置环境变量?这里也是类似。

GOROOT :在GO语言中表示的是 Go语言编译、工具、标准库等的安装路径,通过它可以告诉系统你的 go.exe 是放在哪里,不设置的话,你后面执行 go getgo install 的时候,系统就不认识它了。

而 GOPATH环境变量则表示 Go的工作目录,这个目录指定了需要从哪个地方寻找GO的包、可执行程序等,这个目录可以是多个目录表示。这里我设置成我的工作空间(目录你可以自己定) :F:\Go-Player,如果不设置的话 ,默认是在你的用户目录下的 go 文件夹。

这时要再说一点,GO 项目中,一般来说它的工作目录结构是这样的:

  • bin目录:包含了可执行程序,注意是可执行的,不需要解释执行。

  • pkg目录:包含了使用的包或者说库。

  • src目录:里面包含了go的代码源文件,其中仍按包的不同进行组织。

所以后面我的创建的GO工作目录,也是按照这个标准来,先说明一下。

接下来,要开始配置 VS Code 环境。

打开你的 VS Code软件,先确认你设置的环境变量已经生效,点击 Terminal -> New Terminal,使用 cmd 命令查看环境变量。

image18

如上图所求,我的环境变量是OK的,如果你的输出是指向你的用户目录:%USERPROFILE%\go 建议你不要折腾(因为我无论重启多少次 VS Code,其记录的GOPATH始终指向%USERPROFILE%:raw-latex:go), 直接重启你的电脑。

好了之后,我们要从 github 上下载两个仓库,之所以要手动下载,是因为有墙的存在,在线安装的话,很多插件你会下载失败。

创建目录 src/goland.org/x/,并进入此目录,执行命令

$ git clone https://github.com/golang/tools.git
$ git clone https://github.com/golang/lint.git

点击 File - Open Folder 安装两个插件:

第一个是:Go 语言的扩展插件

image19

第二个是:Code Runner,让你的 VS Code 能够编译运行 Go 的程序。

image20

随便点开一个 go 文件,在你的右下角会提示要你安装一些工具,安装的包有些由于墙的原因,无法下载,为了保证下载顺利,可以设置一下代理。

$ go env -w GOPROXY=https://goproxy.cn,direct

然后再点击 Install All

image21

然后你在 OUTPUT 就能看到安装进度

image22

安装的 exe 文件会放在 %GOPATH%/bin 下,也就是 F:\Go-Player\bin

image23

而此的 src 目录结构是这样的

image24

到这时环境配置完成,编写 HelloWorld,并运行查看输出,一切完成。

image25

4. 配置环境变量

当你在终端使用 go env 的时候,会打印出go 相关的所有环境变量

$ go env
set GO111MODULE=
set GOARCH=amd64
set GOBIN=
set GOCACHE=C:\Users\wangbm\AppData\Local\go-build
set GOENV=C:\Users\wangbm\AppData\Roaming\go\env
set GOEXE=.exe
set GOFLAGS=
set GOHOSTARCH=amd64
set GOHOSTOS=windows
set GONOPROXY=
set GONOSUMDB=
set GOOS=windows
set GOPATH=E:\MING-Code\GoPlayer
set GOPRIVATE=
set GOPROXY=https://goproxy.cn,direct
set GOROOT=D:\Program Files (x86)\Go-1.13.6
set GOSUMDB=sum.golang.org
set GOTMPDIR=
set GOTOOLDIR=D:\Program Files (x86)\Go-1.13.6\pkg\tool\windows_amd64
set GCCGO=gccgo
set AR=ar
set CC=gcc
set CXX=g++
set CGO_ENABLED=1
set GOMOD=
set CGO_CFLAGS=-g -O2
set CGO_CPPFLAGS=
set CGO_CXXFLAGS=-g -O2
set CGO_FFLAGS=-g -O2
set CGO_LDFLAGS=-g -O2
set PKG_CONFIG=pkg-config

想查看几个特定的环境变量就加在 go env 后面

$ go env GOPATH
E:\MING-Code\GoPlayer
$ go env GOROOT
D:\Program Files (x86)\Go-1.13.6
$ go env GOPROXY
https://goproxy.cn,direct

以上环境变量很多,这里仅设置下面这两个就足够了

  • 一个是GO111MODULE 设置为 on,表示使用 go modules 模式

$ go env -w GO111MODULE=on
  • 一个是开启代理,防止下载包失败(前面可能你已经设置过)

$ go env -w GOPROXY=https://goproxy.cn,direct

image26

1.2 五种变量创建的方法

对于只有 Python 语言经验的朋友,也许会不太理解声明这个词,在 Python 中直接拿来就用,也不用声明类型啥的。

Go 语言是静态类型语言,由于编译时,编译器会检查变量的类型,所以要求所有的变量都要有明确的类型。

变量在使用前,需要先声明。声明类型,就约定了你这个变量只能赋该类型的值。

声明一般有以下四种方法,其中前面两种同样也可用于定义常量,只需把关键字 var 变成 const 即可。

第一种 :一行声明一个变量

var <name> <type>

其中 var 是关键字(固定不变),name 是变量名,type 是类型。

使用 var ,虽然只指定了类型,但是 Go 会对其进行隐式初始化,比如 string 类型就初始化为空字符串,int 类型就初始化为0,float 就初始化为 0.0,bool类型就初始化为false,指针类型就初始化为 nil。

若想在声明过程,顺便也初始化,可以这样写

var name sting = "Python编程时光"

在 Go 文件中的完整代码如下,为了不写重复性的代码,后续不再貼完整代码,只貼关键代码

package main

import "fmt"

func main()  {
    var name string = "Python编程时光"
    fmt.Println(name)
}

从右值(等号右边的值,rvalue)来看,明显是个 string 类型(这里要注意,在 Python 双引号与单引号等价,但在 Go 中双引号和单引号是不一样的,这里要一定要使用双引号,表示字符串,而在单引号表示rune 类型的字符,这个后续会单独介绍),因此也可以将其简化为

var name = "Python编程时光"

若你的右值带有小数点,在不指定类型的情况下,编译器会将你的这个变量声明为 float64,但是很多情况下,我们并不需要这么高的精度(占用的内存空间更大)

这种情况下,推荐指定类型,不要偷懒

var rate float32 0.89

第二种:多个变量一起声明

声明多个变量,除了可以按照上面写成多行之外,还可以写成下面这样

var (
    name string
    age int
    gender string
)

第三种:声明和初始化一个变量

使用 := (推导声明写法或者短类型声明法:编译器会自动根据右值类型推断出左值的对应类型。),可以声明一个变量,并对其进行(显式)初始化。

name := "Python编程时光"

// 等价于

var name string = "Python编程时光"

// 等价于

var name = "Python编程时光"

但这种方法有个限制就是,只能用于函数内部

第四种:声明和初始化多个变量

name, age := "wangbm", 28

这种方法,也经常用于变量的交换

var a int = 100
var b int = 200
b, a = a, b

第五种:new 函数声明一个指针变量

在这里要先讲一下,指针的相关内容。

变量分为两种 普通变量指针变量

普通变量,存放的是数据本身,而指针变量存放的是数据的地址。

如下代码,age 是一个普通变量,存放的内容是 28,而 ptr 是 存放变量age值的内存地址:0xc000010098

package main

import "fmt"

func main()  {
    var age int = 28
    var ptr = &age  // &后面接变量名,表示取出该变量的内存地址
    fmt.Println("age: ", age)
    fmt.Println("ptr: ", ptr)
}

输出

age:  28
ptr:  0xc000010098

而这里要说的 new 函数,是 Go 里的一个内建函数。

使用表达式 new(Type) 将创建一个Type类型的匿名变量,初始化为Type类型的零值,然后返回变量地址,返回的指针类型为*Type

package main

import "fmt"

func main()  {
    ptr := new(int)
    fmt.Println("ptr address: ", ptr)
    fmt.Println("ptr value: ", *ptr)  // * 后面接指针变量,表示从内存地址中取出值
}

输出

ptr address:  0xc000010098
ptr value:  0

用new创建变量和普通变量声明语句方式创建变量没有什么区别,除了不需要声明一个临时变量的名字外,我们还可以在表达式中使用new(Type)。换言之,new函数类似是一种语法糖,而不是一个新的基础概念。

如下两种写法,可以说是等价的

// 使用 new
func newInt() *int {
    return new(int)
}

// 使用传统的方式
func newInt() *int {
    var dummy int
    return &dummy
}

以上不管哪种方法,变量/常量都只能声明一次,声明多次,编译就会报错。

但也有例外,这就要说到一个特殊变量:匿名变量,也称作占位符,或者空白标识符,用下划线表示。

匿名变量,优点有三:

  • 不分配内存,不占用内存空间

  • 不需要你为命名无用的变量名而纠结

  • 多次声明不会有任何问题

通常我们用匿名接收必须接收,但是又不会用到的值。

func GetData() (int, int) {
    return 100, 200
}
func main(){
    a, _ := GetData()
    _, b := GetData()
    fmt.Println(a, b)
}

image0

1.3 数据类型:整型与浮点型

1. 整型

Go 语言中,整数类型可以再细分成10个类型,为了方便大家学习,我将这些类型整理成一张表格。

image0

int 和 uint 的区别就在于一个 u,有 u 说明是无符号,没有 u 代表有符号。

解释这个符号的区别

int8uint8 举例,8 代表 8个bit,能表示的数值个数有 2^8 = 256。

uint8 是无符号,能表示的都是正数,0-255,刚好256个数。

int8 是有符号,既可以正数,也可以负数,那怎么办?对半分呗,-128-127,也刚好 256个数。

int8 int16 int32 int64 这几个类型的最后都有一个数值,这表明了它们能表示的数值个数是固定的。

而 int 没有并没有指定它的位数,说明它的大小,是可以变化的,那根据什么变化呢?

  • 当你在32位的系统下,int 和 uint 都占用 4个字节,也就是32位。

  • 若你在64位的系统下,int 和 uint 都占用 8个字节,也就是64位。

出于这个原因,在某些场景下,你应当避免使用 int 和 uint ,而使用更加精确的 int32 和 int64,比如在二进制传输、读写文件的结构描述(为了保持文件的结构不会受到不同编译目标平台字节长度的影响)

不同进制的表示方法

出于习惯,在初始化数据类型为整型的变量时,我们会使用10进制的表示法,因为它最直观,比如这样,表示整数10.

var num int = 10

不过,你要清楚,你一样可以使用其他进制来表示一个整数,这里以比较常用的2进制、8进制和16进制举例。

2进制:以0b0B为前缀

var num01 int = 0b1100

8进制:以0o或者 0O为前缀

var num02 int = 0o14

16进制:以0x 为前缀

var num03 int = 0xC

下面用一段代码分别使用二进制、8进制、16进制来表示 10 进制的数值:12

package main

import (
    "fmt"
)

func main() {
    var num01 int = 0b1100
    var num02 int = 0o14
    var num03 int = 0xC

    fmt.Printf("2进制数 %b 表示的是: %d \n", num01, num01)
    fmt.Printf("8进制数 %o 表示的是: %d \n", num02, num02)
    fmt.Printf("16进制数 %X 表示的是: %d \n", num03, num03)
}

输出如下

2进制数 1100 表示的是: 12
8进制数 14 表示的是: 12
16进制数 C 表示的是: 12

以上代码用过了 fmt 包的格式化功能,你可以参考这里去看上面的代码

%b    表示为二进制
%c    该值对应的unicode码值
%d    表示为十进制
%o    表示为八进制
%q    该值对应的单引号括起来的go语法字符字面值,必要时会采用安全的转义表示
%x    表示为十六进制,使用a-f
%X    表示为十六进制,使用A-F
%U    表示为Unicode格式:U+1234,等价于"U+%04X"
%E    用科学计数法表示
%f    用浮点数表示

2. 浮点型

浮点数类型的值一般由整数部分、小数点“.”和小数部分组成。

其中,整数部分和小数部分均由10进制表示法表示。不过还有另一种表示方法。那就是在其中加入指数部分。指数部分由“E”或“e”以及一个带正负号的10进制数组成。比如,3.7E-2表示浮点数0.037。又比如,3.7E+1表示浮点数37

有时候,浮点数类型值的表示也可以被简化。比如,37.0可以被简化为37。又比如,0.037可以被简化为.037

有一点需要注意,在Go语言里,浮点数的相关部分只能由10进制表示法表示,而不能由8进制表示法或16进制表示法表示。比如,03.7表示的一定是浮点数3.7

float32 和 float64

Go语言中提供了两种精度的浮点数 float32 和 float64。

float32,也即我们常说的单精度,存储占用4个字节,也即4*8=32位,其中1位用来符号,8位用来指数,剩下的23位表示尾数

img

img

float64,也即我们熟悉的双精度,存储占用8个字节,也即8*8=64位,其中1位用来符号,11位用来指数,剩下的52位表示尾数

img

img

那么精度是什么意思?有效位有多少位?

精度主要取决于尾数部分的位数。

对于 float32(单精度)来说,表示尾数的为23位,除去全部为0的情况以外,最小为2^-23,约等于1.19*10^-7,所以float小数部分只能精确到后面6位,加上小数点前的一位,即有效数字为7位。

同理 float64(单精度)的尾数部分为 52位,最小为2^-52,约为2.22*10^-16,所以精确到小数点后15位,加上小数点前的一位,有效位数为16位。

通过以上,可以总结出以下几点:

一、float32 和 float64 可以表示的数值很多

浮点数类型的取值范围可以从很微小到很巨大。浮点数取值范围的极限值可以在 math 包中找到:

  • 常量 math.MaxFloat32 表示 float32 能取到的最大数值,大约是 3.4e38;

  • 常量 math.MaxFloat64 表示 float64 能取到的最大数值,大约是 1.8e308;

  • float32 和 float64 能表示的最小值分别为 1.4e-45 和 4.9e-324。

二、数值很大但精度有限

人家虽然能表示的数值很大,但精度位却没有那么大。

  • float32的精度只能提供大约6个十进制数(表示后科学计数法后,小数点后6位)的精度

  • float64的精度能提供大约15个十进制数(表示后科学计数法后,小数点后15位)的精度

这里的精度是什么意思呢?

比如 10000018这个数,用 float32 的类型来表示的话,由于其有效位是7位,将10000018 表示成科学计数法,就是 1.0000018 * 10^7,能精确到小数点后面6位。

此时用科学计数法表示后,小数点后有7位,刚刚满足我们的精度要求,意思是什么呢?此时你对这个数进行+1或者-1等数学运算,都能保证计算结果是精确的

import "fmt"
var myfloat float32 = 10000018
func main()  {
    fmt.Println("myfloat: ", myfloat)
    fmt.Println("myfloat: ", myfloat+1)
}

输出如下

myfloat:  1.0000018e+07
myfloat:  1.0000019e+07

上面举了一个刚好满足精度要求数据的临界情况,为了做对比,下面也举一个刚好不满足精度要求的例子。只要给这个数值多加一位数就行了。

换成 100000187,同样使用 float32类型,表示成科学计数法,由于精度有限,表示的时候小数点后面7位是准确的,但若是对其进行数学运算,由于第八位无法表示,所以运算后第七位的值,就会变得不精确。

这里我们写个代码来验证一下,按照我们的理解下面 myfloat01 = 100000182 ,对其+5 操作后,应该等于 myfloat02 = 100000187,

import "fmt"

var myfloat01 float32 = 100000182
var myfloat02 float32 = 100000187

func main() {
    fmt.Println("myfloat: ", myfloat01)
    fmt.Println("myfloat: ", myfloat01+5)
    fmt.Println(myfloat02 == myfloat01+5)
}

但是由于其类型是 float32,精度不足,导致最后比较的结果是不相等(从小数点后第七位开始不精确)

myfloat:  1.00000184e+08
myfloat:  1.0000019e+08
false

由于精度的问题,就会出现这种很怪异的现象,myfloat == myfloat +1 会返回 true

1.4 数据类型:byte、rune与字符串

1. byte 与 rune

byte,占用1个节字,就 8 个比特位,所以它和 uint8 类型本质上没有区别,它表示的是 ACSII 表中的一个字符。

如下这段代码,分别定义了 byte 类型和 uint8 类型的变量 a 和 b

import "fmt"

func main() {
    var a byte = 65
    // 8进制写法: var c byte = '\101'     其中 \ 是固定前缀
    // 16进制写法: var c byte = '\x41'    其中 \x 是固定前缀

    var b uint8 = 66
    fmt.Printf("a 的值: %c \nb 的值: %c", a, b)
    // 或者使用 string 函数
    // fmt.Println("a 的值: ", string(a)," \nb 的值: ", string(b))
}

在 ASCII 表中,由于字母 A 的ASCII 的编号为 65 ,字母 B 的ASCII 编号为 66,所以上面的代码也可以写成这样

import "fmt"

func main() {
    var a byte = 'A'
    var b uint8 = 'B'
    fmt.Printf("a 的值: %c \nb 的值: %c", a, b)
}

他们的输出结果都是一样的。

a 的值: A
b 的值: B

rune,占用4个字节,共32位比特位,所以它和 uint32 本质上也没有区别。它表示的是一个 Unicode字符(Unicode是一个可以表示世界范围内的绝大部分字符的编码规范)。

import (
    "fmt"
    "unsafe"
)

func main() {
    var a byte = 'A'
    var b rune = 'B'
    fmt.Printf("a 占用 %d 个字节数\nb 占用 %d 个字节数", unsafe.Sizeof(a), unsafe.Sizeof(b))
}

输出如下

a 占用 1 个字节数
b 占用 4 个字节数

由于 byte 类型能表示的值是有限,只有 2^8=256 个。所以如果你想表示中文的话,你只能使用 rune 类型。

var name rune = '中'

或许你已经发现,上面我们在定义字符时,不管是 byte 还是 rune ,我都是使用单引号,而没使用双引号。

对于从 Python 转过来的人,这里一定要注意了,在 Go 中单引号与 双引号并不是等价的。

单引号用来表示字符,在上面的例子里,如果你使用双引号,就意味着你要定义一个字符串,赋值时与前面声明的前面会不一致,这样在编译的时候就会出错。

cannot use "A" (type string) as type byte in assignment

上面我说了,byte 和 uint8 没有区别,rune 和 uint32 没有区别,那为什么还要多出一个 byte 和 rune 类型呢?

理由很简单,因为uint8 和 uint32 ,直观上让人以为这是一个数值,但是实际上,它也可以表示一个字符,所以为了消除这种直观错觉,就诞生了 byte 和 rune 这两个别名类型。

2. 字符串

字符串,可以说是大家很熟悉的数据类型之一。定义方法很简单

var mystr string = "hello"

上面说的byte 和 rune 都是字符类型,若多个字符放在一起,就组成了字符串,也就是这里要说的 string 类型。

比如 hello ,对照 ascii 编码表,每个字母对应的编号是:104,101,108,108,111

import (
    "fmt"
)

func main() {
    var mystr01 sting = "hello"
    var mystr02 [5]byte = [5]byte{104, 101, 108, 108, 111}
    fmt.Printf("mystr01: %s\n", mystr01)
    fmt.Printf("mystr02: %s", mystr02)
}

输出如下,mystr01 和 mystr02 输出一样,说明了 string 的本质,其实是一个 byte数组

mystr01: hello
mystr02: hello

通过以上学习,我们知道字符分为 byte 和 rune,占用的大小不同。

这里来考一下大家,hello,中国 占用几个字节?

要回答这个问题,你得知道 Go 语言的 string 是用 uft-8 进行编码的,英文字母占用一个字节,而中文字母占用 3个字节,所以 hello,中国 的长度为 5+1+(3*2)= 12个字节。

import (
    "fmt"
)

func main() {
    var country string = "hello,中国"
    fmt.Println(len(country))
}
// 输出
12

以上虽然我都用双引号表示 一个字符串,但这并不是字符串的唯一表示方式。

除了双引号之外 ,你还可以使用反引号。

大多情况下,二者并没有区别,但如果你的字符串中有转义字符\ ,这里就要注意了,它们是有区别的。

使用反引号号包裹的字符串,相当于 Python 中的 raw 字符串,会忽略里面的转义。

比如我想表示 \r\n 这个 字符串,使用双引号是这样写的,这种叫解释型表示法

var mystr01 string = "\\r\\n"

而使用反引号,就方便多了,所见即所得,这种叫原生型表示法

var mystr02 string = `\r\n`

他们的打印结果 都是一样的

import (
    "fmt"
)

func main() {
    var mystr01 string = "\\r\\n"
    var mystr02 string = `\r\n`
    fmt.Println(mystr01)
    fmt.Println(mystr02)
}

// output
\r\n
\r\n

如果你仍然想使用解释型的字符串,但是各种转义实在太麻烦了。你可以使用 fmt 的 %q 来还原一下。

import (
    "fmt"
)

func main() {
    var mystr01 string = `\r\n`
    fmt.Println(`\r\n`)
    fmt.Printf("的解释型字符串是: %q", mystr01)
}

输出如下

\r\n
的解释型字符串是: "\\r\\n"

同时反引号可以不写换行符(因为没法写)来表示一个多行的字符串。

import (
    "fmt"
)

func main() {
    var mystr01 string = `你好呀!
我的公众号是: Go编程时光,欢迎大家关注`

    fmt.Println(mystr01)
}

输出如下

你好呀!
我的公众号是: Go编程时光,欢迎大家关注

image0

1.5 数据类型:数组与切片

1. 数组

数组是一个由固定长度的特定类型元素组成的序列,一个数组可以由零个或多个元素组成。因为数组的长度是固定的,所以在Go语言中很少直接使用数组。

声明数组,并给该数组里的每个元素赋值(索引值的最小有效值和其他大多数语言一样是 0,不是1)

// [3] 里的3 表示该数组的元素个数及容量
var arr [3]int
arr[0] = 1
arr[1] = 2
arr[2] = 3

声明并直接初始化数组

// 第一种方法
var arr [3]int = [3]int{1,2,3}

// 第二种方法
arr := [3]int{1,2,3}

上面的 3 表示数组的元素个数 ,万一你哪天想往该数组中增加元素,你得对应修改这个数字,为了避免这种硬编码,你可以这样写,使用 ... 让Go语言自己根据实际情况来分配空间。

arr := [...]int{1,2,3}

[3]int[4]int 虽然都是数组,但他们却是不同的类型,使用 fmt 的 %T 可以查得。

import (
    "fmt"
)

func main() {
    arr01 := [...]int{1, 2, 3}
    arr02 := [...]int{1, 2, 3, 4}
    fmt.Printf("%d 的类型是: %T\n", arr01, arr01)
    fmt.Printf("%d 的类型是: %T", arr02, arr02)
}

输出 如下

[1 2 3] 的类型是: [3]int
[1 2 3 4] 的类型是: [4]int

如果你觉得每次写 [3]int 有点麻烦,你可以为 [3]int 定义一个类型字面量,也就是别名类型。

使用 type 关键字可以定义一个类型字面量,后面只要你想定义一个容器大小为3,元素类型为int的数组 ,都可以使用这个别名类型。

import (
    "fmt"
)

func main() {
    type arr3 [3]int

    myarr := arr3{1,2,3}
    fmt.Printf("%d 的类型是: %T", myarr, myarr)
}

输出 如下

[1 2 3] 的类型是: main.arr3

2. 切片

切片(Slice)与数组一样,也是可以容纳若干类型相同的元素的容器。与数组不同的是,无法通过切片类型来确定其值的长度。每个切片值都会将数组作为其底层数据结构。我们也把这样的数组称为切片的底层数组。

切片是对数组的一个连续片段的引用,所以切片是一个引用类型,这个片段可以是整个数组,也可以是由起始和终止索引标识的一些项的子集,需要注意的是,终止索引标识的项不包括在切片内(意思是这是个左闭右开的区间)

import (
    "fmt"
)

func main() {
    myarr := [...]int{1, 2, 3}
    fmt.Printf("%d 的类型是: %T", myarr[0:2], myarr[0:2])
}

输出 如下

[1 2] 的类型是: []int

切片的构造,有三种方式

  1. 对数组进行片段截取(上面例子已经展示:myarr[0:2],0是索引起始值,2是索引终止值,区间左半右开),当你使用这种方式生成切片对象时,切片的容量会从截取的起始索引到原数组的终止索引

    如下这段代码所示,切片从索引值 1 开始,到原数组终止索引值5,中间还可以容纳4个元素,所以容量为 4,但是由于我们切片的时候只要求取到索引值2 (3-1),所以当我们对这个切片进行打印时,并不会打印索引值3,4,5 对应的元素值。

    package main
    
    import "fmt"
    
    func main(){
        myarr := [5]int{1,2,3,4,5}
        fmt.Printf("myarr 的长度为:%d,容量为:%d\n", len(myarr), cap(myarr))
    
        mysli := myarr[1:3]
        fmt.Printf("mysli 的长度为:%d,容量为:%d\n", len(mysli), cap(mysli))
    
        fmt.Println(mysli)
    }
    

    输出如下

    myarr 的长度为:5,容量为:5
    mysli 的长度为:2,容量为:4
    [2,3]
    
  2. 从头声明赋值(例子如下)

    // 声明字符串切片
    var strList []string
    
    // 声明整型切片
    var numList []int
    
    // 声明一个空切片
    var numListEmpty = []int{}
    
  3. 使用 make 函数构造,make 函数的格式:make( []Type, size, cap )

    这个函数刚好指出了,一个切片具备的三个要素:类型(Type),长度(size),容量(cap)

    import (
     "fmt"
    )
    
    func main() {
     a := make([]int, 2)
     b := make([]int, 2, 10)
     fmt.Println(a, b)
     fmt.Println(len(a), len(b))
     fmt.Println(cap(a), cap(b))
    }
    

    输出 如下

    [0 0] [0 0]
    2 2
    2 10
    

关于 len 和 cap 的概念,可能不好理解 ,这里举个例子:

  • 公司名,相当于字面量,也就是变量名。

  • 公司里的所有工位,相当于已分配到的内存空间

  • 公司里的员工,相当于元素。

  • cap 代表你这个公司最多可以容纳多少员工

  • len 代表你这个公司当前有多少个员工

由于 切片是引用类型,所以你不对它进行赋值的话,它的零值(默认值)是 nil

var myarr []int
fmt.Println(myarr == nil)
// true

数组 与 切片 有相同点,它们都是可以容纳若干类型相同的元素的容器

也有不同点,数组的容器大小固定,而切片本身是引用类型,它更像是 Python 中的 list ,我们可以对它 append 进行元素的添加。

import (
    "fmt"
)

func main() {
    myarr := []int{1}
    // 追加一个元素
    myarr = append(myarr, 2)
    // 追加多个元素
    myarr = append(myarr, 3, 4)
    // 追加一个切片, ... 表示解包,不能省略
    myarr = append(myarr, []int{7, 8}...)
    // 在第一个位置插入元素
    myarr = append([]int{0}, myarr...)
    // 在中间插入一个切片(两个元素)
    myarr = append(myarr[:5], append([]int{5,6}, myarr[5:]...)...)
    fmt.Println(myarr)
}

输出 如下

[0 1 2 3 4 5 6 7 8]

3. 思考题

最后,给你留道思考题。

package main

import (
    "fmt"
)

func main() {
    var numbers4 = [...]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    myslice := numbers4[4:6:8]
    fmt.Printf("myslice为 %d, 其长度为: %d\n", myslice, len(myslice))

    myslice = myslice[:cap(myslice)]
    fmt.Printf("myslice的第四个元素为: %d", myslice[3])
}

为什么 myslice 的长度为2,却能访问到第四个元素

myslice为 [5 6], 其长度为: 2
myslice的第四个元素为: 8

image0

1.6 数据类型:字典与布尔类型

1. 字典

字典(Map 类型),是由若干个 key:value 这样的键值对映射组合在一起的数据结构。

它是哈希表的一个实现,这就要求它的每个映射里的key,都是唯一的,可以使用 ==!= 来进行判等操作,换句话说就是key必须是可哈希的。

什么叫可哈希的?简单来说,一个不可变对象,都可以用一个哈希值来唯一表示,这样的不可变对象,比如字符串类型的对象(可以说除了切片、 字典,函数之外的其他内建类型都算)。

意思就是,你的 key 不能是切片,不能是字典,不能是函数。。

字典由key和value组成,它们各自有各自的类型。

在声明字典时,必须指定好你的key和value是什么类型的,然后使用 map 关键字来告诉Go这是一个字典。

map[KEY_TYPE]VALUE_TYPE
声明初始化字典

三种声明并初始化字典的方法

// 第一种方法
var scores map[string]int = map[string]int{"english": 80, "chinese": 85}

// 第二种方法
scores := map[string]int{"english": 80, "chinese": 85}

// 第三种方法
scores := make(map[string]int)
scores["english"] = 80
scores["chinese"] = 85

要注意的是,第一种方法如果拆分成多步(声明、初始化、再赋值),和其他两种有很大的不一样了,相对会比较麻烦。

import "fmt"

func main() {
    // 声明一个名为 score 的字典
    var scores map[string]int

    // 未初始化的 score 的零值为nil,无法直接进行赋值
    if scores == nil {
        // 需要使用 make 函数先对其初始化
        scores = make(map[string]int)
    }

    // 经过初始化后,就可以直接赋值
    scores["chinese"] = 90
    fmt.Println(scores)
}
字典的相关操作

添加元素

scores["math"] = 95

更新元素,若key已存在,则直接更新value

scores["math"] = 100

读取元素,直接使用 [key] 即可 ,如果 key 不存在,也不报错,会返回其value-type 的零值。

fmt.Println(scores["math"])

删除元素,使用 delete 函数,如果 key 不存在,delete 函数会静默处理,不会报错。

delete(scores, "math")

当访问一个不存在的key时,并不会直接报错,而是会返回这个 value 的零值,如果 value的类型是int,就返回0。

package main

import "fmt"

func main() {
    scores := make(map[string]int)
    fmt.Println(scores["english"]) // 输出 0
}
判断 key 是否存在

当key不存在,会返回value-type的零值 ,所以你不能通过返回的结果是否是零值来判断对应的 key 是否存在,因为 key 对应的 value 值可能恰好就是零值。

其实字典的下标读取可以返回两个值,使用第二个返回值都表示对应的 key 是否存在,若存在ok为true,若不存在,则ok为false

import "fmt"

func main() {
    scores := map[string]int{"english": 80, "chinese": 85}
    math, ok := scores["math"]
    if ok {
        fmt.Printf("math 的值是: %d", math)
    } else {
        fmt.Println("math 不存在")
    }
}

我们将上面的代码再优化一下

import "fmt"

func main() {
    scores := map[string]int{"english": 80, "chinese": 85}
    if math, ok := scores["math"]; ok {
        fmt.Printf("math 的值是: %d", math)
    } else {
        fmt.Println("math 不存在")
    }
}
如何对字典进行循环

Go 语言中没有提供类似 Python 的 keys() 和 values() 这样方便的函数,想要获取,你得自己循环。

循环还分三种

  1. 获取 key 和 value

import "fmt"

func main() {
    scores := map[string]int{"english": 80, "chinese": 85}

    for subject, score := range scores {
        fmt.Printf("key: %s, value: %d\n", subject, scores)
    }
}
  1. 只获取key,这里注意不用占用符。

import "fmt"

func main() {
    scores := map[string]int{"english": 80, "chinese": 85}

    for subject := range scores {
        fmt.Printf("key: %s\n", subject)
    }
}
  1. 只获取 value,用一个占位符替代。

import "fmt"

func main() {
    scores := map[string]int{"english": 80, "chinese": 85}

    for _, score := range scores {
        fmt.Printf("value: %d\n", score)
    }
}

2. 布尔类型

关于布尔值,无非就两个值:true 和 false。只是这两个值,在不同的语言里可能不同。

在 Python 中,真值用 True 表示,与 1 相等,假值用 False 表示,与 0 相等

>>> True == 1
True
>>> False == 0
True
>>>

而在 Go 中,真值用 true 表示,不但不与 1 相等,并且更加严格,不同类型无法进行比较,而假值用 false 表示,同样与 0 无法比较。

如下图所示,Goland 直接波浪线提示类型不匹配,不能比较。

image0

Go 中确实不如 Python 那样灵活,bool 与 int 不能直接转换,如果要转换,需要你自己实现函数。

bool 转 int

func bool2int(b bool) int {
    if b {
        return 1
    }
    return 0
}

int 转 bool

func int2bool(i int) bool {
    return i != 0
}

在 Python 中使用 not 对逻辑值取反,而 Go 中使用 ! 符号

import "fmt"

var male bool = true
func main()  {
    fmt.Println( !male == false)
    // 或者
    fmt.Println( male != false)
}

// output: true

一个 if 判断语句,有可能不只一个判断条件,在 Python 中是使用 andor 来执行逻辑运算

>>> age = 15
>>> gender = "male"
>>>
>>> gender == "male" and age >18
False

而在 Go 语言中,则使用 && 表示,用 || 表示,并且有短路行为(即左边表达式已经可以确认整个表达式的值,那么右边将不会再被求值。

import "fmt"

var age int = 15
var gender string = "male"
func main()  {
    //  && 两边的表达式都会执行
    fmt.Println( age > 18 && gender == "male")
    // gender == "male" 并不会执行
    fmt.Println( age > 18 || gender == "male")
}

// output: false
// output: true

image1

1.7 数据类型:指针

0. 什么是指针

当我们定义一个变量 name

var name string = "Go编程时光"

此时,name 是变量名,它只是编程语言中方便程序员编写和理解代码的一个标签。

当我们访问这个标签时,机算机会返回给我们它指向的内存地址里存储的值:Go编程时光

出于某些需要,我们会将这个内存地址赋值给另一个变量名,通常叫做 ptr(pointer的简写),而这个变量,我们称之为指针变量。

换句话说,指针变量(一个标签)的值是指针,也就是内存地址。

根据变量指向的值,是否是内存地址,我把变量分为两种:

  • 普通变量:存数据值本身

  • 指针变量:存值的内存地址

1. 指针的创建

指针创建有三种方法

第一种方法

先定义对应的变量,再通过变量取得内存地址,创建指针

// 定义普通变量
aint := 1
// 定义指针变量
ptr := &aint

第二种方法

先创建指针,分配好内存后,再给指针指向的内存地址写入对应的值。

// 创建指针
astr := new(string)
// 给指针赋值
*astr = "Go编程时光"

第三种方法

先声明一个指针变量,再从其他变量取得内存地址赋值给它

aint := 1
var bint *int  // 声明一个指针
bint = &aint   // 初始化

上面的三段代码中,指针的操作都离不开这两个符号:

  • & :从一个普通变量中取得内存地址

  • *:当*在赋值操作值的右边,是从一个指针变量中取得变量值,当*在赋值操作值的左边,是指该指针指向的变量

通过下面这段代码,你可以熟悉这两个符号的用法

package main

import "fmt"

func main() {
    aint := 1     // 定义普通变量
    ptr := &aint  // 定义指针变量
    fmt.Println("普通变量存储的是:", aint)
    fmt.Println("普通变量存储的是:", *ptr)
    fmt.Println("指针变量存储的是:", &aint)
    fmt.Println("指针变量存储的是:", ptr)
}

输出如下

普通变量存储的是: 1
普通变量存储的是: 1
指针变量存储的是: 0xc0000100a0
指针变量存储的是: 0xc0000100a0

要想打印指针指向的内存地址,方法有两种

// 第一种
fmt.Printf("%p", ptr)

// 第二种
fmt.Println(ptr)

2. 指针的类型

我们知道字符串的类型是 string,整型是int,那么指针如何表示呢?

写段代码试验一下就知道了

package main

import "fmt"

func main() {
    astr := "hello"
    aint := 1
    abool := false
    arune := 'a'
    afloat := 1.2

    fmt.Printf("astr 指针类型是:%T\n", &astr)
    fmt.Printf("aint 指针类型是:%T\n", &aint)
    fmt.Printf("abool 指针类型是:%T\n", &abool)
    fmt.Printf("arune 指针类型是:%T\n", &arune)
    fmt.Printf("afloat 指针类型是:%T\n", &afloat)
}

输出如下,可以发现用 *+所指向变量值的数据类型,就是对应的指针类型。

astr 指针类型是:*string
aint 指针类型是:*int
abool 指针类型是:*bool
arune 指针类型是:*int32
afloat 指针类型是:*float64

所以若我们定义一个只接收指针类型的参数的函数,可以这么写

func mytest(ptr *int)  {
    fmt.Println(*ptr)
}

3. 指针的零值

当指针声明后,没有进行初始化,其零值是 nil。

func main() {
    a := 25
    var b *int  // 声明一个指针

    if b == nil {
        fmt.Println(b)
        b = &a  // 初始化:将a的内存地址给b
        fmt.Println(b)
    }
}

输出如下

<nil>
0xc0000100a0

4. 指针与切片

切片与指针一样,都是引用类型。

如果我们想通过一个函数改变一个数组的值,有两种方法

  1. 将这个数组的切片做为参数传给函数

  2. 将这个数组的指针做为参数传给函数

尽管二者都可以实现我们的目的,但是按照 Go 语言的使用习惯,建议使用第一种方法,因为第一种方法,写出来的代码会更加简洁,易读。具体你可以参数下面两种方法的代码实现

使用切片

func modify(sls []int) {
    sls[0] = 90
}

func main() {
    a := [3]int{89, 90, 91}
    modify(a[:])
    fmt.Println(a)
}

使用指针

func modify(arr *[3]int) {
    (*arr)[0] = 90
}

func main() {
    a := [3]int{89, 90, 91}
    modify(&a)
    fmt.Println(a)
}

image0

1.8 流程控制:if-else

1. 条件语句模型

Go里的流程控制方法还是挺丰富,整理了下有如下这么多种:

  • if - else 条件语句

  • switch - case 选择语句

  • for - range 循环语句

  • goto 无条件跳转语句

  • defer 延迟执行

今天先来讲讲 if-else 条件语句

Go 里的条件语句模型是这样的

if 条件 1 {
  分支 1
} else if 条件 2 {
  分支 2
} else if 条件 ... {
  分支 ...
} else {
  分支 else
}

Go编译器,对于 {} 的位置有严格的要求,它要求 else if (或 else)和 两边的花括号,必须在同一行。

由于 Go是 强类型,所以要求你条件表达式必须严格返回布尔型的数据(nil 和 0 和 1 都不行,具体可查看《详解数据类型:字典与布尔类型》)。

对于这个模型,分别举几个例子来看一下。

2. 单分支判断

只有一个 if ,没有 else

import "fmt"

func main() {
    age := 20
    if age > 18 {
        fmt.Println("已经成年了")
    }
}

如果条件里需要满足多个条件,可以使用 &&||

  • &&:表示且,左右都需要为true,最终结果才能为 true,否则为 false

  • ||:表示或,左右只要有一个为true,最终结果即为true,否则 为 false

import "fmt"

func main() {
    age := 20
    gender := "male"
    if (age > 18 && gender == "male") {
        fmt.Println("是成年男性")
    }
}

3. 多分支判断

if - else

import "fmt"

func main() {
    age := 20
    if age > 18 {
        fmt.Println("已经成年了")
    } else {
        fmt.Println("还未成年")
    }
}

if - else if - else

import "fmt"

func main() {
    age := 20
    if age > 18 {
        fmt.Println("已经成年了")
    } else if age >12 {
        fmt.Println("已经是青少年了")
    } else {
        fmt.Println("还不是青少年")
    }
}

4. 高级写法

在 if 里可以允许先运行一个表达式,取得变量后,再对其进行判断,比如第一个例子里代码也可以写成这样

import "fmt"

func main() {
    if age := 20;age > 18 {
        fmt.Println("已经成年了")
    }
}

image0

1.9 流程控制:switch-case

Go里的流程控制方法还是挺丰富,整理了下有如下这么多种:

  • if - else 条件语句

  • switch - case 选择语句

  • for - range 循环语句

  • goto 无条件跳转语句

  • defer 延迟执行

上一篇讲了 if -else 条件语句,今天先来讲讲 switch - case 选择语句。

0. 语句模型

Go 里的选择语句模型是这样的

switch 表达式 {
    case 表达式1:
        代码块
    case 表达式2:
        代码块
    case 表达式3:
        代码块
    case 表达式4:
        代码块
    case 表达式5:
        代码块
    default:
        代码块
}

拿 switch 后的表达式分别和 case 后的表达式进行对比,只要有一个 case 满足条件,就会执行对应的代码块,然后直接退出 switch - case ,如果 一个都没有满足,才会执行 default 的代码块。

1. 最简单的示例

switch 后接一个你要判断变量 education (学历),然后 case 会拿这个 变量去和它后面的表达式(可能是常量、变量、表达式等)进行判等。

如果相等,就执行相应的代码块。如果不相等,就接着下一个 case。

import "fmt"

func main() {
    education := "本科"

    switch education {
    case "博士":
        fmt.Println("我是博士")
    case "研究生":
        fmt.Println("我是研究生")
    case "本科":
        fmt.Println("我是本科生")
    case "大专":
        fmt.Println("我是大专生")
    case "高中":
        fmt.Println("我是高中生")
    default:
        fmt.Println("学历未达标..")
    }
}

输出如下

我是本科生

2. 一个 case 多个条件

case 后可以接多个多个条件,多个条件之间是 的关系,用逗号相隔。

import "fmt"

func main() {
    month := 2

    switch month {
    case 3, 4, 5:
        fmt.Println("春天")
    case 6, 7, 8:
        fmt.Println("夏天")
    case 9, 10, 11:
        fmt.Println("秋天")
    case 12, 1, 2:
        fmt.Println("冬天")
    default:
        fmt.Println("输入有误...")
    }
}

输出如下

冬天

3. case 条件常量不能重复

当 case 后接的是常量时,该常量只能出现一次。

以下两种情况,在编译时,都会报错: duplicate case “male” in switch

错误案例一

gender := "male"

switch gender {
    case "male":
        fmt.Println("男性")
    // 与上面重复
    case "male":
        fmt.Println("男性")
    case "female":
        fmt.Println("女性")
}

错误案例二

gender := "male"

switch gender {
    case "male", "male":
        fmt.Println("男性")
    case "female":
        fmt.Println("女性")
}

4. switch 后可接函数

switch 后面可以接一个函数,只要保证 case 后的值类型与函数的返回值 一致即可。

import "fmt"

// 判断一个同学是否有挂科记录的函数
// 返回值是布尔类型
func getResult(args ...int) bool {
    for _, i := range args {
        if i < 60 {
            return false
        }
    }
    return true
}

func main() {
    chinese := 80
    english := 50
    math := 100

    switch getResult(chinese, english, math) {
    // case 后也必须 是布尔类型
    case true:
        fmt.Println("该同学所有成绩都合格")
    case false:
        fmt.Println("该同学有挂科记录")
    }
}

5. switch 可不接表达式

switch 后可以不接任何变量、表达式、函数。

当不接任何东西时,switch - case 就相当于 if - elseif - else

score := 30

switch {
    case score >= 95 && score <= 100:
        fmt.Println("优秀")
    case score >= 80:
        fmt.Println("良好")
    case score >= 60:
        fmt.Println("合格")
    case score >= 0:
        fmt.Println("不合格")
    default:
        fmt.Println("输入有误...")
}

6. switch 的穿透能力

正常情况下 switch - case 的执行顺序是:只要有一个 case 满足条件,就会直接退出 switch - case ,如果 一个都没有满足,才会执行 default 的代码块。

但是有一种情况是例外。

那就是当 case 使用关键字 fallthrough 开启穿透能力的时候。

s := "hello"
switch {
case s == "hello":
    fmt.Println("hello")
    fallthrough
case s != "world":
    fmt.Println("world")
}

代码输出如下:

hello
world

需要注意的是,fallthrough 只能穿透一层,意思是它只给你一次再判断case的机会,不管你有没有匹配上,都要退出了。

s := "hello"
switch {
case s == "hello":
    fmt.Println("hello")
    fallthrough
case s == "xxxx":
    fmt.Println("xxxx")
case s != "world":
    fmt.Println("world")
}

输出如下,并不会输出 world(即使它符合条件)

hello
xxxx

image0

1.10 流程控制:for 循环

Go里的流程控制方法还是挺丰富,整理了下有如下这么多种:

  • if - else 条件语句

  • switch - case 选择语句

  • for - range 循环语句

  • goto 无条件跳转语句

  • defer 延迟执行

上一篇讲了switch - case 选择语句,今天先来讲讲 for 循环语句。

0. 语句模型

这是 for 循环的基本模型。

for [condition |  ( init; condition; increment ) | Range]
{
   statement(s);
}

可以看到 for 后面,可以接三种类型的表达式。

  1. 接一个条件表达式

  2. 接三个表达式

  3. 接一个 range 表达式

但其实还有第四种

  1. 不接表达式

1. 接一个条件表达式

这个例子会打印 1 到 5 的数值。

a := 1
for a <= 5 {
    fmt.Println(a)
    a ++
}

输出如下

1
2
3
4
5

2. 接三个表达式

for 后面,紧接着三个表达式,使用 ; 分隔。

这三个表达式,各有各的用途

  • 第一个表达式:初始化控制变量,在整个循环生命周期内,只运行一次;

  • 第二个表达式:设置循环控制条件,当返回true,继续循环,返回false,结束循环;

  • 第三个表达式:每次循完开始(除第一次)时,给控制变量增量或减量。

这边的例子和上面的例子,是等价的。

import "fmt"

func main() {
    for i := 1; i <= 5; i++ {
        fmt.Println(i)
    }
}

输出如下

1
2
3
4
5

2. 不接表达式:无限循环

在 Go 语言中,没有 while 循环,如果要实现无限循环,也完全可以 for 来实现。

当你不加任何的判断条件时, 就相当于你每次的判断都为 true,程序就会一直处于运行状态,但是一般我们并不会让程序处于死循环,在满足一定的条件下,可以使用关键字 break 退出循环体,也可以使用 continue 直接跳到下一循环。

下面两种写法都是无限循环的写法。

for {
    代码块
}

// 等价于
for ;; {
    代码块
}

举个例子

import "fmt"

func main() {
    var i int = 1
    for {
        if i > 5 {
            break
        }
        fmt.Printf("hello, %d\n", i)
        i++
    }
}

输出如下

hello, 1
hello, 2
hello, 3
hello, 4
hello, 5

3. 接 for-range 语句

遍历一个可迭代对象,是一个很常用的操作。在 Go 可以使用 for-range 的方式来实现。

range 后可接数组、切片,字符串等

由于 range 会返回两个值:索引和数据,若你后面的代码用不到索引,需要使用 _ 表示 。

import "fmt"

func main() {
    myarr := [...]string{"world", "python", "go"}
    for _, item := range myarr {
        fmt.Printf("hello, %s\n", item)
    }
}

输出如下

hello, world
hello, python
hello, go

image0

1.11 流程控制:goto 无条件跳转

Go里的流程控制方法还是挺丰富,整理了下有如下这么多种:

  • if - else 条件语句

  • switch - case 选择语句

  • for - range 循环语句

  • goto 无条件跳转语句

  • defer 延迟执行

前面三种,我已经都讲过了,今天要讲讲 goto 的无条件跳转。

很难想象在 Go 居然会保留 goto,因为很多人不建议使用 goto,所以在一些编程语言中甚至直接取消了 goto。

我感觉 Go 既然保留,一定有人家的理由,只是我目前还没感受到。不管怎样,咱还是照常学习吧。

0. 基本模型

goto 顾言思义,是跳转的意思。

goto 后接一个标签,这个标签的意义是告诉 Go程序下一步要执行哪里的代码。

所以这个标签如何放置,放置在哪里,是 goto 里最需要注意的。

goto 标签;
...
...
标签: 表达式;

1. 最简单的示例

goto 可以打破原有代码执行顺序,直接跳转到某一行执行代码。

import "fmt"

func main() {

    goto flag
    fmt.Println("B")
flag:
    fmt.Println("A")

}

执行结果,并不会输出 B ,而只会输出 A

A

2. 如何使用?

goto 语句通常与条件语句配合使用。可用来实现条件转移, 构成循环,跳出循环体等功能。

这边举一个例子,用 goto 的方式来实现一个打印 1到5 的循环。

import "fmt"

func main() {
    i := 1
flag:
    if i <= 5 {
        fmt.Println(i)
        i++
        goto flag
    }
}

输出如下

1
2
3
4
5

再举个例子,使用 goto 实现 类型 break 的效果。

import "fmt"

func main() {
    i := 1
    for {
        if i > 5 {
            goto flag
        }
        fmt.Println(i)
        i++
    }
flag:
}

输出如下

1
2
3
4
5

最后再举个例子,使用 goto 实现 类型 continue的效果,打印 1到10 的所有偶数。

import "fmt"

func main() {
    i := 1
flag:
    for i <= 10 {
        if i%2 == 1 {
            i++
            goto flag
        }
        fmt.Println(i)
        i++
    }
}

输出如下

2
4
6
8
10

3. 注意事项

goto语句与标签之间不能有变量声明,否则编译错误。

import "fmt"

func main() {
    fmt.Println("start")
    goto flag
    var say = "hello oldboy"
    fmt.Println(say)
flag:
    fmt.Println("end")
}

编译错误

.\main.go:7:7: goto flag jumps over declaration of say at .\main.go:8:6

image0

1.12 流程控制:defer 延迟语句

Go里的流程控制方法还是挺丰富,整理了下有如下这么多种:

  • if - else 条件语句

  • switch - case 选择语句

  • for - range 循环语句

  • goto 无条件跳转语句

  • defer 延迟执行

今天是最后一篇讲控制流程了,内容是 defer 延迟语句,这个在其他编程语言里好像没有见到。应该是属于 Go 语言里的独有的关键字,但即使如此,阅读后这篇文章后,你可以发现 defer 在其他编程语言里的影子。

1. 延迟调用

defer 的用法很简单,只要在后面跟一个函数的调用,就能实现将这个 xxx 函数的调用延迟到当前函数执行完后再执行。

defer xxx()

这是一个很简单的例子,可以很快帮助你理解 defer 的使用效果。

import "fmt"

func myfunc() {
    fmt.Println("B")
}

func main() {
    defer myfunc()
    fmt.Println("A")
}

输出如下

A
B

当然了,对于上面这个例子可以简写为成如下,输出结果是一致的

import "fmt"

func main() {
    defer fmt.Println("B")
    fmt.Println("A")
}

2. 即时求值的变量快照

使用 defer 只是延时调用函数,此时传递给函数里的变量,不应该受到后续程序的影响。

比如这边的例子

import "fmt"

func main() {
    name := "go"
    defer fmt.Println(name) // 输出: go

    name = "python"
    fmt.Println(name)      // 输出: python
}

输出如下,可见给 name 重新赋值为 python,后续调用 defer 的时候,仍然使用未重新赋值的变量值,就好在 defer 这里,给所有的这是做了一个快照一样。

python
go

3. 多个defer 反序调用

当我们在一个函数里使用了 多个defer,那么这些defer 的执行函数是如何的呢?

做个试验就知道了

import "fmt"

func main() {
    name := "go"
    defer fmt.Println(name) // 输出: go

    name = "python"
    defer fmt.Println(name) // 输出: python

    name = "java"
    fmt.Println(name)
}

输出如下,可见 多个defer 是反序调用的,有点类似栈一样,后进先出。

java
python
go

3. defer 与 return 孰先孰后

至此,defer 还算是挺好理解的。在一般的使用上,是没有问题了。

在这里提一个稍微复杂一点的问题,defer 和 return 到底是哪个先调用?

使用下面这段代码,可以很容易的观察出来

import "fmt"

var name string = "go"

func myfunc() string {
    defer func() {
        name = "python"
    }()

    fmt.Printf("myfunc 函数里的name:%s\n", name)
    return name
}

func main() {
    myname := myfunc()
    fmt.Printf("main 函数里的name: %s\n", name)
    fmt.Println("main 函数里的myname: ", myname)
}

输出如下

myfunc 函数里的name:go
main 函数里的name: python
main 函数里的myname:  go

来一起理解一下这段代码,第一行很直观,name 此时还是全局变量,值还是go

第二行也不难理解,在 defer 里改变了这个全局变量,此时name的值已经变成了 python

重点在第三行,为什么输出的是 go ?

解释只有一个,那就是 defer 是return 后才调用的。所以在执行 defer 前,myname 已经被赋值成 go 了。

4. 为什么要有 defer?

看完上面的例子后,不知道你是否和我一样,对这个defer的使用效果感到熟悉?貌似在 Python 也见过类似的用法。

虽然 Python 中没有 defer ,但是它有 with 上下文管理器。我们知道在 Python 中可以使用 defer 实现对资源的管理。最常用的例子就是文件的打开关闭。

你可能会有疑问,这也没什么意义呀,我把这个放在 defer 执行的函数放在 return 那里执行不就好了。

固然可以,但是当一个函数里有多个 return 时,你得多调用好多次这个函数,代码就臃肿起来了。

若是没有 defer,你可以写出这样的代码

func f() {
    r := getResource()  //0,获取资源
    ......
    if ... {
        r.release()  //1,释放资源
        return
    }
    ......
    if ... {
        r.release()  //2,释放资源
        return
    }
    ......
    if ... {
        r.release()  //3,释放资源
        return
    }
    ......
    r.release()     //4,释放资源
    return
}

使用了 defer 后,代码就显得简单直接,不管你在何处 return,都会执行 defer 后的函数。

func f() {
    r := getResource()  //0,获取资源

    defer r.release()  //1,释放资源
    ......
    if ... {
        ...
        return
    }
    ......
    if ... {
        ...
        return
    }
    ......
    if ... {
        ...
        return
    }
    ......
    return
}

image0

1.13 异常机制:panic 和 recover

编程语言一般都会有异常捕获机制,在 Python 中 是使用raisetry-except 语句来实现的异常抛出和异常捕获的。

在 Golang 中,有不少常规错误,在编译阶段就能提前告警,比如语法错误或类型错误等,但是有些错误仅能在程序运行后才能发生,比如数组访问越界、空指针引用等,这些运行时错误会引起程序退出。

当然能触发程序宕机退出的,也可以是我们自己,比如经过检查判断,当前环境无法达到我们程序进行的预期条件时(比如一个服务指定监听端口被其他程序占用),可以手动触发 panic,让程序退出停止运行。

1. 触发panic

手动触发宕机,是非常简单的一件事,只需要调用 panic 这个内置函数即可,就像这样子

package main

func main() {
    panic("crash")
}

运行后,直接报错宕机

$ go run main.go
go run main.go
panic: crash

goroutine 1 [running]:
main.main()
        E:/Go-Code/main.go:4 +0x40
exit status 2

2. 捕获 panic

发生了异常,有时候就得捕获,就像 Python 中的except 一样,那 Golang 中是如何做到的呢?

这就不得不引出另外一个内建函数 – recover,它可以让程序在发生宕机后起生回生。

但是 recover 的使用,有一个条件,就是它必须在 defer 函数中才能生效,其他作用域下,它是不工作的。

这是一个简单的例子

import "fmt"

func set_data(x int) {
    defer func() {
        // recover() 可以将捕获到的panic信息打印
        if err := recover(); err != nil {
            fmt.Println(err)
        }
    }()

    // 故意制造数组越界,触发 panic
    var arr [10]int
    arr[x] = 88
}

func main() {
    set_data(20)

    // 如果能执行到这句,说明panic被捕获了
    // 后续的程序能继续运行
    fmt.Println("everything is ok")
}

运行后,输出如下

$ go run main.go
runtime error: index out of range [20] with length 10
everything is ok

通常来说,不应该对进入 panic 宕机的程序做任何处理,但有时,需要我们可以从宕机中恢复,至少我们可以在程序崩溃前,做一些操作,举个例子,当 web 服务器遇到不可预料的严重问题时,在崩溃前应该将所有的连接关闭,如果不做任何处理,会使得客户端一直处于等待状态,如果 web 服务器还在开发阶段,服务器甚至可以将异常信息反馈到客户端,帮助调试。

3. 无法跨协程

从上面的例子,可以看到,即使 panic 会导致整个程序退出,但在退出前,若有 defer 延迟函数,还是得执行完 defer 。

但是这个 defer 在多个协程之间是没有效果,在子协程里触发 panic,只能触发自己协程内的 defer,而不能调用 main 协程里的 defer 函数的。

来做个实验就知道了

import (
    "fmt"
    "time"
)

func main() {
    // 这个 defer 并不会执行
    defer fmt.Println("in main")

    go func() {
        defer println("in goroutine")
        panic("")
    }()

    time.Sleep(2 * time.Second)
}

输出如下

in goroutine
panic:

goroutine 6 [running]:
main.main.func1()
        E:/Go-Code/main.go:12 +0x7b
created by main.main
        E:/Go-Code/main.go:10 +0xbc
exit status 2

4. 总结一下

Golang 异常的抛出与捕获,依赖两个内置函数:

  • panic:抛出异常,使程序崩溃

  • recover:捕获异常,恢复程序或做收尾工作

revocer 调用后,抛出的 panic 将会在此处终结,不会再外抛,但是 recover,并不能任意使用,它有强制要求,必须得在 defer 下才能发挥用途。


image0

1.14 Go 语言中的类型断言?

Type Assertion

Type Assertion(中文名叫:类型断言),通过它可以做到以下几件事情

  1. 检查 i 是否为 nil

  2. 检查 i 存储的值是否为某个类型

具体的使用方式有两种:

第一种:

t := i.(T)

这个表达式可以断言一个接口对象(i)里不是 nil,并且接口对象(i)存储的值的类型是 T,如果断言成功,就会返回值给 t,如果断言失败,就会触发 panic。

来写段代码试验一下

package main

import "fmt"

func main() {
    var i interface{} = 10
    t1 := i.(int)
    fmt.Println(t1)

    fmt.Println("=====分隔线=====")

    t2 := i.(string)
    fmt.Println(t2)
}

运行后输出如下,可以发现在执行第二次断言的时候失败了,并且触发了 panic

10
=====分隔线=====
panic: interface conversion: interface {} is int, not string

goroutine 1 [running]:
main.main()
        E:/GoPlayer/src/main.go:12 +0x10e
exit status 2

如果要断言的接口值是 nil,那我们来看看也是不是也如预期一样会触发panic

package main

func main() {
    var i interface{} // nil
    var _ = i.(interface{})
}

输出如下,确实是会 触发 panic

panic: interface conversion: interface is nil, not interface {}

goroutine 1 [running]:
main.main()
        E:/GoPlayer/src/main.go:5 +0x34
exit status 2

第二种

t, ok:= i.(T)

和上面一样,这个表达式也是可以断言一个接口对象(i)里不是 nil,并且接口对象(i)存储的值的类型是 T,如果断言成功,就会返回其类型给 t,并且此时 ok 的值 为 true,表示断言成功。

如果接口值的类型,并不是我们所断言的 T,就会断言失败,但和第一种表达式不同的事,这个不会触发 panic,而是将 ok 的值设为 false ,表示断言失败,此时t 为 T 的零值。

稍微修改下上面的例子,如下

package main

import "fmt"

func main() {
    var i interface{} = 10
    t1, ok := i.(int)
    fmt.Printf("%d-%t\n", t1, ok)

    fmt.Println("=====分隔线1=====")

    t2, ok := i.(string)
    fmt.Printf("%s-%t\n", t2, ok)

    fmt.Println("=====分隔线2=====")

    var k interface{} // nil
    t3, ok := k.(interface{})
    fmt.Println(t3, "-", ok)

    fmt.Println("=====分隔线3=====")
    k = 10
    t4, ok := k.(interface{})
    fmt.Printf("%d-%t\n", t4, ok)

    t5, ok := k.(int)
    fmt.Printf("%d-%t\n", t5, ok)
}

运行后输出如下,可以发现在执行第二次断言的时候,虽然失败了,但并没有触发了 panic。

10-true
=====分隔线1=====
-false
=====分隔线2=====
<nil> - false
=====分隔线3=====
10-true
10-true

上面这段输出,你要注意的是第二个断言的输出在-false 之前并不是有没有输出任何 t2 的值,而是由于断言失败,所以 t2 得到的是 string 的零值也是 "" ,它是零长度的,所以你看不到其输出。

Type Switch

如果需要区分多种类型,可以使用 type switch 断言,这个将会比一个一个进行类型断言更简单、直接、高效。

package main

import "fmt"

func findType(i interface{}) {
    switch x := i.(type) {
    case int:
        fmt.Println(x, "is int")
    case string:
        fmt.Println(x, "is string")
    case nil:
        fmt.Println(x, "is nil")
    default:
        fmt.Println(x, "not type matched")
    }
}

func main() {
    findType(10)      // int
    findType("hello") // string

    var k interface{} // nil
    findType(k)

    findType(10.23) //float64
}

输出如下

10 is int
hello is string
<nil> is nil
10.23 not type matched

额外说明一下:

  • 如果你的值是 nil,那么匹配的是 case nil

  • 如果你的值在 switch-case 里并没有匹配对应的类型,那么走的是 default 分支

1.15 这五点带你理解 select 用法

前面写过两节关于 switch-case 的文章,分别是:

流程控制:switch-case

Go 语言中的类型断言

今天要学习一个跟 switch-case 很像,但还有点个人特色select-case,这一节本应该放在 学习 Go 协程:详解信道/通道 里一起讲的,但是当时漏了,直到有读者给我提出,才注意到,今天就用这篇文章补充一下。

跟 switch-case 相比,select-case 用法比较单一,它仅能用于 信道/通道 的相关操作。

select {
    case 表达式1:
        <code>
    case 表达式2:
        <code>
  default:
    <code>
}

接下来,我们来看几个例子帮助理解这个 select 的模型。

1. 最简单的例子

先创建两个信道,并在 select 前往 c2 发送数据

package main

import (
    "fmt"
)

func main() {
    c1 := make(chan string, 1)
    c2 := make(chan string, 1)

    c2 <- "hello"

    select {
    case msg1 := <-c1:
      fmt.Println("c1 received: ", msg1)
    case msg2 := <-c2:
      fmt.Println("c2 received: ", msg2)
    default:
      fmt.Println("No data received.")
    }
}

在运行 select 时,会遍历所有(如果有机会的话)的 case 表达式,只要有一个信道有接收到数据,那么 select 就结束,所以输出如下

c2 received:  hello

2. 避免造成死锁

select 在执行过程中,必须命中其中的某一分支。

如果在遍历完所有的 case 后,若没有命中(命中:也许这样描述不太准确,我本意是想说可以执行信道的操作语句)任何一个 case 表达式,就会进入 default 里的代码分支。

但如果你没有写 default 分支,select 就会阻塞,直到有某个 case 可以命中,而如果一直没有命中,select 就会抛出 deadlock 的错误,就像下面这样子。

package main

import (
    "fmt"
)

func main() {
    c1 := make(chan string, 1)
    c2 := make(chan string, 1)

    // c2 <- "hello"

    select {
    case msg1 := <-c1:
        fmt.Println("c1 received: ", msg1)
    case msg2 := <-c2:
        fmt.Println("c2 received: ", msg2)
        // default:
        //  fmt.Println("No data received.")
    }
}

运行后输出如下

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [select]:
main.main()
        /Users/MING/GolandProjects/golang-test/main.go:13 +0x10f
exit status 2

解决这个问题的方法有两种

一个是,养成好习惯,在 select 的时候,也写好 default 分支代码,尽管你 default 下没有写任何代码。

package main

import (
    "fmt"
)

func main() {
    c1 := make(chan string, 1)
    c2 := make(chan string, 1)

  // c2 <- "hello"

    select {
    case msg1 := <-c1:
        fmt.Println("c1 received: ", msg1)
    case msg2 := <-c2:
        fmt.Println("c2 received: ", msg2)
    default:

    }
}

另一个是,让其中某一个信道可以接收到数据

package main

import (
    "fmt"
    "time"
)

func main() {
    c1 := make(chan string, 1)
    c2 := make(chan string, 1)

  // 开启一个协程,可以发送数据到信道
    go func() {
        time.Sleep(time.Second * 1)
        c2 <- "hello"
    }()

    select {
    case msg1 := <-c1:
        fmt.Println("c1 received: ", msg1)
    case msg2 := <-c2:
        fmt.Println("c2 received: ", msg2)
    }
}

3. select 随机性

之前学过 switch 的时候,知道了 switch 里的 case 是顺序执行的,但在 select 里却不是。

通过下面这个例子的执行结果就可以看出

image0

4. select 的超时

当 case 里的信道始终没有接收到数据时,而且也没有 default 语句时,select 整体就会阻塞,但是有时我们并不希望 select 一直阻塞下去,这时候就可以手动设置一个超时时间。

package main

import (
    "fmt"
    "time"
)

func makeTimeout(ch chan bool, t int) {
    time.Sleep(time.Second * time.Duration(t))
    ch <- true
}

func main() {
    c1 := make(chan string, 1)
    c2 := make(chan string, 1)
    timeout := make(chan bool, 1)

    go makeTimeout(timeout, 2)

    select {
    case msg1 := <-c1:
        fmt.Println("c1 received: ", msg1)
    case msg2 := <-c2:
        fmt.Println("c2 received: ", msg2)
    case <-timeout:
        fmt.Println("Timeout, exit.")
    }
}

输出如下

Timeout, exit.

5. 读取/写入都可以

上面例子里的 case,好像都只从信道中读取数据,但实际上,select 里的 case 表达式只要求你是对信道的操作即可,不管你是往信道写入数据,还是从信道读出数据。

package main

import (
    "fmt"
)

func main() {
    c1 := make(chan int, 2)

    c1 <- 2
    select {
    case c1 <- 4:
        fmt.Println("c1 received: ", <-c1)
        fmt.Println("c1 received: ", <-c1)
    default:
        fmt.Println("channel blocking")
    }
}

输出如下

c1 received:  2
c1 received:  4

6. 总结一下

select 与 switch 原理很相似,但它的使用场景更特殊,学习了本篇文章,你需要知道如下几点区别:

  1. select 只能用于 channel 的操作(写入/读出),而 switch 则更通用一些;

  2. select 的 case 是随机的,而 switch 里的 case 是顺序执行;

  3. select 要注意避免出现死锁,同时也可以自行实现超时机制;

  4. select 里没有类似 switch 里的 fallthrough 的用法;

  5. select 不能像 switch 一样接函数或其他表达式。

image1

1.16 深入理解Go语言中里反射

反射是指一类应用,它们能够自描述和自控制。也就是说,这类应用通过采用某种机制来实现对自己行为的描述(self-representation)和监测(examination),并能根据自身行为的状态和结果,调整或修改应用所描述行为的状态和相关的语义。

每种语言的反射模型都不同,并且有些语言根本不支持反射。Golang语言实现了反射,反射机制就是在运行时动态的调用对象的方法和属性,官方自带的reflect包就是反射相关的,只要包含这个包就可以使用。

什么是反射?

在 wiki 百科上,关于反射的定义是这样的

image-20200405172350326

image-20200405172350326

说实话,这段定义对于新人来说还是有点难以理解。

Golang 是静态语言,很多变量的类型在编译时,就能确定。但是有一些却不行,它们的类型和值只有在运行时(runtime)才能知道。

这里从 「Go 系列教程 ——第 34 篇:反射」摘一个例子过来

优点

支持反射的语言提供了一些在早期高级语言中难以实现的运行时特性。

  • 可以在一定程度上避免硬编码,提供灵活性和通用性。

  • 可以作为一个第一类对象发现并修改源代码的结构(如代码块、类、方法、协议等)。

  • 可以在运行时像对待源代码语句一样动态解析字符串中可执行的代码(类似JavaScript的eval()函数),进而可将跟class或function匹配的字符串转换成class或function的调用或引用。

  • 可以创建一个新的语言字节码解释器来给编程结构一个新的意义或用途。

劣势
  • 此技术的学习成本高。面向反射的编程需要较多的高级知识,包括框架、关系映射和对象交互,以实现更通用的代码执行。

  • 同样因为反射的概念和语法都比较抽象,过多地滥用反射技术会使得代码难以被其他人读懂,不利于合作与交流。

  • 由于将部分信息检查工作从编译期推迟到了运行期,此举在提高了代码灵活性的同时,牺牲了一点点运行效率。

通过深入学习反射的特性和技巧,它的劣势可以尽量避免,但这需要许多时间和经验的积累。

1. 静态类型和动态类型

静态类型(也即 static type)是你在编码是看见的类型(如int、string),如

var age int   // int 是静态类型

type MyInt int  // int 是静态类型

name := "Go编程时光"   // string 是静态类型

动态类型(也即 concrete type)是 runtime 系统才能看见的类型,主要和 interface 有关,如

var i interface{}   // 静态类型就是 interface{}

i = 18  // 静态类型为interface{}  动态为int
i = "Go编程时光"  // 静态类型为interface{}  动态为string

2. Type 与 Value

在 Go 语言中,每个接口变量都有一个对应的 pair,pair 中记录着实际变量的值和类型。

(value, type)

比如 var age : 25 ,value 就是25,type 就是 int

pair 的存在,是Golang中实现反射的前提,理解了pair,就更容易理解反射。

若要从一个接口值里取得 value 和 type,那就不得不说到 reflect 这个内置包。

这个包有两个方法:

  1. reflect.TypeOf(i) :获得接口值的类型

  2. Reflect.ValueOf(i):获得接口值的值

这两个方法返回的对象,我们称之为反射对象:Type object 和 Value object。

// TypeOf returns the reflection Type of the value in the interface{}.TypeOf returns nil.
func TypeOf(i interface{}) Type

// ValueOf returns a new Value initialized to the concrete value stored in the interface i. ValueOf(nil) returns the zero Value.
func ValueOf(i interface{}) Value

举个例子,看下这两个方法是如何使用的?

package main

import (
    "fmt"
    "reflect"
)

func main() {
    var x interface{}
    x = 3.4

    fmt.Println("type:",reflect.TypeOf(x))
    fmt.Println("value:",reflect.ValueOf(x))

    x = "Go编程时光"
    fmt.Println("type:",reflect.TypeOf(x))
    fmt.Println("value:",reflect.ValueOf(x))
}

输出如下

type: float64
value: 3.4
type: string
value: Go编程时光

3. Kind 与 Type

reflect 包中还有一个重要的类型:Kind

在反射包中,KindType 的类型可能看起来很相似,但在下面代码中,可以很清楚地看出它们的不同之处。

package main

import (
    "fmt"
    "reflect"
)

type profile struct {
    name string
    age int
    gender string
}

func main() {
    //反射操作:通过反射,可以获取一个接口类型变量的 类型和数值
    m := profile{
        name: "wangbm",
        age: 27,
        gender: "male",
    }

    fmt.Println("type:",reflect.TypeOf(m))
    fmt.Println("kind:",reflect.TypeOf(m).Kind())
}

输出如下

type: main.profile
kind: struct

相较于 Type 而言,Kind 所表示的范畴更大。

Kind 是类别,而 Type 是类型,用一个例子来生动描述来类比:Kind 是电子产品,而 Type 是手机。

对于 Kind 的获取,你可以通过 Type ,也可以通过 Value。

reflect.TypeOf(m).Kind()
reflect.ValueOf(m).Kind()

Kind 有哪些呢?

type Kind uint

const (
    Invalid Kind = iota
    Bool
    Int
    Int8
    Int16
    Int32
    Int64
    Uint
    Uint8
    Uint16
    Uint32
    Uint64
    Uintptr
    Float32
    Float64
    Complex64
    Complex128
    Array
    Chan
    Func
    Interface
    Map
    Ptr
    Slice
    String
    Struct
    UnsafePointer
)

4. reflect 其他方法

上面说的 Kind 只是 Type 和 Value 众多方法中的一个,其他的方法还有很多,比如 Int 、String

5. 使用场景

1、动态调用函数(无参数)

package main

import (
    "fmt"
    "reflect"
)

type T struct {}

func main() {
    name := "Do"
    t := &T{}
    reflect.ValueOf(t).MethodByName(name).Call(nil)
}

func (t *T) Do() {
    fmt.Println("hello")
}

2、动态调用函数(有参数)

package main

import (
    "fmt"
    "reflect"
)

type T struct{}

func main() {
    name := "Do"
    t := &T{}
    a := reflect.ValueOf(1111)
    b := reflect.ValueOf("world")
    in := []reflect.Value{a, b}
    reflect.ValueOf(t).MethodByName(name).Call(in)
}

func (t *T) Do(a int, b string) {
    fmt.Println("hello" + b, a)
}

反射的定律三

只有当值是可设置时,才可修改反射对象。

Value 类型有一个方法,叫 CanSet()

通过它,能知道这个 Value 可否设置一个新的值

package main

import (
    "fmt"
    "reflect"
)

func main() {
    var x float64 = 3.4
    v := reflect.ValueOf(x)
    fmt.Println("settability of v:", v.CanSet())
}

输出如下,false 表示,不可设置

settability of v: false

这时,如果你强行设置新值

package main

import (
    "reflect"
)


func main() {
    var x float64 = 3.4
    v := reflect.ValueOf(x)
    v.SetFloat(6.8)
}

就会触发 panic

panic: reflect: reflect.flag.mustBeAssignable using unaddressable value

goroutine 1 [running]:
reflect.flag.mustBeAssignableSlow(0x8e)
        /usr/local/go/src/reflect/value.go:247 +0x138
reflect.flag.mustBeAssignable(...)
        /usr/local/go/src/reflect/value.go:234
reflect.Value.SetFloat(0x1082e20, 0xc00001a068, 0x8e, 0x401b333333333333)
        /usr/local/go/src/reflect/value.go:1587 +0x37
main.main()
        /Users/MING/GolandProjects/golang-test/main.go:11 +0xb3

Process finished with exit code 2

为什么会这样呢?

因为当我们使用 reflect.ValueOf(x) 传递进去的实际是 x 的副本,而不是 x 本身,只有将参数改为指针类型,才是传递 x 本身。

因此可以把上面的代码修改一下

package main

import (
    "fmt"
    "reflect"
)


func main() {
    var x float64 = 3.4
    p := reflect.ValueOf(&x)
    v := p.Elem()
    v.SetFloat(6.8)
    fmt.Println("settability of v:", v.CanSet())
    fmt.Println("value of x:", x)
    fmt.Println("value of v:", v)
}

输出如下

settability of v: true
value of x: 6.8
value of v: 6.8

几点说明

  1. 有 reflect 的代码一般都较难理解,使用时请注意适当。

  2. Golang 的反射很慢,这个和它的 API 设计有关

  3. 反射是一个高级知识点,内容很多,不容易掌握,应该小心谨慎的使用它

  4. 不到不得不用的地步,能避免使用反射就不用。

1.17 如何使用 GDB 调试 Go 程序?

做为新手,熟练掌握一个好的调试工具,对于我们学习语言或者排查问题的时候,非常有帮助。

你如果使用 VS Code 或者 Goland ,可以直接上手,我就不再写这方面的文章了。

其实相比有用户界面的 IDE 调试工具,我更喜欢简单直接的命令行调试,原因有三点:

  1. 速度快,个人感觉在 Windows 下速度巨慢

  2. 依赖少,在 Linux 服务器上 也能轻松调试

  3. 指令简单,我习惯只使用快捷键就能操作

如果你有和我一样的感受和习惯,可以看下今天的文章,介绍的是 GDB 调试工具。

1. 下载安装 Go

在 Linux 上进行调试,那咱所以得先安装 Go ,由于第一节里只讲了 Windows 的下载安装,并没有讲到在 Linux 上如何安装。所以这里要先讲一下,已经安装过了可以直接跳过。

首先在 go 下载页面上(https://golang.org/dl/),查看并复制源码包的的下载地址

image0

登陆 linux 机器 ,使用 wget 下载

$ wget https://dl.google.com/go/go1.14.2.linux-amd64.tar.gz

image1

将下载的源码包解压到 /usr/local 目录下,并设置环境变量

[root@localhost ~]# tar -C /usr/local -xzf go1.14.2.linux-amd64.tar.gz
[root@localhost ~]#
[root@localhost ~]# export PATH=$PATH:/usr/local/go/bin
[root@localhost ~]# which go
/usr/local/go/bin/go
[root@localhost ~]#
[root@localhost ~]# go version
go version go1.14.2 linux/amd64
[root@localhost ~]#

2. 开始进行调试

调试使用的是 GDB (好像要求版本 7.1 + ),使用前,请先确保你的机器上已经安装 GDB

[root@localhost code]# which gdb
/usr/bin/gdb

准备就绪后,先在目录下写一个测试文件

package main

import "fmt"

func main(){
  msg := "hello, world"
  fmt.Println(msg)
}

然后执行 如下命令进行编译,里面有好多个参数,有疑问的可以自行搜索引擎

# 关闭内联优化,方便调试
$ go build -gcflags "-N -l" demo.go

# 发布版本删除调试符号
go build -ldflags “-s -w”

最后使用 GDB 命令进入调试界面

# 如果你喜欢这种界面的话,用这条命令
$ gdb -tui demo

# 如果你跟我一样不喜欢不习惯用界面,就使用这个命令
$ gdb demo

完整操作如下:

image2

进入 GDB 调试界面后,并不是立即可用,你先需要回车,然后再你敲入几行命令,调试窗口就会出现代码。

(gdb) b main.main   # 在 main 包里的 main 函数 加断点
Breakpoint 1 at 0x4915c0: file /home/wangbm/code/demo.go, line 5.
(gdb) run  # 执行进程
Starting program: /home/wangbm/code/demo
Breakpoint 1, main.main () at /home/wangbm/code/demo.go:5
(gdb)

image3

3. 详解调试指令

要熟练使用 GDB ,你得熟悉的掌握它的指令,这里列举一下

  • r:run,执行程序

  • n:next,下一步,不进入函数

  • s:step,下一步,会进入函数

  • b:breakponit,设置断点

  • l:list,查看源码

  • c:continue,继续执行到下一断点

  • bt:backtrace,查看当前调用栈

  • p:print,打印查看变量

  • q:quit,退出 GDB

  • whatis:查看对象类型

  • info breakpoints:查看所有的断点

  • info locals:查看局部变量

  • info args:查看函数的参数值及要返回的变量值

  • info frame:堆栈帧信息

  • info goroutines:查看 goroutines 信息。在使用前 ,需要注意先执行 source /usr/local/go/src/runtime/runtime-gdb.py

  • goroutine 1 bt:查看指定序号的 goroutine 调用堆栈

  • 回车:重复执行上一次操作

其中有几个指令的使用比较灵活

比如 l - list,查看代码

# 查看指定行数上下5行
(gdb) l 8

# 查看指定范围的行数
(gdb) l 5:8

# 查看指定文件的行数上下5行
l demo.go:8

# 可以查看函数,记得加包名
l main.main

把上面的 l 换成 b ,大多数也同样适用

# 在指定行打断点
(gdb) b 8


# 在指定指定文件的行打断点
b demo.go:8

# 在指定函数打断点,记得加包名
b main.main

还有 p - print,打印变量

# 查看变量
(gdb) p var

# 查看对象长度或容量
(gdb) p $len(var)
(gdb) p $cap(var)

# 查看对象的动态类型
(gdb) p $dtype(var)
(gdb) iface var

# 举例如下
(gdb) p i
$4 = {str = "cbb"}
(gdb) whatis i
type = regexp.input
(gdb) p $dtype(i)
$26 = (struct regexp.inputBytes *) 0xf8400b4930
(gdb) iface i
regexp.input: struct regexp.inputBytes *

以上就是关于 GDB 的使用方法,非常简单,可以自己手动敲下体验一下。

参考文章

image4


http://image.python-online.cn/image-20200320125724880.png

第二章:面向对象

这一章介绍的是如何用 Golang 面向对象编程,涉及的知识有:结构体,接口等

本章节,会持续更新,敬请关注…


2.1 面向对象编程:结构体与继承

0. 什么是结构体?

在之前学过的数据类型中,数组与切片,只能存储同一类型的变量。若要存储多个类型的变量,就需要用到结构体,它是将多个容易类型的命令变量组合在一起的聚合数据类型。

每个变量都成为该结构体的成员变量。

可以理解为 Go语言 的结构体struct和其他语言的class有相等的地位,但是Go语言放弃大量面向对象的特性,所有的Go语言类型除了指针类型外,都可以有自己的方法,提高了可扩展性。

在 Go 语言中没有没有 class 类的概念,只有 struct 结构体的概念,因此也没有继承,本篇文章,带你学习一下结构体相关的内容。

1. 定义结构体

声明结构体

type 结构体名 struct {
    属性名   属性类型
    属性名   属性类型
    ...
}

比如我要定义一个可以存储个人资料名为 Profile 的结构体,可以这么写

type Profile struct {
    name   string
    age    int
    gender string
    mother *Profile // 指针
    father *Profile // 指针
}

2. 定义方法

在 Go 语言中,我们无法在结构体内定义方法,那如何给一个结构体定义方法呢,答案是可以使用组合函数的方式来定义结构体方法。它和普通函数的定义方式有些不一样,比如下面这个方法

func (person Profile) FmtProfile() {
    fmt.Printf("名字:%s\n", person.name)
    fmt.Printf("年龄:%d\n", person.age)
    fmt.Printf("性别:%s\n", person.gender)
}

其中fmt_profile 是方法名,而(person Profile) :表示将 fmt_profile 方法与 Profile 的实例绑定。我们把 Profile 称为方法的接收者,而 person 表示实例本身,它相当于 Python 中的 self,在方法内可以使用 person.属性名 的方法来访问实例属性。

完整代码如下:

package main

import "fmt"

// 定义一个名为Profile 的结构体
type Profile struct {
    name   string
    age    int
    gender string
    mother *Profile // 指针
    father *Profile // 指针
}

// 定义一个与 Profile 的绑定的方法
func (person Profile) FmtProfile() {
    fmt.Printf("名字:%s\n", person.name)
    fmt.Printf("年龄:%d\n", person.age)
    fmt.Printf("性别:%s\n", person.gender)
}

func main() {
    // 实例化
    myself := Profile{name: "小明", age: 24, gender: "male"}
    // 调用函数
    myself.FmtProfile()
}

输出如下

名字:小明
年龄:24
性别:male

3. 方法的参数传递方式

上面定义方法的方式叫当你想要在方法内改变实例的属性的时候,必须使用指针做为方法的接收者。

package main

import "fmt"

// 声明一个 Profile 的结构体
type Profile struct {
    name   string
    age    int
    gender string
    mother *Profile // 指针
    father *Profile // 指针
}

// 重点在于这个星号: *
func (person *Profile) increase_age() {
    person.age += 1
}

func main() {
    myself := Profile{name: "小明", age: 24, gender: "male"}
    fmt.Printf("当前年龄:%d\n", myself.age)
    myself.increase_age()
    fmt.Printf("当前年龄:%d", myself.age)
}

输出结果 如下,可以看到在方法内部对 age 的修改已经生效。你可以尝试去掉 *,使用值做为方法接收者,看看age是否会发生改变。

当前年龄:24
当前年龄:25

至此,我们知道了两种定义方法的方式:

  • 以值做为方法接收者

  • 以指针做为方法接收者

那我们如何进行选择呢?以下几种情况,应当直接使用指针做为方法的接收者。

  1. 你需要在方法内部改变结构体内容的时候

  2. 出于性能的问题,当结构体过大的时候

有些情况下,以值或指针做为接收者都可以,但是考虑到代码一致性,建议都使用指针做为接收者。

不管你使用哪种方法定义方法,指针实例对象、值实例对象都可以直接调用,而没有什么约束。这一点Go语言做得非常好。

4. 结构体实现 “继承”

为什么标题的继承,加了双引号,因为Go 语言本身并不支持继承。

但我们可以使用组合的方法,实现类似继承的效果。

在生活中,组合的例子非常多,比如一台电脑,是由机身外壳,主板,CPU,内存等零部件组合在一起,最后才有了我们用的电脑。

同样的,在 Go 语言中,把一个结构体嵌入到另一个结构体的方法,称之为组合。

现在这里有一个表示公司(company)的结构体,还有一个表示公司职员(staff)的结构体。

type company struct {
    companyName string
    companyAddr string
}

type staff struct {
    name string
    age int
    gender string
    position string
}

若要将公司信息与公司职员关联起来,一般都会想到将 company 结构体的内容照抄到 staff 里。

type staff struct {
    name string
    age int
    gender string
    companyName string
    companyAddr string
    position string
}

虽然在实现上并没有什么问题,但在你对同一公司的多个staff初始化的时候,都得重复初始化相同的公司信息,这做得并不好,借鉴继承的思想,我们可以将公司的属性都“继承”过来。

但是在 Go 中没有类的概念,只有组合,你可以将 company 这个 结构体嵌入到 staff 中,做为 staff 的一个匿名字段,staff 就直接拥有了 company 的所有属性了。

type staff struct {
    name string
    age int
    gender string
    position string
    company   // 匿名字段
}

来写个完整的程序验证一下。

package main

import "fmt"

type company struct {
    companyName string
    companyAddr string
}

type staff struct {
    name string
    age int
    gender string
    position string
    company
}

func main()  {
    myCom := company{
        companyName: "Tencent",
        companyAddr: "深圳市南山区",
    }
    staffInfo := staff{
        name:     "小明",
        age:      28,
        gender:   "男",
        position: "云计算开发工程师",
        company: myCom,
    }

    fmt.Printf("%s 在 %s 工作\n", staffInfo.name, staffInfo.companyName)
    fmt.Printf("%s 在 %s 工作\n", staffInfo.name, staffInfo.company.companyName)
}

输出结果如下,可见staffInfo.companyNamestaffInfo.company.companyName 的效果是一样的。

小明  Tencent 工作
小明  Tencent 工作

5. 内部方法与外部方法

在 Go 语言中,函数名的首字母大小写非常重要,它被来实现控制对方法的访问权限。

  • 当方法的首字母为大写时,这个方法对于所有包都是Public,其他包可以随意调用

  • 当方法的首字母为小写时,这个方法是Private,其他包是无法访问的。


image0

2.2 面向对象编程:接口与多态

0. 接口是什么?

这一段摘自 Go语言中文网

在面向对象的领域里,接口一般这样定义:接口定义一个对象的行为。接口只指定了对象应该做什么,至于如何实现这个行为(即实现细节),则由对象本身去确定。

在 Go 语言中,接口就是方法签名(Method Signature)的集合。当一个类型定义了接口中的所有方法,我们称它实现了该接口。这与面向对象编程(OOP)的说法很类似。接口指定了一个类型应该具有的方法,并由该类型决定如何实现这些方法

1. 如何定义接口

使用 type 关键字来定义接口。

如下代码,定义了一个电话接口,接口要求必须实现 call 方法。

type Phone interface {
   call()
}

2. 如何实现接口

如果有一个类型/结构体,实现了一个接口要求的所有方法,这里 Phone 接口只有 call方法,所以只要实现了 call 方法,我们就可以称它实现了 Phone 接口。

意思是如果有一台机器,可以给别人打电话,那么我们就可以把它叫做电话。

这个接口的实现是隐式的,不像 JAVA 中要用 implements 显示说明。

继续上面电话的例子,我们先定义一个 Nokia 的结构体,而它实现了 call 的方法,所以它也是一台电话。

type Nokia struct {
    name string
}

// 接收者为 Nokia
func (phone Nokia) call() {
    fmt.Println("我是 Nokia,是一台电话")
}

3. 接口实现多态

鸭子类型(Duck typing)的定义是,只要你长得像鸭子,叫起来也像鸭子,那我认为你就是一只鸭子。

举个通俗的例子

什么样子的人可以称做老师呢?

不同的人标准不一,有的人认为必须有一定的学历,有的人认为必须要有老师资格证。

而我认为只要能育人,能给传授给其他人知识的,都可以称之为老师。

而不管你教的什么学科?是体育竞技,还是教人烹饪。

也不管你怎么教?是在教室里手执教教鞭、拿着粉笔,还是追求真实,直接实战演练。

通通不管。

这就一个接口(老师)下,在不同对象(人)上的不同表现。这就是多态。

在 Go 语言中,是通过接口来实现的多态。

这里以商品接口来写一段代码演示一下。

先定义一个商品(Good)的接口,意思是一个类型或者结构体,只要实现了settleAccount()orderInfo() 两个方法,那这个类型/结构体就是一个商品。

type Good interface {
    settleAccount() int
    orderInfo() string
}

然后我们定义两个结构体,分别是手机和赠品。

type Phone struct {
    name string
    quantity int
    price int
}

type FreeGift struct {
    name string
    quantity int
    price int
}

然后分别为他们实现 Good 接口的两个方法

// Phone
func (phone Phone) settleAccount() int {
    return phone.quantity * phone.price
}
func (phone Phone) orderInfo() string{
    return "您要购买" + strconv.Itoa(phone.quantity)+ "个" +
        phone.name + "计:" + strconv.Itoa(phone.settleAccount()) + "元"
}

// FreeGift
func (gift FreeGift) settleAccount() int {
    return 0
}
func (gift FreeGift) orderInfo() string{
    return "您要购买" + strconv.Itoa(gift.quantity)+ "个" +
        gift.name + "计:" + strconv.Itoa(gift.settleAccount()) + "元"
}

实现了 Good 接口要求的两个方法后,手机和赠品在Go语言看来就都是商品(Good)类型了。

这里候,我挑选了两件商品(实例化),分别是手机和耳机(赠品,不要钱)

iPhone := Phone{
    name:     "iPhone",
    quantity: 1,
    price:    8000,
}
earphones := FreeGift{
    name:     "耳机",
    quantity: 1,
    price:    200,
}

然后创建一个购物车(也就是类型为 Good的切片),来存放这些商品。

goods := []Good{iPhone, earphones}

最后,定义一个方法来计算购物车里的订单金额

func calculateAllPrice(goods []Good) int {
    var allPrice int
    for _,good := range goods{
        fmt.Println(good.orderInfo())
        allPrice += good.settleAccount()
    }
    return allPrice
}

完整代码,我贴在下面,供你参考。

package main

import (
    "fmt"
    "strconv"
)

// 定义一个接口
type Good interface {
    settleAccount() int
    orderInfo() string
}

type Phone struct {
    name string
    quantity int
    price int
}

func (phone Phone) settleAccount() int {
    return phone.quantity * phone.price
}
func (phone Phone) orderInfo() string{
    return "您要购买" + strconv.Itoa(phone.quantity)+ "个" +
        phone.name + "计:" + strconv.Itoa(phone.settleAccount()) + "元"
}

type FreeGift struct {
    name string
    quantity int
    price int
}

func (gift FreeGift) settleAccount() int {
    return 0
}
func (gift FreeGift) orderInfo() string{
    return "您要购买" + strconv.Itoa(gift.quantity)+ "个" +
        gift.name + "计:" + strconv.Itoa(gift.settleAccount()) + "元"
}

func calculateAllPrice(goods []Good) int {
    var allPrice int
    for _,good := range goods{
        fmt.Println(good.orderInfo())
        allPrice += good.settleAccount()
    }
    return allPrice
}
func main()  {
    iPhone := Phone{
        name:     "iPhone",
        quantity: 1,
        price:    8000,
    }
    earphones := FreeGift{
        name:     "耳机",
        quantity: 1,
        price:    200,
    }

    goods := []Good{iPhone, earphones}
    allPrice := calculateAllPrice(goods)
    fmt.Printf("该订单总共需要支付 %d 元", allPrice)
}

运行后,输出如下

您要购买1个iPhone计:8000元
您要购买1个耳机计:0元
该订单总共需要支付 8000 元

image0

2.3 关键字:make 和 new 的区别?

1. new 函数

在官方文档中,new 函数的描述如下

// The new built-in function allocates memory. The first argument is a type,
// not a value, and the value returned is a pointer to a newly
// allocated zero value of that type.
func new(Type) *Type

可以看到,new 只能传递一个参数,该参数为一个任意类型,可以是Go语言内建的类型,也可以是你自定义的类型

那么 new 函数到底做了哪些事呢:

  • 分配内存

  • 设置零值

  • 返回指针(重要)

举个例子

import "fmt"

type Student struct {
   name string
   age int
}

func main() {
    // new 一个内建类型
    num := new(int)
    fmt.Println(*num) //打印零值:0

    // new 一个自定义类型
    s := new(Student)
    s.name = "wangbm"
}

2. make 函数

在官方文档中,make 函数的描述如下

//The make built-in function allocates and initializes an object //of type slice, map, or chan (only). Like new, the first argument is // a type, not a value. Unlike new, make’s return type is the same as // the type of its argument, not a pointer to it.

func make(t Type, size …IntegerType) Type

翻译一下注释内容

  1. 内建函数 make 用来为 slice,map 或 chan 类型(注意:也只能用在这三种类型上)分配内存和初始化一个对象

  2. make 返回类型的本身而不是指针,而返回值也依赖于具体传入的类型,因为这三种类型就是引用类型,所以就没有必要返回他们的指针了

注意,因为这三种类型是引用类型,所以必须得初始化(size和cap),但是不是置为零值,这个和new是不一样的。

举几个例子

//切片
a := make([]int, 2, 10)

// 字典
b := make(map[string]int)

// 通道
c := make(chan int, 10)

3. 总结

new:为所有的类型分配内存,并初始化为零值,返回指针。

make:只能为 slice,map,chan 分配内存,并初始化,返回的是类型。

另外,目前来看 new 函数并不常用,大家更喜欢使用短语句声明的方式。

a := new(int)
a = 1
// 等价于
a := 1

但是 make 就不一样了,它的地位无可替代,在使用slice、map以及channel的时候,还是要使用make进行初始化,然后才可以对他们进行操作。


image0

2.4 理解语句块与作用域

由于 Go 使用的是词法作用域,而词法作用域依赖于语句块。所以在讲作用域时,需要先了解一下 Go 中的语句块是怎么一回事?

1. 显示语句块与隐式语句块

通俗地说,语句块是由花括弧({})所包含的一系列语句。

语句块内部声明的名字是无法被外部块访问的。这个块决定了内部声明的名字的作用域范围,也就是作用域。

用花括弧包含的语句块,属于显示语句块。

在 Go 中还有很多的隐式语句块:

  • 主语句块:包括所有源码,对应内置作用域

  • 包语句块:包括该包中所有的源码(一个包可能会包括一个目录下的多个文件),对应包级作用域

  • 文件语句块:包括该文件中的所有源码,对应文件级作用域

  • for 、if、switch等语句本身也在它自身的隐式语句块中,对应局部作用域

前面三点好理解,第四点举几个例子

for 循环完后,不能再使用变量 i

for i := 0; i < 5; i++ {
    fmt.Println(i)
}

if 语句判断完后,同样不能再使用变量 i

if i := 0; i >= 0 {
    fmt.Println(i)
}

switch 语句完了后,也是不是再使用变量 i

switch i := 2; i * 4 {
case 8:
    fmt.Println(i)
default:
    fmt.Println(“default”)
}

且每个 switch 语句的子句都是一个隐式的语句块

switch i := 2; i * 4 {
case 8:
    j := 0
    fmt.Println(i, j)
default:
    // "j" is undefined here
    fmt.Println(“default”)
}
// "j" is undefined here

2. 四种作用域的理解

变量的声明,除了声明其类型,其声明的位置也有讲究,不同的位置决定了其拥有不同的作用范围,说白了就是我这个变量,在哪里可用,在哪里不可用。

根据声明位置的不同,作用域可以分为以下四个类型:

  • 内置作用域:不需要自己声明,所有的关键字和内置类型、函数都拥有全局作用域

  • 包级作用域:必須函数外声明,在该包内的所有文件都可以访问

  • 文件级作用域:不需要声明,导入即可。一个文件中通过import导入的包名,只在该文件内可用

  • 局部作用域:在自己的语句块内声明,包括函数,for、if 等语句块,或自定义的 {} 语句块形成的作用域,只在自己的局部作用域内可用

以上的四种作用域,从上往下,范围从大到小,为了表述方便,我这里自己将范围大的作用域称为高层作用域,而范围小的称为低层作用域。

对于作用域,有以下几点总结:

  • 低层作用域,可以访问高层作用域

  • 同一层级的作用域,是相互隔离的

  • 低层作用域里声明的变量,会覆盖高层作用域里声明的变量

在这里要注意一下,不要将作用域和生命周期混为一谈。声明语句的作用域对应的是一个源代码的文本区域;它是一个编译时的属性。

而一个变量的生命周期是指程序运行时变量存在的有效时间段,在此时间区域内它可以被程序的其他部分引用;是一个运行时的概念。

3. 静态作用域与动态作用域

根据局部作用域内变量的可见性,是否是静态不变,可以将编程语言分为如下两种:

  • 静态作用域,如 Go 语言

  • 动态作用域,如 Shell 语言

具体什么是动态作用域,这里用 Shell 的代码演示一下,你就知道了

#!/bin/bash
func01() {
    local value=1
    func02
}
func02() {
    echo "func02 sees value as ${value}"
}

# 执行函数
func01
func02

从代码中,可以看到在 func01 函数中定义了个局部变量 value,按理说,这个 value 变量只在该函数内可用,但由于在 shell 中的作用域是动态的,所以在 func01中也可以调用 func02 时,func02 可以访问到 value 变量,此时的 func02 作用域可以当成是 局部作用域中(func01)的局部作用域。

但若脱离了 func01的执行环境,将其放在全局环境下或者其他函数中, func02 是访问不了 value 变量的。

所以此时的输出结果是

func02 sees value as 1
func02 sees value as

但在 Go 中并不存在这种动态作用域,比如这段代码,在func01函数中,要想取得 name 这个变量,只能从func01的作用域或者更高层作用域里查找(文件级作用域,包级作用域和内置作用域),而不能从调用它的另一个局部作用域中(因为他们在层级上属于同一级)查找。

import "fmt"

func func01() {
    fmt.Println("在 func01 函数中,name:", name)
}

func main()  {
    var name string = "Python编程时光"
    fmt.Println("在 main 函数中,name:", name)

    func01()
}

因此你在执行这段代码时,会报错,提示在func01中的name还未定义。

参考文章:https://studygolang.com/articles/12632


image0

2.5 说说 Go 语言中的空接口

1. 什么是空接口?

空接口是特殊形式的接口类型,普通的接口都有方法,而空接口没有定义任何方法口,也因此,我们可以说所有类型都至少实现了空接口。

type empty_iface interface {
}

每一个接口都包含两个属性,一个是值,一个是类型。

而对于空接口来说,这两者都是 nil,可以使用 fmt 来验证一下

package main

import (
    "fmt"
)

func main() {
    var i interface{}
    fmt.Printf("type: %T, value: %v", i, i)
}

输出如下

type: <nil>, value: <nil>

2. 如何使用空接口?

第一,通常我们会直接使用 interface{} 作为类型声明一个实例,而这个实例可以承载任意类型的值。

package main

import (
    "fmt"
)

func main()  {
    // 声明一个空接口实例
    var i interface{}

    // 存 int 没有问题
    i = 1
    fmt.Println(i)

    // 存字符串也没有问题
    i = "hello"
    fmt.Println(i)

    // 存布尔值也没有问题
    i = false
    fmt.Println(i)
}

第二,如果想让你的函数可以接收任意类型的值 ,也可以使用空接口

接收一个任意类型的值 示例

package main

import (
    "fmt"
)

func myfunc(iface interface{}){
    fmt.Println(iface)
}

func main()  {
    a := 10
    b := "hello"
    c := true

    myfunc(a)
    myfunc(b)
    myfunc(c)
}

接收任意个任意类型的值 示例

package main

import (
    "fmt"
)

func myfunc(ifaces ...interface{}){
    for _,iface := range ifaces{
        fmt.Println(iface)
    }
}

func main()  {
    a := 10
    b := "hello"
    c := true

    myfunc(a, b, c)
}

第三,你也定义一个可以接收任意类型的 array、slice、map、strcut,例如这边定义一个切片

package main

import "fmt"

func main() {
    any := make([]interface{}, 5)
    any[0] = 11
    any[1] = "hello world"
    any[2] = []int{11, 22, 33, 44}
    for _, value := range any {
        fmt.Println(value)
    }
}

3. 空接口几个要注意的坑

坑1:空接口可以承载任意值,但不代表任意类型就可以承接空接口类型的值

从实现的角度看,任何类型的值都满足空接口。因此空接口类型可以保存任何值,也可以从空接口中取出原值。

但要是你把一个空接口类型的对象,再赋值给一个固定类型(比如 int, string等类型)的对象赋值,是会报错的。

package main

func main() {
    // 声明a变量, 类型int, 初始值为1
    var a int = 1

    // 声明i变量, 类型为interface{}, 初始值为a, 此时i的值变为1
    var i interface{} = a

    // 声明b变量, 尝试赋值i
    var b int = i
}

这个报错,它就好比可以放进行礼箱的东西,肯定能放到集装箱里,但是反过来,能放到集装箱的东西就不一定能放到行礼箱了,在 Go 里就直接禁止了这种反向操作。(声明:底层原理肯定还另有其因,但对于新手来说,这样解释也许会容易理解一些。)

.\main.go:11:6: cannot use i (type interface {}) as type int in assignment: need type assertion

坑2::当空接口承载数组和切片后,该对象无法再进行切片

package main

import "fmt"

func main() {
    sli := []int{2, 3, 5, 7, 11, 13}

    var i interface{}
    i = sli

    g := i[1:3]
    fmt.Println(g)
}

执行会报错。

.\main.go:11:8: cannot slice i (type interface {})

坑3:当你使用空接口来接收任意类型的参数时,它的静态类型是 interface{},但动态类型(是 int,string 还是其他类型)我们并不知道,因此需要使用类型断言。

package main

import (
    "fmt"
)

func myfunc(i interface{})  {

    switch i.(type) {
    case int:
        fmt.Println("参数的类型是 int")
    case string:
        fmt.Println("参数的类型是 string")
    }
}

func main() {
    a := 10
    b := "hello"
    myfunc(a)
    myfunc(b)
}

输出如下

参数的类型是 int
参数的类型是 string

image0


http://image.python-online.cn/image-20200320125724880.png

第三章:项目管理

这一章介绍的是如何用 Golang 进行项目的管理,涉及的知识点有:go modules 的使用,go 命令详解等

本章节,会持续更新,敬请关注…


3.1 超详细解读 Go Modules 前世今生及入门使用

在以前,Go 语言的的包依赖管理一直都被大家所诟病,Go官方也在一直在努力为开发者提供更方便易用的包管理方案,从最初的 GOPATH 到 GO VENDOR,再到最新的 GO Modules,虽然走了不少的弯路,但最终还是拿出了 Go Modules 这样像样的解决方案。

目前最主流的包依赖管理方式是使用官方推荐的 Go Modules ,这不前段时间 Go 1.14 版本发布,官方正式放话,强烈推荐你使用 Go Modules,并且有自信可以用于生产中。

本文会大篇幅的讲解 Go Modules 的使用,但是在那之前,我仍然会简要介绍一下前两个解决方案 GOPATH 和 go vendor 到底是怎么回事?我认为这是有必要的,因为只有了解它的发展历程,才能知道 Go Modules 的到来是有多么的不容易,多么的意义非凡。

1. 最古老的 GOPATH

GOPATH 应该很多人都很眼熟了,之前在配置环境的时候,都配置过吧?

你可以将其理解为工作目录,在这个工作目录下,通常有如下的目录结构

image0

每个目录存放的文件,都不相同

  • bin:存放编译后生成的二进制可执行文件

  • pkg:存放编译后生成的 .a 文件

  • src:存放项目的源代码,可以是你自己写的代码,也可以是你 go get 下载的包

将你的包或者别人的包全部放在 $GOPATH/src 目录下进行管理的方式,我们称之为 GOPATH 模式。

在这个模式下,使用 go install 时,生成的可执行文件会放在 $GOPATH/bin

image1

如果你安装的是一个库,则会生成 .a 文件到 $GOPATH/pkg 下对应的平台目录中(由 GOOS 和 GOARCH 组合而成),生成 .a 为后缀的文件。

image2

GOOS,表示的是目标操作系统,有 darwin(Mac),linux,windows,android,netbsd,openbsd,solaris,plan9 等

而 GOARCH,表示目标架构,常见的有 arm,amd64 等

这两个都是 go env 里的变量,你可以通过 go env 变量名 进行查看

image3

至此,你可能不会觉得上面的方案会产生什么样的问题,直到你开始真正使用 GOPATH 去开发程序,就不得不开始面临各种各样的问题,其中最严重的就是版本管理问题,因为 GOPATH 根本没有版本的概念。

以下几点是你使用 GOPATH 一定会碰到的问题:

  • 你无法在你的项目中,使用指定版本的包,因为不同版本的包的导入方法也都一样

  • 其他人运行你的开发的程序时,无法保证他下载的包版本是你所期望的版本,当对方使用了其他版本,有可能导致程序无法正常运行

  • 在本地,一个包只能保留一个版本,意味着你在本地开发的所有项目,都得用同一个版本的包,这几乎是不可能的。

2. go vendor 模式的过渡

为了解决 GOPATH 方案下不同项目下无法使用多个版本库的问题,Go v1.5 开始支持 vendor 。

以前使用 GOPATH 的时候,所有的项目都共享一个 GOPATH,需要导入依赖的时候,都来这里找,正所谓一山不容二虎,在 GOPATH 模式下只能有一个版本的第三方库。

解决的思路就是,在每个项目下都创建一个 vendor 目录,每个项目所需的依赖都只会下载到自己vendor目录下,项目之间的依赖包互不影响。在编译时,v1.5 的 Go 在你设置了开启 GO15VENDOREXPERIMENT=1 (注:这个变量在 v1.6 版本默认为1,但是在 v1.7 后,已去掉该环境变量,默认开启 vendor 特性,无需你手动设置)后,会提升 vendor 目录的依赖包搜索路径的优先级(相较于 GOPATH)。

其搜索包的优先级顺序,由高到低是这样的

  • 当前包下的 vendor 目录

  • 向上级目录查找,直到找到 src 下的 vendor 目录

  • 在 GOROOT 目录下查找

  • 在 GOPATH 下面查找依赖包

虽然这个方案解决了一些问题,但是解决得并不完美。

  • 如果多个项目用到了同一个包的同一个版本,这个包会存在于该机器上的不同目录下,不仅对磁盘空间是一种浪费,而且没法对第三方包进行集中式的管理(分散在各个角落)。

  • 并且如果要分享开源你的项目,你需要将你的所有的依赖包悉数上传,别人使用的时候,除了你的项目源码外,还有所有的依赖包全部下载下来,才能保证别人使用的时候,不会因为版本问题导致项目不能如你预期那样正常运行。

这些看似不是问题的问题,会给我们的开发使用过程变得非常难受,虽然我是初学者,还未使用过 go vendor,但能有很明显的预感,这个方案照样会另我崩溃。

3. go mod 的横空出世

go modules 在 v1.11 版本正式推出,在最新发布的 v1.14 版本中,官方正式发话,称其已经足够成熟,可以应用于生产上。

从 v1.11 开始,go env 多了个环境变量: GO111MODULE ,这里的 111,其实就是 v1.11 的象征标志, go 里好像很喜欢这样的命名方式,比如当初 vendor 出现的时候,也多了个 GO15VENDOREXPERIMENT环境变量,其中 15,表示的vendor 是在 v1.5 时才诞生的。

GO111MODULE 是一个开关,通过它可以开启或关闭 go mod 模式。

它有三个可选值:offonauto,默认值是auto

  1. GO111MODULE=off禁用模块支持,编译时会从GOPATHvendor文件夹中查找包。

  2. GO111MODULE=on启用模块支持,编译时会忽略GOPATHvendor文件夹,只根据 go.mod下载依赖。

  3. GO111MODULE=auto,当项目在$GOPATH/src外且项目根目录有go.mod文件时,自动开启模块支持。

go mod 出现后, GOPATH(肯定没人使用了) 和 GOVENDOR 将会且正在被逐步淘汰,但是若你的项目仍然要使用那些即将过时的包依赖管理方案,请注意将 GO111MODULE 置为 off。

具体怎么设置呢?可以使用 go env 的命令,如我要开启 go mod ,就使用这条命令

$ go env -w GO111MODULE="on"

4. go mod 依赖的管理

接下来,来演示一下 go modules 是如何来管理包依赖的。

go mod 不再依靠 $GOPATH,使得它可以脱离 GOPATH 来创建项目,于是我们在家目录下创建一个 go_test 的目录,用来创建我的项目,详细操作如下:

image4

接下来,进入项目目录,执行如下命令进行 go modules 的初始化

image5

接下来很重要的一点,我们要看看 go install 把下载的包安装到哪里了?

image6

上面我们观察到,在使用 go modules 模式后,项目目录下会多生成两个文件也就是 go.modgo.sum

这两个文件是 go modules 的核心所在,这里不得不好好介绍一下。

image7

go.mod 文件

go.mod 的内容比较容易理解

  • 第一行:模块的引用路径

  • 第二行:项目使用的 go 版本

  • 第三行:项目所需的直接依赖包及其版本

在实际应用上,你会看见更复杂的 go.mod 文件,比如下面这样

module github.com/BingmingWong/module-test

go 1.14

require (
    example.com/apple v0.1.2
    example.com/banana v1.2.3
    example.com/banana/v2 v2.3.4
    example.com/pear // indirect
    example.com/strawberry // incompatible
)

exclude example.com/banana v1.2.4
replace(
    golang.org/x/crypto v0.0.0-20180820150726-614d502a4dac => github.com/golang/crypto v0.0.0-20180820150726-614d502a4dac
    golang.org/x/net v0.0.0-20180821023952-922f4815f713 => github.com/golang/net v0.0.0-20180826012351-8a410e7b638d
    golang.org/x/text v0.3.0 => github.com/golang/text v0.3.0
)

主要是多出了两个 flag:

  • exclude: 忽略指定版本的依赖包

  • replace:由于在国内访问golang.org/x的各个包都需要翻墙,你可以在go.mod中使用replace替换成github上对应的库。

go.sum 文件

反观 go.sum 文件,就比较复杂了,密密麻麻的。

可以看到,内容虽然多,但是也不难理解

每一行都是由 模块路径模块版本哈希检验值 组成,其中哈希检验值是用来保证当前缓存的模块不会被篡改。hash 是以h1:开头的字符串,表示生成checksum的算法是第一版的hash算法(sha256)。

值得注意的是,为什么有的包只有一行

<module> <version>/go.mod <hash>

而有的包却有两行呢

<module> <version> <hash>
<module> <version>/go.mod <hash>

那些有两行的包,区别就在于 hash 值不一行,一个是 h1:hash,一个是 go.mod h1:hash

h1:hashgo.mod h1:hash两者,要不就是同时存在,要不就是只存在 go.mod h1:hash。那什么情况下会不存在 h1:hash 呢,就是当 Go 认为肯定用不到某个模块版本的时候就会省略它的h1 hash,就会出现不存在 h1 hash,只存在 go.mod h1:hash 的情况。[引用自 3]

go.mod 和 go.sum 是 go modules 版本管理的指导性文件,因此 go.mod 和 go.sum 文件都应该提交到你的 Git 仓库中去,避免其他人使用你写项目时,重新生成的go.mod 和 go.sum 与你开发的基准版本的不一致。

5. go mod 命令的使用

  • go mod init:初始化go mod, 生成go.mod文件,后可接参数指定 module 名,上面已经演示过。

  • go mod download:手动触发下载依赖包到本地cache(默认为$GOPATH/pkg/mod目录)

  • go mod graph: 打印项目的模块依赖结构

image8

  • go mod tidy :添加缺少的包,且删除无用的包

  • go mod verify :校验模块是否被篡改过

  • go mod why: 查看为什么需要依赖

  • go mod vendor :导出项目所有依赖到vendor下

image9

  • go mod edit :编辑go.mod文件,接 -fmt 参数格式化 go.mod 文件,接 -require=golang.org/x/text 添加依赖,接 -droprequire=golang.org/x/text 删除依赖,详情可参考 go help mod edit

image10

  • go list -m -json all:以 json 的方式打印依赖详情

image11

如何给项目添加依赖(写进 go.mod)呢?

有两种方法:

  • 你只要在项目中有 import,然后 go build 就会 go module 就会自动下载并添加。

  • 自己手工使用 go get 下载安装后,会自动写入 go.mod 。

image12

7. 总结写在最后

如果让我用一段话来评价 GOPATH 和 go vendor,我会说

GOPATH 做为 Golang 的第一个包管理模式,只能保证你能用,但不保证好用,而 go vendor 解决了 GOPATH 忽视包版的本管理,保证好用,但是还不够好用,直到 go mod 的推出后,才使 Golang 包的依赖管理有了一个能让 Gopher 都统一比较满意的方案,达到了能用且好用的标准。

如果是刚开始学习 Golang ,那么 GOPATH 和 go vendor 可以做适当了解,不必深入研究,除非你要接手的项目由于一些历史原因仍然在使用 go vender 械管理,除此之外,任何 Gopher 应该从此刻就投入 go modules 的怀抱。

以上是我在这几天的学习总结,希望对还未入门阶段的你,有所帮助。另外,本篇文章如有写得不对的,请后台批评指正,以免误导其他朋友,非常感谢。

3.2 一文了解Go语言中编码规范

每个语言都有自己特色的编码规范,学习该语言的命名规范,能让你写出来的代码更加易读,更加不容易出现一些低级错误。

本文根据个人编码习惯以及网络上的一些文章,整理了一些大家能用上的编码规范,可能是一些主流方案,但不代表官方,这一点先声明一下。

1. 文件命名

  1. 由于 Windows平台文件名不区分大小写,所以文件名应一律使用小写

  2. 不同单词之间用下划线分词,不要使用驼峰式命名

  3. 如果是测试文件,可以以 _test.go 结尾

  4. 文件若具有平台特性,应以 文件名_平台.go 命名,比如 utils_ windows.go,utils_linux.go,可用的平台有:windows, unix, posix, plan9, darwin, bsd, linux, freebsd, nacl, netbsd, openbsd, solaris, dragonfly, bsd, notbsd, android,stubs

  5. 一般情况下应用的主入口应为 main.go,或者以应用的全小写形式命名。比如MyBlog 的入口可以为 myblog.go

2. 常量命名

目前在网络上可以看到主要有两种风格的写法

  1. 第一种是驼峰命名法,比如 appVersion

  2. 第二种使用全大写且用下划线分词,比如 APP_VERSION

这两种风格,没有孰好孰弱,可自由选取,我个人更倾向于使用第二种,主要是能一眼与变量区分开来。

如果要定义多个变量,请使用 括号 来组织。

const (
    APP_VERSION = "0.1.0"
  CONF_PATH = "/etc/xx.conf"
)

3. 变量命名

和常量不同,变量的命名,开发者们的喜好就比较一致了,统一使用 驼峰命名法

  1. 在相对简单的环境(对象数量少、针对性强)中,可以将完整单词简写为单个字母,例如:user写为u

  2. 若该变量为 bool 类型,则名称应以 Has, Is, CanAllow 开头。例如:isExist ,hasConflict 。

  3. 其他一般情况下首单词全小写,其后各单词首字母大写。例如:numShips 和 startDate 。

  4. 若变量中有特有名词(以下列出),且变量为私有,则首单词还是使用全小写,如 apiClient

  5. 若变量中有特有名词(以下列出),但变量不是私有,那首单词就要变成全大写。例如:APIClientURLString

这里列举了一些常见的特有名词:

// A GonicMapper that contains a list of common initialisms taken from golang/lint
var LintGonicMapper = GonicMapper{
    "API":   true,
    "ASCII": true,
    "CPU":   true,
    "CSS":   true,
    "DNS":   true,
    "EOF":   true,
    "GUID":  true,
    "HTML":  true,
    "HTTP":  true,
    "HTTPS": true,
    "ID":    true,
    "IP":    true,
    "JSON":  true,
    "LHS":   true,
    "QPS":   true,
    "RAM":   true,
    "RHS":   true,
    "RPC":   true,
    "SLA":   true,
    "SMTP":  true,
    "SSH":   true,
    "TLS":   true,
    "TTL":   true,
    "UI":    true,
    "UID":   true,
    "UUID":  true,
    "URI":   true,
    "URL":   true,
    "UTF8":  true,
    "VM":    true,
    "XML":   true,
    "XSRF":  true,
    "XSS":   true,
}

4. 函数命名

  1. 函数名还是使用 驼峰命名法

  2. 但是有一点需要注意,在 Golang 中是用大小写来控制函数的可见性,因此当你需要在包外访问,请使用 大写字母开头

  3. 当你不需要在包外访问,请使用小写字母开头

另外,函数内部的参数的排列顺序也有几点原则

  1. 参数的重要程度越高,应排在越前面

  2. 简单的类型应优先复杂类型

  3. 尽可能将同种类型的参数放在相邻位置,则只需写一次类型

5. 接口命名

使用驼峰命名法,可以用 type alias 来定义大写开头的 type 给包外访问。

type helloWorld interface {
    func Hello();
}

type SayHello helloWorld

当你的接口只有一个函数时,接口名通常会以 er 为后缀

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

5. 注释规范

注释分为

5.1 包注释
  1. 位于 package 之前,如果一个包有多个文件,只需要在一个文件中编写即可

  2. 如果你想在每个文件中的头部加上注释,需要在版权注释和 Package前面加一个空行,否则版权注释会作为Package的注释。

// Copyright 2009 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package net
  1. 如果是特别复杂的包,可单独创建 doc.go 文件说明

5.2 代码注释

用于解释代码逻辑,可以有两种写法

单行注释使用 // ,多行注释使用 /* comment */

// 单行注释

/*




*/

另外,对于代码注释还有一些更加苛刻的要求,这个看个人了,摘自网络:

  • 所有导出对象都需要注释说明其用途;非导出对象根据情况进行注释。

  • 如果对象可数且无明确指定数量的情况下,一律使用单数形式和一般进行时描述;否则使用复数形式。

  • 包、函数、方法和类型的注释说明都是一个完整的句子。

  • 句子类型的注释首字母均需大写;短语类型的注释首字母需小写。

  • 注释的单行长度不能超过 80 个字符。

  • 类型的定义一般都以单数形式描述:

    // Request represents a request to run a command.  type Request struct { ...
    
  • 如果为接口,则一般以以下形式描述:

    // FileInfo is the interface that describes a file and is returned by Stat and Lstat.
    type FileInfo interface { ...
    
  • 函数与方法的注释需以函数或方法的名称作为开头:

    // Post returns *BeegoHttpRequest with POST method.
    
  • 如果一句话不足以说明全部问题,则可换行继续进行更加细致的描述:

    // Copy copies file from source to target path.
    // It returns false and error when error occurs in underlying function calls.
    
  • 若函数或方法为判断类型(返回值主要为 bool 类型),则以 <name> returns true if 开头:

    // HasPrefix returns true if name has any string in given slice as prefix.
    func HasPrefix(name string, prefixes []string) bool { ...
    
5.3 特别注释
  • TODO:提醒维护人员此部分代码待完成

  • FIXME:提醒维护人员此处有BUG待修复

  • NOTE:维护人员要关注的一些问题说明

6. 包的导入

单行的包导入

import "fmt"

多个包导入,请使用 {} 来组织

import {
  "fmt"
  "os"
}

另外根据包的来源,对排版还有一定的要求

  1. 标准库排最前面,第三方包次之、项目内的其它包和当前包的子包排最后,每种分类以一空行分隔。

  2. 尽量不要使用相对路径来导入包。

import (
    "fmt"
    "html/template"
    "net/http"
    "os"

    "github.com/codegangsta/cli"
    "gopkg.in/macaron.v1"

    "github.com/gogits/git"
    "github.com/gogits/gfm"

    "github.com/gogits/gogs/routers"
    "github.com/gogits/gogs/routers/repo"
    "github.com/gogits/gogs/routers/user"
)

7. 善用 gofmt

除了命名规范外,Go 还有很多格式上的规范,比如

  1. 使用 tab 进行缩进

  2. 一行最长不要超过 80 个字符

因此在格式上的问题,你大部分都可以放心交由 gofmt 帮你调整。关于 gofmt 的文章还在写,应该这两天就会更新。你可以过两天再来看看。

参考文章:


image0

3.3 最全的 Go 命令指南

1. 基本命令

查看版本

$ go version
go version go1.14 darwin/amd64

查看环境变量

$ go env
仅截取部分内容

仅截取部分内容

2. 执行 Go 程序

当前热门的编程语言 Python ,可以不用编译成 二进制文件,就可以直接运行。

但 Go 语言程序的执行,必须得先编译再执行。通常来说有如下两种方法

  1. 先使用 go build 编译成二进制文件,再执行这个二进制文件

    image0

  2. 使用 go run “直接”运行,这个命令还是会去编译,但是不会在当前目录下生成二进制文件,而是编译成临时文件后直接运行。

    image1

3. 编译文件

.go 文件编译成可执行文件,可以使用 go build

如下图所示,helloworld 文件夹下,包含两个 .go 文件,它们都归属于同一个包。

当使用 go build 可指定包里所有的文件,就你下面这样,默认会以第一个文件(main.go)名生成可执行文件(main)。

image2

当然,你也可以不指定,此时生成的可执行文件是以 文件夹名命名

image3

当然你也可以手动指定这个可执行文件名

image4

以上是编译单个文件,当然也可以编译多个文件

4. 清除编译文件

使用 go install 或 go install 有可能会生成很多的文件,如可执行文件,归档文件等,它们的后缀名非常多,有 .exe.a.test.o.so.5.6.8,如果要手动一个一个去清理他们,可以说是相当麻烦的,这里你可以通过使用 go clean 一键清理。

image5

实际开发中go clean命令使用的可能不是很多,一般都是利用go clean命令清除编译文件,然后再将源码递交到 github 上,方便对于源码的管理。

go clean 有不少的参数:

  • -i:清除关联的安装的包和可运行文件,也就是通过go install安装的文件;

  • -n: 把需要执行的清除命令打印出来,但是不执行,这样就可以很容易的知道底层是如何运行的;

  • -r: 循环的清除在 import 中引入的包;

  • -x: 打印出来执行的详细命令,其实就是 -n 打印的执行版本;

  • -cache: 删除所有go build命令的缓存

  • -testcache: 删除当前包所有的测试结果

4. 下载代码包

在 Golang 中,除了可以从官方网站(golang.org)下载包之外,还可以从一些代码仓库中下载,诸如 github.com,bitbucket.org 这样的代码托管网站。

go get 这条命令,你以后会最经常用到,它可以借助代码管理工具通过远程拉取或更新代码包及其依赖包,并自动完成编译和安装。整个过程就像安装一个 App 一样简单。

这个命令可以动态获取远程代码包,目前支持的有 BitBucket、GitHub、Google Code 和 Launchpad。在使用 go get 命令前,需要安装与远程包匹配的代码管理工具,如 Git、SVN等。

go get 会根据域名的不同,使用不同的工具去拉取代码包,具体可参考下图

image-20200312203244402

image-20200312203244402

下载和安装,原本是两个动作,但使用 go get 后,它默认会将下载(源码包)和安装(go install)合并起来,当然你也可以通过参数指定将拆散它们。

在终端执行 go help get,会弹出 go get 的帮助文档,我这里汉化总结一下,来帮助大家学习。

go get [-d] [-f] [-t] [-u] [-v] [-fix] [-insecure] [build flags] [packages]

其中几个参数详解如下

  • -u

    用于下载指定的路径包及其依赖包,默认情况下,不会下载本地已经存在的,只会下载本地不存在的代码包。就是口中常说的更新包 比如:go get -u github.com/jinzhu/gorm。会把最新的 gorm 包下载到你本地

  • -d

    让命令程序只执行下载动作,而不执行安装动作。

  • -t

    让命令程序同时下载并安装指定的代码包中的测试源码文件中依赖的代码包

  • -fix

    命令程序在下载代码包后先执行修正动作,而后再进行编译和安装。比如,我的代码是用1.7 开发的,现在go 版本已经是1.13 了,有些包已经发生了变化,那么我们在使用go get命令的时候可以加入-fix标记。这个标记的作用是在检出代码包之后,先对该代码包中不符合Go语言1.7版本的语言规范的语法进行修正,然后再下载它的依赖包,最后再对它们进行编译和安装。

  • -v

    打印出那些下载的代码包的名字

  • -f

    仅在使用-u标记时才有效。该标记会让命令程序忽略掉对已下载代码包的导入路径的检查。如果下载并安装的代码包所属的项目是你从别人那里Fork过来的,那么这样做就尤为重要了

  • -x

    打印出整个过程使用了哪些命令

  • -insecure 允许命令程序使用非安全的scheme(如HTTP)去下载指定的代码包。如果你用的代码仓库(如公司内部的Gitlab)没有HTTPS支持,可以添加此标记。请在确定安全的情况下使用它。(记得 使用工具 git 时,有个版本就是 http 升级为了https)

参数有点多,咱一个一个来。

指定 -d,只下载源码包而不进行安装

image6

由于此时,我们已经下载了 logging 包,当你再次执行 go get 时,并不会重复下载,只有当你指定 -u 时,不管你需不需要更新,都会触发重新下载强制更新。

image7

如果你想看,下载这个过程用到了哪几个命令,可以指定 -x 参数

image8

最后,你可能想说,为什么 golang 里的包含这么长,好难记呀,其实这个路径是有讲究的

image9

这样不同的人开发的包即使使用同一个名,也不会冲突了。

下载的包,可能有不同的版本,如何指定版本下载呢?

# 拉取最新
go get github.com/foo

# 最新的次要版本或者修订版本(x.y.z, z是修订版本号, y是次要版本号)
go get -u github.com/foo

# 升级到最新的修订版本
go get -u=patch github.com/foo

# 指定版本,若存在tag,则代行使用
go get github.com/foo@v1.2.3

# 指定分支
go get github.com/foo@master

# 指定git提交的hash值
go get github.com/foo@e3702bed2

6. 安装代码包

go install 这个命令,如果你安装的是一个可执行文件(包名是 main),它会生成可执行文件到 bin 目录下。这点和 go build 很相似,不同的是,go build 编译生成的可执行文件放在当前目录,而 go install 会将可执行文件统一放至 $GOPATH/bin 目录下。

image10

如果你安装的是一个库,它会将这个库安装到 pkg 目录下,生成 .a 为后缀的文件。

image11

7. 格式化 go 文件

Go语言的开发团队制定了统一的官方代码风格,并且推出了 gofmt 工具(gofmt 或 go fmt)来帮助开发者格式化他们的代码到统一的风格。

gofmt 是一个 cli 程序,会优先读取标准输入,如果传入了文件路径的话,会格式化这个文件,如果传入一个目录,会格式化目录中所有 .go 文件,如果不传参数,会格式化当前目录下的所有 .go 文件。

http://c.biancheng.net/view/4441.html

3.4 Go 语言中关于包导入必学的 8 个知识点

1. 单行导入与多行导入

在 Go 语言中,一个包可包含多个 .go 文件(这些文件必须得在同一级文件夹中),只要这些 .go 文件的头部都使用 package 关键字声明了同一个包。

导入包主要可分为两种方式:

  • 单行导入

import "fmt"
import "sync"
  • 多行导入

import(
    "fmt"
    "sync"
)

如你所见,Go 语言中 导入的包,必须得用双引号包含,在这里吐槽一下。

2. 使用别名

在一些场景下,我们可能需要对导入的包进行重新命名,比如

  • 我们导入了两个具有同一包名的包时产生冲突,此时这里为其中一个包定义别名

import (
    "crypto/rand"
    mrand "math/rand" // 将名称替换为mrand避免冲突
)
  • 我们导入了一个名字很长的包,为了避免后面都写这么长串的包名,可以这样定义别名

import hw "helloworldtestmodule"
  • 防止导入的包名和本地的变量发生冲突,比如 path 这个很常用的变量名和导入的标准包冲突。

import pathpkg "path"

3. 使用点操作

如里在我们程序内部里频繁使用了一个工具包,比如 fmt,那每次使用它的打印函数打印时,都要 包名+方法名。

对于这种使用高频的包,可以在导入的时,就把它定义会 “自己人”(方法是使用一个 . ),自己人的话,不分彼此,它的方法,就是我们的方法。

从此,我们打印再也不用加 fmt 了。

import . "fmt"

func main() {
    Println("hello, world")
}

但这种用法,会有一定的隐患,就是导入的包里可能有函数,会和我们自己的函数发生冲突。

4. 包的初始化

每个包都允许有一个 init 函数,当这个包被导入时,会执行该包的这个 init 函数,做一些初始化任务。

对于 init 函数的执行有两点需要注意

  1. init 函数优先于 main 函数执行

  2. 在一个包引用链中,包的初始化是深度优先的。比如,有这样一个包引用关系:main→A→B→C,那么初始化顺序为

    C.init→B.init→A.init→main
    

5. 包的匿名导入

当我们导入一个包时,如果这个包没有被使用到,在编译时,是会报错的。

但是有些情况下,我们导入一个包,只想执行包里的 init 函数,来运行一些初始化任务,此时怎么办呢?

可以使用匿名导入,用法如下,其中下划线为空白标识符,并不能被访问

// 注册一个PNG decoder
import _ "image/png"

由于导入时,会执行 init 函数,所以编译时,仍然会将这个包编译到可执行文件中。

6. 导入的是路径还是包?

当我们使用 import 导入 testmodule/foo 时,初学者,经常会问,这个 foo 到底是一个包呢,还是只是包所在目录名?

import "testmodule/foo"

为了得出这个结论,专门做了个试验(请看「第七点里的代码示例」),最后得出的结论是:

  • 导入时,是按照目录导入。导入目录后,可以使用这个目录下的所有包。

  • 出于习惯,包名和目录名通常会设置成一样,所以会让你有一种你导入的是包的错觉。

7. 相对导入和绝对导入

据我了解在 Go 1.10 之前,好像是不支持相对导入的,在 Go 1.10 之后才可以。

绝对导入:从 $GOPATH/src$GOROOT 或者 $GOPATH/pkg/mod 目录下搜索包并导入

相对导入:从当前目录中搜索包并开始导入。就像下面这样

import (
    "./module1"
    "../module2"
    "../../module3"
    "../module4/module5"
)

分别举个例子吧

一、使用绝对导入

有如下这样的目录结构(注意确保当前目录在 GOPATH 下)

image0

其中 main.go 是这样的

package main

import (
    "app/utilset"   // 这种使用的就是绝对路径导入
)

func main() {
    utils.PrintHello()
}

而在 main.go 的同级目录下,还有另外一个文件夹 utilset ,为了让你理解 「第六点:import 导入的是路径而不是包」,我在 utilset 目录下定义了一个 hello.go 文件,这个go文件定义所属包为 utils

package utils

import "fmt"

func PrintHello(){
    fmt.Println("Hello, 我在 utilset 目录下的 utils 包里")
}

运行结果如下

image1

二、使用相对导入

还是上面的代码,将绝对导入改为相对导入后

将 GOPATH 路径设置回去(请对比上面使用绝对路径的 GOPATH)

image2

然后再次运行

image3

总结一下,使用相对导入,有两点需要注意

  • 项目不要放在 $GOPATH/src 下,否则会报错(比如我修改当前项目目录为GOPATH后,运行就会报错)

    image4

  • Go Modules 不支持相对导入,在你开启 GO111MODULE 后,无法使用相对导入。

最后,不得不说的是:使用相对导入的方式,项目可读性会大打折扣,不利用开发者理清整个引用关系。

所以一般更推荐使用绝对引用的方式。使用绝对引用的话,又要谈及优先级了

8. 包导入路径优先级

前面一节,介绍了三种不同的包依赖管理方案,不同的管理模式,存放包的路径可能都不一样,有的可以将包放在 GOPATH 下,有的可以将包放在 vendor 下,还有些包是内置包放在 GOROOT 下。

那么问题就来了,如果在这三个不同的路径下,有一个相同包名但是版本不同的包,我们导入的时候,是选择哪个进行导入呢?

这就需要我们搞懂,在 Golang 中包搜索路径优先级是怎样的?

这时候就需要区分,是使用哪种模式进行包的管理的。

如果使用 govendor

当我们导入一个包时,它会:

  1. 先从项目根目录的 vendor 目录中查找

  2. 最后从 $GOROOT/src 目录下查找

  3. 然后从 $GOPATH/src 目录下查找

  4. 都找不到的话,就报错。

为了验证这个过程,我在创建中创建一个 vendor 目录后,就开启了 vendor 模式了,我在 main.go 中随便导入一个包 pkg,由于这个包是我随便指定的,当然会找不到,找不到就会报错, Golang 会在报错信息中打印中搜索的过程,从这个信息中,就可以看到 Golang 的包查找优先级了。

image5

如果使用 go modules

你导入的包如果有域名,都会先在 $GOPATH/pkg/mod 下查找,找不到就连网去该网站上寻找,找不到或者找到的不是一个包,则报错。

而如果你导入的包没有域名(比如 “fmt”这种),就只会到 $GOROOT 里查找。

还有一点很重要,当你的项目下有 vendor 目录时,不管你的包有没有域名,都只会在 vendor 目录中想找。

image6

通常vendor 目录是通过 go mod vendor 命令生成的,这个命令会将项目依赖全部打包到你的项目目录下的 verdor 文件夹中。

3.5 如何开源自己写的包给别人用?

通常之前的学习,我们知道了在 Go 的项目中,可以 import 一个托管在远程仓库的模块,这个模块在我们使用 go get 的时候,会下载到本地。

既然是放在远程仓库上,意味着所有人都可以发布,并且所以人也都可以使用。

今天就来学习一下,如何发布一个开源的模块,并且使用它。

1. 新建仓库

先在你的 Github 上新建一个仓库,记得选 Public(默认)

image0

然后你会得到一个仓库地址,在你的电脑上 使用 git clone 命令克隆下来

2. 编写模块代码

使用前面学过的 go mod init 命令进行初始化,注意这里的模块名,填写我们的git仓库地址(但是要去掉.git哈)

$ git clone https://github.com/BingmingWong/goutils.git
$ go mod init github.com/BingmingWong/goutils

image1

然后新建一个 hash 文件夹,存放编写的一个计算 md5 值工具包,编辑 md5.go

package hash

import (
    "crypto/md5"
    "encoding/hex"
    "errors"
    "fmt"
    "io"
    "os"
)

// get file md5
func FileMd5(filename string) (string, error) {
    file, err := os.Open(filename)
    if err != nil {
    return "", errors.New(
        fmt.Sprintf("md5.go hash.FileMd5 os open error %v", err))
    }
    h := md5.New()
    _, err = io.Copy(h, file)
    if err != nil {
        return "", errors.New(fmt.Sprintf("md5.go hash.FileMd5 io copy error %v", err))
    }

    return hex.EncodeToString(h.Sum(nil)), nil
}

// get string md5
func StringMd5(s string) string {
    md5 := md5.New()
    md5.Write([]byte(s))
    return hex.EncodeToString(md5.Sum(nil))
}

由于我们使用的都是内置包,没有引入第三方的包,所以接下来可以把你刚刚那些新增的文件,全部 push 到 git 仓库。

$ git add -A
$ git commit -m "Add a md5 function"
$ git push

3. 发布版本

一切完成后,刷新我们的仓库,就可以看到我们的刚刚上传的项目代码了,点击 release 发布一个版本

image2

image3

然后像下图一样,添加一些版本说明

image4

最后点击一个 Publish release,就发布了一个版本

image5

4. 如何使用?

使用 go get 命令下载我们的发布的模块

$ go get github.com/BingmingWong/goutils

image6

再使用 tree 命令,查看一下我们下载的包已经放入了 $GOPATH/pkg/mod 下。

有一点很有趣的是,我的 Github 用户名(BingmingWong)是有大写字母的,下载下来后,在目录中大写字母会对应变成 !小写字母,如下所示

image7

这个用户名看起来有点非主流,你要想改的话,也是可以的。如果你有其他的开源项目,github 并不会为你做重定向,你需要自己评估这个风险。

image8

回过头来,我还是继续讲如何使用吧。

下载下来后,我们试着去调用一下他的函数,有一点需要注意的是,在这个示例里,你不能使用 github.com/BingmingWong/goutils 去导入,因为在这个目录下并没有 package,所以你必须导入 github.com/BingmingWong/goutils/hash

整个过程如下所示,供你参考:

image9

本文参考学习自:https://studygolang.com/articles/22851


image10


http://image.python-online.cn/image-20200320125724880.png

第四章:并发编程

这一章介绍的是如何用 Golang 进行并发编程,涉及的知识点有:函数,协程,通道(信道),锁等。

本章节,会持续更新,敬请关注…


4.1 一篇文章理解 Go 里的函数

1. 关于函数

函数是基于功能或 逻辑进行封装的可复用的代码结构。将一段功能复杂、很长的一段代码封装成多个代码片段(即函数),有助于提高代码可读性和可维护性。

在 Go 语言中,函数可以分为两种:

  • 带有名字的普通函数

  • 没有名字的匿名函数

由于 Go语言是编译型语言,所以函数编写的顺序是无关紧要的,它不像 Python 那样,函数在位置上需要定义在调用之前。

2. 函数的声明

函数的声明,使用 func 关键字,后面依次接 函数名参数列表返回值列表 {} 包裹的代码逻辑体

func 函数名(形式参数列表)(返回值列表){
    函数体
}
  • 形式参数列表描述了函数的参数名以及参数类型,这些参数作为局部变量,其值由参数调用者提供

  • 返回值列表描述了函数返回值的变量名以及类型,如果函数返回一个无名变量或者没有返回值,返回值列表的括号是可以省略的。

举个例子,定义一个 sum 函数,接收两个 int 类型的参数,在运行中,将其值分别赋值给 a,b,并规定必须返回一个int类型的值 。

func sum(a int, b int) (int){
    return a + b
}
func main() {
    fmt.Println(sum(1,2))
}

3. 函数实现可变参数

上面举的例子,参数个数都是固定的,这很好理解 ,指定什么类型的参数就传入什么类型的变量,数量上,不能多一个,也不能少一个。(好像没有可选参数)。

在 Python 中我们可以使用 *args 和 **kw ,还实现可变参数的函数。

可变参数分为几种:

  • 多个类型一致的参数

  • 多个类型不一致的参数

多个类型一致的参数

首先是多个类型一致的参数。

这边定义一个可以对多个数值进行求和的函数,

使用 ...int,表示一个元素为int类型的切片,用来接收调用者传入的参数。

// 使用 ...类型,表示一个元素为int类型的切片
func sum(args ...int) int {
    var sum int
    for _, v := range args {
        sum += v
    }
    return sum
}
func main() {
    fmt.Println(sum(1, 2, 3))
}

// output: 6

其中 ... 是 Go 语言为了方便程序员写代码而实现的语法糖,如果该函数下会多个类型的函数,这个语法糖必须得是最后一个参数。

同时这个语法糖,只能在定义函数时使用。

多个类型不一致的参数

上面那个例子中,我们的参数类型都是 int,如果你希望传多个参数且这些参数的类型都不一样,可以指定类型为 ...interface{} (你可能会问 interface{} 是什么类型,它是空接口,也是一个很重要的知识点,可以这篇文章查看相关内容),然后再遍历。

比如下面这段代码,是Go语言标准库中 fmt.Printf() 的函数原型:

import "fmt"
func MyPrintf(args ...interface{}) {
    for _, arg := range args {
        switch arg.(type) {
            case int:
                fmt.Println(arg, "is an int value.")
            case string:
                fmt.Println(arg, "is a string value.")
            case int64:
                fmt.Println(arg, "is an int64 value.")
            default:
                fmt.Println(arg, "is an unknown type.")
        }
    }
}

func main() {
    var v1 int = 1
    var v2 int64 = 234
    var v3 string = "hello"
    var v4 float32 = 1.234
    MyPrintf(v1, v2, v3, v4)
}

在某些情况下,我们需要定义一个参数个数可变的函数,具体传入几个参数,由调用者自己决定,但不管传入几个参数,函数都能够处理。

比如这边实现一个累加

func myfunc(args ...int) {
    for _, arg := range args {
        fmt.Println(arg)
    }
}

4. 多个可变参数函数传递参数

上面提到了可以使用 ... 来接收多个参数,除此之外,它还有一个用法,就是用来解序列,将函数的可变参数(一个切片)一个一个取出来,传递给另一个可变参数的函数,而不是传递可变参数变量本身。

同样这个用法,也只能在给函数传递参数里使用。

例子如下:

import "fmt"

func sum(args ...int) int {
    var result int
    for _, v := range args {
        result += v
    }
    return result
}

func Sum(args ...int) int {
    // 利用 ... 来解序列
    result := sum(args...)
    return result
}
func main() {
    fmt.Println(sum(1, 2, 3))
}

5. 函数的返回值

Go语言中的函数,在你定义的时候,就规定了此函数

  1. 有没有返回值?

    当没有指明返回值的类型时, 函数体不能有 return,Go并不像 Python 那样没有return,就默认返回None

  2. 返回几个值?

    Go 支持一个函数返回多个值

    func double(a int) (int, int) {
     b := a * 2
     return a, b
    }
    func main() {
        // 接收参数用逗号分隔
     a, b := double(2)
     fmt.Println(a, b)
    }
    
  3. 怎么返回值?

    Go支持返回带有变量名的值

    func double(a int) (b int) {
        // 不能使用 := ,因为在返回值哪里已经声明了为int
     b = a * 2
        // 不需要指明写回哪个变量,在返回值类型那里已经指定了
     return
    }
    func main() {
     fmt.Println(double(2))
    }
    // output: 4
    

6. 方法与函数

方法,在上一节《08. 面向对象编程:结构体与继承》里已经介绍过了,它的定义与函数有些不同,你可以点击前面的标题进行交叉学习。

方法和函数有什么区别? 为防会有朋友第一次接触面向对象,这里多嘴一句。

方法,是一种特殊的函数。当你一个函数和对象/结构体进行绑定的时候,我们就称这个函数是一个方法。

7. 匿名函数的使用

所谓匿名函数,就是没有名字的函数,它只有函数逻辑体,而没有函数名。

定义的格式如下

func(参数列表)(返回参数列表){
    函数体
}

一个名字实际上并没有多大区别,所有使用匿名函数都可以改成普通有名函数,那么什么情况下,会使用匿名函数呢?

定义变量名,是一个不难但是还费脑子的事情,对于那到只使用一次的函数,是没必要拥有姓名的。这才有了匿名函数。

有了这个背景,决定了匿名函数只有拥有短暂的生命,一般都是定义后立即使用。

就像这样,定义后立马执行(这里只是举例,实际代码没有意义)。

func(data int) {
    fmt.Println("hello", data)
}(100)

亦或是做为回调函数使用

// 第二个参数为函数
func visit(list []int, f func(int)) {
    for _, v := range list {
        // 执行回调函数
        f(v)
    }
}
func main() {
    // 使用匿名函数直接做为参数
    visit([]int{1, 2, 3, 4}, func(v int) {
        fmt.Println(v)
    })
}

image0

4.2 理解 Go 协程:goroutine

说到Go语言,很多没接触过它的人,对它的第一印象,一定是它从语言层面天生支持并发,非常方便,让开发者能快速写出高性能且易于理解的程序。

在 Python (为Py为例,主要是我比较熟悉,其他主流编程语言也类似)中,并发编程的门槛并不低,你要学习多进程,多线程,还要掌握各种支持并发的库 asyncio,aiohttp 等,同时你还要清楚它们之间的区别及优缺点,懂得在不同的场景选择不同的并发模式。

而 Golang 作为一门现代化的编程语言,它不需要你直面这些复杂的问题。在 Golang 里,你不需要学习如何创建进程池/线程池,也不需要知道什么情况下使用多线程,什么时候使用多进程。因为你没得选,也不需要选,它原生提供的 goroutine (也即协程)已经足够优秀,能够自动帮你处理好所有的事情,而你要做的只是执行它,就这么简单。

一个 goroutine 本身就是一个函数,当你直接调用时,它就是一个普通函数,如果你在调用前加一个关键字 go ,那你就开启了一个 goroutine。

// 执行一个函数
func()

// 开启一个协程执行这个函数
go func()

1. 协程的初步使用

一个 Go 程序的入口通常是 main 函数,程序启动后,main 函数最先运行,我们称之为 main goroutine

在 main 中或者其下调用的代码中才可以使用 go + func() 的方法来启动协程。

main 的地位相当于主线程,当 main 函数执行完成后,这个线程也就终结了,其下的运行着的所有协程也不管代码是不是还在跑,也得乖乖退出。

因此如下这段代码运行完,只会输出 hello, world ,而不会输出hello, go(因为协程的创建需要时间,当 hello, world打印后,协程还没来得及并执行)

import "fmt"

func mytest() {
    fmt.Println("hello, go")
}

func main() {
    // 启动一个协程
    go mytest()
    fmt.Println("hello, world")
}

对于刚学习Go的协程同学来说,可以使用 time.Sleep 来使 main 阻塞,使其他协程能够有机会运行完全,但你要注意的是,这并不是推荐的方式(后续我们会学习其他更优雅的方式)。

当我在代码中加入一行 time.Sleep 输出就符合预期了。

import (
    "fmt"
    "time"
)

func mytest() {
    fmt.Println("hello, go")
}

func main() {
    go mytest()
    fmt.Println("hello, world")
    time.Sleep(time.Second)
}

输出如下

hello, world
hello, go

2. 多个协程的效果

为了让你看到并发的效果,这里举个最简单的例子

import (
    "fmt"
    "time"
)

func mygo(name string) {
    for i := 0; i < 10; i++ {
        fmt.Printf("In goroutine %s\n", name)
        // 为了避免第一个协程执行过快,观察不到并发的效果,加个休眠
        time.Sleep(10 * time.Millisecond)
    }
}

func main() {
    go mygo("协程1号") // 第一个协程
    go mygo("协程2号") // 第二个协程
    time.Sleep(time.Second)
}

输出如下,可以观察到两个协程就如两个线程一样,并发执行

In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程2号

通过以上简单的例子,是不是折服于Go的这种强大的并发特性,将同步代码转为异步代码,真的只要一个关键字就可以了,也不需要使用其他库,简单方便。

本篇只介绍了协程的简单使用,真正的并发程序还是要结合 信道 (channel)来实现。关于信道的内容,将在下一篇文章中介绍。


image0

4.3 学习 Go 协程:详解信道/通道

Go 语言之所以开始流行起来,很大一部分原因是因为它自带的并发机制。

如果说 goroutine 是 Go语言程序的并发体的话,那么 channel(信道) 就是 它们之间的通信机制。channel,是一个可以让一个 goroutine 与另一个 goroutine 传输信息的通道,我把他叫做信道,也有人将其翻译成通道,二者都是一个概念。

信道,就是一个管道,连接多个goroutine程序 ,它是一种队列式的数据结构,遵循先入先出的规则。

1. 信道的定义与使用

每个信道都只能传递一种数据类型的数据,所以在你声明的时候,你得指定数据类型(string int 等等)

var 信道实例 chan 信道类型

// 定义容量为10的信道
var 信道实例 [10]chan 信道类型

声明后的信道,其零值是nil,无法直接使用,必须配合make函进行初始化。

信道实例 = make(chan 信道类型)

亦或者,上面两行可以合并成一句,以下我都使用这样的方式进行信道的声明

信道实例 := make(chan 信道类型)

假如我要创建一个可以传输int类型的信道,可以这样子写。

// 定义信道
pipline := make(chan int)

信道的数据操作,无非就两种:发送数据与读取数据

// 往信道中发送数据
pipline<- 200

// 从信道中取出数据,并赋值给mydata
mydata := <-pipline

信道用完了,可以对其进行关闭,避免有人一直在等待。但是你关闭信道后,接收方仍然可以从信道中取到数据,只是接收到的会永远是 0。

close(pipline)

对一个已关闭的信道再关闭,是会报错的。所以我们还要学会,如何判断一个信道是否被关闭?

当从信道中读取数据时,可以有多个返回值,其中第二个可以表示 信道是否被关闭,如果已经被关闭,ok 为 false,若还没被关闭,ok 为true。

x, ok := <-pipline

2. 信道的容量与长度

一般创建信道都是使用 make 函数,make 函数接收两个参数

  • 第一个参数:必填,指定信道类型

  • 第二个参数:选填,不填默认为0,指定信道的容量(可缓存多少数据)

对于信道的容量,很重要,这里要多说几点:

  • 当容量为0时,说明信道中不能存放数据,在发送数据时,必须要求立马有人接收,否则会报错。此时的信道称之为无缓冲信道

  • 当容量为1时,说明信道只能缓存一个数据,若信道中已有一个数据,此时再往里发送数据,会造成程序阻塞。 利用这点可以利用信道来做锁。

  • 当容量大于1时,信道中可以存放多个数据,可以用于多个协程之间的通信管道,共享资源。

至此我们知道,信道就是一个容器。

若将它比做一个纸箱子

  • 它可以装10本书,代表其容量为10

  • 当前只装了1本书,代表其当前长度为1

信道的容量,可以使用 cap 函数获取 ,而信道的长度,可以使用 len 长度获取。

package main

import "fmt"

func main() {
    pipline := make(chan int, 10)
    fmt.Printf("信道可缓冲 %d 个数据\n", cap(pipline))
    pipline<- 1
    fmt.Printf("信道中当前有 %d 个数据", len(pipline))
}

输出如下

信道可缓冲 10 个数据
信道中当前有 1 个数据

3. 缓冲信道与无缓冲信道

按照是否可缓冲数据可分为:缓冲信道无缓冲信道

缓冲信道

允许信道里存储一个或多个数据,这意味着,设置了缓冲区后,发送端和接收端可以处于异步的状态。

pipline := make(chan int, 10)

无缓冲信道

在信道里无法存储数据,这意味着,接收端必须先于发送端准备好,以确保你发送完数据后,有人立马接收数据,否则发送端就会造成阻塞,原因很简单,信道中无法存储数据。也就是说发送端和接收端是同步运行的。

pipline := make(chan int)

// 或者
pipline := make(chan int, 0)

4. 双向信道与单向信道

通常情况下,我们定义的信道都是双向通道,可发送数据,也可以接收数据。

但有时候,我们希望对信道的数据流向做一些控制,比如这个信道只能接收数据或者这个信道只能发送数据。

因此,就有了 双向信道单向信道 两种分类。

双向信道

默认情况下你定义的信道都是双向的,比如下面代码

import (
    "fmt"
    "time"
)

func main() {
    pipline := make(chan int)

    go func() {
        fmt.Println("准备发送数据: 100")
        pipline <- 100
    }()

    go func() {
        num := <-pipline
        fmt.Printf("接收到的数据是: %d", num)
    }()
    // 主函数sleep,使得上面两个goroutine有机会执行
    time.Sleep(1)
}

单向信道

单向信道,可以细分为 只读信道只写信道

定义只读信道

var pipline = make(chan int)
type Receiver = <-chan int // 关键代码:定义别名类型
var receiver Receiver = pipline

定义只写信道

var pipline = make(chan int)
type Sender = chan<- int  // 关键代码:定义别名类型
var sender Sender = pipline

仔细观察,区别在于 <- 符号在关键字 chan 的左边还是右边。

  • <-chan 表示这个信道,只能从里发出数据,对于程序来说就是只读

  • chan<- 表示这个信道,只能从外面接收数据,对于程序来说就是只写

有同学可能会问:为什么还要先声明一个双向信道,再定义单向通道呢?比如这样写

type Sender = chan<- int
sender := make(Sender)

代码是没问题,但是你要明白信道的意义是什么?(以下是我个人见解

信道本身就是为了传输数据而存在的,如果只有接收者或者只有发送者,那信道就变成了只入不出或者只出不入了吗,没什么用。所以只读信道和只写信道,唇亡齿寒,缺一不可。

当然了,若你往一个只读信道中写入数据 ,或者从一个只写信道中读取数据 ,都会出错。

完整的示例代码如下,供你参考:

import (
    "fmt"
    "time"
)
 //定义只写信道类型
type Sender = chan<- int

//定义只读信道类型
type Receiver = <-chan int

func main() {
    var pipline = make(chan int)

    go func() {
        var sender Sender = pipline
        fmt.Println("准备发送数据: 100")
        sender <- 100
    }()

    go func() {
        var receiver Receiver = pipline
        num := <-receiver
        fmt.Printf("接收到的数据是: %d", num)
    }()
    // 主函数sleep,使得上面两个goroutine有机会执行
    time.Sleep(1)
}

5. 遍历信道

遍历信道,可以使用 for 搭配 range关键字,在range时,要确保信道是处于关闭状态,否则循环会阻塞。

import "fmt"

func fibonacci(mychan chan int) {
    n := cap(mychan)
    x, y := 1, 1
    for i := 0; i < n; i++ {
        mychan <- x
        x, y = y, x+y
    }
    // 记得 close 信道
    // 不然主函数中遍历完并不会结束,而是会阻塞。
    close(mychan)
}

func main() {
    pipline := make(chan int, 10)

    go fibonacci(pipline)

    for k := range pipline {
        fmt.Println(k)
    }
}

6. 用信道来做锁

当信道里的数据量已经达到设定的容量时,此时再往里发送数据会阻塞整个程序。

利用这个特性,可以用当他来当程序的锁。

示例如下,详情可以看注释

package main

import {
    "fmt"
    "time"
}

// 由于 x=x+1 不是原子操作
// 所以应避免多个协程对x进行操作
// 使用容量为1的信道可以达到锁的效果
func increment(ch chan bool, x *int) {
    ch <- true
    *x = *x + 1
    <- ch
}

func main() {
    // 注意要设置容量为 1 的缓冲信道
    pipline := make(chan bool, 1)

    var x int
    for i:=0;i<1000;i++{
        go increment(pipline, &x)
    }

    // 确保所有的协程都已完成
    // 以后会介绍一种更合适的方法(Mutex),这里暂时使用sleep
    time.Sleep(3)
    fmt.Println("x 的值:", x)
}

输出如下

x 的值:1000

如果不加锁,输出会小于1000。

7. 几个注意事项

  1. 关闭一个未初始化的 channel 会产生 panic

  2. 重复关闭同一个 channel 会产生 panic

  3. 向一个已关闭的 channel 发送消息会产生 panic

  4. 从已关闭的 channel 读取消息不会产生 panic,且能读出 channel 中还未被读取的消息,若消息均已被读取,则会读取到该类型的零值。

  5. 从已关闭的 channel 读取消息永远不会阻塞,并且会返回一个为 false 的值,用以判断该 channel 是否已关闭(x,ok := <- ch)

  6. 关闭 channel 会产生一个广播机制,所有向 channel 读取消息的 goroutine 都会收到消息

  7. channel 在 Golang 中是一等公民,它是线程安全的,面对并发问题,应首先想到 channel。


image0

4.4 几个信道死锁经典错误案例详解

刚接触 Go 语言的信道的时候,经常会遇到死锁的错误,而导致这个错误的原因有很多种,这里整理了几种常见的。

fatal error: all goroutines are asleep - deadlock!

错误示例一

看下面这段代码

package main

import "fmt"

func main() {
    pipline := make(chan string)
    pipline <- "hello world"
    fmt.Println(<-pipline)
}

运行会抛出错误,如下

fatal error: all goroutines are asleep - deadlock!

看起来好像没有什么问题?先往信道中存入数据,再从信道中读取数据。

回顾前面的基础,我们知道使用 make 创建信道的时候,若不传递第二个参数,则你定义的是无缓冲信道,而对于无缓冲信道,在接收者未准备好之前,发送操作是阻塞的.

因此,对于解决此问题有两种方法:

  1. 使接收者代码在发送者之前执行

  2. 使用缓冲信道,而不使用无缓冲信道

第一种方法

若要程序正常执行,需要保证接收者程序在发送数据到信道前就进行阻塞状态,修改代码如下

package main

import "fmt"

func main() {
    pipline := make(chan string)
    fmt.Println(<-pipline)
    pipline <- "hello world"
}

运行的时候还是报同样的错误。问题出在哪里呢?

原来我们将发送者和接收者写在了同一协程中,虽然保证了接收者代码在发送者之前执行,但是由于前面接收者一直在等待数据 而处于阻塞状态,所以无法执行到后面的发送数据。还是一样造成了死锁。

有了前面的经验,我们将接收者代码写在另一个协程里,并保证在发送者之前执行,就像这样的代码

package main

func hello(pipline chan string)  {
    <-pipline
}

func main()  {
    pipline := make(chan string)
    go hello(pipline)
    pipline <- "hello world"
}

运行之后 ,一切正常。

第二种方法

接收者代码必须在发送者代码之前 执行,这是针对无缓冲信道才有的约束。

既然这样,我们改使用可缓冲信道不就OK了吗?

package main

import "fmt"

func main() {
    pipline := make(chan string, 1)
    pipline <- "hello world"
    fmt.Println(<-pipline)
}

运行之后,一切正常。

错误示例二

每个缓冲信道,都有容量,当信道里的数据量等于信道的容量后,此时再往信道里发送数据,就失造成阻塞,必须等到有人从信道中消费数据后,程序才会往下进行。

比如这段代码,信道容量为 1,但是往信道中写入两条数据,对于一个协程来说就会造成死锁。

package main

import "fmt"

func main() {
    ch1 := make(chan string, 1)

    ch1 <- "hello world"
    ch1 <- "hello China"

    fmt.Println(<-ch1)
}

错误示例三

当程序一直在等待从信道里读取数据,而此时并没有人会往信道中写入数据。此时程序就会陷入死循环,造成死锁。

比如这段代码,for 循环接收了两次消息(“hello world”和“hello China”)后,再也没有人发送数据了,接收者就会处于一个等待永远接收不到数据的囧境。陷入死循环,造成死锁。

package main

import "fmt"

func main() {
    pipline := make(chan string)
    go func() {
        pipline <- "hello world"
        pipline <- "hello China"
        // close(pipline)
    }()
    for data := range pipline{
        fmt.Println(data)
    }
}

包子铺里的包子已经卖完了,可还有人在排队等着买,如果不再做包子,就要告诉排队的人:不用等了,今天的包子已经卖完了,明日请早呀。

不能让人家死等呀,不跟客人说明一下,人家还以为你们店后面还在蒸包子呢。

所以这个问题,解决方法很简单,只要在发送完数据后,手动关闭信道,告诉 range 信道已经关闭,无需等待就行。

package main

import "fmt"

func main() {
    pipline := make(chan string)
    go func() {
        pipline <- "hello world"
        pipline <- "hello China"
        close(pipline)
    }()
    for data := range pipline{
        fmt.Println(data)
    }
}

image0

4.5 学习 Go 协程:WaitGroup

在前两篇文章里,我们学习了 协程信道 的内容,里面有很多例子,当时为了保证 main goroutine 在所有的 goroutine 都执行完毕后再退出,我使用了 time.Sleep 这种简单的方式。

由于写的 demo 都是比较简单的, sleep 个 1 秒,我们主观上认为是够用的。

但在实际开发中,开发人员是无法预知,所有的 goroutine 需要多长的时间才能执行完毕,sleep 多了吧主程序就阻塞了, sleep 少了吧有的子协程的任务就没法完成。

因此,使用time.Sleep 是一种极不推荐的方式,今天主要就要来介绍 一下如何优雅的处理这种情况。

1. 使用信道来标记完成

“不要通过共享内存来通信,要通过通信来共享内存”

学习了信道后,我们知道,信道可以实现多个协程间的通信,那么我们只要定义一个信道,在任务完成后,往信道中写入true,然后在主协程中获取到true,就认为子协程已经执行完毕。

import "fmt"

func main() {
    done := make(chan bool)
    go func() {
        for i := 0; i < 5; i++ {
            fmt.Println(i)
        }
        done <- true
    }()
    <-done
}

输出如下

0
1
2
3
4

2. 使用 WaitGroup

上面使用信道的方法,在单个协程或者协程数少的时候,并不会有什么问题,但在协程数多的时候,代码就会显得非常复杂,有兴趣可以自己尝试一下。

那么有没有一种更加优雅的方式呢?

有,这就要说到 sync包 提供的 WaitGroup 类型。

WaitGroup 你只要实例化了就能使用

var 实例名 sync.WaitGroup

实例化完成后,就可以使用它的几个方法:

  • Add:初始值为0,你传入的值会往计数器上加,这里直接传入你子协程的数量

  • Done:当某个子协程完成后,可调用此方法,会从计数器上减一,通常可以使用 defer 来调用。

  • Wait:阻塞当前协程,直到实例里的计数器归零。

举一个例子:

import (
    "fmt"
    "sync"
)

func worker(x int, wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 5; i++ {
        fmt.Printf("worker %d: %d\n", x, i)
    }
}

func main() {
    var wg sync.WaitGroup

    wg.Add(2)
    go worker(1, &wg)
    go worker(2, &wg)

    wg.Wait()
}

输出如下

worker 2: 0
worker 2: 1
worker 2: 2
worker 2: 3
worker 2: 4
worker 1: 0
worker 1: 1
worker 1: 2
worker 1: 3
worker 1: 4

以上就是我们在 Go 语言中实现一主多子的协程协作方式,推荐使用 sync.WaitGroup。。


image0

4.6 学习 Go 协程:互斥锁和读写锁

在 「19. 学习 Go 协程:详解信道/通道」这一节里我详细地介绍信道的一些用法,要知道的是在 Go 语言中,信道的地位非常高,它是 first class 级别的,面对并发问题,我们始终应该优先考虑使用信道,如果通过信道解决不了的,不得不使用共享内存来实现并发编程的,那 Golang 中的锁机制,就是你绕不过的知识点了。

今天就来讲一讲 Golang 中的锁机制。

在 Golang 里有专门的方法来实现锁,还是上一节里介绍的 sync 包。

这个包有两个很重要的锁类型

一个叫 Mutex, 利用它可以实现互斥锁。

一个叫 RWMutex,利用它可以实现读写锁。

1. 互斥锁 :Mutex

使用互斥锁(Mutex,全称 mutual exclusion)是为了来保护一个资源不会因为并发操作而引起冲突导致数据不准确。

举个例子,就像下面这段代码,我开启了三个协程,每个协程分别往 count 这个变量加1000次 1,理论上看,最终的 count 值应试为 3000

package main

import (
    "fmt"
    "sync"
)

func add(count *int, wg *sync.WaitGroup) {
    for i := 0; i < 1000; i++ {
        *count = *count + 1
    }
    wg.Done()
}

func main() {
    var wg sync.WaitGroup
    count := 0
    wg.Add(3)
    go add(&count, &wg)
    go add(&count, &wg)
    go add(&count, &wg)

    wg.Wait()
    fmt.Println("count 的值为:", count)
}

可运行多次的结果,都不相同

// 第一次
count 的值为: 2854

// 第二次
count 的值为: 2673

// 第三次
count 的值为: 2840

原因就在于这三个协程在执行时,先读取 count 再更新 count 的值,而这个过程并不具备原子性,所以导致了数据的不准确。

解决这个问题的方法,就是给 add 这个函数加上 Mutex 互斥锁,要求同一时刻,仅能有一个协程能对 count 操作。

在写代码前,先了解一下 Mutex 锁的两种定义方法

// 第一种
var lock *sync.Mutex
lock = new(sync.Mutex)

// 第二种
lock := &sync.Mutex{}

然后就可以修改你上面的代码,如下所示

import (
    "fmt"
    "sync"
)

func add(count *int, wg *sync.WaitGroup, lock *sync.Mutex) {
    for i := 0; i < 1000; i++ {
        lock.Lock()
        *count = *count + 1
        lock.Unlock()
    }
    wg.Done()
}

func main() {
    var wg sync.WaitGroup
    lock := &sync.Mutex{}
    count := 0
    wg.Add(3)
    go add(&count, &wg, lock)
    go add(&count, &wg, lock)
    go add(&count, &wg, lock)

    wg.Wait()
    fmt.Println("count 的值为:", count)
}

此时,不管你执行多少次,输出都只有一个结果

count 的值为: 3000

使用 Mutext 锁虽然很简单,但仍然有几点需要注意:

  • 同一协程里,不要在尚未解锁时再次使加锁

  • 同一协程里,不要对已解锁的锁再次解锁

  • 加了锁后,别忘了解锁,必要时使用 defer 语句

3. 读写锁:RWMutex

Mutex 是最简单的一种锁类型,他提供了一个傻瓜式的操作,加锁解锁加锁解锁,让你不需要再考虑其他的。

简单同时意味着在某些特殊情况下有可能会造成时间上的浪费,导致程序性能低下。

举个例子,我们平时去图书馆,要嘛是去借书,要嘛去还书,借书的流程繁锁,没有办卡的还要让管理员给你办卡,因此借书通常都要排老长的队,假设图书馆里只有一个管理员,按照 Mutex(互斥锁)的思想, 这个管理员同一时刻只能服务一个人,这就意味着,还书的也要跟借书的一起排队。

可还书的步骤非常简单,可能就把书给管理员扫下码就可以走了。

如果让还书的人,跟借书的人一起排队,那估计有很多人都不乐意了。

因此,图书馆为了提高整个流程的效率,就允许还书的人,不需要排队,可以直接自助还书。

图书管将馆里的人分得更细了,对于读者的不同需求提供了不同的方案。提高了效率。

RWMutex,也是如此,它将程序对资源的访问分为读操作和写操作

  • 为了保证数据的安全,它规定了当有人还在读取数据(即读锁占用)时,不允计有人更新这个数据(即写锁会阻塞)

  • 为了保证程序的效率,多个人(线程)读取数据(拥有读锁)时,互不影响不会造成阻塞,它不会像 Mutex 那样只允许有一个人(线程)读取同一个数据。

理解了这个后,再来看看,如何使用 RWMutex?

定义一个 RWMuteux 锁,有两种方法

// 第一种
var lock *sync.RWMutex
lock = new(sync.RWMutex)

// 第二种
lock := &sync.RWMutex{}

RWMutex 里提供了两种锁,每种锁分别对应两个方法,为了避免死锁,两个方法应成对出现,必要时请使用 defer。

  • 读锁:调用 RLock 方法开启锁,调用 RUnlock 释放锁

  • 写锁:调用 Lock 方法开启锁,调用 Unlock 释放锁(和 Mutex类似)

接下来,直接看一下例子吧

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    lock := &sync.RWMutex{}
    lock.Lock()

    for i := 0; i < 4; i++ {
        go func(i int) {
            fmt.Printf("第 %d 个协程准备开始... \n", i)
            lock.RLock()
            fmt.Printf("第 %d 个协程获得读锁, sleep 1s 后,释放锁\n", i)
            time.Sleep(time.Second)
            lock.RUnlock()
        }(i)
    }

    time.Sleep(time.Second * 2)

    fmt.Println("准备释放写锁,读锁不再阻塞")
    // 写锁一释放,读锁就自由了
    lock.Unlock()

    // 由于会等到读锁全部释放,才能获得写锁
    // 因为这里一定会在上面 4 个协程全部完成才能往下走
    lock.Lock()
    fmt.Println("程序退出...")
    lock.Unlock()
}

输出如下

第 1 个协程准备开始...
第 0 个协程准备开始...
第 3 个协程准备开始...
第 2 个协程准备开始...
准备释放写锁,读锁不再阻塞
第 2 个协程获得读锁, sleep 1s 后,释放锁
第 3 个协程获得读锁, sleep 1s 后,释放锁
第 1 个协程获得读锁, sleep 1s 后,释放锁
第 0 个协程获得读锁, sleep 1s 后,释放锁
程序退出...

image0

4.7 如何把自己写的包上传到github给别人用

如何把自己写的包上传到github给别人用

https://studygolang.com/articles/22826

如何使用 go modules 导入本地包

https://mp.weixin.qq.com/s/jvqjIzfBlGh3vty_qHl50w

4.8 学习一些常见的并发模型

本篇内容主要是了解下并发编程中的一些概念,及讲述一些常用的并发模型都是什么样的,从而理解 Golang 中的 协程在这些众多模型中是一种什么样的存在及地位。可能和本系列的初衷(零基础学Go)有所出入,因此你读不读本篇都不会对你学习Go有影响,尽管我个人觉得这是有必要了解的。

你可以自行选择,若你只想学习 Golang 有关的内容,完全可以跳过本篇。

0. 并发与并行

讲到并发,那不防先了解下什么是并发,与之相对的并行有什么区别?

这里我用两个例子来形象描述:

  • 并发:当你在跑步时,发现鞋带松,要停下来系鞋带,这时候跑步和系鞋带就是并发状态。

  • 并行:你跑步时,可以同时听歌,那么跑步和听歌就是并行状态,谁也不影响谁。

在计算机的世界中,一个CPU核严格来说同一时刻只能做一件事,但由于CPU的频率实在太快了,人们根本感知不到其切换的过程,所以我们在编码的时候,实际上是可以在单核机器上写多进程的程序(但你要知道这是假象),这是相对意义上的并行。

而当你的机器有多个 CPU 核时,多个进程之间才能真正的实现并行,这是绝对意义上的并行。

接着来说并发,所谓的并发,就是多个任务之间可以在同一时间段里一起执行。

但是在单核CPU里,他同一时刻只能做一件事情 ,怎么办?

谁都不能偏坦,我就先做一会 A 的活,再做一会B 的活,接着去做一会 C 的活,然后再去做一会 A 的活,就这样不断的切换着,大家都很开心,其乐融融。

1. 并发编程的模型

在计算机的世界里,实现并发通常有几种方式:

  1. 多进程模型:创建新的线程处理请求

  2. 多线程模型:创建新的进程处理请求

  3. 使用线程池:线程/进程创建销毁开销大

  4. I/O 多路复用+单/多线程

2. 多进程与多线程

对于普通的用户来说,进程是最熟悉的存在,比如一个 QQ ,一个微信,它们都是一个进程。

进程是计算机资源分配的最小单位,而线程是比进程更小的执行单元,它不能脱离于进程单独存在。

在一个进程里,至少有一个线程,那个线程叫主线程,同时你也可以创建多个线程,多个线程之间是可以并发执行的。

线程是调度的基本单位,在多线程里,在调度过程中,需要由 CPU 和 内核层参与上下文的切换。如果你跑了A线程,然后切到B线程,内核调用开始,CPU需要对A线程的上下文保留,然后切到B线程,然后把控制权交给你的应用层调度。

而进程的切换,相比线程来说,会更加麻烦。

因为进程有自己的独立地址空间,多个进程之间的地址空间是相互隔离的,这和线程有很大的不同,单个进程内的多个线程 共享进程中的数据的,使用相同的地址空间,所以CPU切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。

此外,由于同一进程下的线程共享全局变量、静态变量等数据,使得线程间的通信非常方便,相比之下,进程间的通信(IPC,InterProcess Communication)就略显复杂,通常的进程间的通信方式有:管道,消息队列,信号量,Socket,Streams 等

说了这么多,好像都在说线程优于进程,也不尽然。

比如多线程更多用于有IO密集型的业务场景,而对于计算密集型的场景,应该优先选择多进程。

同时,多进程程序更健壮,多线程程序只要有一个线程死掉,整个进程也死掉了,而一个进程死掉并不会对另外一个进程造成影响,因为进程有自己独立的地址空间。

3. I/O多路复用

I/O多路复用 ,英文全称为 I/O multiplexing,这个中文翻译和把 socket 翻译成 套接字一样,影响了我对其概念的理解。

在互联网早期,为了实现一个服务器可以处理多个客户端的连接,程序猿是这样做的。服务器得知来了一个请求后,就去创建一个线程处理这个请求,假如有10个客户请求,就创建10个线程,这在当时联网设备还比较匮乏的时代,是没有任何问题的。

但随着科技的发展,人们越来越富裕,都买得起电脑了,网民也越来越多了,由于一台机器的能开启的线程数是有限制的,当请求非常集中量大到一定量时,服务器的压力就巨大无比。

终于到了 1983年,人们意识到这种问题,提出了一种最早的 I/O 多路复用的模型(select实现),这种模型,对比之前最大的不同就是,处理请求的线程不再是根据请求来定,后端请求的进程只有一个。虽然这种模型在现在看来还是不行,但在当时已经大大减小了服务器系统的开销,可以解决服务器压力太大的问题,毕竟当时的电脑都是很珍贵的。

再后来,家家都有了电脑,手机互联网的时代也要开始来了,联网设备爆炸式增长,之前的 select ,早已不能支撑用户请求了。

由于使用 select 最多只能接收 1024 个连接,后来程序猿们又改进了 select 发明了 pool,pool 使用的链表存储,没有最大连接数的限制。

select 和 pool ,除了解决了连接数的限制 ,其他似乎没有本质的区别。

都是服务器知道了有一个连接来了,由于并不知道是哪那几个流(可能有一个,多个,甚至全部),所以只能一个一个查过去(轮循),假如服务器上有几万个文件描述符(下称fd,file descriptor),而你要处理一个请求,却要遍历几万个fd,这样是不是很浪费时间和资源。

由此程序员不得不持续改进 I/O多路复用的策略,这才有了后来的 epoll 方法。

epoll 解决了前期 select 和 poll 出现的一系列的尴尬问题,比如:

  • select 和 poll 无差别轮循fd,浪费资源,epool 使用通知回调机制,有流发生 IO事件时就会主动触发回调函数

  • select 和 poll 线程不安全,epool 线程安全

  • select 请求连接数的限制,epool 能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)

  • select 和 pool 需要频繁地将fd复制到内核空间,开销大,epoll通过内核和用户空间共享一块内存来减少这方面的开销。

虽然 I/O 多路复用经历了三种实现:select -> pool -> epool,这也不是就说 epool 出现了, select 就会被淘汰掉。

epool 关注的是活跃的连接数,当连接数非常多但活跃连接少的情况下(比如长连接数较多),epool 的性能最好。

而 select 关注的是连接总数,当连接数多而且大部分的连接都很活跃的情况下,选择 select 会更好,因为 epool 的通知回调机制需要很多的函数回调。

另外还有一点是,select 是 POSIX 规定的,一般操作系统均有实现,而 epool 是 Linux 所有的,其他平台上没有。

IO多路复用除了以上三种不同的具体实现的区别外,还可以根据线程数的多少来分类

  • 一个线程的IO多路复用,比如 Redis

  • 多个线程的IO多路复用,比如 goroutine

IO多路复用 + 单进(线)程有个好处,就是不会有并发编程的各种坑问题,比如在nginx里,redis里,编程实现都会很简单很多。编程中处理并发冲突和一致性,原子性问题真的是很难,极易出错。

4. 三种线程模型?

实际上,goroutine 并非传统意义上的协程。

现在主流的线程模型分三种:

  • 内核级线程模型

  • 用户级线程模型

  • 两级线程模型(也称混合型线程模型)

传统的协程库属于用户级线程模型,而 goroutine 和它的 Go Scheduler 在底层实现上其实是属于两级线程模型,因此,有时候为了方便理解可以简单把 goroutine 类比成协程,但心里一定要有个清晰的认知 — goroutine并不等同于协程。

关于这块,想详细了解的,可以前往:https://studygolang.com/articles/13344

5. 协程的优势在哪?

协程,可以认为是轻量级的“线程”。

对比线程,有如下几个明显的优势。

  1. 协程的调度由 Go 的 runtime 管理,协程切换不需要经由操作系统内核,开销较小。

  2. 单个协程的堆栈只有几个kb,可创建协程的数量远超线程数。

同时,在 Golang 里,我还体会到了这种现代化编程语言带来的优势,它考虑得面面俱到,让编码变得更加的傻瓜式,goroutine的定义不需要在定义时区分是否异步函数(相对Python的 async def 而言),运行时只需要一个关键字 go,就可以轻松创建一个协程。

使用 -race 来检测数据 访问的冲突

协程什么时候会切换

  1. I/O,select

  2. channel

  3. 等待锁

  4. 函数调用(有时

  5. runtime.Gosched()

4.9 理解 Go 语言中的 Context

1. 什么是 Context?

在 Go 1.7 版本之前,context 还是非编制的,它存在于 golang.org/x/net/context 包中。

后来,Golang 团队发现 context 还挺好用的,就把 context 收编了,在 Go 1.7 版本正式纳入了标准库。

Context,也叫上下文,它的接口定义如下

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

可以看到 Context 接口共有 4 个方法

  • Deadline:返回的第一个值是 截止时间,到了这个时间点,Context 会自动触发 Cancel 动作。返回的第二个值是 一个布尔值,true 表示设置了截止时间,false 表示没有设置截止时间,如果没有设置截止时间,就要手动调用 cancel 函数取消 Context。

  • Done:返回一个只读的通道(只有在被cancel后才会返回),类型为 struct{}。当这个通道可读时,意味着parent context已经发起了取消请求,根据这个信号,开发者就可以做一些清理动作,退出goroutine。

  • Err:返回 context 被 cancel 的原因。

  • Value:返回被绑定到 Context 的值,是一个键值对,所以要通过一个Key才可以获取对应的值,这个值一般是线程安全的。

2. 为何需要 Context?

当一个协程(goroutine)开启后,我们是无法强制关闭它的。

常见的关闭协程的原因有如下几种:

  1. goroutine 自己跑完结束退出

  2. 主进程crash退出,goroutine 被迫退出

  3. 通过通道发送信号,引导协程的关闭。

第一种,属于正常关闭,不在今天讨论范围之内。

第二种,属于异常关闭,应当优化代码。

第三种,才是开发者可以手动控制协程的方法,代码示例如下:

func main() {
    stop := make(chan bool)

    go func() {
        for {
            select {
            case <-stop:
                fmt.Println("监控退出,停止了...")
                return
            default:
                fmt.Println("goroutine监控中...")
                time.Sleep(2 * time.Second)
            }
        }
    }()

    time.Sleep(10 * time.Second)
    fmt.Println("可以了,通知监控停止")
    stop<- true
    //为了检测监控过是否停止,如果没有监控输出,就表示停止了
    time.Sleep(5 * time.Second)

}

例子中我们定义一个stop的chan,通知他结束后台goroutine。实现也非常简单,在后台goroutine中,使用select判断stop是否可以接收到值,如果可以接收到,就表示可以退出停止了;如果没有接收到,就会执行default里的监控逻辑,继续监控,只到收到stop的通知。

以上是一个 goroutine 的场景,如果是多个 goroutine ,每个goroutine 底下又开启了多个 goroutine 的场景呢?在 飞雪无情的博客 里关于为何要使用 Context,他是这么说的

chan+select的方式,是比较优雅的结束一个goroutine的方式,不过这种方式也有局限性,如果有很多goroutine都需要控制结束怎么办呢?如果这些goroutine又衍生了其他更多的goroutine怎么办呢?如果一层层的无穷尽的goroutine呢?这就非常复杂了,即使我们定义很多chan也很难解决这个问题,因为goroutine的关系链就导致了这种场景非常复杂。

在这里我不是很赞同他说的话,因为我觉得就算只使用一个通道也能达到控制(取消)多个 goroutine 的目的。下面就用例子来验证一下。

该例子的原理是:使用 close 关闭通道后,如果该通道是无缓冲的,则它会从原来的阻塞变成非阻塞,也就是可读的,只不过读到的会一直是零值,因此根据这个特性就可以判断 拥有该通道的 goroutine 是否要关闭。

package main

import (
    "fmt"
    "time"
)

func monitor(ch chan bool, number int)  {
    for {
        select {
        case v := <-ch:
            // 仅当 ch 通道被 close,或者有数据发过来(无论是true还是false)才会走到这个分支
            fmt.Printf("监控器%v,接收到通道值为:%v,监控结束。\n", number,v)
            return
        default:
            fmt.Printf("监控器%v,正在监控中...\n", number)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    stopSingal := make(chan bool)

    for i :=1 ; i <= 5; i++ {
        go monitor(stopSingal, i)
    }

    time.Sleep( 1 * time.Second)
    // 关闭所有 goroutine
    close(stopSingal)

    // 等待5s,若此时屏幕没有输出 <正在监控中> 就说明所有的goroutine都已经关闭
    time.Sleep( 5 * time.Second)

    fmt.Println("主程序退出!!")

}

输出如下

监控器4,正在监控中...
监控器1,正在监控中...
监控器2,正在监控中...
监控器3,正在监控中...
监控器5,正在监控中...
监控器2,接收到通道值为:false,监控结束。
监控器3,接收到通道值为:false,监控结束。
监控器5,接收到通道值为:false,监控结束。
监控器1,接收到通道值为:false,监控结束。
监控器4,接收到通道值为:false,监控结束。
主程序退出!!

上面的例子,说明当我们定义一个无缓冲通道时,如果要对所有的 goroutine 进行关闭,可以使用 close 关闭通道,然后在所有的 goroutine 里不断检查通道是否关闭(前提你得约定好,该通道你只会进行 close 而不会发送其他数据,否则发送一次数据就会关闭一个goroutine,这样会不符合咱们的预期,所以最好你对这个通道再做一层封装做个限制)来决定是否结束 goroutine。

所以你看到这里,我做为初学者还是没有找到使用 Context 的必然理由,我只能说 Context 是个很好用的东西,使用它方便了我们在处理并发时候的一些问题,但是它并不是不可或缺的。

换句话说,它解决的并不是 能不能 的问题,而是解决 更好用 的问题。

3. 简单使用 Context

如果不使用上面 close 通道的方式,还有没有其他更优雅的方法来实现呢?

有,那就是本文要讲的 Context

我使用 Context 对上面的例子进行了一番改造。

package main

import (
    "context"
    "fmt"
    "time"
)

func monitor(ctx context.Context, number int)  {
    for {
        select {
        // 其实可以写成 case <- ctx.Done()
        // 这里仅是为了让你看到 Done 返回的内容
        case v :=<- ctx.Done():
            fmt.Printf("监控器%v,接收到通道值为:%v,监控结束。\n", number,v)
            return
        default:
            fmt.Printf("监控器%v,正在监控中...\n", number)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    for i :=1 ; i <= 5; i++ {
        go monitor(ctx, i)
    }

    time.Sleep( 1 * time.Second)
    // 关闭所有 goroutine
    cancel()

    // 等待5s,若此时屏幕没有输出 <正在监控中> 就说明所有的goroutine都已经关闭
    time.Sleep( 5 * time.Second)

    fmt.Println("主程序退出!!")

}

这里面的关键代码,也就三行

第一行:以 context.Background() 为 parent context 定义一个可取消的 context

ctx, cancel := context.WithCancel(context.Background())

第二行:然后你可以在所有的goroutine 里利用 for + select 搭配来不断检查 ctx.Done() 是否可读,可读就说明该 context 已经取消,你可以清理 goroutine 并退出了。

case <- ctx.Done():

第三行:当你想到取消 context 的时候,只要调用一下 cancel 方法即可。这个 cancel 就是我们在创建 ctx 的时候返回的第二个值。

cancel()

运行结果输出如下。可以发现我们实现了和 close 通道一样的效果。

监控器3,正在监控中...
监控器4,正在监控中...
监控器1,正在监控中...
监控器2,正在监控中...
监控器2,接收到通道值为:{},监控结束。
监控器5,接收到通道值为:{},监控结束。
监控器4,接收到通道值为:{},监控结束。
监控器1,接收到通道值为:{},监控结束。
监控器3,接收到通道值为:{},监控结束。
主程序退出!!

4. 根Context 是什么?

创建 Context 必须要指定一个 父 Context,当我们要创建第一个Context时该怎么办呢?

不用担心,Go 已经帮我们实现了2个,我们代码中最开始都是以这两个内置的context作为最顶层的parent context,衍生出更多的子Context。

var (
    background = new(emptyCtx)
    todo       = new(emptyCtx)
)

func Background() Context {
    return background
}

func TODO() Context {
    return todo
}

一个是Background,主要用于main函数、初始化以及测试代码中,作为Context这个树结构的最顶层的Context,也就是根Context,它不能被取消。

一个是TODO,如果我们不知道该使用什么Context的时候,可以使用这个,但是实际应用中,暂时还没有使用过这个TODO。

他们两个本质上都是emptyCtx结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的Context。

type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
    return
}

func (*emptyCtx) Done() <-chan struct{} {
    return nil
}

func (*emptyCtx) Err() error {
    return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
    return nil
}

5. Context 的继承衍生

上面在定义我们自己的 Context 时,我们使用的是 WithCancel 这个方法。

除它之外,context 包还有其他几个 With 系列的函数

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

这四个函数有一个共同的特点,就是第一个参数,都是接收一个 父context。

通过一次继承,就多实现了一个功能,比如使用 WithCancel 函数传入 根context ,就创建出了一个子 context,该子context 相比 父context,就多了一个 cancel context 的功能。

如果此时,我们再以上面的子context(context01)做为父context,并将它做为第一个参数传入WithDeadline函数,获得的子子context(context02),相比子context(context01)而言,又多出了一个超过 deadline 时间后,自动 cancel context 的功能。

接下来我会举例介绍一下这几种 context,其中 WithCancel 在上面已经讲过了,下面就不再举例了

例子 1:WithDeadline
package main

import (
    "context"
    "fmt"
    "time"
)

func monitor(ctx context.Context, number int)  {
    for {
        select {
        case <- ctx.Done():
            fmt.Printf("监控器%v,监控结束。\n", number)
            return
        default:
            fmt.Printf("监控器%v,正在监控中...\n", number)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    ctx01, cancel := context.WithCancel(context.Background())
    ctx02, cancel := context.WithDeadline(ctx01, time.Now().Add(1 * time.Second))

    defer cancel()

    for i :=1 ; i <= 5; i++ {
        go monitor(ctx02, i)
    }

    time.Sleep(5  * time.Second)
    if ctx02.Err() != nil {
        fmt.Println("监控器取消的原因: ", ctx02.Err())
    }

    fmt.Println("主程序退出!!")
}

输出如下

监控器5,正在监控中...
监控器1,正在监控中...
监控器2,正在监控中...
监控器3,正在监控中...
监控器4,正在监控中...
监控器3,监控结束。
监控器4,监控结束。
监控器2,监控结束。
监控器1,监控结束。
监控器5,监控结束。
监控器取消的原因:  context deadline exceeded
主程序退出!!
例子 2:WithTimeout

WithTimeout 和 WithDeadline 使用方法及功能基本一致,都是表示超过一定的时间会自动 cancel context。

唯一不同的地方,我们可以从函数的定义看出

func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithDeadline 传入的第二个参数是 time.Time 类型,它是一个绝对的时间,意思是在什么时间点超时取消。

而 WithTimeout 传入的第二个参数是 time.Duration 类型,它是一个相对的时间,意思是多长时间后超时取消。

package main

import (
    "context"
    "fmt"
    "time"
)

func monitor(ctx context.Context, number int)  {
    for {
        select {
        case <- ctx.Done():
            fmt.Printf("监控器%v,监控结束。\n", number)
            return
        default:
            fmt.Printf("监控器%v,正在监控中...\n", number)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    ctx01, cancel := context.WithCancel(context.Background())

    // 相比例子1,仅有这一行改动
    ctx02, cancel := context.WithTimeout(ctx01, 1* time.Second)

    defer cancel()

    for i :=1 ; i <= 5; i++ {
        go monitor(ctx02, i)
    }

    time.Sleep(5  * time.Second)
    if ctx02.Err() != nil {
        fmt.Println("监控器取消的原因: ", ctx02.Err())
    }

    fmt.Println("主程序退出!!")
}

输出的结果和上面一样

监控器1,正在监控中...
监控器5,正在监控中...
监控器3,正在监控中...
监控器2,正在监控中...
监控器4,正在监控中...
监控器4,监控结束。
监控器2,监控结束。
监控器5,监控结束。
监控器1,监控结束。
监控器3,监控结束。
监控器取消的原因:  context deadline exceeded
主程序退出!!
例子 3:WithValue

通过Context我们也可以传递一些必须的元数据,这些数据会附加在Context上以供使用。

元数据以 Key-Value 的方式传入,Key 必须有可比性,Value 必须是线程安全的。

还是用上面的例子,以 ctx02 为父 context,再创建一个能携带 value 的ctx03,由于他的父context 是 ctx02,所以 ctx03 也具备超时自动取消的功能。

package main

import (
    "context"
    "fmt"
    "time"
)

func monitor(ctx context.Context, number int)  {
    for {
        select {
        case <- ctx.Done():
            fmt.Printf("监控器%v,监控结束。\n", number)
            return
        default:
            // 获取 item 的值
            value := ctx.Value("item")
            fmt.Printf("监控器%v,正在监控 %v \n", number, value)
            time.Sleep(2 * time.Second)
        }
    }
}

func main() {
    ctx01, cancel := context.WithCancel(context.Background())
    ctx02, cancel := context.WithTimeout(ctx01, 1* time.Second)
    ctx03 := context.WithValue(ctx02, "item", "CPU")

    defer cancel()

    for i :=1 ; i <= 5; i++ {
        go monitor(ctx03, i)
    }

    time.Sleep(5  * time.Second)
    if ctx02.Err() != nil {
        fmt.Println("监控器取消的原因: ", ctx02.Err())
    }

    fmt.Println("主程序退出!!")
}

输出如下

监控器4,正在监控 CPU
监控器5,正在监控 CPU
监控器1,正在监控 CPU
监控器3,正在监控 CPU
监控器2,正在监控 CPU
监控器2,监控结束。
监控器5,监控结束。
监控器3,监控结束。
监控器1,监控结束。
监控器4,监控结束。
监控器取消的原因:  context deadline exceeded
主程序退出!!

6. Context 使用注意事项

  1. 通常 Context 都是做为函数的第一个参数进行传递(规范性做法),并且变量名建议统一叫 ctx

  2. Context 是线程安全的,可以放心地在多个 goroutine 中使用。

  3. 当你把 Context 传递给多个 goroutine 使用时,只要执行一次 cancel 操作,所有的 goroutine 就可以收到 取消的信号

  4. 不要把原本可以由函数参数来传递的变量,交给 Context 的 Value 来传递。

  5. 当一个函数需要接收一个 Context 时,但是此时你还不知道要传递什么 Context 时,可以先用 context.TODO 来代替,而不要选择传递一个 nil。

  6. 当一个 Context 被 cancel 时,继承自该 Context 的所有 子 Context 都会被 cancel。

7. 参考文章

image0

4.10 如何实现一个协程池?

协程池的实现

type Pool struct {
  work chan func() // 任务  sem  chan struct{} // 数量
}
func New(size int) *Pool {
    return &Pool{
        work: make(chan func()),
        sem:  make(chan struct{}, size),
    }
}
func (p *Pool) Schedule(task func()) error { // 任务调度
    select {
    case p.work <- task:
    case p.sem <- struct{}{}:
        go p.worker(task)
    }
}
func (p *Pool) worker(task func()) { // 任务执行
    defer func() { <-p.sem }
    for {
        task()
        task = <-p.work
    }
}

协程池的调用

pool := gopool.New(128)
pool.Schedule(func(){
  fmt.println("task run")
})

http://image.python-online.cn/image-20200320125724880.png

第五章:Web实战

这一章的内容主要是教大家如何使用 Go 自带的 web 框架: gin 完成博客的搭建。

本章节,会持续更新,敬请关注…


5.1 Gin 实战:Hello World

1. Go 环境的准备

打开你的 Goland,设置好 GOPATH,我这里设置的是 /Users/MING/Code/Golang

设置 GOPATH

image-20200419171345699

image-20200419171345699

开启 GO MODULES,并设置代理

image-20200419165821668

image-20200419165821668

然后在终端下,使用 go get 下载安装 gin 框架

$ go get -u github.com/gin-gonic/gin

进入 gin-example 目录,使用 go mod init 命令进行项目的初始化。

$ go mod init github.com/iswbm/gin-example
go: creating new go.mod: module github.com/iswbm/gin-example

2. 简单的示例

在 gin-example 根目录下,新建一个 main.go 的文件

package main

import (
    "github.com/gin-gonic/gin"
)


func main(){
    r :=gin.Default()
    r.GET("/ping", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "message": "pong",
        })
    })

    r.Run("localhost:8080")
}

将上面这段代码放入 Goland 中后,如果碰到和我一样的情况(如下图),请将光标放在导入的这行,并使用快捷键 Option + Enter (Mac OS)同步包的信息。

image-20200419173514746

image-20200419173514746

确保在 Goland 中没有红色字体后,点击那个运行按钮启动程序

image-20200419174031233

image-20200419174031233

或者你也可以使用 go run 命令来启动

$ go run main.go

从输出来看,它提示我们监听端口是 8080

image-20200419174203094

image-20200419174203094

接下来我们就可以测试一下刚刚写的HTTP接口是否可通。

方法有很多种

  1. 直接浏览器访问:http://localhost:8080/ping

  2. 使用 PostMan 发送请求

  3. 直接使用 Goland 中自带的 HTTP 客户端工具进行请求

这里为了方便,我使用第三种方法,步骤如下:

image-20200419174858761

image-20200419174858761

填写请求信息后,点击运行,效果如下:

image-20200419175221473

image-20200419175221473

3. 表的设计

用户(users)

字段

类型

说明

角色

id

int(10)

主键

name

varchar(100)

用户名

passwd

varchar(100)

密码

email

varchar(100)

邮箱

profile_picture

varchar(100)

头像路径

last_login

timestamp

最后登陆时间

create_time

timestamp

创建时间

update_times

timestamp

更新时间

delete_times

timestamp

删除时间

CREATE TABLE `users` (
    `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
    `name` varchar(100) NOT NULL COMMENT '用户名',
    `passwd` VARCHAR(100) NOT NULL COMMENT '密码',
    `email` VARCHAR(100) DEFAULT '' COMMENT '邮箱',
    `profile_picture` VARCHAR(10) COMMENT '头像路径',
    `last_login` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '最后登陆时间',
    `create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
    `delete_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '删除时间',

    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户管理';
标签(tags)

字段

类型

说明

属性

id

int(10)

主键

name

varchar(100)

标签名

count

int(10)

文章数量

create_time

timestamp

创建时间

update_time

timestamp

更新时间

CREATE TABLE `tags` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(100) DEFAULT '' COMMENT '标签名称',
  `count` int(4) DEFAULT '0' COMMENT '文章数量',
  `create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `delete_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '删除时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='文章标签管理';
分类(categories)

字段

类型

说明

属性

id

int(10)

主键

name

varchar(100)

分类名

count

int(10)

文章数量

create_time

timestamp

创建时间

update_time

timestamp

更新时间

CREATE TABLE `categories` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(100) DEFAULT '' COMMENT '分类名称',
  `count` int(4) DEFAULT '0' COMMENT '文章数量',
  `create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `delete_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '删除时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='文章分类管理';
文章(articles)

字段

类型

说明

属性

id

int

不为空,自增长

主键

title

varchar

标题

tag_id

int(10)

标签

外键

category_id

int(10)

类别

外键

author_id

int(10)

作者

外键

content

text

正文

update_time

timestamp

更新时间

create_time

timestamp

发布时间

delete_time

timestamp

发布时间

page_view

int(10)

浏览量

like_count

int(10)

点赞数

CREATE TABLE `articles` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `tag_id` int(10) unsigned COMMENT '标签ID',
  `category_id` int(10) unsigned COMMENT '文章分类id',
  `author_id` int(10) unsigned COMMENT '作者id',
  `title` varchar(100) DEFAULT '' COMMENT '文章标题',
  `content` text COMMENT '文章正文',
  `create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `delete_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '删除时间',
  `page_view` int(10) unsigned DEFAULT '0' COMMENT '文章浏览量',
  `like_count` int(10) unsigned DEFAULT '0' COMMENT '文章点赞数',
  PRIMARY KEY (`id`),
    FOREIGN KEY(category_id) references categories(id),
  FOREIGN KEY(tag_id) references tags(id),
    FOREIGN KEY(author_id) references users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='文章管理';

由于有外键的约束,所以请先创建标签表和分类表后再执行文章表的创建

CREATE TABLE `categories` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(100) DEFAULT '' COMMENT '分类名称',
  `count` int(4) DEFAULT '0' COMMENT '文章数量',
  `create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `delete_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '删除时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='文章分类管理';

CREATE TABLE `tags` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(100) DEFAULT '' COMMENT '标签名称',
  `count` int(4) DEFAULT '0' COMMENT '文章数量',
  `create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `delete_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '删除时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='文章标签管理';


CREATE TABLE `users` (
    `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
    `name` varchar(100) NOT NULL COMMENT '用户名',
    `passwd` VARCHAR(100) NOT NULL COMMENT '密码',
    `email` VARCHAR(100) DEFAULT '' COMMENT '邮箱',
    `profile_picture` VARCHAR(10) COMMENT '头像路径',
    `last_login` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '最后登陆时间',
    `create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
    `delete_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '删除时间',

    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户管理';


CREATE TABLE `articles` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `tag_id` int(10) unsigned COMMENT '标签ID',
  `category_id` int(10) unsigned COMMENT '文章分类id',
  `author_id` int(10) unsigned COMMENT '作者id',
  `title` varchar(100) DEFAULT '' COMMENT '文章标题',
  `content` text COMMENT '文章正文',
  `create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间',
  `delete_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '删除时间',
  `page_view` int(10) unsigned DEFAULT '0' COMMENT '文章浏览量',
  `like_count` int(10) unsigned DEFAULT '0' COMMENT '文章点赞数',
  PRIMARY KEY (`id`),
    FOREIGN KEY(category_id) references categories(id),
  FOREIGN KEY(tag_id) references tags(id),
    FOREIGN KEY(author_id) references users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='文章管理';

以下两个表,以后有需求再加入,现在暂不考虑

评论(comments)

字段

类型

说明

角色

id

int(10)

主键

post_in

int(10)

外键

created_by

int(10)

外键

comment

text

评论

create_time

timestamp

创建时间

update_time

timestamp

更新时间

CREATE TABLE `comments` (
  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
    `post_in` int(10) UNSIGNED COMMENT '文章id',
  `created_by` int(10) UNSIGNED COMMENT '评论者id',
  `comment` text COMMENT '评论内容',
  `create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `delete_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '删除时间',
  PRIMARY KEY (`id`),
    FOREIGN KEY (`created_by`) REFERENCES users(id),
    FOREIGN KEY (`post_in`) REFERENCES articles(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='评论管理';

4. 接口整理

文章
新增文章
Request
  • Method: POST

  • URL: /v1.0/article

  • Headers: Content-Type:application/json

  • Body:

{
  "title": "如何从零开始学习 Golang ?",
  "tag": "Golang",
  "category": "Golang 学习",
  "author": "王炳明",
  "content": "正文"
}
Response
  • Body

{
  "code": 200,
  "message": "OK"
}
删除文章
Request
  • Method: DELETE

  • URL: /v1.0/article

  • Headers: Content-Type:application/json

  • Body:

{
  "id": "0001"
}
Response
{
  "code": 200,
  "message": "OK"
}
更新文章
Request
  • Method: UPDATE

  • URL: /v1.0/article

  • Headers: Content-Type:application/json

  • Body:

{
  "id": "0001",
  "title": "如何从零开始学习 Golang ?",
  "tag": "Golang",
  "category": "Golang 学习",
  "author": "王炳明",
  "content": "正文",
}
Response
{
  "code": 200,
  "message": "OK"
}

5. 项目结构解析

以之前创建的项目目录为根目录,继续创建如下目录

  • conf:用于存储配置文件

  • middleware:应用中间件

  • models:应用数据库模型

  • pkg:存放各种代码包

  • routers:路由逻辑处理

  • runtimestamp:应用运行时数据


http://image.python-online.cn/image-20200320125724880.png

第六章:暂未分类

这一章的内容还未定,请期待

本章节,会持续更新,敬请关注…


6.1 整理了 20 个学习 Go 语言的精品网站

1. Go 编程时光

这是我的个人网站,我在公众号《Go编程时光》发表的所有文章都会同步到该网站上。

公众号的文章发表后,有且仅有一次修改的机会,而且最多只能修改 20 个字,相当于只能应付错别字,如果观点表述或者个人理解有误,是无法大篇幅修改的。

因此,我会将这勘误部分更新至这个网站,确保文章的准确无误,目前网站的内容也会随着公众号的文章的发布而逐渐丰富起来。

所以推荐你里把我的个人网站收藏一下,完全可以当做 wiki 使用。

网站链接http://golang.iswbm.com/

image0

2. Go 语言入门教程

C语言中文网的系列教程,对新手非常友好的一个入门教程,很多内容我也是从这里学习的,推荐大家看看。

网站链接http://c.biancheng.net/golang/

image1

3. 菜鸟教程

菜鸟教程(RUNOOB)是一个一站式编程入门学习网站,想当年我学习 Python 时也经常 在这里同样也有 Go 语言的系列。

网站链接https://www.runoob.com/go/go-tutorial.html

image2

4. W3Cschool

W3Cschool 也是一个专业的编程入门学习及技术文档查询应用,提供包括HTMLCSSJavascriptjQueryCPHPJavaPythonSql,MySQL,Golang 等编程语言和开源技术的在线教程及使用手册,是类似国外w3schools的学习社区及菜鸟编程平台。

网站链接https://www.w3cschool.cn/go/

image3

5. 易百教程

和菜鸟教程,W3Cschool一样,易百教程也是一个一站式的编程语言入门教程网站,目前为止,共发布了 157 个系列教程,每个教程都很适合新手学习。

网站链接https://www.yiibai.com/go/

image4

6. Go by Example

这个网站的 idea 非常好,网站里收集了很多的小例子,来帮助你快速了解 Go 语言里那些基础的知识点。不过要深入理解这些知识,还是需要你学习更多的资料,并加以练习。

网站链接https://gobyexample-cn.github.io/

image5

7. TopGoer 教程

这个网站是我在搜索资料的时候偶然发现的,从目录可以看出内容非常多,网络编程、并发编程,很多主流的 Web 框架都有。

网站链接http://www.topgoer.com/

image6

8. Go命令教程

go 的命令非常多,如果想系统的学习,推荐郝林的 Go 命令教程,非常的全。

网站链接

1、 https://hyper0x.github.io/go_command_tutorial/#/

2、https://wiki.jikexueyuan.com/project/go-command-tutorial/0.0.html

image7

9. Uber 编程规范

Uber 开源了其公司内部使用的《Go 语言编程规范》。该指南是为了使代码库更易于管理,同时让工程师有效地使用 Go 语言特性。文档中详细描述了在 Uber 编写 Go 代码的各种注意事项,包括具体的“Dos and Don’ts of writing Go code at Uber”,也就是 Go 代码应该怎样写、不该怎样写。

网站链接

英文原文:https://github.com/uber-go/guide/blob/master/style.md

中文译文:https://www.infoq.cn/article/G6c95VyU5telNXXCC9yO

image8

10. Go Walker

Go Walker 是一个可以在线生成并浏览 Go 项目 API 文档的 Web 服务器,目前已支持包括 GitHub 等代码托管平台。

image9

11. The Way to Go

《the way to go》的 中文版本,目前还在持续更新中,截止今天(2020/04/30)翻译进度已经达到 96.92%。

网站链接https://learnku.com/docs/the-way-to-go

image10

12. Go语言圣经

本书由《C程序设计语言》 的作者Kernighan和谷歌公司Go团队合作编写而成,是关于Go语言编程的权威著作。

网站链接https://books.studygolang.com/gopl-zh/

image11

13. 跟煎鱼学Go

煎鱼大佬的博客,跟着学习了很多的干货。良心推荐一波。

网站链接https://eddycjy.gitbook.io/golang/

image12

14. mojotv 进阶系列

一个致力于 Go 语言编程知识分享的高质量网站,里面有大量关于 Go 进阶的文章,此外还有 Python、 Docker ,K8S ,算法的文章。我试图在网站上找出作者的相关信息,不过并没有什么收获。

网站链接https://mojotv.cn/404#Golang

image13

15. 极客学院

极客学院,这个网站里收集了很多领域的编程语言相关的教程,在这里依然可以找到不少 Go 的教程。

我不知道这些内容是否获得授权,但对于要学习需求的同学来说,确实是一个不错的网站。

网站链接https://wiki.jikexueyuan.com/list/go/

image14

16. Go 夜读

这是一个由众多资深 Gopher 组织的开源项目,主要是对 Go 源码进行解读,并约定每周四晚上进行技术分享。

网站链接https://talkgo.org/

image15

17. CTOLib 码库

网站链接https://www.ctolib.com/go/categories/go-guide.html

image16


18. Go 语言简明教程

网站链接https://geektutu.com/post/quick-golang.html

image17

20. Go 语言中文网

Go 语言爱好者的聚集地,是目前最大的Go 语言中文社区,关于 Go 语言 你所需要的,不需要的,都可以在这里找得到,包括 Go历史版本的下载,各种高质量的电子书资源,各种大牛写的高质量文章等。

网站链接https://studygolang.com/

image19

21. Go 语言设计与实现

网站链接https://draveness.me/golang/

image20

image21

6.2 配置高颜值 IDE:Goland


http://image.python-online.cn/image-20200320125724880.png

关于作者


http://image.python-online.cn/image-20200320125724880.png

Roadmap

2020/03/15:

|    github项目搭建,readthedocs文档生成。
|    整个项目的框架完成

http://image.python-online.cn/image-20200320125724880.png

http://image.python-online.cn/image-20200320125724880.png