Go单元测试与性能测试

在Go语言中做单元测试和性能测试是一件非常容易的事情——Go自带了测试工具包,testing包。

通常,测试代码和功能代码在同一个包中,测试代码以“_test”结尾。测试有两种类型,一种是单元测试(UnitTest),一种是性能测试(Benchmark)。接下来将会介绍一下如何写这两种测试。

单元测试

首先创建一个测试文件,命名foo_test.go(提示: 以test结尾

接下来是创建测试函数,Test_Foo(提示:测试函数以Test开头,传入*testing.T指针),代码如下:

func Test_Foo(t *testing.T) {

}

这里什么也没做,只是创建了一个空的测试函数,但确实是能运行的测试函数!如果你的编辑器支持测试提示(提示:vscode支持),那么你可以在函数上面看到一行小字,如下:

直接点击就可以运行测试。如果没有这种提示,那么可以执行命令:go test foo_test.go 来运行这个测试,测试的结果将会是:

ok      command-line-arguments  0.007s

做一个完整的测试和其他的语言或者和我们想象中的并没有什么差异。基本流程都是:传入我们假设的参数,执行待测试的函数,断言待测试的函数的输出是否和我们预期的相符合。

一个简单的单元测试的例子,测试max函数是否能返回正确的值。

func max(a int, b int) int {
	if a >= b {
		return a
	}
	return b
}

func Test_Foo(t *testing.T) {
	a := 1
	b := 2
	c := max(a, b)
	if c != 2 {
		t.Errorf("max()=%d, expected %d", c, 2)
	}
}

把 max函数 中的 if a>=b 改成 a<=b,测试将不会通过。

性能测试

性能测试用于测试某个函数或者某段代码的性能,测试代码以Benchmark开头,传入*testing.B指针。

针对上面的max函数创建一个性能测试,代码如下:

func Benchmark_Max(b *testing.B) {
	for i := 0; i < b.N; i++ {
		max(1, 2)
	}
}

如果你的编辑器支持,那么你将会在Benchmark上面看到一行小字

如果不支持,那么可以用命令行运行:go test foo_test.go –bench=.

Go笔记六:常用标准库

这是我的Go学习的第六篇笔记,也是Go入门的最后一篇笔记。在大多数语言中,了解了变量和数据类型,流程控制,函数,面向对象,再加上标准库,就可以用这门语言去写一些项目了。

首先让我想想,在工作中通常会用语言频繁处理什么问题或者处理什么数据?最常见的应该是各种字符串操作,日期和时间,读写文件、socket等IO相关的操作!

字符串处理 — Strings

String提供了一组处理字符串的操作,常用的有:

  1. 判断一个字符串是否在另一个字符串中
  2. 分割字符串为[]string和组合[]string为一个字符串
  3. 字符串替换

太多了,就不一一列举了,这里列出一些常用的字符串操作。

字符串判断

// 判断子串substr是否在s中
func Contains(s, substr string) bool

字符串分割与合并

// 根据sep将字符串分割成一个数组
func Split(s, sep string) []string
// 将数组a用sep拼接成一个字符串
func Join(a []string, sep string) string

字符串转换

// 转换成大写
func ToUpper(s string) string
// 转换成小写
func ToLower(s string) string

字符串清理

// 去除首尾的cut字符串
func Trim(s string, cut string) string
// 去除左侧的cut字符串
func TrimLeft(s string, cut string) string
// 去除右侧的cut字符串
func TrimRight(s string, cut string) string

字符串替换

// s字符串中的将old替换成new,替换n次,n<0不限制替换次数
func Replace(s string, old string, new string, n int) string

IO操作——fmt、os、ioutil

Go语言io操作涉及到不止一个包。

终端的输入输出 – fmt

// 格式化输入 空格作为分隔符
func Scanf(format string, a ...interface{})
// 从终端获取输入,存入参数中,空格和换行作为分隔
func Scan(a ...interface{})
// 从终端获取输入,存入参数,空格作为分隔,遇到换行结束
func Scanln(a ...interface{})

// 格式化输出
func Printf(format string, a ...interface{})
func Println(s string)

// 格式化字符串
func Sprintf(format string, a ...interface{}) string

文件读写 – os,ioutil

文件读写使用的是os包。Go提供了两个常见的打开模式,一个是只读模式,一个是只写模式,如下

// 只读打开文件,内部使用的是OpenFile并以只读的方式打开
func Open(name string) (*File, error)
// 只写模式打开文件,将会清空内容
func Create(name string) (*File, error)

更多的操作需要使用OpenFile来操作,OpenFile定义如下:

OpenFile(name string, flag int, perm FileMode) (*File, error)

其中flag可以是下面常量的任意组合,含义如下:

// 打开方式,必须要指定
O_RDONLY 只读模式
O_WRONLY 只写模式
O_RDWR   读写模式
// 打开行为,和上面的组合使用
O_APPEND 以追加方式打开文件,写入的数据将追加到文件尾。
O_CREATE 当文件不存在时创建文件。
O_EXCL 与 O_CREATE 一起使用,当文件已经存在时 Open 操作失败。
O_SYNC 以同步方式打开文件。每次 write 系统调用后都等待实际的物理 I/O 完成后才返回,默认(不使用该标记)是使用缓冲的,也就是说每次的写操作是写到系统内核缓冲区中,等系统缓冲区满后才写到实际存储设备。
O_TRUNC 如果文件已存在,打开时将会清空文件内容。必须于 O_WRONLY 或 O_RDWR 配合使用。截断文件,需要有写的权限。

以追加方式打开一个文件的方法在os包里面没有直接提供,我们可以尝试自己写一个,如下:

// OpenAppend 以追加写入的方式打开一个文件,不存在将会创建
func OpenAppend(name string) (*os.File, error) {
    return os.OpenFile(name, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
}

读写文件

将文件打开之后,接下来就是文件的读写。Go提供了一个ioutil应付简单的文件读写,ioutil是对os中文件读写的封装。文件读写方法如下(在iotuil包下)

// 读文件
func ReadFile(filename string) ([]byte, error)
// 写入文件
func WriteFile(filename string, data []byte, perm os.FileMode) error

日期和时间库

在程序里面,获取日期和时间,休眠n秒是一个非常高频的场景。Go里面提供了time包用于时间的获取。

// unix时间戳,先调用time.Now()生成当前时间对象
func Unix() int64
// unix纳秒时间戳
func UnixNano() int64
// 格式化日期
func Format() string
// 休眠一段时间,注意参数是纳秒
func Sleep(d Duration)

调用例子,打印当前unix时间戳

fmt.Println(time.Now().Unix())

格式化日志相对其他语言比较特殊,其中的layout是一个固定的日期:2006-01-02T15:04:05Z07:00,比如y-m-d H:i:s用这个表示就是2006-01-02 15:04:05。这个其实就是1234567这几个数字,对于这个日期是怎么来的感兴趣的话可以网上搜索一下。总体来说还是比较好记的。

以比较常见的日期格式格式化当前日期,例子如下

fmt.Println(time.Now().Format("2006-01-02 15:04:05"))

例子,休眠1秒

time.Sleep(1 * time.Second)

附:这里有一些常见的格式参考

ANSIC       = "Mon Jan _2 15:04:05 2006"
UnixDate    = "Mon Jan _2 15:04:05 MST 2006"
RubyDate    = "Mon Jan 02 15:04:05 -0700 2006"
RFC822      = "02 Jan 06 15:04 MST"
RFC822Z     = "02 Jan 06 15:04 -0700" // RFC822 with numeric zone
RFC850      = "Monday, 02-Jan-06 15:04:05 MST"
RFC1123     = "Mon, 02 Jan 2006 15:04:05 MST"
RFC1123Z    = "Mon, 02 Jan 2006 15:04:05 -0700" // RFC1123 with numeric zone
RFC3339     = "2006-01-02T15:04:05Z07:00"
RFC3339Nano = "2006-01-02T15:04:05.999999999Z07:00"
Kitchen     = "3:04PM"
// Handy time stamps.
Stamp      = "Jan _2 15:04:05"
StampMilli = "Jan _2 15:04:05.000"
StampMicro = "Jan _2 15:04:05.000000"
StampNano  = "Jan _2 15:04:05.000000000"

文件路径操作 —— path

path提供了路径的常见操作。

// 返回文件的扩展名
func Ext(path string) string
// 返回文件名
func Base(path string) string
// 返回文件目录
func Dir(path string) string

需要注意的是这些操作都不会检测文件是否会存在,都是普通的字符串操作。

正则 — regexp

Go的正则包为regexp。这里不讨论如何写正则表达式,只说明go的正则包要怎么使用。

正则匹配

所用到的方法有

// 编译正则表达式,使用perl正则语法
Compile(expr string) (*Regexp, error)

// 查找匹配项,匹配前n项,如果n小于0则匹配所有
func (re *Regexp) FindAllString(s string, n int) []string
func (re *Regexp) FindAll(b []byte, n int) [][]byte

// 匹配子串,匹配前n项,如果n<0则匹配所有
func (re *Regexp) FindAllStringSubmatch(s string, n int) [][]stringfunc (re *Regexp) FindAllSubmatch(b []byte, n int) [][][]byte

举个例子,匹配数字

r, err := regexp.Compile(`[0-9]`)
res := r.FindAllString("1234hello", -1)
// 如果等待匹配的是byte类型,则使用 r.FindAll()
// 输出
// [1234]

如果需要匹配子组(正则表达式有括号的情况),则需要用FindAllStringSubmatch

r1, _ := regexp.Compile(`h([0-9]+)`)
res1 := r1.FindAllStringSubmatch("h1234hah12", -1)
// 如果等待匹配的是byte类型,则使用 r.FindAllSubmatch()
fmt.Println(res1)
// 输出
// [[h1234 1234] [h12 12]]

Go笔记五:goroutine和channel

这是我Go学习的第五篇笔记,学习的是go的语言的其他特性,这些特性是其他语言所不具备的。这次主要学习的是goroutine和channel。

我的语言学习过程一般分为下面几个:

1. 变量和数据类型
2. 流程控制方法
3. 函数声明和调用
4. 面向对象
5. 语言特性
6. 常用标准库

goroutine介绍和使用

Go语言中,每个并发执行的单元称为goroutine(可类比线程)。当一个程序启动时候,main函数在一个main goroutine中运行。如果想要创建新的goroutine,使用go关键字!

语法

创建一个新的 goroutine

go 函数名()

channel是goroutine的通信机制,比如创建一个能够接收int类型的channel,使用make创建:

// 创建一个channel
ch := make(chan int)

// 将i发送到channel
i := 1
ch <- i

// 从channel中接收数据
j := <-ch

// 丢弃channel中的数据
<-ch

同样,可以定义接收任意数据类型的channel

ch := make(chan interface{})

使用

并发通常应用在io操作较密集的地方,比如发起多个网络请求!通常串行发起网络请求是很慢的,大部分时间浪费在等待网络io上了。通过并发去请求能够显著提高整体耗时!

package main

import (
	"fmt"
	"io/ioutil"
	"net/http"
)

// 发起一个http get请求
// 把获取到的内容写入到channel
func httpget(url string, chc chan map[string]string) {
	resp, err := http.Get(url)
	if err != nil {
		// 错误处理
	}
	defer resp.Body.Close()
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		// 错误处理
	}
	var i = make(map[string]string)
	i[url] = string(body)
	chc <- i
}

func main() {
	chc := make(chan map[string]string)
	go httpget("http://www.baidu.com/", chc)
	go httpget("http://www.qq.com/", chc)
	// 等待完成
	var content map[string]string
	content = <-chc
	fmt.Println(content)
	content = <-chc
	fmt.Println(content)
}

Go笔记四:面向对象

这是我的Go学习笔记的第四篇,面向对象!现代语言几乎都会面向对象进行了支持!当然,Go也具备面向对象的特性!

我的语言学习过程一般分为下面几个:

1. 变量和数据类型
2. 流程控制方法
3. 函数声明和调用
4. 面向对象
5. 语言特性
6. 标准库

Go语言中的面向对象有点特殊。在Go语言里面,没有显式的class、extends等面向对象语言经常使用的关键词,但是却有面向对象的特性。看看Go怎么实现的把!

创建一个类

按照我的理解,类实际上就是某种模板,这个模板里面含有有限多个属性和方法。在Go里面,定义这个模板的语法使用type来实现!

比如单个int类型可以构成一个类(没错,你甚至可以在int数据类型上定义一些操作)

type num int

比如某几种复合类型构成的类

type human struct {
    name string
    age int
    weight int
    stature int
    ...
}

上面的type姑且称之为类,方法之后会通过某种方式绑定在这个类上!

创建一个方法

函数声明的时,在其前方增加一个特定类型的变量,即创建了一个属于这个变量类型的方法。

这个特定类型的变量称为方法接收器(Receiver),可以类比为一些面向对象语言里面的this,self!通常的做法是用类型的首字母作为方法接收器的名字。

比如下面的一个例子,在函数toAbs前面增加一个命名为n的num类型变量,即在num类型上增加了一个toAbs方法。

package main

import "fmt"

type num int

// 在toAbs函数前增加一个变量n,就定一个num类型上的方法了!
func (n num) toAbs() num {
	if n > 0 {
		return n
	} else {
		n = -n
		return n
	}
}

func main() {
	// 调用上面的toAbs方法
	var i num = -1
	fmt.Printf("调用toAbs返回 %d\n", i.toAbs())
	fmt.Printf("原来的i为 %d\n", i)
}

//上面将会输出
// 调用toAbs返回 1
// 原来的i为 -1

上面使用的是变量作为接收器,另一种做法是使用变量的指针作为方法接收器。这两者的区别很明显:使用变量作为接收器,方法内会对变量做拷贝,对变量的修改不影响外部变量,使用变量指针作为接收器,方法内不会对变量做拷贝,对变量的修改会影响外部变量。

package main

import "fmt"

type num int

// 在toAbs函数前增加一个变量n,就定一个num类型上的方法了!
func (n *num) toAbs() num {
	if *n < 0 {
		*n = -*n
	}
	return *n
}

func main() {
	// 调用上面的toAbs方法
	var i num = -1
	fmt.Printf("调用toAbs返回 %d\n", i.toAbs())
	fmt.Printf("原来的i为 %d\n", i)
}

// 上面输出
// 调用toAbs返回 1
// 原来的i为 1

使用指针作为接收器,将会改变原先的值!

继承

Go语言实际上是没有继承这种写法的,为了让类能够复用,Go使用了组合!

在设计模式中,“多用组合,少用继承”是一种非常常见的思想。Go语言干脆走向了一个极端,没有继承,只有组合。

无论是继承还是组合,本质上都是让代码能够更好地复用,让结构更加清晰。Go这种设计总体来讲还是利大于弊。

组合

接上面,接下来学习Go语言中类的组合。

来一个简单的例子,通过制作三明治的过程来看看组合是怎么用的

package main

import "fmt"

// 面包
type Bread struct {
}

// 培根
type Bacon struct {
}

//生菜
type Lettuct struct {
}

// 鸡蛋
type Egg struct {
}

// 通过组合的方法来做一个简单的三明治
type Sandwich struct {
	bread   Bread
	bacon   Bacon
	lettuct Lettuct
	egg     Egg
}

func (b Bread) make() {
	fmt.Println("将面包切片, 得到一些面包片")
}

func (b Bacon) make() {
	fmt.Println("把培根放入面包片夹层中")
}

func (l Lettuct) make() {
	fmt.Println("将生菜洗干净放入面包夹层")
}

func (e Egg) make() {
	fmt.Println("将鸡蛋煎熟, 放入面包夹层")
}

func (s Sandwich) make() {
	s.bread.make()
	s.bacon.make()
	s.lettuct.make()
	s.egg.make()
	fmt.Println("对着弄好的材料斜着切一下")
	fmt.Println("得到两个三明治!")
}

func main() {
	var sandwich Sandwich
	sandwich.make()
}

上面的运行结果是

将面包切片, 得到一些面包片
把培根放入面包片夹层中
将生菜洗干净放入面包夹层
将鸡蛋煎熟, 放入面包夹层
对着弄好的材料斜着切一下
得到两个三明治!

封装

如果一个类里面的属性或者方法对外是不可见的,那么我们把这种做法叫做封装。Go语言只有一种封装的方法,那就是大写首字母字母开头标识符会被导出(公有),小写字母的则不会(私有),这个规则对struct也适用。

type Page struct {
    title string
    content string
}
// 这个结构体里面的title和content是不能被导出的
// 引用这个包直接对title和content赋值将会编译不通过
// 比如这个包叫page,那么可以直接给page.Page.title赋值将会编译失败

type Page struct {
    Title string
    Content string
}
//这个结构体里面的Title和Content是可以被导出的
// 在包外可以直接对Title和Content直接赋值
// 比如这个包叫page,那么可以直接给page.Page.Title赋值

接口

正如很多面向对象的语言一样,Go也拥有接口。接口实际上是一种契约,里面包含了一些必须要实现的方法,如果某个类实现了这个接口里面所有的方法,那么就称为这个类实现了这个接口。

我喜欢用实际生活中存在的事物去类比,比如锅盖。要怎么实现一个锅盖的接口,这个接口文字描述可以是:只要能够盖住一定大小范围内的锅,都是锅盖。至于用玻璃材质去实现还是用木头材质去实现还是其他材质,这个接口并不去关注。

接口的好处就是实现了这个接口的类可以互相替换,正如上面的锅盖,只要实现了锅盖接口,无论是铁的,玻璃的,木头的都可以互相替换。

Go语言的接口声明

package main

import "fmt"

// 声明一个锅盖接口
// 内有一个cover方法
// 只要实现了cover方法的类,都称可以称为锅盖
type PotCover interface {
	cover()
}

type GlassCover struct {
}

func (g GlassCover) cover() {
	fmt.Println("玻璃锅盖")
}

// 定义一个男人类型
type Man struct {
	name string
}

func main() {
	// 声明c是一个锅盖
	var c PotCover
	// 把玻璃锅盖赋值给c
	// 玻璃锅盖实现了cover方法
	// 因此是个锅盖
	c = new(GlassCover)
	c.cover()
        // 男人没有cover方法,没有实现锅盖接口
        // 这里将会编译出错
	c = new(Man)
}

空接口

interface{} 称为空接口,空接口是一个很有用的接口,因为它没有实现任何方法。所以任意变量都可以赋值给空接口。

比如我们定义一个map[string]int,这个map的值只能是int类型,不管怎么定义,map的值看起来都只能是一个单一的类型。这里空接口就能让map的值支持任意类型。

i := make(map[string]interface{})
i["0"] = 1
i["1"] = "hello"
fmt.Println(i)
// map[0:1 1:hello]

同样,函数的参数也可以使用空接口,这允许我们传入任意类型的参数。比如fmt.Println的参数就是个空接口,可以传入任意类型的参数。

Go笔记三:函数的声明和调用

这是我Go学习笔记的第三篇!接下来学习的是Go的函数声明和调用。

我的语言学习过程一般分为下面几个:

1. 变量和数据类型
2. 流程控制方法
3. 函数声明和调用
4. 面向对象
5. 语言特性
6. 标准库

函数声明

func 函数名称(参数表) 返回值类型 {
   // 函数体
}

写一个函数是非常简单的,掌握语法格式就可以了。函数是一个功能的封装,能让函数体内的代码得到很好的复用。

比如我要输出个人信息,我可以把个人信息封装到函数里面,后续直接调用这个函数而不是每次都print一堆信息了

package main

import "fmt"

// 定义一个能够打印个人信息的函数
func info() {
	fmt.Println("姓名: hc")
	fmt.Println("性别: 男")
	fmt.Println("职位: 程序员")
}

func main() {
        // 调用上面定义的函数
	info()
}

上面定义的函数没有参数,也没有返回值,非常简单的一个函数。如果我想让姓名可变,那么可以定义一个带有参数的函数

package main

import "fmt"

// 定义一个能够打印个人信息的函数
// 这里加入了name参数,类型为string
func info(name string) {
	fmt.Printf("姓名: %s\n", name)
	fmt.Println("性别: 男")
	fmt.Println("职位: 程序员")
}

func main() {
        // 调用的时候传入参数
	info("hc")
}

接下来定义一个有意义的带有参数和返回值的函数,比如计算一个数字的绝对值

package main

import "fmt"

// 返回一个数字的绝对值
func abs(n int) int {
	if n < 0 {
                n = -n
	}
	return n
}

func main() {
	fmt.Println(abs(-1))
}

参数传递

参数传递有两种,一种是传值,另一种是传引用。

传值的话就是上面定义的函数,传入的值是一个copy,参数传递进去,就生成了一个新的copy,修改不影响原值。

传引用则是传进去是是原来的变量,在函数内部修改是对原来变量的直接修改。

比如上面之前定义的一个abs函数就是一个传值的函数。看下面的代码

i := -1
fmt.Println(abs(i)) // 输出的是1
fmt.Println(i) // 输出的是-1,原值并没有被改变

传引用

传引用会改变原参数的值,因为传入的是变量的地址。abs的函数我们改写一下就可以将参数传引用了!

package main

import "fmt"

func abs(n *int) int {
	if *n < 0 {
		*n = -*n
	}
	return *n
}

func main() {
	var i int
	i = -1
	fmt.Println(abs(&i)) // 输出的是1
	fmt.Println(i) // 输出的还是1,i原值被改变了
}

一个比较经典的传引用的例子是交换两个变量的值

func swap(x *int, y *int) {
    var temp int
    temp = *x
    *x = *y
    *y = temp
}
// 调用
x := 1
y := 2
swap(&x, &y) //x变成2,y变成1了

可以看到,函数的参数是一个地址。(&在变量的前面是取地址运算符),使用*来从地址取值。

Go笔记一:变量和数据类型

这是学习Go语言的第一篇笔记,主要学习的是变量和基本数据类型。如果您也在开始学习Go语言,那么这篇笔记一定能帮助您学习的更快!

我的语言学习过程一般分为下面几个:

1. 变量和数据类型
2. 流程控制方法
3. 函数声明和调用
4. 面向对象
5. 语言特性
6. 标准库

变量声明

Go语言的变量声明有三种

第一种,var identifier type

// 先声明后赋值
var identifier type
identifier = value
// 声明并且赋值
var identifier type = value

开始实战一下!比如声明一个int类型变量

var i int
i = 1
// 或者
var i int = 1
(更多…)

Go笔记二:流程控制

这是我的go语言学习笔记的第二篇,go语言的流程控制。流程控制是计算机语言的基本组成部分。一般的流程控制有顺序,分支,循环。这次来学习一下go语言的流程控制都有哪些,语法是什么样的。

我将会通过以下步骤来入门go语言

1. 变量和数据类型
2. 流程控制方法
3. 函数声明和调用
4. 面向对象
5. 语言特性
6. 标准库

条件分支

go语言的条件分支有: if语句,if…else…语句,switch…case…语句。和大多数语言差别不大!

if 语句语法

// 纯if
if 条件语句 {
    表达式
}

// 带有else 的 if
if 条件语句1 {
    表达式1
} else 条件语句2 {
    表达式2
}

// 带有if else 的if
if 条件语句1 {
    表达式1
} else if 条件语句2 {
    表达式2
} else {
    表达式3
}

Go语言的 if 语句没有括号(for,switch也没有)!习惯了括号的需要反这个习惯!

(更多…)