Go开篇

版本的特性:

  • Go1.5 自举,即通过Go语言编写程序来实现Go语言自身

  • Go1.7 极速垃圾回收器

  • Go1.10 对自带工具全面升级,做程序依赖管理的go mod命令

GOPATH和工作区

环境变量:

  • GOROOT:Go语言安装根目录的路径,即Go语言的安装路径

  • GOPATH:若干工作区目录的路径,基于Go Module,用户自定义的工作空间可以在任何地方

  • GOBIN:Go程序生成的可执行文件的路径

GOPATH是Go语言的工作目录,它的值是一个或多个目录的路径,每个目录都代表Go语言的一个工作区(Workspace)。

GOPATH工作区存放如下文件:

  1. Go语言的源码文件(source file)

  2. 安装后的归档文件(archive file,以.a为扩展名的文件)

  3. 可执行文件

事实上,Go语言项目在其生命周期内的所有操作(编码、依赖管理、构建、测试、安装等)基本上都是围绕着GOPATH和工作区进行的。

GOPATH背后有三个知识点:

  1. Go语言源码的组织方式

  2. 源码安装后的结果(只有在安装后,Go语言源码才能被其他代码使用)

  3. 构建和安装Go程序的过程(开发问题以及查找问题的时候非常有用)

源码的组织方式

Go语言的源码以代码包为基本组织单位,在文件系统中,这些代码包是与目录一一对应的(目录有子目录,代码包有子包)。

  • 一个代码包中包含多个以.go为扩展名的源码文件,这些源码文件都需要被声明属于同一个代码包

  • 代码包的名称一般会与源码文件所在的目录同名。(如果不同名在构建安装过程中以代码包的名字为准

  • 每个代码包都会有导入路径(其他代码在使用该包中的程序实体时,需要引入的路径)

在实际使用程序实体之前,必须先导入其所在的代码包。具体方式就是import该代码包的导入路径:import "github.com/labstack/echo"在工作区中,一个代码包的导入路径实际就是从src子目录到该包的实际存储位置的相对路径

Go语言源码的组织方式就是以环境变量GOPATH、工作区、src目录和代码包为主线的。

一般情况下,Go语言的源码文件都需要被存放在环境变量GOPATH包含的某个工作区的src目录的某个包中。

  • 源码文件会存放在工作区的src子目录中,

  • 安装后的归档文件会存放在工作区的pkg子目录中,

  • 如果安装后产生可执行文件,会放在该工作区的bin子目录中。

源码文件是以代码包的形式组织起来的,一个代码包对应一个目录,安装某个代码包而产生的归档文件是与这个代码包同名的,放置它的相对目录就是该代码包的导入路径的直接父级

如下一个已经存在的代码包的导入路径如下:

# 该代码包的源码文件存在GitHub网站的labstack用户的代码仓库echo中
github.com/labstack/echo


# 执行安装命令
go install github.com/labstack/echo

# 生成的归档文件的相对目录是
$GOPATH/src/github.com/labstack

注意:在go mod发布之前,在归档文件的相对目录与pkg目录之间还有一级目录,叫做平台相关目录。平台相关目录的名称是由build(构建)过程的:目标操作系统+下划线+目标计算机架构的代号三部分组成。

总之,某个工作区的src子目录下的源码文件在安装之后,一般会被放置在当前工作区的pkg子目录下对应的目录中,或者被直接放置在该工作区的bin子目录中。

  • 构建使用go build

    1. 如果构建的是库源码文件,那么操作后产生的结果文件只会存在于临时目录中。这里的构建主要意义在于检查和验证

    2. 如果构建的是命令源码文件,那么操作的结果文件会被搬运到源码文件所在的目录中。

  • 安装使用go install,安装操作会先执行构建,然后还会进行链接操作,并且把结果文件搬运到指定目录。

    1. 如果安装的是库源文件,那么结果文件会被搬运到它所在工作区的pkg目录下的某个子目录中。

    2. 如果安装的是命令源文件,那么结果文件会被搬运到它所在工作区的bin目录中,或者环境变量GOBIN指向的目录中。

构建和安装代码包的过程中都会执行编译、打包等操作,并且操作生成的任何文件都会先被保存在某个临时的目录中。

Go Module

go.mod 文件生成之后,会被 go toolchain 掌控维护,在执行:

  • go run

  • go build

  • go get:将会使用 Git 等代码工具远程获取代码包,并自动完成编译和安装到 GOPATH/binGOPATH/pkg 目录下

  • go mod

    • init:初始化

    • download:在手动修改go.mod文件后,手动更新项目的依赖关系

    • tidy:与download类似,但是会移除掉go.mod文件中没有被使用的require模块

各类命令执行时自动修改和维护 go.mod 文件中的依赖内容。

可以通过 Go Modules 引入远程依赖包,如 Git Hub 中开源的 Go 开发工具包。但可能会由于网络环境问题,在拉取 GitHub 中的开发依赖包时,有时会失败,可以使用七牛云搭建的 GOPROXY,方便在开发中更好地拉取远程依赖包。在项目目录下执行以下命令即可配置新的 GOPROXY:go env -w GOPROXY=https://goproxy.cn,direct

go.mod文件中的关键字:

  • require:为项目引入版本是 vX.Y.Z 的依赖包,该依赖包可以在开发中引入使用

  • replace:替换依赖模块

  • exclude:忽略依赖模块

命令源码文件

环境变量GOPATH指向的是一个或多个工作区,每个工作区中都会有以代码包为基本组织形式的源码文件。这里的源码文件分为三种:命令源码文件、库源码文件、测试源码文件,它们有着不同的用途和编写规则。

命令源码文件是独立的程序入口,属于main包,包含无参数无结果的main函数。可通过go run 命令运行,可接受命令行参数,main函数执行结束意味着当前程序运行结束。同一个代码包中不要放多个命令源码文件,命令源码文件与库码源文件也不要放在同一个代码包中。

构建后生成可执行文件(executable file),可在命令行中运行的文件。在Windows中就是扩展名为.exe的文件,在Linux中一般没有扩展名,生成位置在命令执行目录。

安装后生成可执行文件,生成位置在当前工作区的bin子目录或GOPATH包含的目录。

在学习Go语言的过程中,经常会编写可以直接运行的程序,这样的程序肯定会涉及命令源码文件的编写,命令源码文件可以很方便地使用go run 命令启动。

命令源码文件是程序的运行入口,是每个可独立运行的程序必须拥有的。通过构建或安装,生成与其对应的可执行文件,后者一般会与该命令源码文件的直接父目录同名。

  1. 一个源码文件声明属于main包

  2. 包含一个无参数声明且无结果声明的main函数

那么它就是一个命令源码文件。如下所示:

package main

import "fmt"

func main() {
  fmt.Println("Hello, world!")
}

// 执行go run命令后在标准输中显示
Hello, world!

当需要模块化编程的时候,往往会将代码拆分到多个文件,甚至拆分到不同的代码包中。无论怎样,对于一个独立的程序来说,命令源码文件永远只有也只能有一个。如果有与命令源码文件同包的源码文件,那么它们也应该声明属于main包。

不论是什么操作系统,在命令行中执行的命令都是可以接收参数的。通过构建或安装命令源码文件,生成的可执行文件就可以被看作是命令,所以它也具备接收参数的能力。

接收参数

package main

import (
    // 需在此处添加代码。[1]
    "fmt"
    "flag"
)

var name string

func init() {
    // 需在此处添加代码。[2]
    flag.StringVar(&name, "name", "everyone", "The greeting object.")
}

func main() {
    // 需在此处添加代码。[3]
    flag.Parse()
    fmt.Printf("Hello, %s!\n", name)
}

在注释处编写代码,完成“根据运行程序给定的参数问候某人”的功能。

  1. Go语言标准库中有一个代码包(flag包)专门用于接收和解析命令参数。为了调用这个包中的程序实体来读取命令行参数,首先需要先将这个包导入。代码包的名字需要用英文半角的引号引起来

  2. 人名都是由字符串组成,因此调用flag包中的StringVar函数。函数flag.StringVar接收4个参数:

    • 第一个参数用于存储该命令参数值的地址,即变量name的地址(&name)

    • 第二个参数用于指定该命令参数的名称,即name

    • 第三个参数用于指定在未追加该命令参数时的默认值,即everyone

    • 第四个参数用于对该命令参数进行说明,这在打印命令说明时用到

  3. 在主函数中调用flag.Parse()函数,用于真正解析命令参数,并把它的值赋给相应的变量。对该函数的调用必须在所有命令参数存储载体的声明(即name变量)和设置(即flag.StringVar函数调用)之后,并且在读取任何命令参数值之前。所以在此处把Parse的调用放在main函数的第一行。

flag还有一个String函数,直接返回一个已经分配好的用于存储命令参数值的地址。

如果使用flag.String,进行如下修改:

package main

import (
 // 需在此处添加代码。[1]
    "fmt"
    "flag"
)

func init() {
    // 需在此处添加代码。[2]
    var name = flag.String("name", "everyone", "The greeting object.")
}

func main() {
 // 需在此处添加代码。[3]
 fmt.Printf("Hello, %s!\n", name)
}

假设上面的命令源文件名字为demo2.go。

  1. 运行如下命令,为参数name传值

     go run demo2.go -name=Robert
    
     # 运行后,在标准输出中打印如下内容:
    
     Hello,Robert!
  2. 运行如下命令,查看命令源码文件的参数说明

     go run demo2.go --help
    
     # 运行后,在标准输出中打印如下内容:
    
     Usage of /var/folders/ts/7lg_tl_x2gd_k1lm5g_48c7w0000gn/T/go-build155438482/b001/exe/demo2:
      -name string
         The greeting object. (default "everyone")
     exit status 2

    输出中的/var/folders/ts/7lg_tl_x2gd_k1lm5g_48c7w0000gn/T/go-build155438482/b001/exe/demo2是go run 命令构建上述命令源码文件时临时生成的可执行文件的完整路径。

    换个方式,先构建在执行:

     go build demo2.go
     ./demo2 --help
    
     # 运行后,在标准输出中打印如下内容:
    
     Usage of ./demo2:
      -name string
         The greeting object. (default "everyone")

自定义参数

有多种方式可以实现,最简单的是对变量flag.Usage重新赋值。flag.Usage的类型是func(),即一种无参数声明且无结果声明的函数类型。

flag.Usage变量在声明时就已经被赋值了,所以在运行上述go run demo2.go --help时看到正确的结果。flag.Usage的赋值必须在调用flag.Parse函数之前

在demo2.go的基础上修改demo3.go,在main函数的开始处添加如下代码:

flag.Usage = func(){
    fmt.Fprint(os.Stderr, "Usage of %s:\n", "question")
    flag.PrintDefault()
}

运行demo3.go:

go run demo3.go --help

# 在标准输出中打印如下内容

Usage of question:
 -name string
    The greeting object. (default "everyone")
exit status 2

在调用flag包中的一些函数(StringVar,Parse等)时,实际上是在调用flag.CommandLine变量的对应方法。

flag.CommandLine相当于默认情况下的命令参数容器。通过对flag.CommandLine重新赋值,可以更深层次地定制当前命令源码文件的参数使用说明。

修改demo2.go中的init函数体:

flag.CommandLine = flag.NewFlagSet("", flag.ExitOnError)
flag.CommandLine.Usage = func(){
    fmt.Fprint(os.Stderr, "Usage of %s:\n", "question")
    flag.PrintDefaults()
}

再次执行go run demo2.go --help输入的结果与demo3.go相同,不过这种方式更好,可以通过修改flag.NewFlagSet的第二个参数来实现不同输出效果的目的。如修改为flag.PanicOnError,这些都是flag包中的常量。

  • flag.ExitOnError:告诉命令参数容器,当命令后跟--help或者参数设置不正确的时候,在打印命令参数使用说明后以状态码2(表示用户错误的使用命令)退出当前程序。

  • flag.PanicOnError:与上面的区别在于,最后跑出一个运行时恐慌(panic)。

运行时恐慌是Go程序处理错误的方式。

不使用flag.CommandLine,自己创建一个私有的命令参数容器:

var cmdLine = flag.NewFlagSet("question", flag.ExitOnError)

然后,把flag.StringVar的调用替换为cmdLine.StringVar调用,再把flag.Parse()替换为cmdLine.Parse(os.Args[1:])

*flag.FlagSet类型的变量cmdLIne拥有很多有意思的方法。

这样的自定义更灵活,且不会影响到全局变量flag.CommandLine。通过上述方法,可以使用Go语言编写命令,并且像其他操作系统中的命令那样被使用,也可以嵌入到各种脚本中。

库源码文件

用于放置可供其他代码使用的程序实体。

构建的作用在于检查和验证,构建后只生成临时文件,在操作系统的临时目录下,开发者一般不关心。

安装后生成归档文件(archive file),扩展名为.a的文件,即为静态链接库文件,生成位置在当前工作区的pkg子目录。

库源码文件是不能被直接运行的源码文件,它仅用于存放程序实体,这些程序实体可以被其他代码使用(遵从Go语言规范)。

其他代码的位置:

  1. 与被使用的程序实体在同一个源码文件内,

  2. 在其他源码文件内,

  3. 在其他代码包中。

在Go语言中,程序实体是变量、常量、函数、结构体和接口的统称。我们总是先声明(或定义)程序实体,然后再去使用。程序实体的名字被统称为标识符(可以是任何Unicode编码可以表示的字母字符、数字以及下划线,但是首字母不能为数字)。从规则上来看,可以使用中文作为变量的名字

下面样例代码demo4.go是把命令源码文件中的代码拆分到其他库源码文件:

package main

import (
    "flag"
)

var name string

func init() {
    flag.StringVar(&name, "name", "everyone", "The greeting object.")
}

func main() {
    flag.Parse()
    hello(name)
}

函数hello()被声明在另外一个源码文件中:

// 需在此处添加代码。[1]

import "fmt"

func hello(name string) {
    fmt.Printf("Hello, %s!\n", name)
}

注释处需要填入package main,因为同一个目录下的源码文件需要被声明属于同一个代码包。如果该目录下有一个命令源码文件,那么为了让同一个目录下的文件都通过编译,其他源码文件应该被声明为属于main包。

代码包声明的基本原则:

  1. 同目录下的源码文件的代码包声明语句必须要一致。即它们要同属于一个代码包。这对所有源码文件都适用。如果目录中有命令源码文件,那么其他种类的源码文件也应该声明属于main包,这是可以成功构建并运行的前提。

  2. 源码文件声明的代码包的名称可以与其所在的目录的名称不同。在针对代码包进行构建时,生成的结果文件的主名称与其父母的名称一致

导入路径:源码文件所在的目录相对src目录的相对路径就是它的代码包导入路径,限定符:实际使用其程序实体时给定的限定符要与它声明所属的代码包名称对应。

通常情况下,总是让声明的包名与其父目录的名称一致。

名称的首字母大写的程序实体才可以被当前包外的代码引用,否则它就只能被当前包内的代码引用。

Go1.5之后的版本,通过创建internal代码包让一些程序实体仅仅能被当前模块中的其他代码引用。这是Go语言的第三种访问权限:模块级私有。internal代码包中声明的公开程序实体仅能被该代码的直接父包及其子包中的代码引用。引用之前先要导入这个internal包,对于其他代码包,导入行为是非法的,不能通过编译。

测试源码文件

功能测试源码文件:

  • 测试函数名称(TestXXX)

  • 测试函数签名(t *testing.T)

性能(基准)测试源码文件:

  • 测试函数名称(BenchmarkXXX)

  • 测试函数签名(b *testing.B)

示例(样本)测试源码文件:

  • 测试函数名称(ExampleXXX)

  • 测试函数签名(没有硬性要求)

  • 测试函数期望输出

    • 放置在函数末尾

    • 用注释行表示

    • 形如//Output:xxx

对于程序或软件的测试分为很多种:

  • 单元测试

  • API测试

  • 集成测试

  • 灰度测试

单元测试

单元测试也称为程序员测试,就是程序员本该做的自我检查工作之一。

Go程序提供了丰富的API和工具,可以创建测试源码文件,并为命令源码文件和库源码文件中的程序实体编写测试用例。在Go语言中,一个测试用例往往会由一个或多个函数来代表,大多数情况下,每个测试用例仅用一个测试函数(往往用于描述某个程序实体的某方面功能)就足够了。

Go程序可以编写三类测试:

  • 功能测试(test):

  • 基准测试(benchmark,性能测试):

  • 示例测试(example):也是功能测试的一种,更关注程序打印出来的内容

一般情况下,一个测试源码文件只会针对某个命令源码文件或者库文件做测试,所以应该把它们放在一个代码包中。测试源码文件的主名称以被测源码文件的主名称前导,并且,必须以“_test”为后缀。如demo.godemo_test.go

每个测试源码文件都必须包含一个测试函数,用来做任何一类测试,通常把三类测试都放在一起,把控好测试函数的分组和数量即可。分组依据:

  1. 依据测试函数针对的不同程序实体,把它们分成不同的逻辑组,利用注释以及帮助类的变量或测试函数来做分割

  2. 依据被测源码文件中程序实体的先后顺序,来安排测试源文件中测试函数的顺序

测试函数的名称和签名的规定:

  1. 功能测试函数:名称必须以Test为前缀,并且参数列表中只应有一个×testing.T类型的参数声明

  2. 性能测试函数:名称必须以Benchmark为前缀,并且唯一参数的类型必须是×testing.B类型

  3. 示例测试函数:名称必须以Example为前缀,函数的参数列表没有强制规定

go test命令运行的前置条件:

  1. 测试源码文件的名称对了

  2. 测试函数名称和签名对了

go test的执行流程:

  1. 准备工作:确定内部需要用到的命令,检查制定的代码包和源码文件的有效性,判断给予的标记是否合法

  2. 开始执行:针对每个被测试代码包,依次进行构建、执行包中符合要求的测试函数,清理临时文件,打印测试结果

注意是依次,对每个被测代码包go test命令会串行地执行测试流程中的每个步骤。但是为了加快测试速度,通常会并发地对多个被测代码包进行功能测试,只不过在最后打印测试结果的时候,会依照给定的测试逐个进行。并发地测试会让性能测试的结果出现偏差,所以性能测试一般是串行的(只要在所有构建步骤都做完之后,go test命令才会真正开始进行性能测试)

Go语言是一门重视测试的语言,不但自带testing包,还有专用于程序测试的go test命令。要想真正用好一个工具,必须先了解它的核心逻辑

功能测试

// 第一次的测试结果
ok   puzzlers/article20/q2 0.008s

// 第二次的测试结果
ok   puzzlers/article20/q2 (cached)

测试结果分为三个部分:

  1. ok:表示此次测试成功

  2. 被测代码包的导入路径

  3. 对该代码包的测试消耗的时间

再次运行测试命令,在测试结果中第三部分不再显示测试时间,而是显示(cached)。

  • 由于测试代码和被测试代码没有变化,所以go test会直接将缓存测试成功的结果打印出来。

  • 一旦有任何改动,缓存数据失效,go命令再次真正的执行操作。

go 命令通常会缓存程序构建的结果,以便在将来的构建中重用。运行go env GOCACHE命令来查看缓存目录的路径。缓存的数据总是能够正确地放反映出当时的各种源码文件、构建环境、编译器选项等的真实情况。

go 命令会定期清除最近未使用的缓存数据,执行go clean -cache命令手动删除缓存数据,执行go clean -testcache手动删除所有的测试结果缓存。

设置环境变量GODEBUG的值可以改变go命令的缓存行为。比如设置gocacheverify=1将导致go命令绕过任何的缓存数据,真正的执行操作并重新生成所有结果,然后再去检查新的结果与缓存数据是否一致。

程序实体

Go语言中的程序实体包括:

  • 常量

  • 变量

  • 函数

  • 结构体

  • 接口

Go语言是静态类型的编程语言,需要在声明变量常量的时候,指定它们的类型或给予足够的信息,让Go语言能够推导出它们的类型。

  • 常量的合法类型:Go语言预定义的基本类型

  • 变量的合法类型:

    1. Go语言预定义的类型

    2. 自定义的函数、结构体、接口

变量

// 变量声明方式的不同方式

var name string

var name="string"   // 类型推断 【编程语言在编译期自动解释表达式类型的能力】
                    // 表达式类型是对表达式求值后得到结果的类型
                    // 类型推断只能用于变量或常量的初始化

name := "string"    // 短变量声明 【只能在函数体内部使用】,也属于类型推断

类型推断在编译期执行,对程序的运行效率没有影响。不显式地指定变量或常量的类型,使得它可以被赋予任何类型的值,变量的类型在初始化的时候,由程序动态确定。

代码重构:通常把不改变某个程序与外界的任何交互方式和规则,而只改变内部实现的代码修改方式,重构对象(代码、函数、模块、系统),可以随意修改被重构部分的代码,而不影响调用它的部分。

Go是静态类型语言,所以一旦初始化变量就确定了类型,之后就不能再改变

使用短变量声明,可以对统一代码块中的变量进行重声明。

代码块的含义:

  1. 全域代码块

  2. 代码包

  3. 源文件

  4. 函数

  5. if、for、switch、select、case语句

  6. 空代码块

变量重声明的前提:

  1. 变量的类型在初始化时已经确定,重声明的类型必须与元类型相同,否则产生编译错误

  2. 变量的重声明只能发生在某一个代码块

  3. 变量的重声明只有使用短变量声明时才会发生,否则无法通过编译

  4. 声明并赋值的变量必须是多个,并且其中至少有一个是新的变量

使用短变量声明时可不用判断被赋值的多个变量中是否包含旧变量

作用域:

  1. 包级私有:代码包,代码块

  2. 模块级私有:代码包,代码块

  3. 公开:全域代码块

一个程序实体的作用域总是会被限制在某个代码块中,而这个作用域最大的用处,就是对程序实体的访问权限的控制。

  1. 代码引用变量的时候,总是会优先查找当前代码块中的变量,不包含子代码块。

  2. 如果当前代码块中没有声明以此为名的变量,那么程序沿着代码块的嵌套关系,从直接包含的当前块开始,一层一层地查找。

不会查找代码导入的其他包,除非代码包导入语句如下import . XXX,这表示让XXX包中公开的程序实体被视为当前源码文件中的实体。

不同代码块中的可重名变量与变量重声明中的变量的区别:

差异

变量重声明

可重名变量

代码块

统一代码块

不同代码块

数量

只有一个变量

多个变量

变量类型

类型不可变,与初始化时相同

类型任意

变量屏蔽

代码块嵌套,则出现变量屏蔽

判断变量类型的方法:

  • 类型断言表达式:x.(T),x代表要判断类型的值

  • 类型转换表达书:T(x),x源值,x的类型是源类型,T目标类型

在Go语言中interface{}代表空接口,任何类型都是它的实现类型。interface{}(x),将x转换为空接口的值。

{}的含义,空代码块或者空数据类型。

value,ok := interface{}(container).([]string)  //value:类型转换后的值,ok:断言是否成功

// value,ok := interface{}(<变量>).(<变量类型>)

// interface{}(<变量>),将变量的值转换为空接口的值
// .(<变量类型>),判断前者类型是否为变量类型

类型转换规则注意事项:

  1. 类型转换表达式T(x)中,x可以使变量,代表值的字面量,结果只有一个值的表达式

  2. 类型转换的合法性,大范围转为小范围会被截取

  3. 整数转string,如果对应的整数无有效的unicode代码点,转换后变成,在Unicode中专门替换未知的,不被认可的,无法展示的字符

  4. string类型与切片类型之间的互转:

    1. string->[]byte:UTF-8字符串被拆分为ASCII字节

    2. string->[]rune:UTF-8字符串被拆分为Unicode字符

关键字type声明自定义的各种类型,这些类型必须在Go语言基本类型和高级类型之内。

// MyString是string的别名类型,只是名称有差别,只要用于代码重构
type MyString = string


// byte是uint8的别名类型
// rune是int32的别名类型


type Mystring2 string   // 对类型在定义

// Mystring2是一个新的类型
// 此处string成为潜在类型
  1. 潜在类型相同的不同类型的值之间可以进行类型转换

  2. 潜在类型相同的不同类型的值之间不能判等

最后更新于

这有帮助吗?