《Effective Go》大家入门的时候一般都会去看,本篇属于Effective的“加强版”,主要聚焦在三个方向:
- runtime篇:常用数据结构及关键词的实现与实践
- 测试篇:测试的价值以及如何做单元测试和接口测试
- QA环节:技术转型中的思考
1. runtime篇
1.1 什么是runtime
runtime是go程序执行时使用的库,它控制着:
- slice, string, map, chan等数据类型以及反射的实现
- goroutine调度,内存分配,gc
- pprof, trace, race, CGO
与Java、Python不同,Go没有虚拟机的概念,runtime也直接与用户代码打包在一个可执行文件,编译成机器码,加上静态链接的原因,Go的可执行文件一般会比较大。
如果使用-ldflags’-w -s’进行编译,则将获得最小的二进制文件。-w关闭DWARF调试信息:将无法在二进制文件上使用gdb查看特定功能或设置断点或获取堆栈跟踪,因为将不包含gdb所需的所有元数据。也将无法使用依赖于该信息的其他工具,例如pprof分析。-s关闭Go符号表的生成:将无法使用’go tool nm’在二进制文件中列出符号。
由于篇幅有限,本篇runtime主要聚焦在常用的数据结构和关键词,以及我们日常使用的过程中需要注意的点。
1.2 slice
reflect.SliceHeader是切片的运行时表示形式。Data 是一片连续的内存空间,这片内存空间可以用于存储切片中的全部元素,数组中的元素只是逻辑上的概念,底层存储其实都是连续的,所以我们可以将切片理解成一片连续的内存空间加上长度与容量的标识。
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
扩容操作只关心容量,会把原Slice数据拷贝到新Slice,追加数据由append在扩容结束后完成。 runtime.growslice扩容容量的选择:
func growslice(et *_type, old slice, cap int) slice {
newcap := old.cap
doublecap := newcap + newcap
if cap > doublecap {
newcap = cap
} else {
if old.len < 1024 {
newcap = doublecap
} else {
for 0 < newcap && newcap < cap {
newcap += newcap / 4
}
if newcap <= 0 {
…
- 如果原slice容量小于1024,则新Slice容量将扩大为原来的2倍;
- 如果原slice容量大于等于1024,则新Slice容量将扩大为原来的1.25倍;
- 根据slice中元素大小对齐内存,确定最终扩容容量
func main() {
arr := []int{1, 3, 4}
test1(arr)
fmt.Println(arr) // 2,3,4
test2(arr)
fmt.Println(arr) // 2,3,4
}
func test1(arr []int) {
arr[0] = 2
}
func test2(arr []int) {
arr = append(arr, 5)
}
重新开辟内存空间,通过copy拷贝元素
func main() {
arr := []int{1, 3, 4}
arr2 := arr
arr[0] = 3
fmt.Println(arr2) // 3,3,4
arr3 := make([]int, len(arr))
copy(arr3, arr)
arr[0] = 4
fmt.Println(arr3) // 3,3,4
}
由于slice扩容拷贝的性质,我们一般建议尽量提前分配好slice的容量。
1.3 string
reflect.StringHeader是字符串在运行时的表示,相比于切片只少了一个Cap字段
type StringHeader struct {
Data uintptr
Len int
}
源码src/builtin/builtin.go
中对string
类型的描述:
// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string
所以一般情况下可以认为string
是一个只读的字节切片。
只读并不代表不能修改,我们可以通过转化为字节切片和拷贝内存的方式,主要通过runtime.slicebytetostring
对字符串变量的值做修改
- 先将这段内存拷贝到堆或者栈上;
- 将变量的类型转换成
[]byte
后并修改字节数据; - 将修改后的字节数组转换回
string
;
多个字符串拼接,也会通过拷贝内存的方式来实现, runtime.concatstrings
是拼接字符串的核心逻辑:
func concatstrings(buf *tmpBuf, a []string) string {
idx := 0
l := 0
count := 0
for i, x := range a { // 计算拼接后的字符串总长度
// ...
l += n
count++
idx = i
}
s, b := rawstringtmp(buf, l) // rawstring
for _, x := range a {
copy(b, x) // string无法修改,只能通过切片修改
b = b[len(x):]
}
return s
}
rawstring初始化string和切片共享内存空间,最终通过copy的方式向切片中拷贝数据,也间接修改了string
func rawstring(size int) (s string, b []byte) {
p := mallocgc(uintptr(size), nil, false)
stringStructOf(&s).str = p
stringStructOf(&s).len = size
*(*slice)(unsafe.Pointer(&b)) = slice{p, size, size}
return
}
在涉及到大量字符串更新的场景,内存拷贝无疑会产生很多的内存碎片,加大go的内存分配和gc压力,所以这些场景下更推荐使用strings.builder
,strings.builder
使用更为激进的申请内存策略,以及地址转换的方式生产最终字符串(非并发安全)。
// grow copies the buffer to a new, larger buffer so that there are at least n
// bytes of capacity beyond len(b.buf).
func (b *Builder) grow(n int) {
buf := make([]byte, len(b.buf), 2*cap(b.buf)+n)
copy(buf, b.buf)
b.buf = buf
}
// String returns the accumulated string.
func (b *Builder) String() string {
return *(*string)(unsafe.Pointer(&b.buf)) // 有别于bytes.Buffer string(slice)类型转换 重新开辟空间
}
1.4 struct
reflect.StructField作为struct的每个field表示,同时可以通过StructTag
携带tag
// A StructField describes a single field in a struct.
type StructField struct {
// Name is the field name.
Name string
// PkgPath is the package path that qualifies a lower case (unexported)
// field name. It is empty for upper case (exported) field names.
// See https://golang.org/ref/spec#Uniqueness_of_identifiers
PkgPath string
Type Type // field type
Tag StructTag // field tag string
Offset uintptr // offset within struct, in bytes
Index []int // index sequence for Type.FieldByIndex
Anonymous bool // is an embedded field
}
// Multiple keys may map to a single shared value by separating the keys
// with spaces, as in
// key1 key2:"value"
type StructTag string
使用Tag
可以动态给struct field
赋值,例如JSON、ORM的序列化与反序列化。
package main
import (
"reflect"
"fmt"
)
type Server struct {
ServerName string `json:"value1" yaml:"value11"`
ServerIP string `gorm:"value2"`
}
func main() {
s := Server{}
st := reflect.TypeOf(s)
field1 := st.Field(0)
fmt.Printf("key1:%v\n", field1.Tag.Get("json"))
fmt.Printf("key11:%v\n", field1.Tag.Get("yaml"))
filed2 := st.Field(1)
fmt.Printf("key2:%v\n", filed2.Tag.Get("gorm"))
}
和c语言类似,struct
在内存分配上也会进行对齐,进而优化CPU的运算处理
unsafe
标准库提供了 Alignof
方法,可以返回一个类型的对齐值,也可以叫做对齐系数或者对齐倍数, Size and alignment guarantees – golang spec 描述了 unsafe.Alignof
的规则。
- For a variable x of any type: unsafe.Alignof(x) is at least 1.
- For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1.
- For a variable x of array type: unsafe.Alignof(x) is the same as the alignment of a variable of the array’s element type.
最终struct
内存占用还需要遵循两个原则:
- field内存起始地址偏移量应该是自身大小的整数倍
- 整个结构体的大小是结构体最大field大小的整数倍
通过unsafe.Sizeof
(变量占用内存大小)和unsafe.Offsetof
(field内存起始偏移)验证一下:
func main() {
type test struct {
a int8 // 1
b int32 // 4
c int16 // 2
d int64 // 8
}
t := test{}
fmt.Println(unsafe.Offsetof(t.a)) // 0
fmt.Println(unsafe.Offsetof(t.b)) // 4
fmt.Println(unsafe.Offsetof(t.c)) // 8
fmt.Println(unsafe.Offsetof(t.d)) // 16
fmt.Println(unsafe.Sizeof(t)) // 24
}
在一些内存使用比较敏感的情况下,需要注意struct Field
的排列顺序
一个空struct
是不会分配内存的,在编译阶段,go会把struct
直接指向zerobase变量
// base address for all 0-byte allocations
var zerobase uintptr
利用这个特性,可以把空struct
用在占位符场景,例如:map set、channel通信等等
type Set map[string]struct{}
func (s Set) Append(k string) {
s[k] = struct{}{}
}
func (s Set) Remove(k string) {
delete(s, k)
}
func (s Set) Exist(k string) bool {
_, ok := s[k]
return ok
}
func main() {
set := Set{}
set.Append("ab")
set.Append("cd")
fmt.Println(set.Exist("ab"))
}
1.5 map
每个map底层结构是hmap,是由若干个结构为bmap的bucket组成的数组,每个bmap会有8组元素,每个bmap内部会根据每个key的hash值高8位决定存储位置,当超过8个元素要存储时,hmap会将数据存储在extra的overflow(溢出桶)中。
// A header for a Go map.
type hmap struct {
count int // 元素个数
flags uint8
B uint8 // 扩容常量相关字段B是buckets数组的长度的对数 2^B
noverflow uint16 // 溢出的bucket个数
hash0 uint32 // hash seed
buckets unsafe.Pointer // buckets 数组指针
oldbuckets unsafe.Pointer // 结构扩容的时候用于赋值的buckets数组(渐进式扩容)
nevacuate uintptr // 搬迁进度
extra *mapextra // 用于扩容的指针
}
type mapextra struct {
overflow *[]*bmap
oldoverflow *[]*bmap
nextOverflow *bmap
}
// A bucket for a Go map.
type bmap struct {
tophash [bucketCnt]uint8 // len为8的数组
}
Map access is unsafe only when updates are occurring. As long as all goroutines are only reading—looking up elements in the map, including iterating through it using a
for
range
loop—and not changing the map by assigning to elements or doing deletions, it is safe for them to access the map concurrently without synchronization.
官方的faq里有说明,考虑到有性能损失,map没有设计成原子操作,在并发读写时会有问题。
runtime.mapassign负责map写入:
func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
if h == nil {
panic(plainError("assignment to entry in nil map"))
}
if raceenabled {
callerpc := getcallerpc()
pc := funcPC(mapassign)
racewritepc(unsafe.Pointer(h), callerpc, pc)
raceReadObjectPC(t.key, key, callerpc, pc)
}
if msanenabled {
msanread(key, t.key.size)
}
if h.flags&hashWriting != 0 {
throw("concurrent map writes") // throw
}
...
runtime.mapaccess1和runtime.mapaccess2负责map的访问,同样也会检测是否有并发写:
v := hash[key] // => v := *mapaccess1(maptype, hash, &key)
v, ok := hash[key] // => v, ok := mapaccess2(maptype, hash, &key)
...
if h.flags&hashWriting != 0 {
throw("concurrent map read and map write")
}
...
runtime.throw会调用runtime.fatalthrow退出进程
func fatalthrow() {
...
systemstack(func() {
startpanic_m()
if dopanic_m(gp, pc, sp) {
// crash uses a decent amount of nosplit stack and we're already
// low on stack in throw, so crash on the system stack (unlike
// fatalpanic).
crash()
}
exit(2) // 退出进程
})
*(*int)(nil) = 0 // not reached
}
一般情况下map并发读写,可以采用:
- 场景单一,map元素可控场景,直接使用sync.Map
- map元素较多,使用map shard和mutex/rwmutex,降低锁粒度,例如:concurrent-map
- 全局配置等,使用atomic.Value存储map
map元素删除使用delete
,实际执行的是runtime.mapdelete_fast64。 可以看出,delete并不会真正释放内存,而只是标记删除,在一些场景下可能会导致OOM,常见的方案是对map及时重建,例如:safe map
其次,在较多map元素的gc上,go 1.5之后,如果map的元素不包含指针(内存结构上,string也包含指针),那么gc时将会忽略map元素,避免map元素长时间标记影响gc耗时。
// bmap makes the map bucket type given the type of the map.
func bmap(t *types.Type) *types.Type {
...
// If keys and elems have no pointers, the map implementation
// can keep a list of overflow pointers on the side so that
// buckets can be marked as having no pointers.
// Arrange for the bucket to have no pointers by changing
// the type of the overflow field to uintptr in this case.
// See comment on hmap.overflow in runtime/map.go.
otyp := types.NewPtr(bucket)
if !types.Haspointers(elemtype) && !types.Haspointers(keytype) {
otyp = types.Types[TUINTPTR]
}
overflow := makefield("overflow", otyp)
field = append(field, overflow)
...
return bucket
}
bigcache采用方案是使用map[uint64]uint32和bytes array的二级索引结构来进行优化,其中map的key经过hash运算转换为整形,value存储bytes array的offset。
type cacheShard struct {
hashmap map[uint64]uint32
// 存储数据在 entries 中具体位置, key 为数据 key 的 hash,value 为位置(key -> value index)
entries queue.BytesQueue // 数据最终存储的位置
lock sync.RWMutex
entryBuffer []byte
}
func wrapEntry(timestamp uint64, hash uint64, key string, entry []byte, buffer *[]byte) []byte {
keyLength := len(key) // key 的长度
blobLength := len(entry) + headersSizeInBytes + keyLength
if blobLength > len(*buffer) {
*buffer = make([]byte, blobLength)
}
blob := *buffer
// 数据存储采用小端序 LittleEndian
binary.LittleEndian.PutUint64(blob, timestamp)
binary.LittleEndian.PutUint64(blob[timestampSizeInBytes:], hash)
binary.LittleEndian.PutUint16(blob[timestampSizeInBytes+hashSizeInBytes:], uint16(keyLength))
copy(blob[headersSizeInBytes:], key)
copy(blob[headersSizeInBytes+keyLength:], entry)
return blob[:blobLength]
}
1.6 defer
runtime._defer
结构体是延迟调用链表上的一个元素,多个defer会通过 link
字段串联成链表,按照LIFO的方式执行。
type _defer struct {
siz int32 // 参数和结果的内存大小
started bool
openDefer bool
sp uintptr // 栈指针
pc uintptr // 调用方程序计数器
fn *funcval // defer执行函数
_panic *_panic // 触发当前defer的panic指针
link *_defer // 指向自身结构的指针,用于链接多个defer
}
使用runtime.deferproc 会创建对应的defer
func deferproc(siz int32, fn *funcval) { // arguments of fn follow fn
sp := getcallersp()
argp := uintptr(unsafe.Pointer(&fn)) + unsafe.Sizeof(fn) // 指向defer函数的第一个参数
d := newdefer(siz)
if d._panic != nil {
throw("deferproc: d.panic != nil after newdefer")
}
...
// 把defer函数需要用到的参数拷贝到d结构体后面
switch siz {
case 0:
// Do nothing.
case sys.PtrSize:
*(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp))
default:
memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz))
}
由于创建时会立即拷贝参数,使用时要格外注意:
func main() {
i := 0
defer fmt.Println(i) // 0
i++
}
可以使用匿名函数,即使拷贝了函数指针,函数内部不会影响
func main() {
start := time.Now()
defer func() { fmt.Println(time.Since(start)) }() // 1s
time.Sleep(time.Second)
}
1.7 panic & recover
panic
会立刻停止执行当前函数的剩余代码,并在当前Goroutine
中立即调用defer
。recover
可以终止panic
,但只在defer中执行。runtime._defer
中会把defer
和panic
指针关联,即跨goroutine
失效:
func main() {
defer println("in main")
go func() {
defer println("in goroutine")
panic("")
}()
time.Sleep(1 * time.Second)
}
// in goroutine
panic:
...
panic
关键字在由 runtime._panic
表示:
type _panic struct {
argp unsafe.Pointer // defer调用时参数的指针
arg interface{} // 调用panic传入的参数 panic可以被连续多次调用
link *_panic // panic prev链表
recovered bool // 是否被recover恢复
aborted bool
pc uintptr
sp unsafe.Pointer
goexit bool
}
运行时panic
会交给runtime.gopanic
处理
func gopanic(e interface{}) {
gp := getg()
...
var p _panic
p.arg = e
p.link = gp._panic // 创建panic并添加到Goroutine的_panic的链表最前面
gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
for {
// 循环不断从_defer链表中获取并调用运行defer函数
// 如果遇到runtime.gorecover就会将_panic.recovered标记为true
d := gp._defer
if d == nil {
break
}
d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))
reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))
d._panic = nil
d.fn = nil
gp._defer = d.link
freedefer(d)
if p.recovered { // 存在recover 需要恢复
gp._panic = p.link
for gp._panic != nil && gp._panic.aborted {
gp._panic = gp._panic.link
}
if gp._panic == nil {
gp.sig = 0
}
gp.sigcode0 = uintptr(sp)
gp.sigcode1 = pc
mcall(recovery) // 根据sp pc调回defer关键字调用的位置 编译器恢复正常执行流程
throw("recovery failed")
}
}
fatalpanic(gp._panic) // 终止程序
*(*int)(nil) = 0
}
recover
会被转换为 runtime.gorecover
,但只负责标记,恢复工作交由gopanic
func gorecover(argp uintptr) interface{} {
gp := getg()
p := gp._panic
if p != nil && !p.recovered && argp == uintptr(p.argp) {
p.recovered = true
return p.arg
}
return nil
}
由于panic
跨goroutine
失效问题,所以在recover
时,需要打印详细的堆栈信息帮助我们进一步定位,一个创建gouroutine的exp:
go func(handler func() error) {
defer func() {
if e := recover(); e != nil {
buf := make([]byte, 1024)
buf = buf[:runtime.Stack(buf, false)]
log.Errorf("[PANIC]%v\n%s\n", e, buf)
}
}()
return handler()
}(f)
1.8 for-range
在汇编中,无论是for
还是for-range
都会使用JMP
跳回循环体的开始位置复用代码,for-range
被编译器转换为普通的for
循环
// 遍历数组或切片
ha := a // 将数组或切片赋值给变量ha
hv1 := 0
hn := len(ha) // 遍历前确定遍历次数
v1 := hv1
v2 := nil // 循环使用v2 会在每一次迭代被重新赋值而覆盖,因此在for _, v := range arr 使用v地址会完全相同
for ; hv1 < hn; hv1++ {
tmp := ha[hv1]
v1, v2 = hv1, tmp
...
}
循环中追加元素不会改变循环执行的次数:
func main() {
arr := []int{1, 3, 4}
for _, v := range arr {
arr = append(arr, v)
}
fmt.Println(arr) // 1,3,4,1,3,4
}
循环中使用value地址,会是同一个地址:
func main() {
newArr := []*int{}
for _, v := range []int{1, 3, 4} {
newArr = append(newArr, &v)
}
// 循环结束 v已赋值为4
for _, v := range newArr {
fmt.Println(*v) // 4,4,4
}
}
匿名函数:
func main() {
for _, v := range []int{1, 3, 4} {
go func() {
fmt.Println(v) // 4,4,4
}()
}
time.Sleep(1 * time.Second)
for _, v := range []int{1, 3, 4} {
v := v // 重新赋值
go func() {
fmt.Println(v) // 1,3,4
}()
}
time.Sleep(1 * time.Second)
}
runtime.mapiterinit遍历map为了保证向下兼容,不保证后续迭代算法的变更,会随机选择起始桶的位置
// 遍历map
ha := a
hit := hiter(n.Type)
th := hit.Type
mapiterinit(typename(t), ha, &hit)
for ; hit.key != nil; mapiternext(&hit) {
key := *hit.key
val := *hit.val
}
// 遍历正常桶和溢出桶
func mapiterinit(t *maptype, h *hmap, it *hiter) {
it.t = t
it.h = h
it.B = h.B
it.buckets = h.buckets
r := uintptr(fastrand()) // 随机选择桶的起始位置
it.startBucket = r & bucketMask(h.B)
it.offset = uint8(r >> h.B & (bucketCnt - 1))
it.bucket = it.startBucket
mapiternext(it)
}
map数据的无序性
m := map[string]string{
"1": "11",
"2": "22",
"3": "33",
}
for k, v := range m {
fmt.Println(k, v)
}
for k, v := range m {
fmt.Println(k, v)
}
// 2 22
// 3 33
// 1 11
// 1 11
// 2 22
// 3 33
遍历字符串,会把对应字节转换成 rune
类型,使用下标访问是字节。
// 遍历字符串
ha := s
for hv1 := 0; hv1 < len(ha); {
hv1t := hv1
hv2 := rune(ha[hv1]) // 转换为rune类型 直接使用下标得到字节类型
if hv2 < utf8.RuneSelf { // ASCII 只占一字节
hv1++
} else {
hv2, hv1 = decoderune(ha, hv1) // Unicode 使用decoderune解码
}
v1, v2 = hv1t, hv2
}
1.9 channel
channel从某种程度上讲,是一个用于同步和通信的有锁队列。在运行时的内部表示是:runtime.hchan
type hchan struct {
qcount uint // channel中元素的个数
dataqsiz uint // channel中队列的长度
buf unsafe.Pointer // channel的缓冲区指针
elemsize uint16 // channel能够收发的元素大小
closed uint32 // 是否已关闭
elemtype *_type // channel能够收发的元素类型
sendx uint // channel的发送操作处理到的位置
recvx uint // channel的接收操作处理到的位置
recvq waitq // 等待读消息的goroutine队列 双向链表 {first *sudog last *sudog}
sendq waitq // 等待写消息的goroutine队列
lock mutex // 互斥锁,chan不允许并发读写
}
从channel读数据,如果channel缓冲区为空或者没有缓冲区,当前goroutine会被阻塞。 向channel写数据,如果channel缓冲区已满或者没有缓冲区,当前goroutine会被阻塞。
一个没有缓冲区的channel,有几个goroutine阻塞等待读数据:
从channel中读取数据:
ch := make(chan struct{}, 20) // 同时运行的goroutine数
for i := 0; i < count; i++ {
wg.Add(1)
ch <- struct{}{} // 缓冲区满阻塞
go func(i int) {
defer func() {
wg.Done()
<-ch
}()
}(i)
}
wg.Wait()
go func() {
close(ch)
}()
1.10 context
上下文 context.Context
用来控制超时和传递上下文信息。
type Context interface {
Deadline() (deadline time.Time, ok bool) // 返回被取消的时间
Done() <-chan struct{} // 返回一个channel,这个channel会在当前工作或者上下文被取消后关闭
Err() error // 返回ctx结束的原因
Value(key interface{}) interface{} // 请求传递的数据
}
context.WithCancel函数能够从context
中衍生出一个新的子上下文并返回用于取消该上下文的函数。一旦我们执行返回的取消函数,当前上下文以及它的子上下文都会被取消,所有的 Goroutine 都会同步收到这一取消信号。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent)
propagateCancel(parent, &c) // 构建父子上下文之间的关联,当父上下文被取消时,子上下文也会被取消
return &c, func() { c.cancel(true, Canceled) }
}
func propagateCancel(parent Context, child canceler) {
done := parent.Done()
if done == nil {
return
}
select {
case <-done:
child.cancel(false, parent.Err()) // 父上下文已经被取消
return
default:
}
...
}
context.WithDeadline
和 context.WithTimeout 通过持有定时器timer和截止时间,实现取消功能
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
...
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded)
})
}
return c, func() { c.cancel(true, Canceled) }
}
实现MQ消息分发和平滑退出:
// 注册退出信号
sig := make(chan os.Signal)
signal.Notify(sig, syscall.SIGUSR1, syscall.SIGUSR2)
go func() {
<-sig
fmt.Printf("kill -HUP at %v\n", time.Now())
this.cancel()
}()
// 处理消息分发
ctx, cancel := context.WithCancel(context.Background())
for {
select {
case <-ctx.Done():
fmt.Printf("[%v] Quit at %v\n", c.Queue, time.Now().String())
return nil
case msg := <-msgs:
if err = c.Job.OnReceive(msg.Body); err != nil {
fmt.Printf("c.Job.OnReceive() error %v\n", err)
}
msg.Ack(false)
}
}
2.测试
这是非常经典的测试金字塔模型,模型最底端的单元测试,我们写的最多,效率往往也是最高的,这种效率体现在运行速度,问题定位等等。越往上,集成测试(接口测试)、端到端测试,会越发全面,但是效率也在逐步降低。所以我们通常会更倾向于关注效率更高的单元测试和接口测试。
2.1 Why单元测试
- 让我们对重构与修改有信心 新功能的增加,代码复杂性的提高,优化代码的需要,或新技术的出现都会导致重构代码的需求。在没有写单元测试的情况下,对代码进行大规模修改,是一件不敢想象的事情,因为写错的概率实在太大了。而如果原代码有单元测试,即使修改了代码单测依然通过,说明没有破坏程序正确性,一点都不慌!
- 及早发现问题,降低定位问题的成本 bug发现的越晚,修复它的费用就越高,而且呈指数增长的趋势。作为编码人员,也是单元测试的主要执行者,能在单测阶段发现的问题,就不用等到联调测试再暴露出来,减少解决成本。
- 代码设计的提升 为了实现功能而编码的时候,大多时候我们考虑的是函数实现,一顿编写,写好了运行成功就万事大吉了。而写单元测试的时候,我们跳出了函数,从输入输出的角度去思考函数/结构体的功能。此时我们不由得,这个函数真的需要吗?这个函数的功能是不是可以简化/抽象/拆分一下?这个函数考虑的情况似乎不够全面吧?这里的使用外部依赖是否真的合适?这些思考,能推动我们更仔细思考代码的设计,加深对代码功能的理解,从而形成更合理的设计和结构。
- 单元测试也是一种编写文档的行为 单元测试是产品代码的第⼀个使⽤者,并伴随代码⽣命周期的始终。它⽐任何⽂字⽂档更直观、更准确、更有效,⽽且永不过时。当产品代码更新时单元测试就会同步更新(否则通不过测试);而⽂字⽂档则更新往往滞后,甚⾄不更新,从⽽对后来的开发者和维护者产⽣误导,正所谓:过时的⽂档⽐没有⽂档更有害。
2.2 单元测试的时机
2.2.1 编码前:TDD
TDD是测试驱动开发(Test-Driven Development)的英文简称,是敏捷开发中的一项核心实践和技术,也是一种设计方法论。
TDD 的基本思想就是在开发功能代码之前,先编写测试代码。也就是说在明确要开发某个功能后,首先思考如何对这个功能进行测试,并完成测试代码的编写,然后编写相关的代码满足这些测试用例。然后循环进行添加其他功能,直到完全部功能的开发。
传统 的开发模式:
- 一般来说,拿到一个需求,我们会先做需求分析。可能会因为时间紧任务重,可能需求还没想清楚就说,我们就开始写代码了。
- 然后因为需求细节不是那么明确,写的过程中就会和产品人员反复确认。需求的很多细节是一边写一边再明确的,这里可能会导致一些因为需求理解不一致导致的返工的问题。
- 写完功能逻辑,需要对代码进行测试。一般是怎么做的呢?我们可能会把一个服务启起来,然后手工的构造一些请求参数来调用服务,通过打印屏幕或者日志来看是否有预期的结果;有时也会在main函数里写一些工具代码来做测试。
- 通常情况下这可能是一个不全面的测试,我们看到功能工作正常就转给测试了。
- 但是这样的开发过程中的自测往往是不完善的,QA很容易就测出一些bug,那么我们修这些bug可能又会加一些逻辑,打一些补丁。这么搞定后,确实解决了目前的问题。但是加来加去代码就会变得很乱了。而且软件工程里也有一个破窗效应,越烂的代码越不敢改动,因为成本太高了。
这样做有哪些问题?
- 首先大部分测试都是手动的,测试逻辑没法管理,甚至有的项目根本没有自测,开发完成直接丢给测试同学了,全靠黑盒的功能测试保证质量。
- 如果有线上bug需要复现,很可能之前的一些小工具或者测试代码都没了,又得花很多实际构造参数进行调试。
- 因为没有单元测试做保障,那么也不敢轻易重构代码。或者说不是每个人都有信心重构代码。因为理解成本高,担心改出其它问题,自己又没办法覆盖到。
- 当然,还有一些其他的问题。比如没有测试用例,也就没有一些API的使用说明,很多就只能靠看代码理解了。带来的项目维护成本也会增加,而一些新同学可能都看不懂整个项目,信心也受到打击。
所以TDD的核心目标是期望用一种标准化的流程来保证项目流程透明,风险可控。达到几个标准:
- 不写没有测试的代码
- 更清楚准确地知道需要写哪些代码
- 更早地知道自己的代码出错了
- 你对每一步完成的工作质量都很信任
TDD为什么难以落地?
第一,大家更多关注快速交付和实现,很多团队迫于业务的压力根本没有时间写单元测试。
第二,很多业务变化是非常快的,底层功能并不能一直保证稳定。一些单测和自动化测试发现的有效bug可能还不如专业测试人员,所以开发团队推行的意愿可能也不足。
TDD是有一定的适用场景的。做好TDD 有几个问题需要解决。
- 项目:什么样的项目适合做TDD?项目是否足够稳定,积累的单测能力是否能持续有效?
- 时间:投入多少时间做TDD,TDD占项目时间大约多少比例合适。
- 人:谁来做,开发做还是测试来做?如果是开发做,工作量怎么预估?
- 效果:要达成什么效果,以什么为合格的TDD的指标,功能覆盖?代码覆盖率?
- 方式:要怎么做?只做部分核心函数的单元测试?还是需要覆盖到API的场合?是否要开发自动化框架来辅助?
所以TDD,更倾向于把它作为保证质量的一种手段,通过一些实践方式来提升团队成员工程素养,把质量意识作为常规要求融入到日常开发中。
2.2.2 编码后:存量
在完成业务需求后,可能由于上线时间较为紧、没有单测相关规划的历史缘故,当时只手动测试是否符合功能。
而这部分存量代码出现较大的新需求或者维护已经成为问题,需要大规模重构时,是推动补全单测的好时机。因为为存量代码补充上单测一方面能够推进重构者进一步理解原先逻辑,另一方面能够增强重构后的信心,降低风险。
但补充存量单测可能需要再次回忆理解需求和逻辑设计等细节,甚至写单测者并不是原编码设计者。
2.2.3 与编码同步进行:增量
及时为增量代码写上单测是一种良好的习惯。因为此时有对需求有一定的理解,能够更好地写出单元测试来验证正确性。并且能在单测阶段发现问题,修复的成本也是最小的,不必等到联调测试中发现。
另一方面在写单测的过程中也能够反思业务代码的正确性、合理性,能推动我们在实现的过程中更好地反思代码的设计并及时调整。
2.2.4 mock与stub
一般来说,单元测试中是不允许有外部依赖的,那么也就是说这些外部依赖都需要被模拟。 Mock(模拟)和Stub(桩)是在测试过程中,模拟外部依赖行为的两种常用的技术手段。通过Mock和Stub我们不仅可以让测试环境没有外部依赖而且还可以模拟一些异常行为,普遍来说,我们遇到最常见的依赖无非下面几种:
- 网络依赖——函数执行依赖于网络请求,比如第三方http-api,rpc服务,消息队列等等
- 数据库依赖
- I/O依赖
在Go语言中,可以这样描述Mock和Stub:
- Mock:在测试包中创建一个结构体,满足某个外部依赖的接口 interface{}
- Stub:在测试包中创建一个模拟方法,用于替换生成代码中的方法
打桩和mock应该是最容易混淆的,而且习惯上我们统一用mock去形容模拟返回的能力,习惯成自然,也就把mock常挂在嘴边了。 stub可以理解为mock的子集,mock更强大一些:
- mock可以验证实现过程,验证某个函数是否被执行,被执行几次
- mock可以依条件生效,比如传入特定参数,才会使mock效果生效
- mock可以指定返回结果
- 当mock指定任何参数都返回固定的结果时,它等于stub
2.2.5 实战
- goconvey:管理和运行测试用例,提供丰富的assert
- sqlmock:mock sql调用
- gostub:为全局变量、函数变量、过程(没有返回值的函数)打桩
- gomock:为interface打桩
- monkey:为方法(成员函数)打桩,非并发安全
常规流程
// myFunc
func myFunc(arg string) {
NewSchedule().Lock()
// ...
}
先使用mockgen生成mock文件mockgen -source=schedule.go -destination=./mocks/schedule.go -package=mocks
type interface Schedule {
Lock(key string) error
}
var NewSchedule = func() Schedule {
return &ScheduleRedis{}
}
func (s *ScheduleRedis) Lock(key string) error {
}
测试用例
// mock
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mock := mocks.NewMockSchedule(ctrl)
stubs := gostub.StubFunc(&NewSchedule, mock)
defer stubs.Reset()
convey.Convey("正常流程", t, func() {
mock.EXPECT().Lock(gomock.Any()).Return(int64(1), nil)
myFunc(tt.args.arg1)
// convey.so...
}
CRUD
convey.Convey("正常流程", t, func() {
mysqlClient.EXPECT().Query(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, next mysql.NextFunc, query string, args ...interface{}) error {
mockRows := sqlmock.NewRows([]string{"rowa", "rowb", "rowc", "rowd"}).
AddRow("valuea", 1, 2, "3")
sqlMock.ExpectQuery("SELECT").WillReturnRows(mockRows)
sqlRows, _ := sqlDB.Query("SELECT")
defer sqlRows.Close()
for sqlRows.Next() {
err = next(sqlRows)
if err != nil {
return err
}
}
return nil
}).Times(1)
feature, err := repo.Value(context.Background(), []string{""}) // mock掉Value方法中的sql调用
convey.So(err, convey.ShouldBeNil)
convey.So(feature, convey.ShouldResemble, []*entity.Value{})
}
成员方法
type Etcd struct {}
func (e *Etcd) Get(instanceId string) []string {
taskList := make([]string, 0)
...
return taskList
}
var e *Etcd
guard := PatchInstanceMethod(
reflect.TypeOf(e),
"Get",
func(_ *Etcd, _ string) []string {
return []string{"task1", "task5", "task8"}
})
defer guard.Unpatch()
2.3 Why接口测试
接口测试主要用于外部系统与系统之间以及内部各个子系统之间的交互点,定义特定的交互点,然后通过这些交互点和一些特殊的规则也就是协议,来进行数据之间的交互。
通过模拟客户端向服务器发送请求报文,服务器接收请求报文后对相应的报文做处理并向客户端返回应答,客户端接收应答的过程。目的是为了:
- 尽早进行系统集成测试,暴露Bug
- 解决系统测试复杂度
- 屏蔽UI层的不稳定性
- 检查系统安全性,稳定性
对于接口本身,需要验证:
- 接口是否可用
- 其请求和响应的情况
- 其业务逻辑是否正确
- 产生的效应是否符合系统设计行为
2.4 接口测试的时机
- 【用例管理】编写测试用例
- 【用例集管理】使用测试套件组合归类
- 【任务管理】配置执行计划
- 【报告列表】执行与报告
接口测试平台我们使用yapi,既可以做接口管理,也可以做测试用例管理。在yapi创建完接口(或通过swagger/postman等导入)后,添加测试集合
我们通过请求“服务端测试地址”,来执行自动化测试,生成测试报告
curl "https://yapi.mydomain.com/api/open/run_auto_test?id=635&token=xxx&mode=html"
有了单元测试和接口测试,后续就可以很方便的和我们的CI/CD流水线进行集成,进行自动化测试。
3.phper/pythoner to gopher
我们公司golang转型背景:2017-2018 互联网红利期过渡到存量期,面对资本压力和业务瓶颈,各大互联网公司开始资源盘点,削资源、砍成本。
Go自身的一些优势:
- 资源开销低
- Nginx fastcgi资源消耗
- 解释型语言翻译成本
- goroutine、栈内存开销、cpu上下文开销
- 运维成本低
- https://blog.xstudio.mobi/a/130.html
- 开发视角逐渐向底层靠拢,例如如何优化gc、锁、内存分配等
- 技术后发优势
- 弯道超车
- 面向未来(良好社区、重大变革、雄厚背景)
Go的一些劣势:
- 业务生态,除了在云原生和中间件领域,在业务工程,相比于java,还缺少一定的杀手级应用(框架、组件、标准化实践)
- 泛型的支持、err处理等也使得go广受诟病
4 Refrence
- Effective Go https://golang.org/doc/effective_go
- A Tour of GO https://tour.golang.org/welcome/1
- CodeReviewComments https://github.com/golang/go/wiki/CodeReviewComments
- Go Src https://github.com/golang/go
- Go设计与实现 https://draveness.me/golang/Awesome
- GoExpertProgramming https://github.com/RainbowMango/GoExpertProgramming
- Go https://github.com/avelino/awesome-go
- Practical Go: Real world advice for writing maintainable Go programs https://dave.cheney.net/practical-go/presentations/qcon-china.html
- Yapi自动化测试https://hellosean1025.github.io/yapi/documents/case.html#服务端自动化测试
- 从入门到掉坑:Go 内存池/对象池技术介绍 https://zhuanlan.zhihu.com/p/145819350
- HASH表在go语言中的实现 https://zhuanlan.zhihu.com/p/365405724